578 lines
19 KiB
Swift
578 lines
19 KiB
Swift
import AppKit
|
|
import Combine
|
|
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
struct MenuBarHiddenComponentView: View {
|
|
@ObservedObject var controller: MenuBarHiddenController
|
|
let context: PluginComponentContext
|
|
|
|
private var canShowIcons: Bool { controller.permissions.canManageItems }
|
|
|
|
var body: some View {
|
|
iconPanel
|
|
}
|
|
|
|
private var iconPanel: some View {
|
|
Group {
|
|
if canShowIcons {
|
|
MenuBarHiddenComponentIconFlowLayout(
|
|
horizontalSpacing: MenuBarHiddenComponentIconLayout.horizontalItemSpacing,
|
|
minimumVerticalSpacing: MenuBarHiddenComponentIconLayout.minimumVerticalSpacing
|
|
) {
|
|
ForEach(displayItems) { item in
|
|
MenuBarHiddenComponentIconButton(
|
|
item: item,
|
|
iconCache: controller.manager.iconCache,
|
|
onLeftClick: { click(item, button: .left) },
|
|
onRightClick: { click(item, button: .right) }
|
|
)
|
|
}
|
|
}
|
|
.padding(.horizontal, MenuBarHiddenComponentIconLayout.horizontalPadding)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
.background(
|
|
MenuBarHiddenComponentBackground(
|
|
cornerRadius: MenuBarHiddenComponentIconLayout.cardCornerRadius
|
|
)
|
|
)
|
|
.clipShape(
|
|
RoundedRectangle(
|
|
cornerRadius: MenuBarHiddenComponentIconLayout.cardCornerRadius,
|
|
style: .continuous
|
|
)
|
|
)
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private var displayItems: [MenuBarItem] {
|
|
controller.snapshot.hiddenItems + controller.snapshot.alwaysHiddenItems
|
|
}
|
|
|
|
private func click(_ item: MenuBarItem, button: CGMouseButton) {
|
|
context.dismiss()
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(200))
|
|
controller.clickItem(item, button: button)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MenuBarHiddenComponentIconLayout {
|
|
/// Fixed row height for all icon cells.
|
|
static let rowHeight: CGFloat = 26
|
|
/// Inset applied inside each icon cell for the background padding and image bounds.
|
|
static let iconInset: CGFloat = 3
|
|
/// Minimum and maximum cell widths (including insets).
|
|
static let minItemWidth: CGFloat = 20
|
|
static let maxItemWidth: CGFloat = 104
|
|
static let horizontalItemSpacing: CGFloat = 4
|
|
static let minimumVerticalSpacing: CGFloat = 4
|
|
static let horizontalPadding: CGFloat = 7
|
|
static let cardCornerRadius: CGFloat = PluginComponentPanelLayoutMetrics.cardCornerRadius
|
|
static let itemCornerRadius: CGFloat = 7
|
|
|
|
static func naturalContentHeight(forRowHeights rowHeights: [CGFloat]) -> CGFloat {
|
|
guard !rowHeights.isEmpty else {
|
|
return 0
|
|
}
|
|
|
|
return rowHeights.reduce(0, +)
|
|
+ CGFloat(rowHeights.count + 1) * minimumVerticalSpacing
|
|
}
|
|
|
|
static func balancedVerticalGap(
|
|
forRowHeights rowHeights: [CGFloat],
|
|
availableHeight: CGFloat
|
|
) -> CGFloat {
|
|
guard !rowHeights.isEmpty, availableHeight.isFinite else {
|
|
return minimumVerticalSpacing
|
|
}
|
|
|
|
let totalRowHeight = rowHeights.reduce(0, +)
|
|
let gapCount = CGFloat(rowHeights.count + 1)
|
|
let distributedGap = (availableHeight - totalRowHeight) / gapCount
|
|
return max(minimumVerticalSpacing, distributedGap)
|
|
}
|
|
|
|
@MainActor
|
|
static func iconCellWidth(for item: MenuBarItem, image: MenuBarHiddenIconCache.CapturedImage?) -> CGFloat {
|
|
MenuBarHiddenComponentIconNSView.naturalSize(for: item, image: image).width
|
|
}
|
|
|
|
@MainActor
|
|
static func rowCount(
|
|
forItems items: [MenuBarItem],
|
|
iconCache: MenuBarHiddenIconCache,
|
|
availableWidth: CGFloat = PluginComponentPanelLayoutMetrics.default.gridWidth
|
|
) -> Int {
|
|
let contentWidth = max(0, availableWidth - horizontalPadding * 2)
|
|
guard !items.isEmpty, contentWidth > 0 else {
|
|
return 1
|
|
}
|
|
|
|
var rows = 1
|
|
var currentWidth: CGFloat = 0
|
|
|
|
for item in items {
|
|
let itemWidth = iconCellWidth(for: item, image: iconCache.image(for: item.tag))
|
|
let candidateWidth = currentWidth == 0
|
|
? itemWidth
|
|
: currentWidth + horizontalItemSpacing + itemWidth
|
|
|
|
if currentWidth > 0, candidateWidth > contentWidth {
|
|
rows += 1
|
|
currentWidth = itemWidth
|
|
} else {
|
|
currentWidth = candidateWidth
|
|
}
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
@MainActor
|
|
static func spanHeight(
|
|
forItems items: [MenuBarItem],
|
|
iconCache: MenuBarHiddenIconCache,
|
|
metrics: PluginComponentPanelLayoutMetrics = .default
|
|
) -> Int {
|
|
let rows = rowCount(
|
|
forItems: items,
|
|
iconCache: iconCache,
|
|
availableWidth: metrics.itemWidth(forSpanWidth: PluginComponentSpan.maximumWidth)
|
|
)
|
|
return rows * 4 + 1
|
|
}
|
|
}
|
|
|
|
private struct MenuBarHiddenComponentBackground: View {
|
|
let cornerRadius: CGFloat
|
|
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
|
.fill(MenuBarHiddenComponentColor.cardFill)
|
|
}
|
|
}
|
|
|
|
private enum MenuBarHiddenComponentColor {
|
|
static let cardFill = Color.primary.opacity(0.045)
|
|
|
|
static func itemFill(isHovered: Bool, isLightAppearance: Bool) -> NSColor {
|
|
if isLightAppearance {
|
|
return NSColor.black.withAlphaComponent(isHovered ? 0.26 : 0.18)
|
|
} else {
|
|
return NSColor.white.withAlphaComponent(isHovered ? 0.16 : 0.10)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MenuBarHiddenComponentIconFlowLayout: Layout {
|
|
let horizontalSpacing: CGFloat
|
|
let minimumVerticalSpacing: CGFloat
|
|
|
|
func sizeThatFits(
|
|
proposal: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache _: inout ()
|
|
) -> CGSize {
|
|
let layout = makeLayout(
|
|
subviews: subviews,
|
|
maxWidth: proposedWidth(proposal.width, subviews: subviews),
|
|
proposedHeight: proposal.height
|
|
)
|
|
return layout.size
|
|
}
|
|
|
|
func placeSubviews(
|
|
in bounds: CGRect,
|
|
proposal _: ProposedViewSize,
|
|
subviews: Subviews,
|
|
cache _: inout ()
|
|
) {
|
|
let layout = makeLayout(
|
|
subviews: subviews,
|
|
maxWidth: bounds.width,
|
|
proposedHeight: bounds.height
|
|
)
|
|
for (index, placement) in layout.placements.enumerated() {
|
|
subviews[index].place(
|
|
at: CGPoint(
|
|
x: bounds.minX + placement.origin.x,
|
|
y: bounds.minY + placement.origin.y
|
|
),
|
|
proposal: ProposedViewSize(placement.size)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func proposedWidth(_ width: CGFloat?, subviews: Subviews) -> CGFloat {
|
|
if let width, width > 0 {
|
|
return width
|
|
}
|
|
let naturalWidth = subviews.reduce(CGFloat(0)) { partial, subview in
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
return partial + size.width + (partial == 0 ? 0 : horizontalSpacing)
|
|
}
|
|
return max(naturalWidth, MenuBarHiddenComponentIconLayout.rowHeight)
|
|
}
|
|
|
|
private func makeLayout(
|
|
subviews: Subviews,
|
|
maxWidth: CGFloat,
|
|
proposedHeight: CGFloat?
|
|
) -> (size: CGSize, placements: [(origin: CGPoint, size: CGSize)]) {
|
|
guard !subviews.isEmpty else {
|
|
return (.zero, [])
|
|
}
|
|
|
|
let effectiveMaxWidth = max(maxWidth, MenuBarHiddenComponentIconLayout.rowHeight)
|
|
let rows = makeRows(subviews: subviews, maxWidth: effectiveMaxWidth)
|
|
let rowHeights = rows.map(\.height)
|
|
let naturalHeight = MenuBarHiddenComponentIconLayout.naturalContentHeight(forRowHeights: rowHeights)
|
|
let layoutHeight = proposedHeight.flatMap { height in
|
|
height.isFinite ? max(height, naturalHeight) : nil
|
|
} ?? naturalHeight
|
|
let verticalGap = max(
|
|
minimumVerticalSpacing,
|
|
MenuBarHiddenComponentIconLayout.balancedVerticalGap(
|
|
forRowHeights: rowHeights,
|
|
availableHeight: layoutHeight
|
|
)
|
|
)
|
|
var placements: [(origin: CGPoint, size: CGSize)] = Array(
|
|
repeating: (origin: .zero, size: .zero),
|
|
count: subviews.count
|
|
)
|
|
var y = verticalGap
|
|
|
|
for row in rows {
|
|
for item in row.items {
|
|
placements[item.index] = (
|
|
CGPoint(x: item.x, y: y),
|
|
item.size
|
|
)
|
|
}
|
|
y += row.height + verticalGap
|
|
}
|
|
|
|
return (
|
|
CGSize(width: effectiveMaxWidth, height: layoutHeight),
|
|
placements
|
|
)
|
|
}
|
|
|
|
private func makeRows(
|
|
subviews: Subviews,
|
|
maxWidth: CGFloat
|
|
) -> [Row] {
|
|
var rows: [Row] = []
|
|
var items: [RowItem] = []
|
|
var x: CGFloat = 0
|
|
var rowHeight: CGFloat = 0
|
|
|
|
for (index, subview) in subviews.enumerated() {
|
|
let size = subview.sizeThatFits(.unspecified)
|
|
if x > 0, x + size.width > maxWidth {
|
|
rows.append(Row(items: items, height: rowHeight))
|
|
items = []
|
|
x = 0
|
|
rowHeight = 0
|
|
}
|
|
|
|
items.append(RowItem(index: index, x: x, size: size))
|
|
x += size.width + horizontalSpacing
|
|
rowHeight = max(rowHeight, size.height)
|
|
}
|
|
|
|
if !items.isEmpty {
|
|
rows.append(Row(items: items, height: rowHeight))
|
|
}
|
|
|
|
return rows
|
|
}
|
|
|
|
private struct Row {
|
|
let items: [RowItem]
|
|
let height: CGFloat
|
|
}
|
|
|
|
private struct RowItem {
|
|
let index: Int
|
|
let x: CGFloat
|
|
let size: CGSize
|
|
}
|
|
}
|
|
|
|
private struct MenuBarHiddenComponentIconButton: NSViewRepresentable {
|
|
let item: MenuBarItem
|
|
let iconCache: MenuBarHiddenIconCache
|
|
let onLeftClick: () -> Void
|
|
let onRightClick: () -> Void
|
|
|
|
func makeNSView(context _: Context) -> MenuBarHiddenComponentIconNSView {
|
|
MenuBarHiddenComponentIconNSView(
|
|
item: item,
|
|
iconCache: iconCache,
|
|
onLeftClick: onLeftClick,
|
|
onRightClick: onRightClick
|
|
)
|
|
}
|
|
|
|
func updateNSView(_ nsView: MenuBarHiddenComponentIconNSView, context _: Context) {
|
|
nsView.update(
|
|
item: item,
|
|
iconCache: iconCache,
|
|
onLeftClick: onLeftClick,
|
|
onRightClick: onRightClick
|
|
)
|
|
}
|
|
|
|
func sizeThatFits(_ proposal: ProposedViewSize, nsView: MenuBarHiddenComponentIconNSView, context _: Context) -> CGSize? {
|
|
nsView.intrinsicContentSize
|
|
}
|
|
}
|
|
|
|
private final class MenuBarHiddenComponentIconNSView: NSView {
|
|
private var item: MenuBarItem
|
|
private var iconCache: MenuBarHiddenIconCache
|
|
private var onLeftClick: () -> Void
|
|
private var onRightClick: () -> Void
|
|
private var cachedImage: MenuBarHiddenIconCache.CapturedImage? {
|
|
didSet {
|
|
guard !MenuBarHiddenIconCache.CapturedImage.isVisuallyEqual(oldValue, cachedImage) else { return }
|
|
invalidateIntrinsicContentSize()
|
|
needsDisplay = true
|
|
}
|
|
}
|
|
|
|
private var isHovered = false
|
|
private var trackingArea: NSTrackingArea?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var lastLeftMouseDownDate: Date?
|
|
private var lastRightMouseDownDate: Date?
|
|
private var lastLeftMouseDownLocation = CGPoint.zero
|
|
private var lastRightMouseDownLocation = CGPoint.zero
|
|
|
|
init(
|
|
item: MenuBarItem,
|
|
iconCache: MenuBarHiddenIconCache,
|
|
onLeftClick: @escaping () -> Void,
|
|
onRightClick: @escaping () -> Void
|
|
) {
|
|
self.item = item
|
|
self.iconCache = iconCache
|
|
self.onLeftClick = onLeftClick
|
|
self.onRightClick = onRightClick
|
|
let image = iconCache.image(for: item.tag)
|
|
self.cachedImage = image
|
|
super.init(
|
|
frame: NSRect(
|
|
origin: .zero,
|
|
size: Self.naturalSize(for: item, image: image)
|
|
)
|
|
)
|
|
wantsLayer = true
|
|
updateAccessibilityAndTooltip()
|
|
configureCancellables()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder _: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var intrinsicContentSize: NSSize {
|
|
Self.naturalSize(for: item, image: cachedImage)
|
|
}
|
|
|
|
/// Computes cell size so the icon fills the cell height minus insets,
|
|
/// with width proportional to the actual icon aspect ratio.
|
|
fileprivate static func naturalSize(
|
|
for item: MenuBarItem,
|
|
image: MenuBarHiddenIconCache.CapturedImage?
|
|
) -> NSSize {
|
|
let rowH = MenuBarHiddenComponentIconLayout.rowHeight
|
|
let inset = MenuBarHiddenComponentIconLayout.iconInset
|
|
let iconH = rowH - inset * 2
|
|
|
|
let sourceSize: CGSize
|
|
if let nsImage = image?.nsImage, nsImage.size.height > 0 {
|
|
sourceSize = nsImage.size
|
|
} else if item.bounds.height > 0 {
|
|
sourceSize = item.bounds.size
|
|
} else {
|
|
return NSSize(width: rowH, height: rowH)
|
|
}
|
|
|
|
let scale = iconH / sourceSize.height
|
|
let iconW = sourceSize.width * scale
|
|
let naturalCellWidth = iconW + inset * 2
|
|
let cellW = min(
|
|
max(naturalCellWidth, MenuBarHiddenComponentIconLayout.minItemWidth),
|
|
MenuBarHiddenComponentIconLayout.maxItemWidth
|
|
)
|
|
return NSSize(width: ceil(cellW), height: rowH)
|
|
}
|
|
|
|
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
|
|
|
func update(
|
|
item: MenuBarItem,
|
|
iconCache: MenuBarHiddenIconCache,
|
|
onLeftClick: @escaping () -> Void,
|
|
onRightClick: @escaping () -> Void
|
|
) {
|
|
let didChangeItem = self.item.tag != item.tag
|
|
let didChangeCache = self.iconCache !== iconCache
|
|
self.item = item
|
|
self.iconCache = iconCache
|
|
self.onLeftClick = onLeftClick
|
|
self.onRightClick = onRightClick
|
|
cachedImage = iconCache.image(for: item.tag)
|
|
updateAccessibilityAndTooltip()
|
|
if didChangeItem || didChangeCache {
|
|
configureCancellables()
|
|
}
|
|
needsDisplay = true
|
|
}
|
|
|
|
private func updateAccessibilityAndTooltip() {
|
|
toolTip = item.displayName
|
|
setAccessibilityLabel(item.displayName)
|
|
}
|
|
|
|
private func configureCancellables() {
|
|
cancellables.removeAll()
|
|
let tag = item.tag
|
|
iconCache.$images
|
|
.map { [weak iconCache] _ in iconCache?.image(for: tag) }
|
|
.removeDuplicates(by: MenuBarHiddenIconCache.CapturedImage.isVisuallyEqual)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] image in
|
|
self?.cachedImage = image
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
if let trackingArea {
|
|
removeTrackingArea(trackingArea)
|
|
}
|
|
let area = NSTrackingArea(
|
|
rect: bounds,
|
|
options: [.mouseEnteredAndExited, .activeAlways],
|
|
owner: self,
|
|
userInfo: nil
|
|
)
|
|
addTrackingArea(area)
|
|
trackingArea = area
|
|
}
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
super.mouseEntered(with: event)
|
|
isHovered = true
|
|
needsDisplay = true
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
super.mouseExited(with: event)
|
|
isHovered = false
|
|
needsDisplay = true
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
super.mouseDown(with: event)
|
|
lastLeftMouseDownDate = .now
|
|
lastLeftMouseDownLocation = NSEvent.mouseLocation
|
|
}
|
|
|
|
override func rightMouseDown(with event: NSEvent) {
|
|
super.rightMouseDown(with: event)
|
|
lastRightMouseDownDate = .now
|
|
lastRightMouseDownLocation = NSEvent.mouseLocation
|
|
}
|
|
|
|
override func mouseUp(with event: NSEvent) {
|
|
super.mouseUp(with: event)
|
|
guard
|
|
let lastLeftMouseDownDate,
|
|
Date.now.timeIntervalSince(lastLeftMouseDownDate) < 0.5,
|
|
Self.distance(lastLeftMouseDownLocation, NSEvent.mouseLocation) < 5
|
|
else {
|
|
return
|
|
}
|
|
onLeftClick()
|
|
}
|
|
|
|
override func rightMouseUp(with event: NSEvent) {
|
|
super.rightMouseUp(with: event)
|
|
guard
|
|
let lastRightMouseDownDate,
|
|
Date.now.timeIntervalSince(lastRightMouseDownDate) < 0.5,
|
|
Self.distance(lastRightMouseDownLocation, NSEvent.mouseLocation) < 5
|
|
else {
|
|
return
|
|
}
|
|
onRightClick()
|
|
}
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
super.draw(dirtyRect)
|
|
|
|
drawItemBackground()
|
|
guard let image = cachedImage?.nsImage else { return }
|
|
|
|
let inset = MenuBarHiddenComponentIconLayout.iconInset
|
|
let iconRect = bounds.insetBy(dx: inset, dy: inset)
|
|
let targetRect = aspectFitRect(imageSize: image.size, in: iconRect)
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
NSGraphicsContext.current?.imageInterpolation = .high
|
|
image.draw(
|
|
in: targetRect,
|
|
from: .zero,
|
|
operation: .sourceOver,
|
|
fraction: 1
|
|
)
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
}
|
|
|
|
private func drawItemBackground() {
|
|
let r = min(MenuBarHiddenComponentIconLayout.itemCornerRadius, bounds.height / 2)
|
|
MenuBarHiddenComponentColor
|
|
.itemFill(isHovered: isHovered, isLightAppearance: isLightAppearance)
|
|
.setFill()
|
|
NSBezierPath(
|
|
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
|
|
xRadius: r,
|
|
yRadius: r
|
|
).fill()
|
|
}
|
|
|
|
private var isLightAppearance: Bool {
|
|
effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) != .darkAqua
|
|
}
|
|
|
|
private func aspectFitRect(imageSize: CGSize, in rect: CGRect) -> CGRect {
|
|
guard imageSize.width > 0, imageSize.height > 0 else { return .zero }
|
|
let scale = min(rect.width / imageSize.width, rect.height / imageSize.height)
|
|
let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
|
|
return CGRect(
|
|
x: rect.midX - size.width / 2,
|
|
y: rect.midY - size.height / 2,
|
|
width: size.width,
|
|
height: size.height
|
|
)
|
|
}
|
|
|
|
private static func distance(_ lhs: CGPoint, _ rhs: CGPoint) -> CGFloat {
|
|
hypot(lhs.x - rhs.x, lhs.y - rhs.y)
|
|
}
|
|
}
|