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
167 changes: 162 additions & 5 deletions PanBar/Popover/PopoverController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@ final class PopoverController {
private let popover: NSPopover
private let refresher: QuoteRefresher
private let viewModel: PopoverViewModel
private let holdingsRepo: HoldingsRepository
private let appearancePrefs: AppearancePreferences
private let tickerPrefs: TickerPreferences
private let container: DependencyContainer
private let minimumPopoverWidth: CGFloat = 360
private let popoverHeight: CGFloat = 520
private let metricColumnMinimumWidth: CGFloat = 58
private let metricColumnSpacing: CGFloat = 6
private let metricsGridWidth: CGFloat = 160
/// 监听 popover 之外的点击,关 popover。`.transient` 行为对菜单栏 popover 有时漏
/// (尤其是点系统菜单栏 / 通知 / 其它 app 时),这里多加一层保险。
private var eventMonitor: Any?
private var didCloseObserver: NSObjectProtocol?
var onClose: (() -> Void)?

var isShown: Bool { popover.isShown }

Expand All @@ -28,6 +39,10 @@ final class PopoverController {
watchlistRepo: watchlistRepo,
settingsRepo: settingsRepo
)
self.holdingsRepo = holdingsRepo
self.appearancePrefs = appearancePrefs
self.tickerPrefs = tickerPrefs
self.container = container

let popover = NSPopover()
popover.behavior = .transient
Expand All @@ -45,32 +60,174 @@ final class PopoverController {

// popover 通过 .transient 行为自己关时,也要更新 refresher 状态
// (否则 pace 会一直停在 .popoverOpen,后台多耗一点请求)
NotificationCenter.default.addObserver(
didCloseObserver = NotificationCenter.default.addObserver(
forName: NSPopover.didCloseNotification,
object: popover,
queue: .main
) { [weak self] _ in
self?.handlePopoverClosed()
Task { @MainActor in
self?.handlePopoverClosed()
}
}
}

deinit {
if let m = eventMonitor { NSEvent.removeMonitor(m) }
NotificationCenter.default.removeObserver(self)
if let observer = didCloseObserver {
NotificationCenter.default.removeObserver(observer)
}
}

private func handlePopoverClosed() {
stopOutsideClickMonitor()
refresher.setPopoverOpen(false)
onClose?()
}

func show(relativeTo view: NSView) {
func show(relativeTo view: NSView, anchorWidth: CGFloat? = nil) {
let width = preferredPopoverWidth()
applyContentWidth(width)
refresher.setPopoverOpen(true)
refresher.refreshNow()
popover.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
popover.show(relativeTo: positioningRect(relativeTo: view, anchorWidth: anchorWidth), of: view, preferredEdge: .minY)
startOutsideClickMonitor()
Comment on lines +87 to 93
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

在 AppKit/macOS 中,NSScreen.main 的定义是什么?它是否保证等于某个给定 NSView.window?.screen?使用 NSPopover.show(relativeTo:of:preferredEdge:) 时,多显示器场景应优先使用哪个 screen 的 visibleFrame 来限制弹窗尺寸?

💡 Result:

  1. 在 AppKit/macOS 中,NSScreen.mainScreen(Swift 里常以 NSScreen.main 表示)被定义为“包含具有键盘焦点的窗口的屏幕”。它不是“主显示器/菜单栏所在显示器”的同义词;Apple 明确其含义与“接收键盘事件的屏幕”相关。 [1] 2) NSScreen.main 是否保证等于某个给定 NSView.window?.screen?不保证。 - 从语义上看,NSScreen.main 是“有键盘焦点的窗口”所在屏幕;而 NSView.window?.screen 反映的是该 view 对应 window 所属的屏幕。两者可能在焦点还没切换、或弹窗/激活导致键盘焦点在别的窗口时不同。Apple 对 NSScreen.mainScreen 的定义是基于键盘焦点。 [1] - 社区也提到过 NSScreen.main 可能对应“键盘激活的显示器”,不一定是用户主屏。 [2] 3) 使用 NSPopover.show(relativeTo:of:preferredEdge:) 多显示器场景中,限制弹窗尺寸时应优先使用哪个 screen 的 visibleFrame?优先使用“定位视图(positioningView)的 window.screen”,也就是:与 positioningRect 所在的 positioningView/其 window 同一块屏幕对应的 NSScreen.visibleFrame。 - NSPopover.show(relativeTo:of:preferredEdge:) 的文档说明它是“将弹窗锚定在指定 view(positioningView)上、相对 positioningRect(通常是 positioningView.bounds)定位”。 [3] - 由于锚定语义绑定到 positioningView 所在屏幕,你用该 screen 的 visibleFrame 来算“能放下的最大区域”更符合 AppKit 实际尝试放置的位置区域(避免把其他屏幕的可见区域当作约束来源)。 [3] - 同时,visibleFrame 与 mainScreen 的差异(mainScreen 基于键盘焦点)意味着若你用 NSScreen.main 的 visibleFrame,可能在多屏焦点不一致时出现尺寸/可见区域计算不匹配。 [1][2] 建议代码思路(概念性): - let targetScreen = positioningView.window?.screen?? NSScreen.main - 用 targetScreen?.visibleFrame 参与尺寸上限计算 (可用 fallback 兜底,但主要约束应来自 positioningView.window.screen。)

Citations:


按定位视图所在屏幕限制弹窗最大宽度(避免多显示器错配)

maximumPopoverWidth() 目前用 NSScreen.main?.visibleFrame.widthNSScreen.main 表示“包含键盘焦点窗口的屏幕”,不保证等于 NSView.window?.screen(也不保证与 NSPopover.show(relativeTo:of:preferredEdge:) 的定位语义同屏),多显示器下会导致最大宽度约束计算落错屏幕,从而出现尺寸异常。应优先改用 view.window?.screen?.visibleFrame,必要时再用 NSScreen.main 兜底。

🧭 建议改法
-    func show(relativeTo view: NSView, anchorWidth: CGFloat? = nil) {
-        let width = preferredPopoverWidth()
+    func show(relativeTo view: NSView, anchorWidth: CGFloat? = nil) {
+        let width = preferredPopoverWidth(relativeTo: view)
         applyContentWidth(width)
         refresher.setPopoverOpen(true)
         refresher.refreshNow()
         popover.show(relativeTo: positioningRect(relativeTo: view, anchorWidth: anchorWidth), of: view, preferredEdge: .minY)
         startOutsideClickMonitor()
     }
@@
-    private func preferredPopoverWidth() -> CGFloat {
+    private func preferredPopoverWidth(relativeTo view: NSView) -> CGFloat {
         let holdings = (try? holdingsRepo.all()) ?? viewModel.holdings
         guard !holdings.isEmpty else { return minimumPopoverWidth }
@@
-        return min(maximumPopoverWidth(), max(minimumPopoverWidth, ceil(widestRow)))
+        return min(maximumPopoverWidth(relativeTo: view), max(minimumPopoverWidth, ceil(widestRow)))
     }
@@
-    private func maximumPopoverWidth() -> CGFloat {
-        let screenWidth = NSScreen.main?.visibleFrame.width ?? 900
+    private func maximumPopoverWidth(relativeTo view: NSView) -> CGFloat {
+        let screenWidth = view.window?.screen?.visibleFrame.width
+            ?? NSScreen.main?.visibleFrame.width
+            ?? 900
         return max(minimumPopoverWidth, min(760, screenWidth - 80))
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PanBar/Popover/PopoverController.swift` around lines 87 - 93, The max-width
calculation currently uses NSScreen.main which can pick the wrong display;
change maximumPopoverWidth() to first read the anchor view's screen via
view.window?.screen?.visibleFrame (or accept an NSView/NSWindow parameter to
obtain that screen) and only fall back to NSScreen.main?.visibleFrame if the
view's screen is nil; update any callers (e.g., preferredPopoverWidth() or
show(relativeTo:anchorWidth:) which rely on maximumPopoverWidth()) to pass the
anchor view or use the new overload so the popover width is computed against
view.window?.screen rather than NSScreen.main.

}

private func applyContentWidth(_ width: CGFloat) {
popover.contentSize = NSSize(width: width, height: popoverHeight)
popover.contentViewController = NSHostingController(rootView:
PopoverRoot()
.environmentObject(viewModel)
.environmentObject(refresher)
.environmentObject(appearancePrefs)
.environmentObject(tickerPrefs)
.environment(\.container, container)
.frame(width: width, height: popoverHeight)
)
}

private func preferredPopoverWidth() -> CGFloat {
let holdings = (try? holdingsRepo.all()) ?? viewModel.holdings
guard !holdings.isEmpty else { return minimumPopoverWidth }

let positionsByID = Dictionary(uniqueKeysWithValues: refresher.snapshot.positions.map { ($0.holding.id, $0) })
var widestRow: CGFloat = minimumPopoverWidth

for holding in holdings {
let quote = refresher.quotes[holding.symbol] ?? positionsByID[holding.id]?.quote
widestRow = max(widestRow, estimatedHoldingRowWidth(holding: holding, quote: quote))
}

return min(maximumPopoverWidth(), max(minimumPopoverWidth, ceil(widestRow)))
}

private func estimatedHoldingRowWidth(holding: Holding, quote: Quote?) -> CGFloat {
let position = refresher.snapshot.positions.first { $0.holding.id == holding.id }
let rowPadding = appearancePrefs.density.rowHorizontalPadding * 2
let titleWidth = textWidth(displayCode(holding.symbol), size: 11, weight: .regular)
+ 4
+ textWidth(holding.name, size: 12, weight: .semibold)
+ 14
let detailWidth = textWidth(detailText(for: holding), size: 10, weight: .regular)
let leftWidth = max(titleWidth, detailWidth)
let metricsWidth = max(metricsGridWidth, estimatedMetricsGridWidth(holding: holding, quote: quote, position: position))

return leftWidth + 8 + metricsWidth + rowPadding + 14
}

private func maximumPopoverWidth() -> CGFloat {
let screenWidth = NSScreen.main?.visibleFrame.width ?? 900
return max(minimumPopoverWidth, min(760, screenWidth - 80))
}

private func estimatedMetricsGridWidth(holding: Holding, quote: Quote?, position: HoldingPosition?) -> CGFloat {
let allTimeWidth = max(
metricColumnMinimumWidth,
textWidth(L("summary.allTime", comment: ""), size: 10, weight: .medium),
metricValueWidth(value: nativePnL(holding: holding, quote: quote), currency: holding.currency),
baseMetricWidth(value: position?.basePnL)
)
let todayWidth = max(
metricColumnMinimumWidth,
estimatedQuoteWidth(holding: holding, quote: quote),
metricLineWidth(label: L("summary.today", comment: ""), value: nativeTodayPnL(holding: holding, quote: quote), currency: holding.currency),
baseMetricWidth(value: position?.baseTodayPnL)
)
return allTimeWidth + metricColumnSpacing + todayWidth
}

private func metricLineWidth(label: String, value: Decimal?, currency: Currency) -> CGFloat {
textWidth(label, size: 10, weight: .medium)
+ 3
+ textWidth(value.map { signedPnL($0, currency: currency) } ?? "—", size: 11, weight: .semibold)
}

private func metricValueWidth(value: Decimal?, currency: Currency) -> CGFloat {
textWidth(value.map { signedPnL($0, currency: currency) } ?? "—", size: 11, weight: .semibold)
}

private func baseMetricWidth(value: Decimal?) -> CGFloat {
textWidth(value.map { "≈ " + signedPnL($0, currency: container.settingsRepo.baseCurrency) } ?? "—", size: 11, weight: .semibold)
Comment on lines +143 to +170
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

宽度估算和实际渲染用了不同的基准币来源。

这里的 baseMetricWidth 取自 container.settingsRepo.baseCurrency,但 HoldingRow 实际渲染用的是 refresher.snapshot.baseCurrency。当设置已切换而 snapshot 还没同步时,估算宽度和最终文本会用两套币种格式,弹窗宽度就可能算小并发生截断。建议两边统一使用 refresher.snapshot.baseCurrency

💱 建议改法
         let allTimeWidth = max(
             metricColumnMinimumWidth,
             textWidth(L("summary.allTime", comment: ""), size: 10, weight: .medium),
             metricValueWidth(value: nativePnL(holding: holding, quote: quote), currency: holding.currency),
-            baseMetricWidth(value: position?.basePnL)
+            baseMetricWidth(value: position?.basePnL, currency: refresher.snapshot.baseCurrency)
         )
         let todayWidth = max(
             metricColumnMinimumWidth,
             estimatedQuoteWidth(holding: holding, quote: quote),
             metricLineWidth(label: L("summary.today", comment: ""), value: nativeTodayPnL(holding: holding, quote: quote), currency: holding.currency),
-            baseMetricWidth(value: position?.baseTodayPnL)
+            baseMetricWidth(value: position?.baseTodayPnL, currency: refresher.snapshot.baseCurrency)
         )
         return allTimeWidth + metricColumnSpacing + todayWidth
     }
@@
-    private func baseMetricWidth(value: Decimal?) -> CGFloat {
-        textWidth(value.map { "≈ " + signedPnL($0, currency: container.settingsRepo.baseCurrency) } ?? "—", size: 11, weight: .semibold)
+    private func baseMetricWidth(value: Decimal?, currency: Currency) -> CGFloat {
+        textWidth(value.map { "≈ " + signedPnL($0, currency: currency) } ?? "—", size: 11, weight: .semibold)
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func estimatedMetricsGridWidth(holding: Holding, quote: Quote?, position: HoldingPosition?) -> CGFloat {
let allTimeWidth = max(
metricColumnMinimumWidth,
textWidth(L("summary.allTime", comment: ""), size: 10, weight: .medium),
metricValueWidth(value: nativePnL(holding: holding, quote: quote), currency: holding.currency),
baseMetricWidth(value: position?.basePnL)
)
let todayWidth = max(
metricColumnMinimumWidth,
estimatedQuoteWidth(holding: holding, quote: quote),
metricLineWidth(label: L("summary.today", comment: ""), value: nativeTodayPnL(holding: holding, quote: quote), currency: holding.currency),
baseMetricWidth(value: position?.baseTodayPnL)
)
return allTimeWidth + metricColumnSpacing + todayWidth
}
private func metricLineWidth(label: String, value: Decimal?, currency: Currency) -> CGFloat {
textWidth(label, size: 10, weight: .medium)
+ 3
+ textWidth(value.map { signedPnL($0, currency: currency) } ?? "", size: 11, weight: .semibold)
}
private func metricValueWidth(value: Decimal?, currency: Currency) -> CGFloat {
textWidth(value.map { signedPnL($0, currency: currency) } ?? "", size: 11, weight: .semibold)
}
private func baseMetricWidth(value: Decimal?) -> CGFloat {
textWidth(value.map { "" + signedPnL($0, currency: container.settingsRepo.baseCurrency) } ?? "", size: 11, weight: .semibold)
private func estimatedMetricsGridWidth(holding: Holding, quote: Quote?, position: HoldingPosition?) -> CGFloat {
let allTimeWidth = max(
metricColumnMinimumWidth,
textWidth(L("summary.allTime", comment: ""), size: 10, weight: .medium),
metricValueWidth(value: nativePnL(holding: holding, quote: quote), currency: holding.currency),
baseMetricWidth(value: position?.basePnL, currency: refresher.snapshot.baseCurrency)
)
let todayWidth = max(
metricColumnMinimumWidth,
estimatedQuoteWidth(holding: holding, quote: quote),
metricLineWidth(label: L("summary.today", comment: ""), value: nativeTodayPnL(holding: holding, quote: quote), currency: holding.currency),
baseMetricWidth(value: position?.baseTodayPnL, currency: refresher.snapshot.baseCurrency)
)
return allTimeWidth + metricColumnSpacing + todayWidth
}
private func metricLineWidth(label: String, value: Decimal?, currency: Currency) -> CGFloat {
textWidth(label, size: 10, weight: .medium)
3
textWidth(value.map { signedPnL($0, currency: currency) } ?? "", size: 11, weight: .semibold)
}
private func metricValueWidth(value: Decimal?, currency: Currency) -> CGFloat {
textWidth(value.map { signedPnL($0, currency: currency) } ?? "", size: 11, weight: .semibold)
}
private func baseMetricWidth(value: Decimal?, currency: Currency) -> CGFloat {
textWidth(value.map { "" + signedPnL($0, currency: currency) } ?? "", size: 11, weight: .semibold)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PanBar/Popover/PopoverController.swift` around lines 143 - 170, The width
estimator uses container.settingsRepo.baseCurrency while the renderer
(HoldingRow) uses refresher.snapshot.baseCurrency, causing mismatched formats;
update baseMetricWidth (and its callers in estimatedMetricsGridWidth) to use
refresher.snapshot.baseCurrency instead of container.settingsRepo.baseCurrency
(or change baseMetricWidth to accept a Currency parameter and pass
refresher.snapshot.baseCurrency from estimatedMetricsGridWidth) so the estimator
and renderer use the same base currency (reference symbols: baseMetricWidth,
estimatedMetricsGridWidth, refresher.snapshot.baseCurrency, HoldingRow).

}

private func nativePnL(holding: Holding, quote: Quote?) -> Decimal? {
guard let quote else { return nil }
return (quote.price - holding.costPrice) * holding.quantity
}

private func nativeTodayPnL(holding: Holding, quote: Quote?) -> Decimal? {
guard let quote else { return nil }
return (quote.price - quote.prevClose) * holding.quantity
}

private func estimatedQuoteWidth(holding: Holding, quote: Quote?) -> CGFloat {
guard let quote else {
return textWidth("—", size: 12, weight: .semibold)
}
let price = holding.currency.format(quote.price)
let pct = String(format: "%+.2f%%", quote.changePct * 100)
return textWidth(price, size: 12, weight: .semibold)
+ 5
+ textWidth(pct, size: 10, weight: .semibold)
+ 10
}

private func textWidth(_ text: String, size: CGFloat, weight: NSFont.Weight) -> CGFloat {
let font = NSFont.systemFont(ofSize: size, weight: weight)
return ceil((text as NSString).size(withAttributes: [.font: font]).width)
}

private func displayCode(_ symbol: SymbolID) -> String {
symbol.market == .us ? symbol.code.uppercased() : symbol.code
}

private func detailText(for holding: Holding) -> String {
let qtyDisplay = "\(holding.quantity)"
let costDisplay = holding.currency.format(holding.costPrice, fractionDigits: 3)
return String(format: L("holding.detail", comment: ""), qtyDisplay, costDisplay)
}

private func signedPnL(_ value: Decimal, currency: Currency) -> String {
let sign = value >= 0 ? "+" : "-"
return sign + currency.format(value.magnitude)
}

private func positioningRect(relativeTo view: NSView, anchorWidth: CGFloat?) -> NSRect {
let rect: NSRect
if let anchorWidth {
let width = max(view.bounds.width, anchorWidth)
rect = NSRect(
x: view.bounds.maxX - width,
y: view.bounds.minY,
width: width,
height: view.bounds.height
)
} else {
rect = view.bounds
}
return rect
}

func close() {
stopOutsideClickMonitor()
popover.performClose(nil)
Expand Down
Loading