diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index ce4a0c592..4c5019bac 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -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 diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index eb7b0e517..6760b46a3 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -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) @@ -101,6 +109,59 @@ extension StatusItemController { return item } + func makeOverviewMenuRowItem( + _ 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> + if let recycled = self.takeRecyclableMenuCardView( + for: id, + as: OverviewMenuRowHostingView>.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 diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 094a9e992..27f648349 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -1,6 +1,7 @@ import AppKit import CodexBarCore import Observation +import QuartzCore import SwiftUI extension StatusItemController { @@ -197,6 +198,121 @@ final class MenuCardItemHostingView: NSHostingView, Menu } } +@MainActor +final class OverviewMenuRowHostingView: NSView, MenuCardHighlighting, MenuCardMeasuring { + private let hostingView: NSHostingView + 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 + } + + 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) + } + + 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: View { @Bindable var highlightState: MenuCardHighlightState let showsSubmenuIndicator: Bool @@ -229,3 +345,13 @@ struct MenuCardSectionContainerView: View { } } } + +struct OverviewMenuRowContainerView: View { + let refreshMonitor: MenuCardRefreshMonitor? + @ViewBuilder let content: () -> Content + + var body: some View { + self.content() + .environment(\.menuCardRefreshMonitor, self.refreshMonitor) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 03fc1785a..5b7d3980b 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -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) { @@ -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() } @@ -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 { diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift index 5efbc803a..95d679cb3 100644 --- a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -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> 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> + 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)