Memoji character (11 expressions) on the engine's motion; Claude limit shows time to reset; ChatGPT weekly limit from Codex logs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -17,6 +17,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
_ = KeychainStore.shared
|
_ = KeychainStore.shared
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
setupMenuBarItem()
|
setupMenuBarItem()
|
||||||
|
CodexUsageMonitor.shared.start()
|
||||||
setupIsland()
|
setupIsland()
|
||||||
#if PHONE_LINK
|
#if PHONE_LINK
|
||||||
CloudProbe.shared.startIfEnabled()
|
CloudProbe.shared.startIfEnabled()
|
||||||
|
|||||||
@@ -395,6 +395,8 @@ final class AppState: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Claude plan gauge (from statusline hook)
|
// 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 {
|
@Published var claudePlanUsage: PlanUsage? = nil {
|
||||||
didSet {
|
didSet {
|
||||||
if let u = claudePlanUsage,
|
if let u = claudePlanUsage,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct BotCanvasView: View {
|
|||||||
|
|
||||||
// One engine per view instance (main bot)
|
// One engine per view instance (main bot)
|
||||||
@StateObject private var engine = BotEngine()
|
@StateObject private var engine = BotEngine()
|
||||||
|
@State private var fader = MemojiFader()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in
|
TimelineView(.animation(paused: state.mode == .hidden)) { timeline in
|
||||||
@@ -69,26 +70,15 @@ struct BotCanvasView: View {
|
|||||||
engine.update(dt: dt)
|
engine.update(dt: dt)
|
||||||
var ctx = context
|
var ctx = context
|
||||||
engine.applyDance(&ctx, size: size)
|
engine.applyDance(&ctx, size: size)
|
||||||
// Rigid-roll: when Mochi wears an outfit (presence > 0.05) and is rolling,
|
// BroV: the Memoji is the character. The engine's box (file upload morph)
|
||||||
// rotate the entire body+accessories context around the body center so the
|
// fades in only while morphing; hands and outfits are not drawn.
|
||||||
// whole character genuinely turns. Particles/badge (drawHandsAndExtras) are
|
if engine.morph > 0.02 {
|
||||||
// drawn outside the rotated context and do not spin.
|
var boxCtx = ctx
|
||||||
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 {
|
boxCtx.opacity = Double(min(1, engine.morph * 2))
|
||||||
let center = engine.bodyCenter(size: size)
|
engine.draw(context: boxCtx, 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)
|
|
||||||
}
|
}
|
||||||
|
engine.drawMemoji(context: ctx, size: size, fader: fader,
|
||||||
|
opacity: Double(max(0, 1 - engine.morph * 2)))
|
||||||
engine.drawHandsAndExtras(context: ctx, size: size)
|
engine.drawHandsAndExtras(context: ctx, size: size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
/// Hex color for a given percentage (nil → grey).
|
||||||
static func color(for pct: Double?) -> String {
|
static func color(for pct: Double?) -> String {
|
||||||
guard let p = pct else { return "#6B7079" }
|
guard let p = pct else { return "#6B7079" }
|
||||||
|
|||||||
@@ -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<Void, Never>?
|
||||||
|
|
||||||
|
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: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ struct DesktopBotView: View {
|
|||||||
@ObservedObject var appState: AppState
|
@ObservedObject var appState: AppState
|
||||||
/// Engine owned by DesktopMochiController; controller calls methods on it directly.
|
/// Engine owned by DesktopMochiController; controller calls methods on it directly.
|
||||||
let engine: BotEngine
|
let engine: BotEngine
|
||||||
|
@State private var fader = MemojiFader()
|
||||||
@ObservedObject var viewState: DesktopBotViewState
|
@ObservedObject var viewState: DesktopBotViewState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -59,23 +60,15 @@ struct DesktopBotView: View {
|
|||||||
var c = ctx
|
var c = ctx
|
||||||
engine.applyDance(&c, size: size)
|
engine.applyDance(&c, size: size)
|
||||||
|
|
||||||
// Rigid roll when outfit is present (matches BotCanvasView)
|
// BroV: the Memoji is the character. The engine's box (file upload morph)
|
||||||
if engine.outfit != .none && engine.outfitPresence > 0.05 && abs(engine.roll) > 0.001 {
|
// fades in only while morphing; hands and outfits are not drawn.
|
||||||
let center = engine.bodyCenter(size: size)
|
if engine.morph > 0.02 {
|
||||||
var rigidCtx = c
|
var boxCtx = c
|
||||||
rigidCtx.translateBy(x: center.x, y: center.y)
|
boxCtx.opacity = Double(min(1, engine.morph * 2))
|
||||||
rigidCtx.rotate(by: .radians(engine.roll))
|
engine.draw(context: boxCtx, size: size)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
engine.drawMemoji(context: c, size: size, fader: fader,
|
||||||
|
opacity: Double(max(0, 1 - engine.morph * 2)))
|
||||||
engine.drawHandsAndExtras(context: c, size: size)
|
engine.drawHandsAndExtras(context: c, size: size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -444,56 +444,15 @@ private func drawMochi(_ ctx: CGContext, p: GreetPose) {
|
|||||||
ctx.rotate(by: CGFloat(p.tilt))
|
ctx.rotate(by: CGFloat(p.tilt))
|
||||||
ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy))
|
ctx.scaleBy(x: CGFloat(p.sx), y: CGFloat(p.sy))
|
||||||
|
|
||||||
drawHandL(ctx, hw: hw, hh: hh, p: p)
|
// BroV: the Memoji replaces the drawn body, eyes and hands.
|
||||||
drawHandR(ctx, hw: hw, hh: hh, p: p)
|
let face: MemojiExpression = {
|
||||||
|
if p.eye == .happy || p.eye == .content { return .happy }
|
||||||
let mpath = mochiPath(hw: hw, hh: hh)
|
if p.open < 0.35 { return .blink }
|
||||||
whiteFill(ctx, mpath, x0: hw*0.6, y0: -hh, x1: -hw*0.6, y1: hh)
|
if p.open < 0.8 { return .halfblink }
|
||||||
|
return .neutral
|
||||||
if p.tint > 0 {
|
}()
|
||||||
let cs = CGColorSpaceCreateDeviceRGB()
|
MemojiCG.draw(face, in: ctx, center: CGPoint(x: CGFloat(p.lookX) * hw * 0.12, y: 0),
|
||||||
let c0 = CGColor(red: 127/255, green: 180/255, blue: 234/255, alpha: CGFloat(p.tint))
|
side: hh * 2 * 1.45)
|
||||||
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()
|
|
||||||
|
|
||||||
// Activity badge
|
// Activity badge
|
||||||
if p.badge > 0.01 {
|
if p.badge > 0.01 {
|
||||||
|
|||||||
@@ -486,9 +486,12 @@ struct IslandHeader: View {
|
|||||||
// Right: plan pill (GitHub build, home view only) + action icons
|
// Right: plan pill (GitHub build, home view only) + action icons
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
#if !APPSTORE
|
#if !APPSTORE
|
||||||
if state.view == .overview && state.showPlanInNotch && state.planRelayInstalled {
|
if (state.view == .overview || state.view == .empty) && state.showPlanInNotch && state.planRelayInstalled {
|
||||||
ClaudePlanHeaderPill(state: state)
|
ClaudePlanHeaderPill(state: state)
|
||||||
}
|
}
|
||||||
|
if (state.view == .overview || state.view == .empty), let w = state.codexWeekly {
|
||||||
|
ChatGPTLimitPill(window: w)
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -564,8 +567,11 @@ struct ClaudePlanHeaderPill: View {
|
|||||||
private var label: String {
|
private var label: String {
|
||||||
guard let usage = state.claudePlanUsage,
|
guard let usage = state.claudePlanUsage,
|
||||||
let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" }
|
let pct = ClaudePlanGauge.dominantPct(usage) else { return "Claude —" }
|
||||||
|
guard let w = ClaudePlanGauge.dominantWindow(usage), w.resetsAt > Date() else {
|
||||||
return "Claude \(Int(pct.rounded()))%"
|
return "Claude \(Int(pct.rounded()))%"
|
||||||
}
|
}
|
||||||
|
return "Claude \(Int(pct.rounded()))% (до сброса \(PlanTime.untilReset(w.resetsAt)))"
|
||||||
|
}
|
||||||
|
|
||||||
private var isActive: Bool { state.showingPlanDetail || isHovered }
|
private var isActive: Bool { state.showingPlanDetail || isHovered }
|
||||||
|
|
||||||
@@ -608,6 +614,30 @@ struct ClaudePlanHeaderPill: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#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)
|
// MARK: - Compact mini mochi grid (2×2 to the right of the notch)
|
||||||
|
|
||||||
struct CompactMiniGrid: View {
|
struct CompactMiniGrid: View {
|
||||||
|
|||||||
@@ -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<MemojiExpression> = [.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,6 +302,17 @@ struct UploadCanvasView: View {
|
|||||||
c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt)))
|
c.concatenate(CGAffineTransform(rotationAngle: CGFloat(f.tilt)))
|
||||||
c.concatenate(CGAffineTransform(scaleX: CGFloat(f.sx), y: CGFloat(f.sy)))
|
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)
|
let (bp, rx, ry) = usBodyPath(m: m, R: R)
|
||||||
|
|
||||||
// ── Body gradient ──────────────────────────────────────────
|
// ── Body gradient ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ targets:
|
|||||||
- path: Resources/sounds
|
- path: Resources/sounds
|
||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
type: folder
|
type: folder
|
||||||
|
- path: Resources/memoji
|
||||||
|
buildPhase: resources
|
||||||
|
type: folder
|
||||||
info:
|
info:
|
||||||
path: Resources/Info.plist
|
path: Resources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ set -e
|
|||||||
cd "$(dirname "$0")/../NotchBuddy"
|
cd "$(dirname "$0")/../NotchBuddy"
|
||||||
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
D="$HOME/Library/Developer/Xcode/DerivedData/BroV"
|
||||||
xcodegen -q
|
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"
|
APP="$D/Build/Products/${1:-Debug}/BroV.app"
|
||||||
[ -d "$APP" ] || { echo "build failed"; exit 1; }
|
|
||||||
pkill -x BroV 2>/dev/null || true
|
pkill -x BroV 2>/dev/null || true
|
||||||
mkdir -p "$HOME/Applications"
|
mkdir -p "$HOME/Applications"
|
||||||
rm -rf "$HOME/Applications/BroV.app"
|
rm -rf "$HOME/Applications/BroV.app"
|
||||||
|
|||||||