Skip to content
Closed
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
29 changes: 17 additions & 12 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -552,19 +552,24 @@ extension StatusItemController {
provider: row.provider,
model: row.model,
width: menuWidth)
let item = self.makeMenuCardItem(
OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth),
let item = self.makeOverviewMenuRowItem(
OverviewMenuCardRowView(
model: row.model,
storageText: storageText,
width: menuWidth,
showsSubmenuIndicator: submenu != nil),
id: identifier,
width: menuWidth,
heightCacheScope: row.provider.rawValue,
heightCacheFingerprint: row.model.heightFingerprint(
section: "overview",
additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]),
submenu: submenu,
onClick: { [weak self, weak interactionMenu] in
guard let self, let interactionMenu else { return }
self.selectOverviewProvider(row.provider, menu: interactionMenu)
})
configuration: OverviewMenuRowItemConfiguration(
width: menuWidth,
heightCacheScope: row.provider.rawValue,
heightCacheFingerprint: row.model.heightFingerprint(
section: "overview",
additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]),
submenu: submenu,
onClick: { [weak self, weak interactionMenu] in
guard let self, let interactionMenu else { return }
self.selectOverviewProvider(row.provider, menu: interactionMenu)
}))
if submenu == nil {
// Keep plain rows wired for keyboard activation and accessibility action paths.
item.target = self
Expand Down
61 changes: 61 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuCardItems.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import AppKit
import SwiftUI

struct OverviewMenuRowItemConfiguration {
let width: CGFloat
let heightCacheScope: String
let heightCacheFingerprint: String
let submenu: NSMenu?
let onClick: (() -> Void)?
}

extension StatusItemController {
func refreshMenuCardHeights(in menu: NSMenu) {
let width = self.renderedMenuWidth(for: menu)
Expand Down Expand Up @@ -101,6 +109,59 @@ extension StatusItemController {
return item
}

func makeOverviewMenuRowItem<RowContent: View>(
_ view: RowContent,
id: String,
configuration: OverviewMenuRowItemConfiguration) -> NSMenuItem
{
let allowsMenuHighlight = configuration.submenu != nil || configuration.onClick != nil
if !self.menuCardRenderingEnabledForController {
let item = NSMenuItem()
item.isEnabled = allowsMenuHighlight
item.representedObject = id
item.submenu = configuration.submenu
if configuration.submenu != nil {
item.target = self
item.action = #selector(self.menuCardNoOp(_:))
}
return item
}

let wrapped = OverviewMenuRowContainerView(refreshMonitor: self.menuCardRefreshMonitor) {
view
}
let hosting: OverviewMenuRowHostingView<OverviewMenuRowContainerView<RowContent>>
if let recycled = self.takeRecyclableMenuCardView(
for: id,
as: OverviewMenuRowHostingView<OverviewMenuRowContainerView<RowContent>>.self)
{
recycled.prepareForReuse(rootView: wrapped, onClick: configuration.onClick)
hosting = recycled
} else {
hosting = OverviewMenuRowHostingView(rootView: wrapped, onClick: configuration.onClick)
}
let height = self.cachedMenuCardHeight(
for: id,
scope: configuration.heightCacheScope,
width: configuration.width,
fingerprint: configuration.heightCacheFingerprint)
{
self.menuCardHeight(for: hosting, width: configuration.width)
}
hosting.frame = NSRect(origin: .zero, size: NSSize(width: configuration.width, height: height))

let item = NSMenuItem()
item.view = hosting
item.isEnabled = allowsMenuHighlight
item.representedObject = id
item.submenu = configuration.submenu
if configuration.submenu != nil {
item.target = self
item.action = #selector(self.menuCardNoOp(_:))
}
return item
}

private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat {
let basePadding: CGFloat = 6
let descenderSafety: CGFloat = 1
Expand Down
126 changes: 126 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuPresentation.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AppKit
import CodexBarCore
import Observation
import QuartzCore
import SwiftUI

extension StatusItemController {
Expand Down Expand Up @@ -197,6 +198,121 @@ final class MenuCardItemHostingView<Content: View>: NSHostingView<Content>, Menu
}
}

@MainActor
final class OverviewMenuRowHostingView<Content: View>: NSView, MenuCardHighlighting, MenuCardMeasuring {
private let hostingView: NSHostingView<Content>
private let selectionLayer = CALayer()
private var measuredHeight: CGFloat?
private var onClick: (() -> Void)?
private var hasClickRecognizer = false
private(set) var isHighlighted = false

var allowsMenuHighlight: Bool {
true
}

override var allowsVibrancy: Bool {
true
}

override var intrinsicContentSize: NSSize {
guard let measuredHeight else {
let size = self.hostingView.fittingSize
return NSSize(width: self.frame.width, height: size.height)
}
return NSSize(width: self.frame.width, height: measuredHeight)
}

init(rootView: Content, onClick: (() -> Void)? = nil) {
self.hostingView = NSHostingView(rootView: rootView)
self.onClick = onClick
super.init(frame: .zero)
self.configureView()
if onClick != nil {
self.installClickRecognizer()
}
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func prepareForReuse(rootView: Content, onClick: (() -> Void)?) {
self.hostingView.rootView = rootView
self.onClick = onClick
if onClick != nil, !self.hasClickRecognizer {
self.installClickRecognizer()
}
self.setHighlighted(false)
}

override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}

override func hitTest(_ point: NSPoint) -> NSView? {
self.bounds.contains(point) ? self : nil
}
Comment on lines +254 to +256

override func layout() {
super.layout()
self.hostingView.frame = self.bounds
self.selectionLayer.frame = self.bounds.insetBy(dx: 6, dy: 2)
}

override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
guard newSize.height > 0 else { return }
let resolvedHeight = ceil(newSize.height)
guard self.measuredHeight != resolvedHeight else { return }
self.measuredHeight = resolvedHeight
self.invalidateIntrinsicContentSize()
}

func measuredHeight(width: CGFloat) -> CGFloat {
self.frame = NSRect(origin: self.frame.origin, size: NSSize(width: width, height: 1))
self.hostingView.frame = self.bounds
self.layoutSubtreeIfNeeded()
return self.hostingView.fittingSize.height
}

func setHighlighted(_ highlighted: Bool) {
guard self.isHighlighted != highlighted else { return }
self.isHighlighted = highlighted
self.selectionLayer.isHidden = !highlighted
}

private func configureView() {
self.wantsLayer = true
self.layer?.masksToBounds = false
self.selectionLayer.cornerRadius = 6
self.selectionLayer.cornerCurve = .continuous
self.selectionLayer.backgroundColor = NSColor.selectedContentBackgroundColor
.withAlphaComponent(0.16)
.cgColor
self.selectionLayer.isHidden = true
self.layer?.addSublayer(self.selectionLayer)

self.hostingView.translatesAutoresizingMaskIntoConstraints = true
self.hostingView.autoresizingMask = [.width, .height]
self.hostingView.frame = self.bounds
self.addSubview(self.hostingView)
}
Comment on lines +286 to +301

private func installClickRecognizer() {
let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:)))
recognizer.buttonMask = 0x1
self.addGestureRecognizer(recognizer)
self.hasClickRecognizer = true
}

@objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) {
guard recognizer.state == .ended else { return }
self.onClick?()
}
}

struct MenuCardSectionContainerView<Content: View>: View {
@Bindable var highlightState: MenuCardHighlightState
let showsSubmenuIndicator: Bool
Expand Down Expand Up @@ -229,3 +345,13 @@ struct MenuCardSectionContainerView<Content: View>: View {
}
}
}

struct OverviewMenuRowContainerView<Content: View>: View {
let refreshMonitor: MenuCardRefreshMonitor?
@ViewBuilder let content: () -> Content

var body: some View {
self.content()
.environment(\.menuCardRefreshMonitor, self.refreshMonitor)
}
}
15 changes: 12 additions & 3 deletions Sources/CodexBar/StatusItemController+MenuTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ struct OverviewMenuCardRowView: View {
let model: UsageMenuCardView.Model
let storageText: String?
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
var showsSubmenuIndicator = false

var body: some View {
VStack(alignment: .leading, spacing: 0) {
Expand All @@ -36,10 +36,10 @@ struct OverviewMenuCardRowView: View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(L("Storage")):")
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.foregroundStyle(MenuHighlightStyle.secondary(false))
Text(storageText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.foregroundStyle(MenuHighlightStyle.secondary(false))
.lineLimit(1)
Spacer()
}
Expand All @@ -50,6 +50,15 @@ struct OverviewMenuCardRowView: View {
}
}
.frame(width: self.width, alignment: .leading)
.overlay(alignment: .topTrailing) {
if self.showsSubmenuIndicator {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(MenuHighlightStyle.secondary(false))
.padding(.top, 8)
.padding(.trailing, 10)
}
}
}

private var hasUsageBlock: Bool {
Expand Down
87 changes: 87 additions & 0 deletions Tests/CodexBarTests/MenuCardViewRecyclingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,93 @@ extension StatusMenuTests {
}
}

@Test
func `overview row hosting consumes hit tests and highlights at appkit boundary`() {
StatusItemController.setMenuRefreshEnabledForTesting(false)
let previousRendering = StatusItemController.menuCardRenderingEnabled
StatusItemController.menuCardRenderingEnabled = true
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }

let settings = self.makeSettings()
settings.statusChecksEnabled = false
let controller = self.makeRecyclingController(settings: settings)
defer { controller.releaseStatusItemsForTesting() }

let menu = NSMenu()
let item = controller.makeOverviewMenuRowItem(
Text("Overview"),
id: "\(StatusItemController.overviewRowIdentifierPrefix)codex",
configuration: OverviewMenuRowItemConfiguration(
width: 300,
heightCacheScope: "codex",
heightCacheFingerprint: "test",
submenu: NSMenu(),
onClick: {}))
menu.addItem(item)

guard let hosting = item.view as? OverviewMenuRowHostingView<OverviewMenuRowContainerView<Text>> else {
Issue.record("expected an overview row hosting view")
return
}

#expect(item.isEnabled)
#expect(hosting.hitTest(NSPoint(x: hosting.bounds.midX, y: hosting.bounds.midY)) === hosting)
#expect(!hosting.isHighlighted)

controller.menu(menu, willHighlight: item)
#expect(hosting.isHighlighted)

controller.menu(menu, willHighlight: nil)
#expect(!hosting.isHighlighted)
}

@Test
func `recycled overview row keeps hosting view and clears appkit highlight state`() {
StatusItemController.setMenuRefreshEnabledForTesting(false)
let previousRendering = StatusItemController.menuCardRenderingEnabled
StatusItemController.menuCardRenderingEnabled = true
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }

let settings = self.makeSettings()
settings.statusChecksEnabled = false
let controller = self.makeRecyclingController(settings: settings)
defer { controller.releaseStatusItemsForTesting() }

let menu = NSMenu()
let original = controller.makeOverviewMenuRowItem(
Text("Before"),
id: "\(StatusItemController.overviewRowIdentifierPrefix)codex",
configuration: OverviewMenuRowItemConfiguration(
width: 300,
heightCacheScope: "codex",
heightCacheFingerprint: "before",
submenu: nil,
onClick: {}))
menu.addItem(original)

guard let originalView = original.view as? OverviewMenuRowHostingView<OverviewMenuRowContainerView<Text>>
else {
Issue.record("expected an overview row hosting view")
return
}
originalView.setHighlighted(true)

controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil)
defer { controller.clearMenuCardViewRecyclePool() }
let rebuilt = controller.makeOverviewMenuRowItem(
Text("After"),
id: "\(StatusItemController.overviewRowIdentifierPrefix)codex",
configuration: OverviewMenuRowItemConfiguration(
width: 300,
heightCacheScope: "codex",
heightCacheFingerprint: "after",
submenu: nil,
onClick: {}))

#expect(rebuilt.view === originalView)
#expect(!originalView.isHighlighted)
}

@Test
func `embedded controls stay enabled without highlighting the card`() {
StatusItemController.setMenuRefreshEnabledForTesting(false)
Expand Down