feat(mac): animate menubar icon
This commit is contained in:
@@ -353,7 +353,7 @@ struct ClawdisApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
MenuBarExtra { menuContent } label: { LobsterStatusLabel(isPaused: state.isPaused) }
|
MenuBarExtra { menuContent } label: { CritterStatusLabel(isPaused: state.isPaused) }
|
||||||
.menuBarExtraStyle(.menu)
|
.menuBarExtraStyle(.menu)
|
||||||
|
|
||||||
Settings {
|
Settings {
|
||||||
@@ -377,54 +377,139 @@ struct ClawdisApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LobsterStatusLabel: View {
|
private struct CritterStatusLabel: View {
|
||||||
var isPaused: Bool
|
var isPaused: Bool
|
||||||
|
|
||||||
|
@State private var blinkAmount: CGFloat = 0
|
||||||
|
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5 ... 8.5))
|
||||||
|
@State private var wiggleAngle: Double = 0
|
||||||
|
@State private var wiggleOffset: CGFloat = 0
|
||||||
|
@State private var nextWiggle = Date().addingTimeInterval(Double.random(in: 6.5 ... 14))
|
||||||
|
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LobsterGlyph()
|
CritterGlyph(blinkAmount: blinkAmount)
|
||||||
.frame(width: 16, height: 16)
|
.frame(width: 16, height: 16)
|
||||||
|
.rotationEffect(.degrees(wiggleAngle), anchor: .center)
|
||||||
|
.offset(x: wiggleOffset)
|
||||||
.foregroundStyle(isPaused ? .secondary : .primary)
|
.foregroundStyle(isPaused ? .secondary : .primary)
|
||||||
|
.onReceive(ticker) { now in
|
||||||
|
guard !isPaused else {
|
||||||
|
resetMotion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= nextBlink {
|
||||||
|
blink()
|
||||||
|
nextBlink = now.addingTimeInterval(Double.random(in: 3.5 ... 8.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
if now >= nextWiggle {
|
||||||
|
wiggle()
|
||||||
|
nextWiggle = now.addingTimeInterval(Double.random(in: 6.5 ... 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: isPaused) { _, paused in
|
||||||
|
if paused {
|
||||||
|
resetMotion()
|
||||||
|
} else {
|
||||||
|
nextBlink = Date().addingTimeInterval(Double.random(in: 1.5 ... 3.5))
|
||||||
|
nextWiggle = Date().addingTimeInterval(Double.random(in: 4.5 ... 9.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetMotion() {
|
||||||
|
blinkAmount = 0
|
||||||
|
wiggleAngle = 0
|
||||||
|
wiggleOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func blink() {
|
||||||
|
withAnimation(.easeInOut(duration: 0.08)) { blinkAmount = 1 }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
|
||||||
|
withAnimation(.easeOut(duration: 0.12)) { blinkAmount = 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wiggle() {
|
||||||
|
let targetAngle = Double.random(in: -4.5 ... 4.5)
|
||||||
|
let targetOffset = CGFloat.random(in: -0.5 ... 0.5)
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||||
|
wiggleAngle = targetAngle
|
||||||
|
wiggleOffset = targetOffset
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
|
||||||
|
withAnimation(.interpolatingSpring(stiffness: 220, damping: 18)) {
|
||||||
|
wiggleAngle = 0
|
||||||
|
wiggleOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LobsterGlyph: View {
|
struct CritterGlyph: View {
|
||||||
|
var blinkAmount: CGFloat
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let w = geo.size.width
|
let w = geo.size.width
|
||||||
let h = geo.size.height
|
let h = geo.size.height
|
||||||
let midX = w / 2
|
|
||||||
let midY = h / 2
|
|
||||||
|
|
||||||
ZStack {
|
let bodyWidth = w * 0.78
|
||||||
// Body
|
let bodyHeight = h * 0.58
|
||||||
Capsule()
|
let bodyRect = CGRect(x: (w - bodyWidth) / 2, y: h * 0.18, width: bodyWidth, height: bodyHeight)
|
||||||
.frame(width: w * 0.4, height: h * 0.55)
|
|
||||||
.offset(y: h * 0.05)
|
let armWidth = w * 0.2
|
||||||
// Claws
|
let armHeight = bodyHeight * 0.6
|
||||||
Capsule(style: .continuous)
|
let armCorner = armWidth * 0.24
|
||||||
.frame(width: w * 0.22, height: h * 0.28)
|
|
||||||
.rotationEffect(.degrees(-25))
|
let legWidth = w * 0.11
|
||||||
.offset(x: -w * 0.32, y: -h * 0.05)
|
let legHeight = h * 0.26
|
||||||
Capsule(style: .continuous)
|
let legSpacing = w * 0.08
|
||||||
.frame(width: w * 0.22, height: h * 0.28)
|
let legStartX = bodyRect.minX + w * 0.05
|
||||||
.rotationEffect(.degrees(25))
|
let legY = bodyRect.maxY - legHeight * 0.2
|
||||||
.offset(x: w * 0.32, y: -h * 0.05)
|
|
||||||
// Antennae
|
let eyeOpen = max(0.02, 1 - blinkAmount)
|
||||||
Path { p in
|
let eyeWidth = bodyWidth * 0.18
|
||||||
p.move(to: CGPoint(x: midX - w * 0.08, y: midY - h * 0.35))
|
let eyeHeight = bodyHeight * 0.22 * eyeOpen
|
||||||
p.addQuadCurve(to: CGPoint(x: midX - w * 0.18, y: midY - h * 0.6), control: CGPoint(x: midX - w * 0.2, y: midY - h * 0.45))
|
let eyeY = bodyRect.midY - bodyHeight * 0.08
|
||||||
p.move(to: CGPoint(x: midX + w * 0.08, y: midY - h * 0.35))
|
let eyeOffset = bodyWidth * 0.2
|
||||||
p.addQuadCurve(to: CGPoint(x: midX + w * 0.18, y: midY - h * 0.6), control: CGPoint(x: midX + w * 0.2, y: midY - h * 0.45))
|
|
||||||
|
Path { path in
|
||||||
|
path.addRoundedRect(in: bodyRect, cornerSize: CGSize(width: w * 0.08, height: w * 0.08))
|
||||||
|
|
||||||
|
path.addRoundedRect(
|
||||||
|
in: CGRect(x: bodyRect.minX - armWidth * 0.65, y: bodyRect.midY - armHeight / 2, width: armWidth, height: armHeight),
|
||||||
|
cornerSize: CGSize(width: armCorner, height: armCorner)
|
||||||
|
)
|
||||||
|
|
||||||
|
path.addRoundedRect(
|
||||||
|
in: CGRect(x: bodyRect.maxX - armWidth * 0.35, y: bodyRect.midY - armHeight / 2, width: armWidth, height: armHeight),
|
||||||
|
cornerSize: CGSize(width: armCorner, height: armCorner)
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in 0 ..< 4 {
|
||||||
|
let x = legStartX + CGFloat(i) * (legWidth + legSpacing)
|
||||||
|
path.addRoundedRect(
|
||||||
|
in: CGRect(x: x, y: legY, width: legWidth, height: legHeight),
|
||||||
|
cornerSize: CGSize(width: legWidth * 0.35, height: legWidth * 0.35)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.stroke(lineWidth: 1.2)
|
|
||||||
// Tail segments
|
let leftEyeX = bodyRect.midX - eyeOffset
|
||||||
VStack(spacing: h * 0.04) {
|
path.move(to: CGPoint(x: leftEyeX - eyeWidth / 2, y: eyeY - eyeHeight))
|
||||||
Capsule().frame(width: w * 0.26, height: h * 0.12)
|
path.addLine(to: CGPoint(x: leftEyeX + eyeWidth / 2, y: eyeY))
|
||||||
Capsule().frame(width: w * 0.22, height: h * 0.11)
|
path.addLine(to: CGPoint(x: leftEyeX - eyeWidth / 2, y: eyeY + eyeHeight))
|
||||||
Capsule().frame(width: w * 0.18, height: h * 0.1)
|
path.closeSubpath()
|
||||||
}
|
|
||||||
.offset(y: h * 0.18)
|
let rightEyeX = bodyRect.midX + eyeOffset
|
||||||
|
path.move(to: CGPoint(x: rightEyeX + eyeWidth / 2, y: eyeY - eyeHeight))
|
||||||
|
path.addLine(to: CGPoint(x: rightEyeX - eyeWidth / 2, y: eyeY))
|
||||||
|
path.addLine(to: CGPoint(x: rightEyeX + eyeWidth / 2, y: eyeY + eyeHeight))
|
||||||
|
path.closeSubpath()
|
||||||
}
|
}
|
||||||
|
.fill(style: FillStyle(eoFill: true, antialiased: true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user