mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 23:29:35 +00:00
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.
353 lines
13 KiB
Swift
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
|
|
}
|
|
}
|