codeburn/mac/Sources/CodeBurnMenubar/CapacityDockPlacement.swift
Resham Joshi 91b4ad87b6
fix(menubar): keep Capacity Dock hover alive after app deactivation (#1170)
SwiftUI onHover rides on NSTrackingAreas that only deliver while the
app is active. The dock lives in a nonactivating panel inside an
accessory app, so the first click into another app killed every hover
interaction until relaunch.

Hover enter/exit is now synthesized in CapacityDockController from the
existing local and global mouse monitors, which observe the pointer
regardless of activation. Provider rows are resolved by inverting the
layoutDetail row-midpoint math (CapacityDockPlacement.providerRowIndex,
unit-tested), and the SwiftUI onHover plumbing is removed so the
controller is the single hover source. Row exits no longer cancel the
rail's pending collapse, and a drag drop re-derives hover so a
stationary pointer resting on a row still opens its bubble.
2026-08-29 18:42:27 +02:00

353 lines
13 KiB
Swift

import CoreGraphics
enum CapacityDockExpansionAnchor: Equatable, Sendable {
case start
case end
}
struct CapacityDockAttachmentCandidate: Equatable, Sendable {
let edge: CapacityDockEdge
let progress: CGFloat
}
/// Pure geometry for a rail that can flare into any display edge or detach into
/// a freely positioned floating widget.
enum CapacityDockPlacement {
static let floatingInset: CGFloat = 12
static let verticalInset: CGFloat = 12
static let defaultTopOffset: CGFloat = 156
static let detailGap: CGFloat = 10
static let detailInset: CGFloat = 8
static let dockSnapDistance: CGFloat = 44
static func screenIndex(containing point: CGPoint, frames: [CGRect]) -> Int? {
frames.firstIndex { $0.contains(point) }
}
static func railFrame(
screenFrame: CGRect,
visibleFrame: CGRect,
size: CGSize,
dockedEdge: CapacityDockEdge?,
normalizedHorizontalOffset: Double?,
normalizedTopOffset: Double?,
anchoredTop: CGFloat? = nil,
anchoredLeading: CGFloat? = nil,
anchoredAxisCoordinate: CGFloat? = nil,
expansionAnchor: CapacityDockExpansionAnchor = .start
) -> CGRect {
let fittedSize = CGSize(
width: min(size.width, max(0, screenFrame.width - floatingInset * 2)),
height: min(size.height, max(0, screenFrame.height - verticalInset * 2))
)
let highestTop = visibleFrame.maxY - verticalInset
let lowestTop = visibleFrame.minY + verticalInset + fittedSize.height
let travel = max(0, highestTop - lowestTop)
let leading: CGFloat
switch dockedEdge {
case .right:
leading = screenFrame.maxX - fittedSize.width
case .left:
leading = screenFrame.minX
case .top, .bottom, nil:
let lowestLeading = visibleFrame.minX + floatingInset
let highestLeading = max(
lowestLeading,
visibleFrame.maxX - floatingInset - fittedSize.width
)
if let anchoredLeading {
leading = min(max(anchoredLeading, lowestLeading), highestLeading)
} else if let anchoredAxisCoordinate {
let candidate = expansionAnchor == .start
? anchoredAxisCoordinate
: anchoredAxisCoordinate - fittedSize.width
leading = min(max(candidate, lowestLeading), highestLeading)
} else if let normalizedHorizontalOffset {
let normalized = min(max(CGFloat(normalizedHorizontalOffset), 0), 1)
leading = lowestLeading + (highestLeading - lowestLeading) * normalized
} else if dockedEdge == .top || dockedEdge == .bottom {
leading = lowestLeading + (highestLeading - lowestLeading) * 0.5
} else {
leading = highestLeading
}
}
let top: CGFloat
switch dockedEdge {
case .top:
// Sit flush against the physical top edge (the notch/menu-bar band),
// like the system notch, so the concave shoulders neck into the very
// top of the screen with no gap below the menu bar.
top = screenFrame.maxY
case .bottom:
top = screenFrame.minY + fittedSize.height
case .left, .right, nil:
if let anchoredTop {
top = min(max(anchoredTop, lowestTop), highestTop)
} else if let anchoredAxisCoordinate {
let candidate = expansionAnchor == .start
? anchoredAxisCoordinate
: anchoredAxisCoordinate + fittedSize.height
top = min(max(candidate, lowestTop), highestTop)
} else if let normalizedTopOffset {
let normalized = min(max(CGFloat(normalizedTopOffset), 0), 1)
top = highestTop - travel * normalized
} else {
top = min(max(visibleFrame.maxY - defaultTopOffset, lowestTop), highestTop)
}
}
return CGRect(
x: leading,
y: top - fittedSize.height,
width: fittedSize.width,
height: fittedSize.height
)
}
static func normalizedHorizontalOffset(
railFrame: CGRect,
visibleFrame: CGRect
) -> Double {
let lowestLeading = visibleFrame.minX + floatingInset
let highestLeading = max(
lowestLeading,
visibleFrame.maxX - floatingInset - railFrame.width
)
let travel = highestLeading - lowestLeading
guard travel > 0 else { return 0 }
return Double(min(max((railFrame.minX - lowestLeading) / travel, 0), 1))
}
/// Uses stable AppKit screen coordinates so moving the panel cannot alter
/// the gesture's own coordinate space and feed jitter back into the drag.
static func pointerAnchoredDragFrame(
startFrame: CGRect,
startPointer: CGPoint,
currentPointer: CGPoint,
size: CGSize
) -> CGRect {
let anchorX = startFrame.width > 0
? min(max((startPointer.x - startFrame.minX) / startFrame.width, 0), 1)
: 0.5
let anchorY = startFrame.height > 0
? min(max((startPointer.y - startFrame.minY) / startFrame.height, 0), 1)
: 0.5
return CGRect(
x: currentPointer.x - anchorX * size.width,
y: currentPointer.y - anchorY * size.height,
width: size.width,
height: size.height
)
}
static func nearestDockEdge(
railFrame: CGRect,
screenFrame: CGRect,
visibleFrame: CGRect
) -> CapacityDockEdge? {
let distances: [(CapacityDockEdge, CGFloat)] = [
(.left, abs(railFrame.minX - screenFrame.minX)),
(.right, abs(screenFrame.maxX - railFrame.maxX)),
(.top, max(0, visibleFrame.maxY - railFrame.maxY)),
(.bottom, abs(railFrame.minY - screenFrame.minY)),
]
return distances
.filter { $0.1 <= dockSnapDistance }
.min { $0.1 < $1.1 }?
.0
}
static func attachmentCandidate(
railFrame: CGRect,
screenFrame: CGRect,
visibleFrame: CGRect
) -> CapacityDockAttachmentCandidate? {
let distances: [(CapacityDockEdge, CGFloat)] = [
(.left, abs(railFrame.minX - screenFrame.minX)),
(.right, abs(screenFrame.maxX - railFrame.maxX)),
(.top, max(0, visibleFrame.maxY - railFrame.maxY)),
(.bottom, abs(railFrame.minY - screenFrame.minY)),
]
guard let nearest = distances.min(by: { $0.1 < $1.1 }),
nearest.1 <= dockSnapDistance else { return nil }
let progress = 1 - nearest.1 / dockSnapDistance
return CapacityDockAttachmentCandidate(
edge: nearest.0,
progress: min(max(progress, 0), 1)
)
}
static func expansionAnchor(
railFrame: CGRect,
visibleFrame: CGRect,
edge: CapacityDockEdge
) -> CapacityDockExpansionAnchor {
if edge.isVertical {
let roomBelow = railFrame.minY - visibleFrame.minY
let roomAbove = visibleFrame.maxY - railFrame.maxY
return roomBelow >= roomAbove ? .start : .end
}
let roomTowardStart = railFrame.minX - visibleFrame.minX
let roomTowardEnd = visibleFrame.maxX - railFrame.maxX
return roomTowardEnd >= roomTowardStart ? .start : .end
}
static func clampedDragFrame(
_ frame: CGRect,
screenFrame: CGRect,
visibleFrame: CGRect
) -> CGRect {
let candidate = attachmentCandidate(
railFrame: frame,
screenFrame: screenFrame,
visibleFrame: visibleFrame
)
var result = frame
let horizontalBounds = switch candidate?.edge {
case .left, .right: screenFrame
case .top, .bottom, nil: visibleFrame
}
let verticalBounds = switch candidate?.edge {
case .bottom: screenFrame
case .left, .right, .top, nil: visibleFrame
}
let lowestLeading = horizontalBounds.minX
let highestLeading = max(lowestLeading, horizontalBounds.maxX - result.width)
result.origin.x = min(max(result.minX, lowestLeading), highestLeading)
let lowestBottom = verticalBounds.minY
let highestBottom = max(lowestBottom, verticalBounds.maxY - result.height)
result.origin.y = min(max(result.minY, lowestBottom), highestBottom)
return result
}
static func normalizedTopOffset(
railFrame: CGRect,
visibleFrame: CGRect
) -> Double {
let highestTop = visibleFrame.maxY - verticalInset
let lowestTop = visibleFrame.minY + verticalInset + railFrame.height
let travel = max(0, highestTop - lowestTop)
guard travel > 0 else { return 0 }
return Double(min(max((highestTop - railFrame.maxY) / travel, 0), 1))
}
static func detailFrame(
size: CGSize,
railFrame: CGRect,
providerRowMidY: CGFloat,
visibleFrame: CGRect,
side: CapacityDockEdge
) -> CGRect {
let fittedSize = CGSize(
width: min(size.width, max(0, visibleFrame.width - detailInset * 2)),
height: min(size.height, max(0, visibleFrame.height - detailInset * 2))
)
let desired: CGRect
switch side {
case .left:
desired = CGRect(
x: railFrame.minX - detailGap - fittedSize.width,
y: providerRowMidY - fittedSize.height / 2,
width: fittedSize.width,
height: fittedSize.height
)
case .right:
desired = CGRect(
x: railFrame.maxX + detailGap,
y: providerRowMidY - fittedSize.height / 2,
width: fittedSize.width,
height: fittedSize.height
)
case .top:
desired = CGRect(
x: providerRowMidY - fittedSize.width / 2,
y: railFrame.maxY + detailGap,
width: fittedSize.width,
height: fittedSize.height
)
case .bottom:
desired = CGRect(
x: providerRowMidY - fittedSize.width / 2,
y: railFrame.minY - detailGap - fittedSize.height,
width: fittedSize.width,
height: fittedSize.height
)
}
return clampedFrame(
desired,
to: visibleFrame,
inset: detailInset
)
}
private static func clampedFrame(
_ frame: CGRect,
to visibleFrame: CGRect,
inset: CGFloat
) -> CGRect {
let availableWidth = max(0, visibleFrame.width - inset * 2)
let availableHeight = max(0, visibleFrame.height - inset * 2)
let width = min(frame.width, availableWidth)
let height = min(frame.height, availableHeight)
let minX = visibleFrame.minX + inset
let maxX = max(minX, visibleFrame.maxX - inset - width)
let minY = visibleFrame.minY + inset
let maxY = max(minY, visibleFrame.maxY - inset - height)
return CGRect(
x: min(max(frame.minX, minX), maxX),
y: min(max(frame.minY, minY), maxY),
width: width,
height: height
)
}
/// Inverse of the controller's row-midpoint layout: which provider row band
/// contains a pointer, given its along-axis distance from the anchor-side
/// content start (padding already subtracted). Returns nil in the spacing
/// gaps and outside the row run.
static func providerRowIndex(
alongOffset: CGFloat,
rowHeight: CGFloat,
rowSpacing: CGFloat,
rowCount: Int,
expansionAnchor: CapacityDockExpansionAnchor
) -> Int? {
guard rowCount > 0, rowHeight > 0, alongOffset >= 0 else { return nil }
let period = rowHeight + rowSpacing
let slot = Int(alongOffset / period)
guard slot < rowCount,
alongOffset - CGFloat(slot) * period <= rowHeight else { return nil }
return expansionAnchor == .start ? slot : rowCount - 1 - slot
}
static func preferredDetailSide(
railFrame: CGRect,
visibleFrame: CGRect,
dockedEdge: CapacityDockEdge?,
preferredEdge: CapacityDockEdge? = nil
) -> CapacityDockEdge {
if let dockedEdge { return dockedEdge.opposite }
let roomLeft = railFrame.minX - visibleFrame.minX
let roomRight = visibleFrame.maxX - railFrame.maxX
let roomBelow = railFrame.minY - visibleFrame.minY
let roomAbove = visibleFrame.maxY - railFrame.maxY
// A detached rail retains the vertical/horizontal orientation of the
// edge it came from, but its card follows the room at its *current*
// position. This prevents a bottom-docked rail dragged near the top (or
// a right-docked rail dragged to the left) from opening through the
// screen edge and being clamped to an apparently unrelated location.
if let preferredEdge {
if preferredEdge.isVertical {
return roomRight >= roomLeft ? .right : .left
}
return roomAbove >= roomBelow ? .top : .bottom
}
if max(roomLeft, roomRight) >= max(roomBelow, roomAbove) {
return roomRight >= roomLeft ? .right : .left
}
return roomAbove >= roomBelow ? .top : .bottom
}
}