Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Antigravity 2.0 と IDE が推定値ではなく実際のクォータを返し
## そのほかにも

- **代表ポケモン** — 図鑑で所有している種を選び、育成中のポケモンとは別にメニューバーと任意のフローティングペットへ固定。固定中はメニューバーがタマゴ・孵化・進化を追わなくなりますが、育成の進行は Home で引き続き確認できます。
- **アニメーション品質** — メニューバーのスプライトとフローティングペットの滑らかさを選べます(バッテリー優先/標準/滑らか)。常に表示される2つの表面が同じ設定を共有します。既定の「バッテリー優先」はこの設定が入る前と同じフレームレートで、「標準」「滑らか」はバッテリーを多く使います(実測アイドル CPU 約1.8%/約5.1%)。
- **インタラクティブなフローティングペット** — ホバーで今日の使用量、クリックでメイン画面、右クリックでメニュー、上限アラートは吹き出しで表示。
- **サービス別タブ** — Claude Code・Codex・Gemini CLI・Antigravity・OpenCode・Hermes Agent・Cursor・Grok CLI・Copilot CLI・Kiro CLI・Pi Agent のうち2つ以上が検出されると、小さなタブでサービス別の詳細を切替(今日の合計は合算のまま)。
- **公式の上限** — Claude・Codex・Antigravity の5時間/週間使用率とリセットのカウントダウンを、今日の数字のすぐ下に。
Expand Down
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Antigravity 2.0 과 IDE 가 추정치가 아닌 실제 할당량을 보고합니
## 이 밖에도

- **대표 포켓몬** — 도감에서 보유한 종을 골라 현재 키우는 포켓몬과 별개로 메뉴바와 선택적 플로팅 펫에 고정. 고정 중에는 메뉴바가 알·부화·진화를 따라가지 않지만, 육성 과정은 Home에서 계속 확인할 수 있습니다.
- **애니메이션 품질** — 메뉴바 스프라이트와 플로팅 펫이 얼마나 부드럽게 움직일지 고릅니다(배터리 절약 / 기본 / 부드럽게). 상시 표시되는 두 표면이 같은 설정을 씁니다. 기본값 "배터리 절약"은 이 설정이 생기기 전과 같은 프레임 레이트이고, "기본"·"부드럽게"는 배터리를 더 씁니다(실측 유휴 CPU 약 1.8% / 약 5.1%).
- **인터랙티브 플로팅 펫** — 호버로 오늘 사용량, 클릭으로 메인 창, 우클릭 메뉴, 한도 알림은 말풍선으로 표시.
- **서비스별 탭** — Claude Code·Codex·Gemini CLI·Antigravity·OpenCode·Hermes Agent·Cursor·Grok CLI·Copilot CLI·Kiro CLI·Pi Agent 중 2개 이상 감지되면 작은 탭으로 상세를 서비스별 전환(오늘 합계는 통합 유지).
- **공식 한도** — Claude·Codex·Antigravity 5시간/주간 사용률 + 리셋 카운트다운을 오늘 숫자 바로 아래에.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ If a tool keeps its sessions outside the built-in paths, add your own roots unde
## Also in the box

- **Representative Pokémon** — pin any owned Pokédex species to the menu bar and optional floating pet, independently of the companion you're raising. While pinned, the menu bar stops following egg, hatch, and evolution changes; raising progress remains visible on Home.
- **Animation quality** — pick how smoothly the menu bar sprite and the floating pet animate (Power saver / Balanced / Smooth). Both always-visible surfaces share the setting. Power saver is the default and keeps the frame rate the app used before this setting existed; Balanced and Smooth trade battery for smoothness (measured idle CPU ~1.8% and ~5.1%).
- **Interactive floating pet** — hover for today's usage, click to open the main window, right-click for a menu, and show limit alerts as speech bubbles.
- **Per-service tabs** — when two or more of Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, Hermes Agent, Cursor, Grok CLI, Copilot CLI, Kiro CLI, and Pi Agent are detected, compact tabs switch between them; today's total stays combined.
- **Official limits** — Claude, Codex & Antigravity 5-hour / weekly utilization with reset countdowns, right under today's numbers.
Expand Down
9 changes: 9 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ struct L {
var todayTokensShort: String { t("오늘 토큰", "Today's tokens", "本日のトークン", "Tokens de hoy", "Tokens du jour", "Tokens de hoje") }
var todayCost: String { t("오늘 비용 ($)", "Today's cost ($)", "本日のコスト ($)", "Coste de hoy ($)", "Coût du jour ($)", "Custo de hoje ($)") }
var limitPercent: String { t("한도 %", "Limit %", "上限 %", "Límite %", "Limite %", "Limite %") }
var animationQualityLabel: String { t("애니메이션", "Animation", "アニメーション", "Animación", "Animation", "Animação") }
var animationQualityHint: String {
t("부드러울수록 배터리를 더 씁니다", "Smoother uses more battery",
"滑らかにするとバッテリー消費が増えます", "Más fluido consume más batería",
"Plus fluide consomme plus de batterie", "Mais fluido consome mais bateria")
}
var animationPowerSaver: String { t("배터리 절약", "Power saver", "バッテリー優先", "Ahorro de batería", "Économie d'énergie", "Economia de bateria") }
var animationBalanced: String { t("기본", "Balanced", "標準", "Equilibrado", "Équilibré", "Equilibrado") }
var animationSmooth: String { t("부드럽게", "Smooth", "滑らか", "Fluido", "Fluide", "Fluido") }
var limitDisplayModeLabel: String { t("한도 표시 방식", "Limit display", "上限の表示", "Visualización del límite", "Affichage de la limite", "Exibição do limite") }
var limitDisplayUsed: String { t("사용량", "Used", "使用量", "Usado", "Utilisé", "Usado") }
var limitDisplayRemaining: String { t("남은 양", "Remaining", "残量", "Restante", "Restant", "Restante") }
Expand Down
25 changes: 25 additions & 0 deletions Sources/PokeTokenBar/Core/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,28 @@ final class UsageStore {
var limitDisplayMode: LimitDisplayMode {
didSet { defaults.set(limitDisplayMode.rawValue, forKey: "limitDisplayMode") }
}
/// 상시 표시 애니메이션(메뉴바 스프라이트 + 플로팅 펫)의 부드러움 ↔ 배터리 절충.
///
/// 값은 GIF 프레임 지속의 **하한**(초)으로, `GIFDecoder.capFrameRate` 가 프레임을 솎아내
/// 적용한다 — 재생 속도는 어느 프리셋에서도 원본과 같고 초당 프레임 수만 달라진다.
/// 왜 사용자 선택인가: 프레임당 비용이 상태바 재합성(FrontBoardServices IPC + 렌더 fence)이라
/// 기기·스프라이트에 따라 체감과 배터리 영향이 갈린다 — 하나의 값으로 모두를 만족시킬 수 없다.
enum AnimationQuality: String, CaseIterable {
case powerSaver, balanced, smooth

/// 프레임 지속 하한(초) = fps 상한. **0 인 케이스를 만들지 마라** — 네이티브 fps 는
/// idle wakeup 회귀다(근거는 defect-log '에너지' 절). 가드: `testNoAnimationQualityPresetDisablesTheCap`.
var frameFloor: TimeInterval {
switch self {
case .powerSaver: 0.4 // ≈2.5fps
case .balanced: 0.2 // ≈5fps
case .smooth: 0.1 // ≈10fps
}
}
}
var animationQuality: AnimationQuality {
didSet { defaults.set(animationQuality.rawValue, forKey: "animationQuality") }
}
// 알림(독립 토글)
var limitNotifications: Bool {
didSet { defaults.set(limitNotifications, forKey: "limitNotifications") }
Expand Down Expand Up @@ -485,6 +507,9 @@ final class UsageStore {
floatingPetEnabled = d.object(forKey: "floatingPetEnabled") as? Bool ?? false
floatingPetSize = d.object(forKey: "floatingPetSize") as? Double ?? 96
floatingPetBubbleAlerts = d.object(forKey: "floatingPetBubbleAlerts") as? Bool ?? true
// 기본 powerSaver — 이 설정이 생기기 전의 고정 캡(0.4s)과 같은 프레임 레이트라, 기존
// 사용자의 배터리 프로파일은 그대로다. 더 부드러운 쪽은 opt-in(실측 idle CPU 1.8%/5.1%).
animationQuality = AnimationQuality(rawValue: d.string(forKey: "animationQuality") ?? "") ?? .powerSaver
disableKeychainAccess = d.object(forKey: "disableKeychainAccess") as? Bool ?? false

reschedule()
Expand Down
56 changes: 45 additions & 11 deletions Sources/PokeTokenBar/PokeTokenBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {

// 메뉴바 캐릭터 애니메이션 — 단일 타이머로 프레임 순환.
// 프레임 = 이미 22px 로 합성된 이미지 + delay. egg/static 은 2프레임 bob, animated 는 GIF 실제 프레임.
private var menuSpriteKey: String? // "id-shiny" — 같은 종이라도 shiny 여부가 바뀌면 재로딩
private var menuSpriteKey: String? // menuSpriteKey(id:shiny:floor:) 결과 — 바뀌면 재로딩
private var menuFrames: [(image: NSImage, delay: TimeInterval)] = []
private var menuIndex = 0
private var menuTimer: Timer?
Expand Down Expand Up @@ -101,13 +101,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}

/// 대표 스프라이트 정체성(종/shiny) 관찰 — 대표 선택·해제뿐 아니라 사탕 진화·졸업(BagView),
/// 세이브 가져오기, 부화·메타몽 리빌 async 완료처럼 store 갱신 틱 없이 companion 만 바뀌는
/// 경로에서도 메뉴바를 즉시 갱신한다. observeStore(menuTitle)만으론 다음 사용량 폴링(기본 120s)까지
/// 대표 스프라이트 정체성(종/shiny/fps 하한) 관찰 — 대표 선택·해제와 애니메이션 품질 변경뿐 아니라
/// 사탕 진화·졸업(BagView), 세이브 가져오기, 부화·메타몽 리빌 async 완료처럼 store 갱신 틱 없이
/// companion 만 바뀌는 경로에서도 메뉴바를 즉시 갱신한다. observeStore(menuTitle)만으론 다음 사용량 폴링(기본 120s)까지
/// 이전 포켓몬이 남는다(사탕 졸업 후 메뉴바 잔상 리포트 — UsageStore.onRefresh 주석과 같은 부류).
///
/// **fps 설정도 여기서 관찰한다**: 프레임은 하한에 맞춰 솎아낸 결과물이라 하한이 곧 정체성의
/// 일부다(`menuSpriteKey`). `observeStore` 는 `menuTitle` 만 추적하므로, 이걸 빼면 설정을
/// 바꿔도 다음 사용량 폴링(기본 120s)까지 옛 fps 로 돈다 — 위 '메뉴바 잔상'과 같은 부류.
/// (플로팅 펫은 `body` 에서 직접 읽어 SwiftUI 관찰이 처리한다.)
private func observeCompanionSprite() {
withObservationTracking {
_ = companion.representativeSubject
_ = store.animationQuality
} onChange: { [weak self] in
Task { @MainActor in
guard let self else { return }
Expand Down Expand Up @@ -193,15 +199,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {

// MARK: 메뉴바 애니메이션

/// 메뉴바 GIF 프레임 지속의 하한(초) = fps 상한. 사용자 설정
/// (`UsageStore.AnimationQuality`)이 값을 정하고, `GIFDecoder.capFrameRate` 가 프레임을
/// 솎아내 적용한다. 하한 자체는 없어질 수 없다 — 근거는 그 enum 과 defect-log '에너지' 절.
/// 히스토리: 0.4s 고정 → 프리셋(0.4/0.2/0.1) 중 사용자 선택. 기기·스프라이트마다 체감과
/// 배터리 영향이 갈려 하나의 값으로 수렴하지 못했다 — 기본값은 고정 캡과 같은 0.4s 다.
private var menuFrameFloor: TimeInterval { store.animationQuality.frameFloor }

/// `Timer.tolerance` 배수 — wakeup 코얼레싱(다른 wakeup 과 합쳐 배터리 절약)의 강도.
///
/// **늦게만 발화시킨다**(Apple: "fire the timer later than the scheduled time, up to the
/// tolerance"). 따라서 이 배수는 곧 애니메이션이 느려질 수 있는 상한이다 — 0.5 였을 때
/// 2.75s 루프가 최대 4.13s(1.5배)까지 늘어, 정확한 타이밍으로 도는 팝오버(`tolerance: .zero`)와
/// 나란히 보면 메뉴바만 느려 보였다(2026-08-20). 코얼레싱은 남기고 늘어짐만 눌러 0.1 로 낮춤.
static let menuFrameTolerance = 0.1

/// 대표 포켓몬에 맞춰 메뉴바 프레임을 준비. 종이 바뀐 경우에만 재로딩.
/// 정적 스프라이트로 먼저 보여주고, animated GIF 가 받아지면 교체한다(메뉴바도 GIF로 움직임).
/// 에너지 통제는 ① delay 하한 0.2s(≈5fps) ② 안 보이면 정지(menuShouldAnimate) ③ 저전력 모드
/// 에너지 통제는 ① delay 하한 `menuFrameFloor` ② 안 보이면 정지(menuShouldAnimate) ③ 저전력 모드
/// 에선 GIF 생략(가벼운 bob)로 처리한다 — 통제된 저프레임 + 비가시 시 정지로 저전력.
private func ensureMenuAnimation() {
let subject = companion.representativeSubject
let id = subject.speciesID
let shiny = subject.isShiny
let key = id.map { "\($0)-\(shiny)" }
let key = id.map { Self.menuSpriteKey(id: $0, shiny: shiny, floor: menuFrameFloor) }
if key == menuSpriteKey, !menuFrames.isEmpty { return } // 이미 이 개체로 애니메이션 중
menuSpriteKey = key
menuLoadGen += 1
Expand All @@ -224,7 +245,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}

// 풀 GIF 애니메이션(저전력 모드에서는 생략하고 bob 유지). delay 하한 0.1s(≤10fps)로 redraw 통제.
// 풀 GIF 애니메이션(저전력 모드에서는 생략하고 bob 유지). delay 하한 `menuFrameFloor` 로 redraw 통제.
guard !ProcessInfo.processInfo.isLowPowerModeEnabled else { return }
Task { @MainActor [weak self] in
guard let self, gen == self.menuLoadGen else { return }
Expand All @@ -236,9 +257,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
guard let data else { return }
let raw = GIFDecoder.frames(from: data)
guard raw.count > 1, gen == self.menuLoadGen else { return }
// 메뉴바 GIF delay 하한 0.4s(≈2.5fps)로 캡 — 22px 스프라이트엔 5fps와 구분 안 되고, 프레임당
// 상태바 재합성(CA 커밋 → 디스플레이 사이클 wakeup)을 절반으로 줄여 배터리 절약. bob(0.5s/2fps)과 유사.
self.setMenuFrames(raw.map { (Self.menuBarImage(from: $0.image, up: false), max(0.4, $0.delay)) })
// fps 캡 = `menuFrameFloor`. 프레임마다 상태바 재합성(CA 커밋 → 디스플레이 사이클
// wakeup)이 붙으므로 네이티브 fps 로는 절대 돌리지 않는다(근거는 상수 주석).
// 솎아낸 **뒤** 22px 로 합성한다 — 버려질 프레임까지 합성하지 않게.
let capped = GIFDecoder.capFrameRate(raw, floor: self.menuFrameFloor)
self.setMenuFrames(capped.map { (Self.menuBarImage(from: $0.image, up: false), $0.delay) })
}
}

Expand Down Expand Up @@ -281,7 +304,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.advanceMenu()
}
}
timer.tolerance = frame.delay * 0.5 // 웨이크업 코얼레싱 (배터리) — 넓힐수록 다른 wakeup 과 합쳐짐
// 웨이크업 코얼레싱 (배터리). 넓힐수록 다른 wakeup 과 합쳐지지만 그만큼 애니메이션이
// 늘어질 수 있다 — 배수 근거는 `menuFrameTolerance` 주석.
timer.tolerance = frame.delay * Self.menuFrameTolerance
RunLoop.main.add(timer, forMode: .common)
menuTimer = timer
}
Expand All @@ -295,6 +320,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
&& (statusItem.button?.window?.occlusionState.contains(.visible) ?? true)
}

/// 메뉴바 프레임 캐시의 정체성 — 이 값이 바뀌면 프레임을 다시 만든다.
///
/// **하한(fps 설정)이 반드시 들어가야 한다.** 프레임은 하한에 맞춰 솎아낸 결과물이라, 키가
/// 종·이로치만 담으면 설정을 바꿔도 다음 진화까지 옛 fps 로 계속 돈다(설계 시 확인된 함정).
/// 순수·테스트용: `testIdentityKeysIncludeTheFrameFloor`.
static func menuSpriteKey(id: Int, shiny: Bool, floor: TimeInterval) -> String {
"\(id)-\(shiny)-\(floor)"
}

// MARK: 프레임 합성 (22px)

/// 스프라이트 정적 + 가벼운 상하 bob 2프레임 (animated 미지원/로딩 폴백).
Expand Down
Loading