Files
brov-macbook/NotchBuddy/Sources/App/UploadCanvasView.swift
T

594 lines
26 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
// Full 640×176 canvas that drives the upload sequence animation.
// Replaces the header + content area when the upload engine is active.
struct UploadCanvasView: View {
@ObservedObject var state: AppState
@State private var fileIcon: NSImage? = nil
private var engine: UploadSequenceEngine { .shared }
var body: some View {
TimelineView(.animation) { tl in
let f = engine.frame(at: tl.date)
let wallTime = tl.date.timeIntervalSinceReferenceDate
ZStack(alignment: .topLeading) {
Canvas { ctx, _ in
drawScene(ctx: ctx, f: f, wallTime: wallTime)
}
.frame(width: 640, height: 176)
// Interactive choose buttons (invisible hit areas at reference positions)
if f.chooseAlpha > 0 {
chooseOverlay(f: f)
.frame(width: 640, height: 176)
}
}
}
.onChange(of: state.droppedFile?.url) { _, url in
if let url { loadIcon(url: url) }
}
.onAppear {
if let url = state.droppedFile?.url { loadIcon(url: url) }
}
.frame(width: 640, height: 176)
}
// MARK: - File icon
private func loadIcon(url: URL) {
let img = NSWorkspace.shared.icon(forFile: url.path)
img.size = NSSize(width: 64, height: 64)
fileIcon = img
}
// MARK: - Choose overlay (transparent SwiftUI buttons over canvas)
@ViewBuilder
private func chooseOverlay(f: USFrame) -> some View {
// Reference positions: button1 x=100 w=176 y=113 h=26, button2 x=284 w=128
ZStack(alignment: .topLeading) {
// Primary: "Ask a question about it"
Button {
withAnimation(.easeInOut(duration: 0.22)) { state.view = .prompt }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
UploadSequenceEngine.shared.deactivate()
}
} label: {
Color.clear
.frame(width: 168, height: 26)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(width: 168, height: 26)
.position(x: 114 + 84, y: 113 + 13) // center = (198, 126)
// Secondary: "Send by email"
Button {
withAnimation(.easeInOut(duration: 0.22)) { state.view = .mail }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
UploadSequenceEngine.shared.deactivate()
}
} label: {
Color.clear
.frame(width: 120, height: 26)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(width: 120, height: 26)
.position(x: 290 + 60, y: 113 + 13) // center = (350, 126)
}
.opacity(f.chooseAlpha)
.allowsHitTesting(f.chooseAlpha > 0.5)
}
// MARK: - Main draw
private func drawScene(ctx: GraphicsContext, f: USFrame, wallTime: Double = 0) {
var c = ctx
// ── Island background ──────────────────────────────────────
c.fill(Path(CGRect(x:0, y:0, width:640, height:176)), with: .color(Color.black))
// ── Card ──────────────────────────────────────────────────
let cardPath = roundedRect(CGRect(x: USC.CARD_X, y: USC.CARD_Y, width: USC.CARD_W, height: USC.CARD_H), r: USC.CARD_R)
var cardCtx = c
cardCtx.clip(to: cardPath)
cardCtx.fill(Path(CGRect(x: USC.CARD_X, y: USC.CARD_Y, width: USC.CARD_W, height: USC.CARD_H)),
with: .color(Color(red:0.051, green:0.055, blue:0.063)))
// Green glow from card bottom — grows slowly with upload progress
if f.greenWash > 0 {
// Center at card bottom edge; gradient fans upward through the card
let gx = USC.CARD_X + USC.CARD_W/2
let gy = USC.CARD_Y + USC.CARD_H // bottom of card
let gGrad = Gradient(stops: [
.init(color: Color(red:0.157,green:0.831,blue:0.510).opacity(f.greenWash * 0.90), location:0),
.init(color: Color(red:0.157,green:0.831,blue:0.510).opacity(f.greenWash * 0.30), location:0.55),
.init(color: Color(red:0.157,green:0.831,blue:0.510).opacity(0), location:1)
])
cardCtx.fill(Path(CGRect(x:USC.CARD_X,y:USC.CARD_Y,width:USC.CARD_W,height:USC.CARD_H)),
with: .radialGradient(gGrad, center:CGPoint(x:gx,y:gy),
startRadius:0, endRadius:USC.CARD_H*1.5))
}
// ── Dashed border (animates left→right while on drop zone) ────
if f.zoneAlpha > 0 {
var borderCtx = c
borderCtx.opacity = f.zoneAlpha
let borderColor = f.zoneOver
? Color(red:0.204,green:0.831,blue:0.600).opacity(0.55)
: Color.white.opacity(0.14)
let inset = CGRect(x: USC.CARD_X+0.75, y: USC.CARD_Y+0.75,
width: USC.CARD_W-1.5, height: USC.CARD_H-1.5)
// dashPhase increases → pattern marches left-to-right at ~20 pt/s
let dashPhase = CGFloat(wallTime * 20)
borderCtx.stroke(roundedRect(inset, r: USC.CARD_R-0.5),
with: .color(borderColor),
style: StrokeStyle(lineWidth:1.5, dash:[6,5], dashPhase: dashPhase))
}
// ── Drop zone text ─────────────────────────────────────────
if f.zoneAlpha > 0 && f.textAlpha > 0 {
drawDropText(ctx: &c, f: f)
}
// ── Progress bar ──────────────────────────────────────────
if f.barAlpha > 0 || f.barReveal > 0 {
drawProgressBar(ctx: &c, f: f)
}
// ── Choose view text ─────────────────────────────────────
if f.chooseAlpha > 0 {
drawChooseView(ctx: &c, f: f)
}
// ── Mochi ─────────────────────────────────────────────────
drawMochi(ctx: &c, f: f)
// ── File / suction ────────────────────────────────────────
if f.fileVisible { drawFile(ctx: &c, f: f) }
}
// MARK: - Drop zone text + chips
private func drawDropText(ctx: inout GraphicsContext, f: USFrame) {
var tCtx = ctx
tCtx.opacity = f.textAlpha
let label = Text("Перетащи файлы сюда")
.font(.system(size:13, weight:.medium))
.foregroundColor(Color(hex:"#D5D7DB"))
tCtx.draw(label, at: CGPoint(x: USC.TEXT_X, y: USC.TEXT_Y - 4), anchor: .leading)
let chips = ["PDF","Фото","Код","Документы"]
var cx = USC.TEXT_X
for chip in chips {
let chipText = Text(chip).font(.system(size:11, weight:.medium)).foregroundColor(Color(hex:"#B9BDC4"))
// measure approximate width
let estW = Double(chip.count) * 6.5 + 16
tCtx.fill(roundedRect(CGRect(x:cx, y:USC.TEXT_Y+9, width:estW, height:18), r:9),
with: .color(Color.white.opacity(0.07)))
tCtx.draw(chipText, at: CGPoint(x: cx + 8, y: USC.TEXT_Y + 18), anchor: .leading)
cx += estW + 6
}
}
// MARK: - Progress bar
private func drawProgressBar(ctx: inout GraphicsContext, f: USFrame) {
var pCtx = ctx
pCtx.opacity = max(f.barAlpha, 0.001)
let x0 = USC.BAR_X0, x1 = USC.BAR_X1, by = USC.BAR_Y
let barLen = (x1-x0) * f.barReveal
// Filename label
let name = state.droppedFile?.name ?? "файл"
let label = Text("Загружаю \(name)")
.font(.system(size:12.5, weight:.medium))
.foregroundColor(Color(hex:"#A9ADB5"))
pCtx.draw(label, at: CGPoint(x: x0, y: by-30), anchor: .leading)
// Checkmark or percentage
if f.check > 0 {
var ckCtx = pCtx
ckCtx.concatenate(CGAffineTransform(translationX: CGFloat(x1-8), y: CGFloat(by-30)))
ckCtx.concatenate(CGAffineTransform(scaleX: CGFloat(f.check), y: CGFloat(f.check)))
var circle = Path(); circle.addEllipse(in: CGRect(x:-8,y:-8,width:16,height:16))
ckCtx.fill(circle, with: .color(Color(hex:"#34D399")))
var ck = Path()
ck.move(to: CGPoint(x:-3.6,y:0.2)); ck.addLine(to: CGPoint(x:-1,y:2.8)); ck.addLine(to: CGPoint(x:3.8,y:-2.6))
ckCtx.stroke(ck, with: .color(Color(red:0.027,green:0.075,blue:0.055)),
style: StrokeStyle(lineWidth:2, lineCap:.round, lineJoin:.round))
} else {
let pct = Text("\(Int(f.progress*100)) %")
.font(.system(size:12.5, weight:.medium).monospacedDigit())
.foregroundColor(Color(hex:"#A9ADB5"))
pCtx.draw(pct, at: CGPoint(x: x1, y: by-30), anchor: .trailing)
}
// Bar track
if barLen > 0 {
pCtx.fill(roundedRect(CGRect(x:x0, y:by-3, width:barLen, height:6), r:3),
with: .color(Color.white.opacity(0.08)))
}
// Bar fill
let fx = usLerp(USC.BAR_X0, USC.BAR_X1, f.progress)
if fx > x0 + 1 {
// Flash color at completion
let flashGreen = Color(
red: usLerp(0.204, 0.431, f.flash),
green: usLerp(0.827, 0.906, f.flash),
blue: usLerp(0.600, 0.718, f.flash))
let fillGrad = Gradient(stops: [
.init(color: Color(hex:"#1FA87A"), location:0),
.init(color: flashGreen, location:1)
])
pCtx.fill(roundedRect(CGRect(x:x0, y:by-3, width:fx-x0, height:6), r:3),
with: .linearGradient(fillGrad,
startPoint: CGPoint(x:x0, y:0),
endPoint: CGPoint(x:fx, y:0)))
}
// Glow trail
if f.progress > 0.01 && f.progress < 1 {
let v = (usProgressAt(f.t+0.01, progStart:USC.T_PROG_START, progEnd:f.progEnd)
- usProgressAt(f.t, progStart:USC.T_PROG_START, progEnd:f.progEnd)) / 0.01
let tl = max(8, min(34, 8 + v*40))
let tGrad = Gradient(stops:[
.init(color: Color(red:0.204,green:0.831,blue:0.600,opacity:0), location:0),
.init(color: Color(red:0.431,green:0.906,blue:0.718,opacity:0.6), location:1)
])
var glowCtx = pCtx
glowCtx.addFilter(.blur(radius:3))
glowCtx.fill(roundedRect(CGRect(x:fx-tl, y:by-4, width:tl, height:8), r:4),
with: .linearGradient(tGrad,
startPoint:CGPoint(x:fx-tl,y:0),
endPoint: CGPoint(x:fx,y:0)))
}
}
// MARK: - Choose view text + buttons (canvas layer)
private func drawChooseView(ctx: inout GraphicsContext, f: USFrame) {
var cCtx = ctx
cCtx.opacity = f.chooseAlpha
// Slide up: translate down by (1-alpha)*4
cCtx.concatenate(CGAffineTransform(translationX: 0, y: CGFloat((1-f.chooseAlpha)*4)))
let name = state.droppedFile?.name ?? "Файл"
let titleText = Text("\(name) готов.")
.font(.system(size:14, weight:.semibold))
.foregroundColor(Color(hex:"#F5F6F8"))
cCtx.draw(titleText, at: CGPoint(x:114, y:80), anchor: .leading)
let subText = Text("Что с ним сделать?")
.font(.system(size:12.5))
.foregroundColor(Color(hex:"#9398A1"))
cCtx.draw(subText, at: CGPoint(x:114, y:100), anchor: .leading)
// Primary button (white fill)
cCtx.fill(roundedRect(CGRect(x:114,y:113,width:168,height:26), r:13),
with: .color(Color(hex:"#F5F6F8")))
let btn1 = Text("Задать вопрос")
.font(.system(size:12.5, weight:.medium))
.foregroundColor(Color(red:0.043,green:0.047,blue:0.055))
cCtx.draw(btn1, at: CGPoint(x:198, y:126), anchor: .center)
// Secondary button (dim fill)
cCtx.fill(roundedRect(CGRect(x:290,y:113,width:120,height:26), r:13),
with: .color(Color.white.opacity(0.09)))
let btn2 = Text("Отправить почтой")
.font(.system(size:12.5, weight:.medium))
.foregroundColor(Color(hex:"#F1F2F4"))
cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center)
}
// MARK: - Mochi (superellipse body + eyes + mouth)
private func drawMochi(ctx: inout GraphicsContext, f: USFrame) {
let R = f.d / 2 / 1.04
let m = f.morph
let mc = max(0, min(m, 1.0))
var c = ctx
c.concatenate(CGAffineTransform(translationX: CGFloat(f.x), y: CGFloat(f.y + f.hop)))
c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt)))
c.concatenate(CGAffineTransform(scaleX: CGFloat(f.sx), y: CGFloat(f.sy)))
// BroV: the Memoji while it's a character; the mailbox fades in as it morphs.
if mc < 0.5, let img = MemojiAssets.image(f.eye == .content ? .happy : (f.eye == .cup ? .surprised : .neutral)) {
var mc2 = c
mc2.opacity = 1 - mc * 2
let side = CGFloat(R) * 2.9
mc2.draw(Image(decorative: img, scale: 1),
in: CGRect(x: -side / 2, y: -side * 0.52, width: side, height: side))
}
guard mc > 0.02 else { return }
c.opacity = min(1, mc * 2)
let (bp, rx, ry) = usBodyPath(m: m, R: R)
// ── Body gradient ──────────────────────────────────────────
let bodyGrad = Gradient(stops:[
.init(color: Color(hex:"#EDEDEF"), location:0),
.init(color: Color(hex:"#C4C5CA"), location:1)
])
c.fill(bp, with: .linearGradient(bodyGrad,
startPoint: CGPoint(x: rx*0.7, y: -ry*0.9),
endPoint: CGPoint(x: -rx*0.8, y: ry*0.9)))
// ── Edge shadow ────────────────────────────────────────────
let shadowGrad = Gradient(stops:[
.init(color: .clear, location:0),
.init(color: .clear, location:0.62),
.init(color: Color.black.opacity(0.12), location:1)
])
c.fill(bp, with: .radialGradient(shadowGrad,
center: CGPoint(x:0,y:0), startRadius: CGFloat(R*0.2), endRadius: CGFloat(R*1.3)))
// ── Top rim (box mode) ─────────────────────────────────────
if mc > 0.3 {
let rimAlpha = max(0, min(1, (mc-0.3)/0.7))
var rimCtx = c
rimCtx.clip(to: bp)
var rim = Path()
rim.move(to: CGPoint(x: -rx*0.72, y: -ry+0.9))
rim.addLine(to: CGPoint(x: rx*0.72, y: -ry+0.9))
rimCtx.stroke(rim, with: .color(Color.white.opacity(0.6*rimAlpha)),
style: StrokeStyle(lineWidth:1.2, lineCap:.round))
}
// ── Mouth hole ─────────────────────────────────────────────
let mh = f.mouth * R * mc
if mh > 0.3 {
let mw = 2*rx - 0.24*R
let mxO = CGFloat(-mw/2)
let myO = CGFloat(-ry + 0.10*R)
let mhr = CGFloat(min(mw/2, mh/2))
var mCtx = c
mCtx.clip(to: bp)
let holeGrad = Gradient(stops:[
.init(color: Color(red:0.012,green:0.012,blue:0.016), location:0),
.init(color: Color(red:0.063,green:0.067,blue:0.078), location:1)
])
let holePath = roundedRect(CGRect(x:mxO, y:myO, width:CGFloat(mw), height:CGFloat(mh)), r:Double(mhr))
mCtx.fill(holePath, with: .linearGradient(holeGrad,
startPoint: CGPoint(x:0, y:myO),
endPoint: CGPoint(x:0, y:myO+CGFloat(mh))))
// Bottom lip
if mh > 4 {
var lip = Path()
lip.move(to: CGPoint(x:mxO+mhr, y:myO+CGFloat(mh)+0.5))
lip.addLine(to: CGPoint(x:mxO+CGFloat(mw)-mhr, y:myO+CGFloat(mh)+0.5))
mCtx.stroke(lip, with: .color(Color.white.opacity(0.55)),
style: StrokeStyle(lineWidth:1, lineCap:.round))
}
}
// ── Eyes ───────────────────────────────────────────────────
let ew = R * 0.25
let eh = R * (0.62 - 0.16*mc)
let ey = R * (0.02 + 0.28*mc)
let sp = R * 0.30
let lx = f.lookX * R * (0.34 - 0.08*mc)
let ly = f.lookY * R * (0.16 - 0.09*mc)
var eCtx = c
eCtx.clip(to: bp)
for sd in [-1.0, 1.0] {
var ec = eCtx
ec.concatenate(CGAffineTransform(translationX: CGFloat(sd*sp+lx), y: CGFloat(ey+ly)))
drawEyeShape(ctx: &ec, shape: f.eye, w: CGFloat(ew), h: CGFloat(eh))
}
}
// MARK: - Eye shapes
private func drawEyeShape(ctx: inout GraphicsContext, shape: USEyeShape, w: CGFloat, h: CGFloat) {
let ink = Color(red:0.055,green:0.059,blue:0.071)
switch shape {
case .pill:
var p = Path()
p.addRoundedRect(in: CGRect(x:-w/2, y:-h/2, width:w, height:h),
cornerSize: CGSize(width:w/2, height:w/2))
ctx.fill(p, with: .color(ink))
case .cup:
// Flat top + semicircle bottom (cup shape)
let hh = h * 0.55
var p = Path()
p.move(to: CGPoint(x:-w/2, y:-hh/2))
p.addLine(to: CGPoint(x: w/2, y:-hh/2))
p.addLine(to: CGPoint(x: w/2, y: hh/2-w/2))
p.addArc(center: CGPoint(x:0, y:hh/2-w/2), radius:w/2, startAngle:.degrees(0), endAngle:.degrees(180), clockwise:false)
p.closeSubpath()
ctx.fill(p, with: .color(ink))
case .content:
// Upward arc (content / happy)
var p = Path()
p.addArc(center: CGPoint(x:0, y:-h*0.12), radius:w*0.85,
startAngle:.degrees(180*0.15), endAngle:.degrees(180*0.85), clockwise:false)
ctx.stroke(p, with: .color(ink),
style: StrokeStyle(lineWidth:w*0.5, lineCap:.round))
}
}
// MARK: - File / suction (drawFile port)
private func drawFile(ctx: inout GraphicsContext, f: USFrame) {
let cx = f.cursorX, cy = f.cursorY + 14
if f.suck <= 0 {
var fc = ctx; fc.opacity = 0.92
drawDoc(ctx: &fc, cx: cx, cy: cy, wsc:1, hsc:1)
return
}
let m = f.mouthRect
let W0 = 34.0, H0 = 42.0
let p = usEIn(f.suck)
let topY = usLerp(cy - H0/2, m.y - 2, usEInOut(f.suck))
let hs = usLerp(1.08, 0.55, usEInOut(f.suck))
let Hh = H0 * hs
let sc = usLerp(1, 0.55, p)
let q = usEOut(f.suck)
let fCx = usLerp(cx, m.x + m.w/2, usEOut(f.suck))
let wob = sin(f.suck * .pi * 2) * 0.1 * (1-p)
let clipY = m.y + m.h * 0.5
// Use withCGContext for the complex strip clipping
ctx.withCGContext { cg in
cg.saveGState()
// Outer clip: above mouth
cg.clip(to: CGRect(x:0, y:0, width:640, height:clipY))
for i in 0..<28 {
let v0 = Double(i) / 28
let wsc = usLerp(1, usLerp(0.92, 0.22*m.w/W0, pow(v0,1.2)), q) * sc
let yy = topY + v0*Hh
let hh = Hh/28 + 0.6
cg.saveGState()
cg.translateBy(x: CGFloat(fCx), y: CGFloat(yy))
cg.rotate(by: CGFloat(wob))
cg.clip(to: CGRect(x: CGFloat(-W0*wsc/2), y:0, width: CGFloat(W0*wsc), height: CGFloat(hh)))
cg.translateBy(x: CGFloat(-fCx), y: CGFloat(-yy))
drawDocCG(cg: cg, cx: fCx, cy: topY+Hh/2, wsc: wsc, hsc: hs, fileIcon: fileIcon)
cg.restoreGState()
}
cg.restoreGState()
}
// Green particles
for i in 0..<4 {
let a = Double(i)/4 * .pi*2 + 0.6
let r0 = 24.0
let k = max(0, min(1, (f.suck - Double(i)*0.08) / 0.7))
guard k > 0 && k < 1 else { continue }
let sx0 = cx + cos(a)*r0, sy0 = cy + sin(a)*r0
let ex = m.x + m.w/2, ey = m.y + m.h*0.3
let kk = pow(k, 0.7)
let px = usLerp(sx0,ex,kk), py = usLerp(sy0,ey,kk) - sin(.pi*k)*6
let rad = 2.2*(1-k*0.5)
var pp = Path(); pp.addEllipse(in: CGRect(x:px-rad, y:py-rad, width:rad*2, height:rad*2))
ctx.fill(pp, with: .color(Color(red:0.204,green:0.831,blue:0.600).opacity(1-k)))
}
}
// MARK: - Doc icon (SwiftUI wrapper)
private func drawDoc(ctx: inout GraphicsContext, cx: Double, cy: Double, wsc: Double, hsc: Double) {
let w = 34*wsc, h = 42*hsc
let x = cx-w/2, y = cy-h/2
let fold = 8*min(wsc,hsc)
var bodyCtx = ctx
bodyCtx.addFilter(.shadow(color:.black.opacity(0.45), radius:8, x:0, y:3))
var body = Path()
body.move(to: CGPoint(x:x+2,y:y))
body.addLine(to: CGPoint(x:x+w-fold,y:y))
body.addLine(to: CGPoint(x:x+w,y:y+fold))
body.addLine(to: CGPoint(x:x+w,y:y+h-2))
body.addQuadCurve(to: CGPoint(x:x+w-2,y:y+h), control:CGPoint(x:x+w,y:y+h))
body.addLine(to: CGPoint(x:x+2,y:y+h))
body.addQuadCurve(to: CGPoint(x:x,y:y+h-2), control:CGPoint(x:x,y:y+h))
body.addLine(to: CGPoint(x:x,y:y+2))
body.addQuadCurve(to: CGPoint(x:x+2,y:y), control:CGPoint(x:x,y:y))
body.closeSubpath()
bodyCtx.fill(body, with: .color(Color(red:0.957,green:0.957,blue:0.965)))
var foldPath = Path()
foldPath.move(to: CGPoint(x:x+w-fold,y:y))
foldPath.addLine(to: CGPoint(x:x+w-fold,y:y+fold))
foldPath.addLine(to: CGPoint(x:x+w,y:y+fold))
ctx.fill(foldPath, with: .color(Color(red:0.835,green:0.839,blue:0.859)))
ctx.fill(roundedRect(CGRect(x:x+w*0.18,y:y+h*0.58,width:w*0.64,height:h*0.16),r:2),
with: .color(Color(red:0.231,green:0.510,blue:0.961)))
}
}
// MARK: - Doc icon in CGContext (for suction strips)
private func drawDocCG(cg: CGContext, cx: Double, cy: Double, wsc: Double, hsc: Double, fileIcon: NSImage?) {
let w = 34*wsc, h = 42*hsc
let x = cx-w/2, y = cy-h/2
let fold = 8.0*min(wsc,hsc)
// Use real file icon if available
if let icon = fileIcon,
let cgImg = icon.cgImage(forProposedRect: nil, context: nil, hints: nil) {
cg.saveGState()
cg.setShadow(offset: CGSize(width:0,height:3), blur:8, color: CGColor(gray:0, alpha:0.45))
let rect = CGRect(x:x, y:y, width:w, height:h)
cg.draw(cgImg, in: rect)
cg.restoreGState()
return
}
// Generic document shape
cg.saveGState()
cg.setShadow(offset: CGSize(width:0,height:3), blur:8, color: CGColor(gray:0, alpha:0.45))
cg.setFillColor(CGColor(red:0.957,green:0.957,blue:0.965,alpha:1))
let bp = CGMutablePath()
bp.move(to: CGPoint(x:x+2,y:y))
bp.addLine(to: CGPoint(x:x+w-fold,y:y))
bp.addLine(to: CGPoint(x:x+w,y:y+fold))
bp.addLine(to: CGPoint(x:x+w,y:y+h-2))
bp.addQuadCurve(to: CGPoint(x:x+w-2,y:y+h), control:CGPoint(x:x+w,y:y+h))
bp.addLine(to: CGPoint(x:x+2,y:y+h))
bp.addQuadCurve(to: CGPoint(x:x,y:y+h-2), control:CGPoint(x:x,y:y+h))
bp.addLine(to: CGPoint(x:x,y:y+2))
bp.addQuadCurve(to: CGPoint(x:x+2,y:y), control:CGPoint(x:x,y:y))
bp.closeSubpath()
cg.addPath(bp); cg.fillPath()
cg.restoreGState()
// Fold
cg.setFillColor(CGColor(red:0.835,green:0.839,blue:0.859,alpha:1))
let fp = CGMutablePath()
fp.move(to: CGPoint(x:x+w-fold,y:y)); fp.addLine(to: CGPoint(x:x+w-fold,y:y+fold)); fp.addLine(to: CGPoint(x:x+w,y:y+fold))
cg.addPath(fp); cg.fillPath()
// Blue accent line
cg.setFillColor(CGColor(red:0.231,green:0.510,blue:0.961,alpha:1))
cg.addRect(CGRect(x:x+w*0.18, y:y+h*0.58, width:w*0.64, height:h*0.16))
cg.fillPath()
}
// MARK: - Superellipse body path (port of reference bodyPath(m, R))
func usBodyPath(m: Double, R: Double) -> (path: Path, rx: Double, ry: Double) {
let mc = max(0, min(m, 1.0))
let n = 2.15 + (5.5-2.15)*mc
let rx = R * (1.04 - 0.04*mc)
let ry = R * (0.97 - 0.03*mc)
var path = Path()
for i in 0...96 {
let a = Double(i)/96 * .pi*2
let ca = cos(a), sa = sin(a)
let px = rx * (ca<0 ? -1 : ca>0 ? 1 : 0) * pow(abs(ca), 2/n)
let py = ry * (sa<0 ? -1 : sa>0 ? 1 : 0) * pow(abs(sa), 2/n)
if i == 0 { path.move(to: CGPoint(x:px,y:py)) }
else { path.addLine(to: CGPoint(x:px,y:py)) }
}
path.closeSubpath()
return (path, rx, ry)
}
// MARK: - Rounded rect helper (mirrors reference rr())
func roundedRect(_ rect: CGRect, r rr: Double) -> Path {
let r = max(0, min(rr, Double(rect.width)/2, Double(rect.height)/2))
var p = Path()
p.addRoundedRect(in: rect, cornerSize: CGSize(width:r, height:r))
return p
}