diff --git a/NotchBuddy/Resources/memoji/blink.png b/NotchBuddy/Resources/memoji/blink.png new file mode 100644 index 0000000..57342cb Binary files /dev/null and b/NotchBuddy/Resources/memoji/blink.png differ diff --git a/NotchBuddy/Resources/memoji/concern.png b/NotchBuddy/Resources/memoji/concern.png new file mode 100644 index 0000000..dcd2e6e Binary files /dev/null and b/NotchBuddy/Resources/memoji/concern.png differ diff --git a/NotchBuddy/Resources/memoji/focused.png b/NotchBuddy/Resources/memoji/focused.png new file mode 100644 index 0000000..61b9469 Binary files /dev/null and b/NotchBuddy/Resources/memoji/focused.png differ diff --git a/NotchBuddy/Resources/memoji/halfblink.png b/NotchBuddy/Resources/memoji/halfblink.png new file mode 100644 index 0000000..e1e1862 Binary files /dev/null and b/NotchBuddy/Resources/memoji/halfblink.png differ diff --git a/NotchBuddy/Resources/memoji/happy.png b/NotchBuddy/Resources/memoji/happy.png new file mode 100644 index 0000000..63796e8 Binary files /dev/null and b/NotchBuddy/Resources/memoji/happy.png differ diff --git a/NotchBuddy/Resources/memoji/neutral.png b/NotchBuddy/Resources/memoji/neutral.png new file mode 100644 index 0000000..57693d6 Binary files /dev/null and b/NotchBuddy/Resources/memoji/neutral.png differ diff --git a/NotchBuddy/Resources/memoji/question.png b/NotchBuddy/Resources/memoji/question.png new file mode 100644 index 0000000..1312577 Binary files /dev/null and b/NotchBuddy/Resources/memoji/question.png differ diff --git a/NotchBuddy/Resources/memoji/speaking.png b/NotchBuddy/Resources/memoji/speaking.png new file mode 100644 index 0000000..6be07dc Binary files /dev/null and b/NotchBuddy/Resources/memoji/speaking.png differ diff --git a/NotchBuddy/Resources/memoji/surprised.png b/NotchBuddy/Resources/memoji/surprised.png new file mode 100644 index 0000000..6794ca6 Binary files /dev/null and b/NotchBuddy/Resources/memoji/surprised.png differ diff --git a/NotchBuddy/Resources/memoji/thinking.png b/NotchBuddy/Resources/memoji/thinking.png new file mode 100644 index 0000000..a449f52 Binary files /dev/null and b/NotchBuddy/Resources/memoji/thinking.png differ diff --git a/NotchBuddy/Resources/memoji/wink.png b/NotchBuddy/Resources/memoji/wink.png new file mode 100644 index 0000000..7f9ccbc Binary files /dev/null and b/NotchBuddy/Resources/memoji/wink.png differ diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift index 91b6a1d..81dee73 100644 --- a/NotchBuddy/Sources/App/AppDelegate.swift +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -17,6 +17,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { _ = KeychainStore.shared NSApp.setActivationPolicy(.accessory) setupMenuBarItem() + CodexUsageMonitor.shared.start() setupIsland() #if PHONE_LINK CloudProbe.shared.startIfEnabled() diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 7c3d9ca..493350b 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -395,6 +395,8 @@ final class AppState: ObservableObject { #endif // Claude plan gauge (from statusline hook) + /// ChatGPT (Codex) weekly plan window, read from ~/.codex/sessions by CodexUsageMonitor. + @Published var codexWeekly: PlanWindow? = nil @Published var claudePlanUsage: PlanUsage? = nil { didSet { if let u = claudePlanUsage, diff --git a/NotchBuddy/Sources/App/BotCanvasView.swift b/NotchBuddy/Sources/App/BotCanvasView.swift index 38f5598..e7f39c3 100644 --- a/NotchBuddy/Sources/App/BotCanvasView.swift +++ b/NotchBuddy/Sources/App/BotCanvasView.swift @@ -11,6 +11,7 @@ struct BotCanvasView: View { // One engine per view instance (main bot) @StateObject private var engine = BotEngine() + @State private var fader = MemojiFader() var body: some View { TimelineView(.animation(paused: state.mode == .hidden)) { timeline in @@ -69,26 +70,15 @@ struct BotCanvasView: View { engine.update(dt: dt) var ctx = context engine.applyDance(&ctx, size: size) - // Rigid-roll: when Mochi wears an outfit (presence > 0.05) and is rolling, - // rotate the entire body+accessories context around the body center so the - // whole character genuinely turns. Particles/badge (drawHandsAndExtras) are - // drawn outside the rotated context and do not spin. - if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { - let center = engine.bodyCenter(size: size) - var rigidCtx = ctx - rigidCtx.translateBy(x: center.x, y: center.y) - rigidCtx.rotate(by: .radians(engine.roll)) - rigidCtx.translateBy(x: -center.x, y: -center.y) - engine.drawHandsBehind(context: rigidCtx, size: size) - engine.drawOutfitBehind(context: rigidCtx, size: size) - engine.draw(context: rigidCtx, size: size) - engine.drawOutfitFront(context: rigidCtx, size: size) - } else { - engine.drawHandsBehind(context: ctx, size: size) - engine.drawOutfitBehind(context: ctx, size: size) - engine.draw(context: ctx, size: size) - engine.drawOutfitFront(context: ctx, size: size) + // BroV: the Memoji is the character. The engine's box (file upload morph) + // fades in only while morphing; hands and outfits are not drawn. + if engine.morph > 0.02 { + var boxCtx = ctx + boxCtx.opacity = Double(min(1, engine.morph * 2)) + engine.draw(context: boxCtx, size: size) } + engine.drawMemoji(context: ctx, size: size, fader: fader, + opacity: Double(max(0, 1 - engine.morph * 2))) engine.drawHandsAndExtras(context: ctx, size: size) } } diff --git a/NotchBuddy/Sources/App/ClaudePlanGauge.swift b/NotchBuddy/Sources/App/ClaudePlanGauge.swift index f95497a..7b37983 100644 --- a/NotchBuddy/Sources/App/ClaudePlanGauge.swift +++ b/NotchBuddy/Sources/App/ClaudePlanGauge.swift @@ -64,6 +64,16 @@ enum ClaudePlanGauge { } } + /// The window with the higher effective percentage (its reset is the one that matters). + static func dominantWindow(_ usage: PlanUsage) -> PlanWindow? { + switch (usage.fiveHour, usage.sevenDay) { + case (nil, nil): return nil + case (let a?, nil): return a + case (nil, let b?): return b + case (let a?, let b?): return effectivePct(b) > effectivePct(a) ? b : a + } + } + /// Hex color for a given percentage (nil → grey). static func color(for pct: Double?) -> String { guard let p = pct else { return "#6B7079" } diff --git a/NotchBuddy/Sources/App/CodexUsage.swift b/NotchBuddy/Sources/App/CodexUsage.swift new file mode 100644 index 0000000..8c915dc --- /dev/null +++ b/NotchBuddy/Sources/App/CodexUsage.swift @@ -0,0 +1,109 @@ +import Foundation + +// MARK: - ChatGPT (Codex) plan limits +// +// The ChatGPT/Codex app writes its plan limits into every session log +// (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) as +// "rate_limits":{"primary":{"used_percent":23.0,"window_minutes":10080,"resets_at":1791634947},"secondary":…} +// We read the newest logs once a minute and keep the weekly window (10080 min). +// Nothing is sent anywhere; the numbers are only as fresh as the last Codex turn. + +enum CodexUsage { + static let weekMinutes = 10080 + + /// Weekly window from the most recent `rate_limits` found in the newest session logs. + static func readWeekly(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> PlanWindow? { + let root = home.appendingPathComponent(".codex/sessions") + for file in newestLogs(in: root, limit: 4) { + if let w = lastWeekly(in: file) { return w } + } + return nil + } + + /// Newest *.jsonl files under the date folders, newest first. + static func newestLogs(in root: URL, limit: Int) -> [URL] { + let fm = FileManager.default + guard let e = fm.enumerator(at: root, includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles]) else { return [] } + var files: [(URL, Date)] = [] + for case let url as URL in e where url.pathExtension == "jsonl" { + let d = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast + files.append((url, d)) + } + return files.sorted { $0.1 > $1.1 }.prefix(limit).map(\.0) + } + + /// Reads the tail of a log and returns the weekly window of its last `rate_limits`. + static func lastWeekly(in file: URL) -> PlanWindow? { + guard let h = try? FileHandle(forReadingFrom: file) else { return nil } + defer { try? h.close() } + let size = (try? h.seekToEnd()) ?? 0 + let chunk: UInt64 = 512 * 1024 + try? h.seek(toOffset: size > chunk ? size - chunk : 0) + guard let data = try? h.readToEnd(), let text = String(data: data, encoding: .utf8) else { return nil } + for line in text.split(separator: "\n").reversed() where line.contains("\"rate_limits\"") { + if let w = parseLine(String(line)) { return w } + } + return nil + } + + static func parseLine(_ line: String) -> PlanWindow? { + guard let obj = try? JSONSerialization.jsonObject(with: Data(line.utf8)), + let limits = findRateLimits(obj) else { return nil } + let windows = [limits["primary"], limits["secondary"]].compactMap { $0 as? [String: Any] } + let weekly = windows.first { ($0["window_minutes"] as? NSNumber)?.intValue == weekMinutes } + guard let w = weekly, + let pct = (w["used_percent"] as? NSNumber)?.doubleValue, + let resets = (w["resets_at"] as? NSNumber)?.doubleValue, resets > 0 else { return nil } + return PlanWindow(usedPct: min(100, max(0, pct)), resetsAt: Date(timeIntervalSince1970: resets)) + } + + /// `rate_limits` can sit at any depth of the event (payload.info…), so search for it. + private static func findRateLimits(_ obj: Any) -> [String: Any]? { + if let d = obj as? [String: Any] { + if let r = d["rate_limits"] as? [String: Any] { return r } + for v in d.values { if let r = findRateLimits(v) { return r } } + } else if let a = obj as? [Any] { + for v in a { if let r = findRateLimits(v) { return r } } + } + return nil + } +} + +@MainActor +final class CodexUsageMonitor { + static let shared = CodexUsageMonitor() + private var task: Task? + + func start() { + guard task == nil else { return } + task = Task { + while !Task.isCancelled { + let w = await Task.detached(priority: .utility) { CodexUsage.readWeekly() }.value + AppState.shared.codexWeekly = w + try? await Task.sleep(for: .seconds(60)) + } + } + } +} + +// MARK: - Reset time wording + +enum PlanTime { + /// "3 ч 20 мин", "2 д 5 ч", "12 мин", "<1 мин". + static func untilReset(_ date: Date, now: Date = Date()) -> String { + let s = max(0, Int(date.timeIntervalSince(now))) + let d = s / 86400, h = (s % 86400) / 3600, m = (s % 3600) / 60 + if d > 0 { return h > 0 ? "\(d) д \(h) ч" : "\(d) д" } + if h > 0 { return m > 0 ? "\(h) ч \(m) мин" : "\(h) ч" } + return m > 0 ? "\(m) мин" : "<1 мин" + } + + /// "пт 14:00" within a week, "12 окт 14:00" beyond. + static func resetMoment(_ date: Date, now: Date = Date()) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "ru_RU") + f.dateFormat = date.timeIntervalSince(now) < 6 * 86400 ? "EE HH:mm" : "d MMM HH:mm" + return f.string(from: date).replacingOccurrences(of: ".", with: "") + } +} diff --git a/NotchBuddy/Sources/App/DesktopMochi.swift b/NotchBuddy/Sources/App/DesktopMochi.swift index b810365..30d3314 100644 --- a/NotchBuddy/Sources/App/DesktopMochi.swift +++ b/NotchBuddy/Sources/App/DesktopMochi.swift @@ -24,6 +24,7 @@ struct DesktopBotView: View { @ObservedObject var appState: AppState /// Engine owned by DesktopMochiController; controller calls methods on it directly. let engine: BotEngine + @State private var fader = MemojiFader() @ObservedObject var viewState: DesktopBotViewState var body: some View { @@ -59,23 +60,15 @@ struct DesktopBotView: View { var c = ctx engine.applyDance(&c, size: size) - // Rigid roll when outfit is present (matches BotCanvasView) - if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 { - let center = engine.bodyCenter(size: size) - var rigidCtx = c - rigidCtx.translateBy(x: center.x, y: center.y) - rigidCtx.rotate(by: .radians(engine.roll)) - rigidCtx.translateBy(x: -center.x, y: -center.y) - engine.drawHandsBehind(context: rigidCtx, size: size) - engine.drawOutfitBehind(context: rigidCtx, size: size) - engine.draw(context: rigidCtx, size: size) - engine.drawOutfitFront(context: rigidCtx, size: size) - } else { - engine.drawHandsBehind(context: c, size: size) - engine.drawOutfitBehind(context: c, size: size) - engine.draw(context: c, size: size) - engine.drawOutfitFront(context: c, size: size) + // BroV: the Memoji is the character. The engine's box (file upload morph) + // fades in only while morphing; hands and outfits are not drawn. + if engine.morph > 0.02 { + var boxCtx = c + boxCtx.opacity = Double(min(1, engine.morph * 2)) + engine.draw(context: boxCtx, size: size) } + engine.drawMemoji(context: c, size: size, fader: fader, + opacity: Double(max(0, 1 - engine.morph * 2))) engine.drawHandsAndExtras(context: c, size: size) } } diff --git a/NotchBuddy/Sources/App/GreetingCanvasView.swift b/NotchBuddy/Sources/App/GreetingCanvasView.swift index db0c59c..05c44e0 100644 --- a/NotchBuddy/Sources/App/GreetingCanvasView.swift +++ b/NotchBuddy/Sources/App/GreetingCanvasView.swift @@ -444,56 +444,15 @@ private func drawMochi(_ ctx: CGContext, p: GreetPose) { ctx.rotate(by: CGFloat(p.tilt)) ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy)) - drawHandL(ctx, hw: hw, hh: hh, p: p) - drawHandR(ctx, hw: hw, hh: hh, p: p) - - let mpath = mochiPath(hw: hw, hh: hh) - whiteFill(ctx, mpath, x0: hw*0.6, y0: -hh, x1: -hw*0.6, y1: hh) - - if p.tint > 0 { - let cs = CGColorSpaceCreateDeviceRGB() - let c0 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: CGFloat(p.tint)) - let c1 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: 0) - if let g = CGGradient(colorsSpace: cs, colors: [c0,c1] as CFArray, locations: [0,1]) { - ctx.saveGState() - ctx.addPath(mpath); ctx.clip() - ctx.drawLinearGradient(g, start: CGPoint(x: 0, y: hh), - end: CGPoint(x: 0, y: -hh*0.1), options: []) - ctx.restoreGState() - } - } - - ctx.saveGState() - ctx.addPath(mpath); ctx.clip() - ctx.setFillColor(gHex("#16171A")) - ctx.setStrokeColor(gHex("#16171A")) - let er = CGFloat(p.hb*0.06) - let sp = CGFloat(p.hb*0.19) - let lx = CGFloat(p.lookX)*hw*0.42 - let ly = CGFloat(p.lookY)*hh*0.28 + hh*0.12 + CGFloat(p.eyeRoll)*hh*1.25 - for sd: CGFloat in [-1, 1] { - ctx.saveGState() - ctx.translateBy(x: sd*sp+lx, y: ly) - if p.eye == .happy { - ctx.setLineWidth(er*0.95); ctx.setLineCap(.round) - ctx.beginPath() - ctx.addArc(center: CGPoint(x: 0, y: er*0.6), radius: er*1.25, - startAngle: .pi*1.15, endAngle: .pi*1.85, clockwise: false) - ctx.strokePath() - } else if p.eye == .content { - ctx.setLineWidth(er*0.95); ctx.setLineCap(.round) - ctx.beginPath() - ctx.addArc(center: CGPoint(x: 0, y: -er*0.5), radius: er*1.25, - startAngle: .pi*0.15, endAngle: .pi*0.85, clockwise: false) - ctx.strokePath() - } else { - ctx.scaleBy(x: 1, y: max(0.12, CGFloat(p.open))) - ctx.addEllipse(in: CGRect(x: -er, y: -er, width: er*2, height: er*2)) - ctx.fillPath() - } - ctx.restoreGState() - } - ctx.restoreGState() + // BroV: the Memoji replaces the drawn body, eyes and hands. + let face: MemojiExpression = { + if p.eye == .happy || p.eye == .content { return .happy } + if p.open < 0.35 { return .blink } + if p.open < 0.8 { return .halfblink } + return .neutral + }() + MemojiCG.draw(face, in: ctx, center: CGPoint(x: CGFloat(p.lookX) * hw * 0.12, y: 0), + side: hh * 2 * 1.45) // Activity badge if p.badge > 0.01 { diff --git a/NotchBuddy/Sources/App/IslandRootView.swift b/NotchBuddy/Sources/App/IslandRootView.swift index eb91f23..5677d22 100644 --- a/NotchBuddy/Sources/App/IslandRootView.swift +++ b/NotchBuddy/Sources/App/IslandRootView.swift @@ -486,9 +486,12 @@ struct IslandHeader: View { // Right: plan pill (GitHub build, home view only) + action icons HStack(spacing: 8) { #if !APPSTORE - if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled { + if (state.view == .overview || state.view == .empty) && state.showPlanInNotch && state.planRelayInstalled { ClaudePlanHeaderPill(state: state) } + if (state.view == .overview || state.view == .empty), let w = state.codexWeekly { + ChatGPTLimitPill(window: w) + } #endif HStack(spacing: 14) { Button(action: { @@ -564,7 +567,10 @@ struct ClaudePlanHeaderPill: View { private var label: String { guard let usage = state.claudePlanUsage, let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" } - return "Claude \(Int(pct.rounded()))%" + guard let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() else { + return "Claude \(Int(pct.rounded()))%" + } + return "Claude \(Int(pct.rounded()))% (до сброса \(PlanTime.untilReset(w.resetsAt)))" } private var isActive: Bool { state.showingPlanDetail || isHovered } @@ -608,6 +614,30 @@ struct ClaudePlanHeaderPill: View { } #endif +/// ChatGPT weekly limit, from the Codex logs: "ChatGPT 23% (сброс пт 14:00)". +struct ChatGPTLimitPill: View { + let window: PlanWindow + + var body: some View { + let pct = ClaudePlanGauge.effectivePct(window) + let color = ClaudePlanGauge.color(for: pct) + let reset = window.resetsAt > Date() ? " (сброс \(PlanTime.resetMoment(window.resetsAt)))" : "" + HStack(spacing: 4) { + Circle().fill(Color(hex: color)).frame(width: 6, height: 6) + Text("ChatGPT \(Int(pct.rounded()))%\(reset)") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(Color(hex: "#6B7079")) + .lineLimit(1) + .fixedSize() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(Color(hex: "#0E0F11"))) + .overlay(Capsule().stroke(Color(hex: color).opacity(0.14), lineWidth: 1)) + .help("Недельный лимит ChatGPT (по логам Codex)") + } +} + // MARK: - Compact mini mochi grid (2×2 to the right of the notch) struct CompactMiniGrid: View { diff --git a/NotchBuddy/Sources/App/Memoji.swift b/NotchBuddy/Sources/App/Memoji.swift new file mode 100644 index 0000000..1c73123 --- /dev/null +++ b/NotchBuddy/Sources/App/Memoji.swift @@ -0,0 +1,159 @@ +import SwiftUI +import AppKit + +// MARK: - Memoji character (BroV) +// +// The character is 11 pre-rendered expressions (Resources/memoji/*.png, 256×256 RGBA). +// BotEngine still runs every animation (gaze, blink, squash, bounce, roll, emotes, +// particles); here we only pick the expression for the engine's state and draw the +// picture with the engine's transforms. Expression changes cross-fade in 220 ms; +// blinks swap neutral → halfblink → blink → halfblink → neutral instantly. + +enum MemojiExpression: String, CaseIterable { + case neutral, blink, halfblink, happy, thinking, question, concern, wink, speaking, focused, surprised +} + +enum MemojiAssets { + nonisolated(unsafe) private static var cache: [MemojiExpression: CGImage] = [:] + private static let lock = NSLock() + + static func image(_ e: MemojiExpression) -> CGImage? { + lock.lock(); defer { lock.unlock() } + if let img = cache[e] { return img } + guard let url = Bundle.main.url(forResource: e.rawValue, withExtension: "png", subdirectory: "memoji"), + let src = CGImageSourceCreateWithURL(url as CFURL, nil), + let img = CGImageSourceCreateImageAtIndex(src, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary) + else { return nil } + cache[e] = img + return img + } + + /// Expression for an agent state (states.json from the kit). + static func forState(_ s: BotState) -> MemojiExpression { + switch s { + case .idle: return .neutral + case .working: return .focused + case .thinking, .searching: return .thinking + case .approval, .question: return .question + case .error: return .concern + case .finished: return .happy + case .ratelimit, .dizzy: return .halfblink + case .sleeping: return .blink + } + } + + /// Expression for an emote / transient eye override from the engine. + static func forEye(_ e: EyeShape) -> MemojiExpression? { + switch e { + case .happy, .heart, .star: return .happy + case .dot, .cup: return .surprised + case .wink: return .wink + case .line, .flat: return .concern + case .closed: return .blink + case .tired, .spiral: return .halfblink + case .pill, .wide: return nil + } + } + + /// Faces whose eyes may be blinked by swapping in the neutral blink frames. + static let blinkable: Set = [.neutral, .focused, .thinking, .question, .concern, .speaking] +} + +/// Cross-fade bookkeeping, one per drawn character. +@MainActor +final class MemojiFader { + private(set) var current: MemojiExpression = .neutral + private(set) var previous: MemojiExpression? + private var changedAt: Double = 0 + static let fade: Double = 0.22 + + /// Returns (previous, current, progress 0…1). + func step(to e: MemojiExpression, now: Double) -> (MemojiExpression?, MemojiExpression, Double) { + if e != current { + previous = current + current = e + changedAt = now + } + let p = min(1, (now - changedAt) / Self.fade) + if p >= 1 { previous = nil } + return (previous, current, p) + } +} + +extension BotEngine { + /// The face to show right now, before blinking. + var memojiBase: MemojiExpression { + if let eye = eyeOverride, let e = MemojiAssets.forEye(eye) { return e } + if isDancing && dancingLevel > 0.15 { return .happy } + return MemojiAssets.forState(state) + } + + /// Draws the Memoji with the engine's current pose. Call after `update(dt:)`. + func drawMemoji(context: GraphicsContext, size: CGSize, fader: MemojiFader, opacity: Double = 1) { + guard opacity > 0.01 else { return } + let W = size.width + let R = W * 0.3 + let center = bodyCenter(size: size) + + // Expression + blink frames. + var face = memojiBase + if MemojiAssets.blinkable.contains(face) { + if open < 0.35 { face = .blink } else if open < 0.8 { face = .halfblink } + } + let isBlinkFrame = face == .blink || face == .halfblink + let now = CACurrentMediaTime() + // Blinks don't cross-fade (they'd look like a dissolve, not a blink). + let (prev, cur, p) = isBlinkFrame && MemojiAssets.blinkable.contains(memojiBase) + ? (nil, face, 1.0) + : fader.step(to: face, now: now) + + var c = context + c.opacity = opacity + c.translateBy(x: center.x, y: center.y) + // Head turn: slide toward the gaze and narrow a little, like turning a sphere. + c.translateBy(x: yaw * R * 0.18, y: -pitch * R * 0.12) + c.rotate(by: .radians(tilt + roll)) + c.scaleBy(x: sx * (1 - abs(yaw) * 0.10), y: sy * (1 - abs(pitch) * 0.06)) + // Status tint as a soft glow behind the head. + if tint > 0.02 { + let glow = Color(red: col.0, green: col.1, blue: col.2) + let gr = R * 1.45 + c.fill(Path(ellipseIn: CGRect(x: -gr, y: -gr * 0.9, width: gr * 2, height: gr * 2)), + with: .radialGradient(Gradient(colors: [glow.opacity(0.45 * Double(tint)), glow.opacity(0)]), + center: .zero, startRadius: 0, endRadius: gr)) + } + let side = R * 2.9 * es.clamped(0.9, 1.3) + let rect = CGRect(x: -side / 2, y: -side * 0.52, width: side, height: side) + if let prev, p < 1, let img = MemojiAssets.image(prev) { + var pc = c + pc.opacity = opacity * (1 - p) + pc.draw(Image(decorative: img, scale: 1), in: rect) + } + if let img = MemojiAssets.image(cur) { + var cc = c + cc.opacity = opacity * (prev == nil ? 1 : p) + cc.draw(Image(decorative: img, scale: 1), in: rect) + } + } +} + +private extension CGFloat { + func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { Swift.min(hi, Swift.max(lo, self)) } +} + +// MARK: - Plain CGContext drawing (greeting scene) + +enum MemojiCG { + /// Draws an expression centred at `center` with height `side` into a y-down CGContext. + static func draw(_ e: MemojiExpression, in ctx: CGContext, center: CGPoint, side: CGFloat, + tilt: CGFloat = 0, sx: CGFloat = 1, sy: CGFloat = 1, alpha: CGFloat = 1) { + guard let img = MemojiAssets.image(e), alpha > 0.01 else { return } + ctx.saveGState() + ctx.setAlpha(alpha) + ctx.translateBy(x: center.x, y: center.y) + ctx.rotate(by: tilt) + ctx.scaleBy(x: sx, y: -sy) // flip: CGImage draws y-up + ctx.draw(img, in: CGRect(x: -side / 2, y: -side * 0.48, width: side, height: side)) + ctx.restoreGState() + } +} diff --git a/NotchBuddy/Sources/App/UploadCanvasView.swift b/NotchBuddy/Sources/App/UploadCanvasView.swift index ed50d50..40d9764 100644 --- a/NotchBuddy/Sources/App/UploadCanvasView.swift +++ b/NotchBuddy/Sources/App/UploadCanvasView.swift @@ -302,6 +302,17 @@ struct UploadCanvasView: View { 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 ────────────────────────────────────────── diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml index cc00606..e7c4744 100644 --- a/NotchBuddy/project.yml +++ b/NotchBuddy/project.yml @@ -45,6 +45,9 @@ targets: - path: Resources/sounds buildPhase: resources type: folder + - path: Resources/memoji + buildPhase: resources + type: folder info: path: Resources/Info.plist properties: diff --git a/scripts/build.sh b/scripts/build.sh index 5fcfa99..1e30775 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -5,9 +5,11 @@ set -e cd "$(dirname "$0")/../NotchBuddy" D="$HOME/Library/Developer/Xcode/DerivedData/BroV" xcodegen -q -xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" build | grep -E 'error:|BUILD (SUCCEEDED|FAILED)' || true +LOG=$(mktemp) +xcodebuild -scheme BroV -configuration "${1:-Debug}" -derivedDataPath "$D" build > "$LOG" 2>&1 || true +grep -E 'error:|BUILD (SUCCEEDED|FAILED)' "$LOG" | sort -u +grep -q 'BUILD SUCCEEDED' "$LOG" || { echo "build failed, BroV not reinstalled"; exit 1; } APP="$D/Build/Products/${1:-Debug}/BroV.app" -[ -d "$APP" ] || { echo "build failed"; exit 1; } pkill -x BroV 2>/dev/null || true mkdir -p "$HOME/Applications" rm -rf "$HOME/Applications/BroV.app"