codeburn/mac/Sources/CodeBurnMenubar/CapacityDockMotion.swift
Aditya Vikram Singh 84fea9d550
feat(menubar): add native Capacity Dock (#1167)
* feat(menubar): add native capacity dock

* fix(menubar): harden dock interaction boundaries

* feat(menubar): polish native Capacity Dock for launch

- Flush top-edge docking via NSHostingView safe-area opt-out; shielding
  window level so the rail necks into the physical top like the system notch
- Liquid Glass legibility scrim so labels stay readable on light wallpapers
- Weekly-else-monthly headline window; unified severity colour bands
  (green <50 / yellow / orange / red) across rings and detail bars
- Squircle gauge + graphite theme defaults; auto-show connected
  subscriptions (up to 5) until the user edits the set, then respect it
- Capacity Dock Settings menu item; re-centre the settings window; info
  glyph for the About row
- Trim dead provider-credential override fields down to apiKey only
- Reconcile and extend tests for all of the above

---------

Co-authored-by: iamtoruk <hello@agentseal.org>
2026-08-29 00:37:30 -07:00

433 lines
16 KiB
Swift

import CoreGraphics
import Foundation
/// Pure motion policy for Capacity Dock. The controller owns AppKit animation;
/// this type only answers durations, timing, and the top-anchored expand/collapse
/// and bubble presentation offsets.
enum CapacityDockMotion {
struct RailFrameSample: Equatable {
let frame: CGRect
let presentationProgress: CGFloat
}
struct FloatingRailAnchors: Equatable {
let top: CGFloat?
let leading: CGFloat?
let axisCoordinate: CGFloat?
}
enum Transaction: Equatable, Sendable {
case immediate
case railExpand
case railCollapse
case dockAttach
case dockDetach
case detailPresent
case detailFollow
case detailDismiss
}
static let railExpandDuration: TimeInterval = 0.52
static let railCollapseDuration: TimeInterval = 0.44
static let railHoverOpenDelay: TimeInterval = 0.08
static let railHoverCloseDelay: TimeInterval = 0.18
static let dockAttachDuration: TimeInterval = 0.28
static let dockDetachDuration: TimeInterval = 0.24
static let detailPresentDuration: TimeInterval = 0.20
static let detailFollowDuration: TimeInterval = 0.22
static let detailDismissDuration: TimeInterval = 0.14
static let detailAppearOffset: CGFloat = 10
static let heightEpsilon: CGFloat = 0.5
static func duration(
for transaction: Transaction,
reduceMotion: Bool = false
) -> TimeInterval {
if reduceMotion { return 0 }
switch transaction {
case .immediate: return 0
case .railExpand: return railExpandDuration
case .railCollapse: return railCollapseDuration
case .dockAttach: return dockAttachDuration
case .dockDetach: return dockDetachDuration
case .detailPresent: return detailPresentDuration
case .detailFollow: return detailFollowDuration
case .detailDismiss: return detailDismissDuration
}
}
static func shouldAnimate(
_ transaction: Transaction,
reduceMotion: Bool = false
) -> Bool {
duration(for: transaction, reduceMotion: reduceMotion) > 0
}
/// Cubic-bezier control points. Ease-in-out for the rail; ease-out present
/// and ease-in dismiss for the bubble.
static func timingControlPoints(
for transaction: Transaction
) -> (Float, Float, Float, Float) {
switch transaction {
case .immediate:
return (0, 0, 1, 1)
case .railExpand:
return (0.22, 1, 0.36, 1)
case .railCollapse:
return (0.32, 0, 0.2, 1)
case .dockAttach:
return (0.16, 1, 0.3, 1)
case .dockDetach:
return (0.2, 0.8, 0.2, 1)
case .detailFollow:
return (0.25, 0.1, 0.25, 1)
case .detailPresent:
return (0.16, 1, 0.3, 1)
case .detailDismiss:
return (0.4, 0, 1, 1)
}
}
static func railTransaction(
fromFrame: CGRect,
toFrame: CGRect,
attachmentFrom: CGFloat,
attachmentTo: CGFloat
) -> Transaction {
if abs(attachmentTo - attachmentFrom) >= 0.01 {
return attachmentTo > attachmentFrom ? .dockAttach : .dockDetach
}
let widthChanged = abs(toFrame.width - fromFrame.width) >= heightEpsilon
let heightChanged = abs(toFrame.height - fromFrame.height) >= heightEpsilon
if widthChanged || heightChanged {
let fromArea = fromFrame.width * fromFrame.height
let toArea = toFrame.width * toFrame.height
return toArea > fromArea ? .railExpand : .railCollapse
}
return .immediate
}
/// Bubble starts slightly toward the rail so it emerges from the dock.
static func detailPresentationStartFrame(
from target: CGRect,
side: CapacityDockEdge
) -> CGRect {
target.offsetBy(
dx: detailOffset(side: side, amount: detailAppearOffset).width,
dy: detailOffset(side: side, amount: detailAppearOffset).height
)
}
static func detailDismissalFrame(
from current: CGRect,
side: CapacityDockEdge
) -> CGRect {
let offset = detailOffset(side: side, amount: detailAppearOffset * 0.6)
return current.offsetBy(dx: offset.width, dy: offset.height)
}
static func interpolate(from: CGRect, to: CGRect, progress: CGFloat) -> CGRect {
let t = min(max(progress, 0), 1)
return CGRect(
x: from.minX + (to.minX - from.minX) * t,
y: from.minY + (to.minY - from.minY) * t,
width: from.width + (to.width - from.width) * t,
height: from.height + (to.height - from.height) * t
)
}
static func interpolateAlpha(from: CGFloat, to: CGFloat, progress: CGFloat) -> CGFloat {
let t = min(max(progress, 0), 1)
return from + (to - from) * t
}
/// Linear interpolation with the top edge locked. Height grows or shrinks
/// downward so the rail never jumps upward.
static func interpolateTopAnchored(from: CGRect, to: CGRect, progress: CGFloat) -> CGRect {
let t = min(max(progress, 0), 1)
let width = from.width + (to.width - from.width) * t
let height = from.height + (to.height - from.height) * t
let x = from.minX + (to.minX - from.minX) * t
let top = from.maxY + (to.maxY - from.maxY) * t
return CGRect(x: x, y: top - height, width: width, height: height)
}
/// A rail always grows away from a stable visual anchor. Floating rails
/// keep their upper-leading corner fixed; attached rails additionally lock
/// the physical screen edge they are touching.
static func interpolateRail(
from: CGRect,
to: CGRect,
dockedEdge: CapacityDockEdge?,
expansionAnchor: CapacityDockExpansionAnchor = .start,
progress: CGFloat
) -> CGRect {
if let dockedEdge {
return interpolateAttachedEdge(
from: from,
to: to,
edge: dockedEdge,
expansionAnchor: expansionAnchor,
progress: progress
)
}
return expansionAnchor == .start
? interpolateTopAnchored(from: from, to: to, progress: progress)
: interpolateBottomAnchored(from: from, to: to, progress: progress)
}
static func interpolateBottomAnchored(from: CGRect, to: CGRect, progress: CGFloat) -> CGRect {
let t = min(max(progress, 0), 1)
let width = from.width + (to.width - from.width) * t
let height = from.height + (to.height - from.height) * t
let x = from.minX + (to.minX - from.minX) * t
let bottom = from.minY + (to.minY - from.minY) * t
return CGRect(x: x, y: bottom, width: width, height: height)
}
static func floatingRailAnchors(
frame: CGRect,
preservedTop: CGFloat? = nil,
isVertical: Bool,
expansionAnchor: CapacityDockExpansionAnchor
) -> FloatingRailAnchors {
if isVertical {
return expansionAnchor == .start
? FloatingRailAnchors(
top: preservedTop ?? frame.maxY,
leading: frame.minX,
axisCoordinate: nil
)
: FloatingRailAnchors(
top: nil,
leading: frame.minX,
axisCoordinate: frame.minY
)
}
return expansionAnchor == .start
? FloatingRailAnchors(
top: preservedTop ?? frame.maxY,
leading: frame.minX,
axisCoordinate: nil
)
: FloatingRailAnchors(
top: preservedTop ?? frame.maxY,
leading: nil,
axisCoordinate: frame.maxX
)
}
static func pixelAlignedRailFrame(
_ frame: CGRect,
backingScale: CGFloat,
dockedEdge: CapacityDockEdge?,
expansionAnchor: CapacityDockExpansionAnchor = .start,
isVertical: Bool = true
) -> CGRect {
let scale = max(backingScale, 1)
func aligned(_ value: CGFloat) -> CGFloat {
(value * scale).rounded() / scale
}
let width = aligned(frame.width)
let height = aligned(frame.height)
let leading: CGFloat
let bottom: CGFloat
switch dockedEdge {
case .right:
leading = aligned(frame.maxX) - width
bottom = expansionAnchor == .start
? aligned(frame.maxY) - height
: aligned(frame.minY)
case .top:
leading = expansionAnchor == .start
? aligned(frame.minX)
: aligned(frame.maxX) - width
bottom = aligned(frame.maxY) - height
case .bottom:
leading = expansionAnchor == .start
? aligned(frame.minX)
: aligned(frame.maxX) - width
bottom = aligned(frame.minY)
case .left:
leading = aligned(frame.minX)
bottom = expansionAnchor == .start
? aligned(frame.maxY) - height
: aligned(frame.minY)
case nil:
if isVertical {
leading = aligned(frame.minX)
bottom = expansionAnchor == .start
? aligned(frame.maxY) - height
: aligned(frame.minY)
} else {
leading = expansionAnchor == .start
? aligned(frame.minX)
: aligned(frame.maxX) - width
bottom = aligned(frame.minY)
}
}
return CGRect(x: leading, y: bottom, width: width, height: height)
}
/// Couples the observable SwiftUI reveal state to the frame AppKit can
/// actually display. Near the ends of an ease curve several timer samples
/// can land on the same backing-pixel frame; publishing their raw timer
/// progress would repeatedly re-layout the rail inside an unchanged panel.
static func alignedRailSample(
_ frame: CGRect,
fromFrame: CGRect,
toFrame: CGRect,
fromPresentationProgress: CGFloat,
toPresentationProgress: CGFloat,
backingScale: CGFloat,
dockedEdge: CapacityDockEdge?,
expansionAnchor: CapacityDockExpansionAnchor = .start,
isVertical: Bool
) -> RailFrameSample {
let alignedFrame = pixelAlignedRailFrame(
frame,
backingScale: backingScale,
dockedEdge: dockedEdge,
expansionAnchor: expansionAnchor,
isVertical: isVertical
)
let alignedFrom = pixelAlignedRailFrame(
fromFrame,
backingScale: backingScale,
dockedEdge: dockedEdge,
expansionAnchor: expansionAnchor,
isVertical: isVertical
)
let alignedTo = pixelAlignedRailFrame(
toFrame,
backingScale: backingScale,
dockedEdge: dockedEdge,
expansionAnchor: expansionAnchor,
isVertical: isVertical
)
let currentLength = isVertical ? alignedFrame.height : alignedFrame.width
let fromLength = isVertical ? alignedFrom.height : alignedFrom.width
let toLength = isVertical ? alignedTo.height : alignedTo.width
let lengthDelta = toLength - fromLength
let geometryProgress: CGFloat
if abs(lengthDelta) < 0.000_001 {
geometryProgress = 1
} else {
geometryProgress = min(max((currentLength - fromLength) / lengthDelta, 0), 1)
}
return RailFrameSample(
frame: alignedFrame,
presentationProgress: interpolateAlpha(
from: fromPresentationProgress,
to: toPresentationProgress,
progress: geometryProgress
)
)
}
static func interpolateAttachedEdge(
from: CGRect,
to: CGRect,
edge: CapacityDockEdge,
expansionAnchor: CapacityDockExpansionAnchor = .start,
progress: CGFloat
) -> CGRect {
let t = min(max(progress, 0), 1)
let width = from.width + (to.width - from.width) * t
let height = from.height + (to.height - from.height) * t
let x: CGFloat
let y: CGFloat
switch edge {
case .left:
x = from.minX + (to.minX - from.minX) * t
y = expansionAnchor == .start
? (from.maxY + (to.maxY - from.maxY) * t) - height
: from.minY + (to.minY - from.minY) * t
case .right:
let trailing = from.maxX + (to.maxX - from.maxX) * t
x = trailing - width
y = expansionAnchor == .start
? (from.maxY + (to.maxY - from.maxY) * t) - height
: from.minY + (to.minY - from.minY) * t
case .top:
x = expansionAnchor == .start
? from.minX + (to.minX - from.minX) * t
: (from.maxX + (to.maxX - from.maxX) * t) - width
let top = from.maxY + (to.maxY - from.maxY) * t
y = top - height
case .bottom:
x = expansionAnchor == .start
? from.minX + (to.minX - from.minX) * t
: (from.maxX + (to.maxX - from.maxX) * t) - width
y = from.minY + (to.minY - from.minY) * t
}
return CGRect(x: x, y: y, width: width, height: height)
}
static func easedProgress(for transaction: Transaction, linear: CGFloat) -> CGFloat {
let t = min(max(linear, 0), 1)
if transaction == .immediate { return t }
let points = timingControlPoints(for: transaction)
return cubicBezier(
t,
p1x: CGFloat(points.0),
p1y: CGFloat(points.1),
p2x: CGFloat(points.2),
p2y: CGFloat(points.3)
)
}
static func cubicBezier(
_ t: CGFloat,
p1x: CGFloat,
p1y: CGFloat,
p2x: CGFloat,
p2y: CGFloat
) -> CGFloat {
if t <= 0 { return 0 }
if t >= 1 { return 1 }
var guess = t
for _ in 0..<6 {
let current = sampleCurveX(guess, p1x: p1x, p2x: p2x)
let delta = current - t
if abs(delta) < 0.000_001 { break }
let derivative = sampleCurveDerivativeX(guess, p1x: p1x, p2x: p2x)
if abs(derivative) < 0.000_001 { break }
guess -= delta / derivative
}
return sampleCurveY(min(max(guess, 0), 1), p1y: p1y, p2y: p2y)
}
static func alpha(for transaction: Transaction, progress: CGFloat, fadingOut: Bool) -> CGFloat {
let t = min(max(progress, 0), 1)
return fadingOut ? 1 - t : t
}
private static func detailOffset(side: CapacityDockEdge, amount: CGFloat) -> CGSize {
switch side {
case .left: CGSize(width: amount, height: 0)
case .right: CGSize(width: -amount, height: 0)
case .top: CGSize(width: 0, height: -amount)
case .bottom: CGSize(width: 0, height: amount)
}
}
private static func sampleCurveX(_ t: CGFloat, p1x: CGFloat, p2x: CGFloat) -> CGFloat {
let a = 1 - 3 * p2x + 3 * p1x
let b = 3 * p2x - 6 * p1x
let c = 3 * p1x
return ((a * t + b) * t + c) * t
}
private static func sampleCurveY(_ t: CGFloat, p1y: CGFloat, p2y: CGFloat) -> CGFloat {
let a = 1 - 3 * p2y + 3 * p1y
let b = 3 * p2y - 6 * p1y
let c = 3 * p1y
return ((a * t + b) * t + c) * t
}
private static func sampleCurveDerivativeX(_ t: CGFloat, p1x: CGFloat, p2x: CGFloat) -> CGFloat {
3 * (1 - 3 * p2x + 3 * p1x) * t * t + 2 * (3 * p2x - 6 * p1x) * t + 3 * p1x
}
}