refactor(mac): inject context card as NSMenuItem view
This commit is contained in:
131
apps/macos/Sources/Clawdis/ContextMenuCardView.swift
Normal file
131
apps/macos/Sources/Clawdis/ContextMenuCardView.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Context usage card shown at the top of the menubar menu.
|
||||
struct ContextMenuCardView: View {
|
||||
private let width: CGFloat
|
||||
private let padding: CGFloat = 10
|
||||
private let barHeight: CGFloat = 4
|
||||
|
||||
@State private var rows: [SessionRow] = []
|
||||
@State private var activeCount: Int = 0
|
||||
|
||||
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
init(width: CGFloat) {
|
||||
self.width = width
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.rows.isEmpty {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(self.padding)
|
||||
.frame(width: self.width, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.white.opacity(0.04))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.task { await self.reload() }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let count = self.activeCount
|
||||
if count == 1 { return "1 session · 24h" }
|
||||
return "\(count) sessions · 24h"
|
||||
}
|
||||
|
||||
private var contentWidth: CGFloat {
|
||||
max(1, self.width - (self.padding * 2))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionRow(_ row: SessionRow) -> some View {
|
||||
let width = self.contentWidth
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(row.key)
|
||||
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(row.tokens.contextSummaryShort)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
.frame(width: width)
|
||||
|
||||
ContextUsageBar(
|
||||
usedTokens: row.tokens.total,
|
||||
contextTokens: row.tokens.contextTokens,
|
||||
width: width,
|
||||
height: self.barHeight)
|
||||
}
|
||||
.frame(width: width)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func reload() async {
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let defaults = SessionDefaults(
|
||||
model: hints.model ?? SessionLoader.fallbackModel,
|
||||
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||
|
||||
guard let loaded = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
|
||||
self.rows = []
|
||||
self.activeCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let active = loaded.filter { row in
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
|
||||
}
|
||||
|
||||
let main = loaded.first(where: { $0.key == "main" })
|
||||
var merged = active
|
||||
if let main, !merged.contains(where: { $0.key == "main" }) {
|
||||
merged.insert(main, at: 0)
|
||||
}
|
||||
|
||||
merged.sort { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
self.rows = merged
|
||||
self.activeCount = active.count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ContextUsageBar: View {
|
||||
@@ -26,27 +25,14 @@ struct ContextUsageBar: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// SwiftUI menus (MenuBarExtraStyle.menu) drop certain view types (including ProgressView/Canvas).
|
||||
// Render the bar as an image to reliably display inside the menu.
|
||||
let fraction = self.clampedFractionUsed
|
||||
Group {
|
||||
if let width = self.width, width > 0 {
|
||||
Image(nsImage: Self.renderBar(
|
||||
width: width,
|
||||
height: self.height,
|
||||
fractionUsed: self.clampedFractionUsed,
|
||||
percentUsed: self.percentUsed))
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
self.barBody(width: width, fraction: fraction)
|
||||
.frame(width: width, height: self.height)
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
Image(nsImage: Self.renderBar(
|
||||
width: proxy.size.width,
|
||||
height: self.height,
|
||||
fractionUsed: self.clampedFractionUsed,
|
||||
percentUsed: self.percentUsed))
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
self.barBody(width: proxy.size.width, fraction: fraction)
|
||||
.frame(width: proxy.size.width, height: self.height)
|
||||
}
|
||||
.frame(height: self.height)
|
||||
@@ -62,48 +48,27 @@ struct ContextUsageBar: View {
|
||||
return "\(pct) percent used"
|
||||
}
|
||||
|
||||
private static func renderBar(
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
fractionUsed: Double,
|
||||
percentUsed: Int?) -> NSImage
|
||||
{
|
||||
let clamped = min(1, max(0, fractionUsed))
|
||||
let size = NSSize(width: max(1, width), height: max(1, height))
|
||||
let image = NSImage(size: size)
|
||||
image.isTemplate = false
|
||||
@ViewBuilder
|
||||
private func barBody(width: CGFloat, fraction: Double) -> some View {
|
||||
let radius = self.height / 2
|
||||
let trackFill = Color.white.opacity(0.12)
|
||||
let trackStroke = Color.white.opacity(0.18)
|
||||
let fillWidth = max(1, floor(width * CGFloat(fraction)))
|
||||
|
||||
image.lockFocus()
|
||||
defer { image.unlockFocus() }
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(trackFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.strokeBorder(trackStroke, lineWidth: 0.75)
|
||||
}
|
||||
|
||||
let rect = NSRect(origin: .zero, size: size)
|
||||
let radius = rect.height / 2
|
||||
|
||||
let background = NSColor.white.withAlphaComponent(0.12)
|
||||
let stroke = NSColor.white.withAlphaComponent(0.18)
|
||||
|
||||
let fill: NSColor = {
|
||||
guard let pct = percentUsed else { return NSColor.secondaryLabelColor }
|
||||
if pct >= 95 { return .systemRed }
|
||||
if pct >= 80 { return .systemOrange }
|
||||
if pct >= 60 { return .systemYellow }
|
||||
return .systemGreen
|
||||
}()
|
||||
|
||||
let track = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||||
background.setFill()
|
||||
track.fill()
|
||||
stroke.setStroke()
|
||||
track.lineWidth = 0.75
|
||||
track.stroke()
|
||||
|
||||
let fillWidth = max(1, floor(rect.width * clamped))
|
||||
let fillRect = NSRect(x: rect.minX, y: rect.minY, width: fillWidth, height: rect.height)
|
||||
let clip = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||||
clip.addClip()
|
||||
fill.setFill()
|
||||
NSBezierPath(rect: fillRect).fill()
|
||||
|
||||
return image
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
.fill(self.tint)
|
||||
.frame(width: fillWidth)
|
||||
.mask {
|
||||
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Single-row context usage display that stays intact inside menu rendering.
|
||||
///
|
||||
/// SwiftUI menus tend to decompose view hierarchies into separate menu rows
|
||||
/// (image row, text row, etc.). We render the combined layout into an image
|
||||
/// so session name + numbers are guaranteed to appear on the same row.
|
||||
struct ContextUsageRow: View {
|
||||
let sessionKey: String
|
||||
let summary: String
|
||||
let usedTokens: Int
|
||||
let contextTokens: Int
|
||||
let width: CGFloat
|
||||
var barHeight: CGFloat = 4
|
||||
var rowHeight: CGFloat = 18
|
||||
var isMain: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Image(nsImage: Self.renderRow(
|
||||
width: self.width,
|
||||
rowHeight: self.rowHeight,
|
||||
barHeight: self.barHeight,
|
||||
sessionKey: self.sessionKey,
|
||||
summary: self.summary,
|
||||
usedTokens: self.usedTokens,
|
||||
contextTokens: self.contextTokens,
|
||||
isMain: self.isMain))
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.frame(width: self.width, height: self.rowHeight)
|
||||
.accessibilityLabel("Context usage")
|
||||
.accessibilityValue("\(self.sessionKey) \(self.summary)")
|
||||
}
|
||||
|
||||
private static func renderRow(
|
||||
width: CGFloat,
|
||||
rowHeight: CGFloat,
|
||||
barHeight: CGFloat,
|
||||
sessionKey: String,
|
||||
summary: String,
|
||||
usedTokens: Int,
|
||||
contextTokens: Int,
|
||||
isMain: Bool
|
||||
) -> NSImage {
|
||||
let safeWidth = max(1, width)
|
||||
let safeRowHeight = max(1, rowHeight)
|
||||
let safeBarHeight = min(max(1, barHeight), safeRowHeight)
|
||||
|
||||
let size = NSSize(width: safeWidth, height: safeRowHeight)
|
||||
let image = NSImage(size: size)
|
||||
image.isTemplate = false
|
||||
|
||||
image.lockFocus()
|
||||
defer { image.unlockFocus() }
|
||||
|
||||
let barRect = NSRect(x: 0, y: 0, width: size.width, height: safeBarHeight)
|
||||
drawBar(in: barRect, usedTokens: usedTokens, contextTokens: contextTokens)
|
||||
|
||||
let textRect = NSRect(
|
||||
x: 0,
|
||||
y: safeBarHeight,
|
||||
width: size.width,
|
||||
height: size.height - safeBarHeight
|
||||
)
|
||||
drawText(in: textRect, sessionKey: sessionKey, summary: summary, isMain: isMain)
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
private static func drawText(in rect: NSRect, sessionKey: String, summary: String, isMain: Bool) {
|
||||
guard rect.width > 1, rect.height > 1 else { return }
|
||||
|
||||
let keyFont = NSFont.systemFont(
|
||||
ofSize: NSFont.smallSystemFontSize,
|
||||
weight: isMain ? .semibold : .regular
|
||||
)
|
||||
let summaryFont = NSFont.monospacedDigitSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
|
||||
|
||||
let keyParagraph = NSMutableParagraphStyle()
|
||||
keyParagraph.alignment = .left
|
||||
keyParagraph.lineBreakMode = .byTruncatingMiddle
|
||||
|
||||
let summaryParagraph = NSMutableParagraphStyle()
|
||||
summaryParagraph.alignment = .right
|
||||
summaryParagraph.lineBreakMode = .byClipping
|
||||
|
||||
let keyAttr = NSAttributedString(
|
||||
string: sessionKey,
|
||||
attributes: [
|
||||
.font: keyFont,
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.paragraphStyle: keyParagraph,
|
||||
]
|
||||
)
|
||||
let summaryAttr = NSAttributedString(
|
||||
string: summary,
|
||||
attributes: [
|
||||
.font: summaryFont,
|
||||
.foregroundColor: NSColor.secondaryLabelColor,
|
||||
.paragraphStyle: summaryParagraph,
|
||||
]
|
||||
)
|
||||
|
||||
let summarySize = summaryAttr.size()
|
||||
let gap: CGFloat = 10
|
||||
let rightWidth = min(rect.width, ceil(summarySize.width))
|
||||
let leftWidth = max(1, rect.width - rightWidth - gap)
|
||||
|
||||
let textHeight = max(keyAttr.size().height, summarySize.height)
|
||||
let y = rect.minY + floor((rect.height - textHeight) / 2)
|
||||
|
||||
let leftRect = NSRect(x: rect.minX, y: y, width: leftWidth, height: textHeight)
|
||||
keyAttr.draw(with: leftRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
||||
|
||||
let rightRect = NSRect(
|
||||
x: rect.maxX - rightWidth,
|
||||
y: y,
|
||||
width: rightWidth,
|
||||
height: textHeight
|
||||
)
|
||||
summaryAttr.draw(with: rightRect, options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine])
|
||||
}
|
||||
|
||||
private static func drawBar(in rect: NSRect, usedTokens: Int, contextTokens: Int) {
|
||||
let radius = rect.height / 2
|
||||
let background = NSColor.white.withAlphaComponent(0.12)
|
||||
let stroke = NSColor.white.withAlphaComponent(0.18)
|
||||
|
||||
let fractionUsed: Double = {
|
||||
guard contextTokens > 0 else { return 0 }
|
||||
return min(1, max(0, Double(usedTokens) / Double(contextTokens)))
|
||||
}()
|
||||
let percentUsed: Int? = {
|
||||
guard contextTokens > 0, usedTokens > 0 else { return nil }
|
||||
return min(100, Int(round(fractionUsed * 100)))
|
||||
}()
|
||||
|
||||
let fill: NSColor = {
|
||||
guard let pct = percentUsed else { return NSColor.secondaryLabelColor }
|
||||
if pct >= 95 { return .systemRed }
|
||||
if pct >= 80 { return .systemOrange }
|
||||
if pct >= 60 { return .systemYellow }
|
||||
return .systemGreen
|
||||
}()
|
||||
|
||||
let track = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||||
background.setFill()
|
||||
track.fill()
|
||||
stroke.setStroke()
|
||||
track.lineWidth = 0.75
|
||||
track.stroke()
|
||||
|
||||
let fillWidth = max(1, floor(rect.width * fractionUsed))
|
||||
let fillRect = NSRect(x: rect.minX, y: rect.minY, width: fillWidth, height: rect.height)
|
||||
let clip = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius)
|
||||
clip.addClip()
|
||||
fill.setFill()
|
||||
NSBezierPath(rect: fillRect).fill()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ClawdisApp: App {
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
@State private var menuInjector = MenuContextCardInjector.shared
|
||||
|
||||
@MainActor
|
||||
private func updateStatusHighlight() {
|
||||
@@ -42,6 +43,7 @@ struct ClawdisApp: App {
|
||||
self.statusItem = item
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
||||
self.installStatusItemMouseHandler(for: item)
|
||||
self.menuInjector.install(into: item)
|
||||
}
|
||||
.onChange(of: self.state.isPaused) { _, paused in
|
||||
self.applyStatusItemAppearance(paused: paused)
|
||||
|
||||
@@ -16,14 +16,6 @@ struct MenuContent: View {
|
||||
@State private var availableMics: [AudioInputDevice] = []
|
||||
@State private var loadingMics = false
|
||||
@State private var sessionMenu: [SessionRow] = []
|
||||
@State private var contextSessions: [SessionRow] = []
|
||||
@State private var contextActiveCount: Int = 0
|
||||
@State private var contextCardWidth: CGFloat = 320
|
||||
|
||||
private let activeSessionWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||
private let contextCardPadding: CGFloat = 10
|
||||
private let contextBarHeight: CGFloat = 4
|
||||
private let contextFallbackWidth: CGFloat = 320
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@@ -32,7 +24,6 @@ struct MenuContent: View {
|
||||
Text(label)
|
||||
}
|
||||
self.statusRow
|
||||
self.contextCardRow
|
||||
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
|
||||
self.heartbeatStatusRow
|
||||
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
|
||||
@@ -191,7 +182,6 @@ struct MenuContent: View {
|
||||
}
|
||||
.task {
|
||||
await self.reloadSessionMenu()
|
||||
await self.reloadContextSessions()
|
||||
}
|
||||
.task {
|
||||
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
|
||||
@@ -257,75 +247,6 @@ struct MenuContent: View {
|
||||
.disabled(true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contextCardRow: some View {
|
||||
MenuHostedItem(
|
||||
width: self.contextCardWidth,
|
||||
rootView: AnyView(self.contextCardView))
|
||||
}
|
||||
|
||||
private var contextPillWidth: CGFloat {
|
||||
let base = self.contextCardWidth > 0 ? self.contextCardWidth : self.contextFallbackWidth
|
||||
return max(1, base - (self.contextCardPadding * 2))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contextCardView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.contextSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.contextSessions.isEmpty {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.contextSessions) { row in
|
||||
self.contextSessionRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(self.contextCardPadding)
|
||||
.frame(width: self.contextCardWidth, alignment: .leading)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.white.opacity(0.04))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contextSubtitle: String {
|
||||
let count = self.contextActiveCount
|
||||
if count == 1 { return "1 session · 24h" }
|
||||
return "\(count) sessions · 24h"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func contextSessionRow(_ row: SessionRow) -> some View {
|
||||
let width = self.contextPillWidth
|
||||
ContextUsageRow(
|
||||
sessionKey: row.key,
|
||||
summary: row.tokens.contextSummaryShort,
|
||||
usedTokens: row.tokens.total,
|
||||
contextTokens: row.tokens.contextTokens,
|
||||
width: width,
|
||||
barHeight: self.contextBarHeight,
|
||||
rowHeight: 18,
|
||||
isMain: row.key == "main")
|
||||
}
|
||||
|
||||
private var heartbeatStatusRow: some View {
|
||||
let (label, color): (String, Color) = {
|
||||
if case .degraded = self.controlChannel.state {
|
||||
@@ -476,39 +397,4 @@ struct MenuContent: View {
|
||||
let name: String
|
||||
var id: String { self.uid }
|
||||
}
|
||||
|
||||
private func reloadContextSessions() async {
|
||||
let hints = SessionLoader.configHints()
|
||||
let store = SessionLoader.resolveStorePath(override: hints.storePath)
|
||||
let defaults = SessionDefaults(
|
||||
model: hints.model ?? SessionLoader.fallbackModel,
|
||||
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
|
||||
|
||||
guard let rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
|
||||
self.contextSessions = []
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let active = rows.filter { row in
|
||||
guard let updatedAt = row.updatedAt else { return false }
|
||||
return now.timeIntervalSince(updatedAt) <= self.activeSessionWindowSeconds
|
||||
}
|
||||
|
||||
let activeCount = active.count
|
||||
let main = rows.first(where: { $0.key == "main" })
|
||||
var merged = active
|
||||
if let main, !merged.contains(where: { $0.key == "main" }) {
|
||||
merged.insert(main, at: 0)
|
||||
}
|
||||
// Keep stable ordering: main first, then most recent.
|
||||
let sorted = merged.sorted { lhs, rhs in
|
||||
if lhs.key == "main" { return true }
|
||||
if rhs.key == "main" { return false }
|
||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||
}
|
||||
|
||||
self.contextSessions = sorted
|
||||
self.contextActiveCount = activeCount
|
||||
}
|
||||
}
|
||||
|
||||
47
apps/macos/Sources/Clawdis/MenuContextCardInjector.swift
Normal file
47
apps/macos/Sources/Clawdis/MenuContextCardInjector.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class MenuContextCardInjector: NSObject, NSMenuDelegate {
|
||||
static let shared = MenuContextCardInjector()
|
||||
|
||||
private let tag = 9_415_227
|
||||
private let cardWidth: CGFloat = 320
|
||||
|
||||
func install(into statusItem: NSStatusItem) {
|
||||
// SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display.
|
||||
statusItem.menu?.delegate = self
|
||||
}
|
||||
|
||||
func menuWillOpen(_ menu: NSMenu) {
|
||||
// Remove any previous injected card items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
|
||||
guard let insertIndex = self.findInsertIndex(in: menu) else { return }
|
||||
|
||||
let cardView = ContextMenuCardView(width: self.cardWidth)
|
||||
let hosting = NSHostingView(rootView: cardView)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.cardWidth, height: size.height))
|
||||
|
||||
let item = NSMenuItem()
|
||||
item.tag = self.tag
|
||||
item.view = hosting
|
||||
item.isEnabled = false
|
||||
|
||||
menu.insertItem(item, at: insertIndex)
|
||||
}
|
||||
|
||||
private func findInsertIndex(in menu: NSMenu) -> Int? {
|
||||
// Prefer inserting before the "Send Heartbeats" toggle item.
|
||||
if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) {
|
||||
return idx
|
||||
}
|
||||
// Fallback: insert after the first two rows (active toggle + status).
|
||||
if menu.items.count >= 2 { return 2 }
|
||||
return menu.items.count
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user