diff --git a/README.md b/README.md index a5382fd..5e0f593 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ CodexIsland is a native macOS overlay that turns the MacBook notch into a Dynamic-Island-style live activity for Claude Code and Codex usage limits. It -sits quietly over the notch, peeks on hover with the 5-hour headline, and -expands on click to show both providers' 5-hour and weekly windows with reset +sits quietly over the notch and expands on hover to show both providers' +5-hour and weekly windows with reset timing, chart controls, local-log cost estimates, and a year-at-a-glance usage history. @@ -36,10 +36,10 @@ providers' own usage endpoints. physical notch, drawn with continuous (squircle) corners that match the hardware. On non-notched displays it falls back to a configurable menu-bar pill. -- **Hover to peek.** The silhouette widens just enough to show each visible - provider's 5-hour percentage and reset headline, or keep those headlines - visible at rest with **Always show usage**. -- **Three swipeable screens.** Click to expand, then swipe between **Usage**, +- **Hover to expand.** Hovering opens the full panel; moving away collapses it + after a 1.5-second grace period. Clicking remains available as a fallback. + **Always show usage** keeps the usage headlines visible at rest. +- **Three swipeable screens.** Expand the panel, then swipe between **Usage**, **Cost**, and **Overview**. Cost estimates today and month-to-date spend and token throughput from local Claude Code, Codex CLI, and OpenCode session data. Overview renders the current year's activity as a contribution-style @@ -160,8 +160,9 @@ the first peek. Opening Settings also triggers a fresh fetch. ## Using the app -- Hover the notch to peek at the current 5-hour usage. -- Click the island to expand the full panel. +- Hover over the island to expand the full panel. Moving the pointer away + collapses it after a 1.5-second grace period; clicking remains available as + a fallback. - Swipe horizontally on the panel (or use the indicator dots) to move between **Usage**, **Cost**, and **Overview**. - Move away to collapse it. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9513e63..f869942 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ > 你的 AI 用量限额,住在 Mac 刘海里。 -CodexIsland 是一个原生 macOS 悬浮层,把 MacBook 刘海变成类似 Dynamic Island 的实时用量状态。它支持 Claude Code 和 Codex,用悬停预览 5 小时窗口,用点击展开完整面板,展示 5 小时与周窗口的用量、重置时间、图表样式,以及从本地会话日志估算的美元成本和 token 吞吐量。 +CodexIsland 是一个原生 macOS 悬浮层,把 MacBook 刘海变成类似 Dynamic Island 的实时用量状态。它支持 Claude Code 和 Codex,悬停即可展开完整面板,展示 5 小时与周窗口的用量、重置时间、图表样式,以及从本地会话日志估算的美元成本和 token 吞吐量;移开鼠标 1.5 秒后自动收起,也可点击展开作为备用操作。 应用免费、开源、未签名,并且以本地优先为原则。它读取 Claude Code / Claude Desktop 和 Codex 已经写入本机的凭据,只调用对应服务自己的用量接口。 @@ -16,8 +16,7 @@ CodexIsland 是一个原生 macOS 悬浮层,把 MacBook 刘海变成类似 Dyn - **两个服务,四个窗口。** 在一个面板里显示 Claude 5 小时 + 7 天,以及 Codex 5 小时 + 7 天。 - **贴合刘海的悬浮层。** 紧凑状态是一个对齐物理刘海的黑色胶囊;没有刘海的 Mac 会退回到菜单栏胶囊。 -- **悬停预览。** 鼠标移到刘海附近时,胶囊会展开到足够显示每个可见服务的 5 小时百分比和重置提示。 -- **点击展开。** 点击岛可打开完整 Usage / Cost / Overview 面板,包含服务列、图表控制和分页。 +- **悬停展开。** 将鼠标移到岛上即可打开完整 Usage / Cost / Overview 面板;移开后会保留 1.5 秒缓冲再自动收起,点击仍可作为备用操作。开启“始终显示用量”后,收起状态也会保留用量摘要。 - **Usage 与 Cost 横向切换。** Cost 页面会从本地 Claude Code 和 Codex 日志估算今天与本月至今的美元成本、token 吞吐量和趋势。 - **可配置 token 统计口径。** 可以选择统计所有 token(包含缓存,接近 ccusage 口径),或只统计输入 + 输出(接近 Anthropic claude.ai 统计面板)。 - **不遮挡岛外点击。** 窗口会忽略可见轮廓外的鼠标事件,菜单栏和后面的 app 仍能正常操作。 @@ -72,8 +71,7 @@ Claude: ## 使用 -- 悬停刘海,预览当前 5 小时用量。 -- 点击岛,展开完整面板。 +- 悬停岛即可展开完整面板;移开 1.5 秒后自动收起,点击也可作为备用操作。 - 在面板上横向滑动,或点击底部圆点,在 **Usage**、**Cost** 和 **Overview** 之间切换。 - 移开鼠标,面板会收起。 - 在展开面板里 Cmd 点击,可切换当前页面的可视化样式。 diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index c2d3cf1..caeb4e4 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -149,6 +149,8 @@ "Click to expand. Command-click to cycle visualization." = "Click to expand. Command-click to cycle visualization."; "Command-click to cycle visualization." = "Command-click to cycle visualization."; "Hover to peek usage. Click to expand. Command-click to cycle visualization." = "Hover to peek usage. Click to expand. Command-click to cycle visualization."; +"Hover to expand. Move away to collapse." = "Hover to expand. Move away to collapse."; +"Hover or activate to expand. Move away to collapse." = "Hover or activate to expand. Move away to collapse."; "Hover to show reset expiration details" = "Hover to show reset expiration details"; "How often to refresh." = "How often to refresh."; "Percentages" = "Percentages"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index d96e12d..a793a30 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -149,6 +149,8 @@ "Click to expand. Command-click to cycle visualization." = "点击展开。Command 点击可切换可视化。"; "Command-click to cycle visualization." = "Command 点击可切换可视化。"; "Hover to peek usage. Click to expand. Command-click to cycle visualization." = "悬停预览用量。点击展开。Command 点击可切换可视化。"; +"Hover to expand. Move away to collapse." = "悬停即可展开,移开后自动收起。"; +"Hover or activate to expand. Move away to collapse." = "悬停或激活即可展开,移开后自动收起。"; "Hover to show reset expiration details" = "悬停查看重置到期详情"; "How often to refresh." = "刷新频率。"; "Percentages" = "百分比"; diff --git a/Sources/Views/IslandRootView.swift b/Sources/Views/IslandRootView.swift index f370cf8..34ac34a 100644 --- a/Sources/Views/IslandRootView.swift +++ b/Sources/Views/IslandRootView.swift @@ -8,6 +8,7 @@ struct IslandRootView: View { @State private var contentVisible = false @State private var pillsVisible = false @State private var pulseToken: UUID? + @State private var collapseRequest = UUID() /// Image decode from disk is ~150µs per call. Computed properties /// re-decoded both logos every render — inside a 120Hz TimelineView @@ -139,31 +140,9 @@ struct IslandRootView: View { } return } - // Plain click: enter the full panel. Works from .peek - // (the common case after hover) or .compact (cold click). - // Pills travel outward with the growing shape under the - // single openMorph spring, then quietly retire after the - // expanded content has settled. - guard model.state == .peek || model.state == .compact else { return } - withAnimation(.openMorph) { - model.setState(.expanded) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { - guard model.state == .expanded else { return } - withAnimation(.strongEaseOut) { - contentVisible = true - } - } - // Guard against a hover-out landing inside the 250ms - // wait: under always-show it restores the pills at peek, - // and this stale callback would hide them again — leaving - // the rest state pill-less until the next hover cycle. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - guard model.state == .expanded else { return } - withAnimation(.easeOut(duration: 0.18)) { - pillsVisible = false - } - } + // Keep click as an accessibility/fallback path; ordinary + // pointer use expands immediately on hover. + expandPanel() } .onHover { h in hovering = h @@ -174,60 +153,9 @@ struct IslandRootView: View { NSHapticFeedbackManager.defaultPerformer.perform( .levelChange, performanceTime: .now ) - // PEEK ENTER: shape morphs out to peek width. Pills - // fade in 60ms later so the eye sees the shape commit - // first, then content arrives. Hover does NOT open - // the full panel — that requires a click. - if model.state == .compact { - withAnimation(.openMorph) { - model.setState(.peek) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { - guard model.state == .peek else { return } - withAnimation(.easeOut(duration: 0.18)) { - pillsVisible = true - } - } - } + expandPanel() } else { - // EXIT: pills fade first (unless we're pinning peek), - // then the shape settles at the rest state — `.compact` - // normally, `.peek` under always-show. - if !alwaysShow.enabled { - withAnimation(.easeOut(duration: 0.08)) { - pillsVisible = false - } - } - withAnimation(.easeOut(duration: 0.10)) { - contentVisible = false - } - // Start the shape morph after only 20ms — overlapping - // with the content fade — so the silhouette begins - // shrinking while the content is still fading out. - // The original 100ms wait caused a visible "flash black" - // because the full-size black shape was exposed for the - // entire fade before the closeMorph fired. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { - guard !hovering else { return } - // Re-read restState here — the user may have flipped - // the always-show toggle during the 20ms wait, and - // a captured-at-creation-time `target` would settle - // at the wrong state for them. - let target = restState - if model.state != target { - withAnimation(.closeMorph) { - model.setState(target) - } - } - // Coming out of `.expanded` under always-show, the - // pills were hidden by the open-panel branch — bring - // them back as the shape resettles at peek. - if alwaysShow.enabled && !pillsVisible { - withAnimation(.easeOut(duration: 0.18)) { - pillsVisible = true - } - } - } + scheduleCollapseAfterHoverExit() } } Spacer(minLength: 0) @@ -345,13 +273,65 @@ struct IslandRootView: View { alwaysShow.enabled ? .peek : .compact } + private func expandPanel() { + // Invalidates any delayed collapse that was scheduled on a brief + // pointer exit while the shape was morphing. + collapseRequest = UUID() + if model.state == .expanded { + if !contentVisible { + withAnimation(.strongEaseOut) { + contentVisible = true + } + } + return + } + + withAnimation(.easeOut(duration: 0.08)) { + pillsVisible = false + } + withAnimation(.openMorph) { + model.setState(.expanded) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.14) { + guard model.state == .expanded else { return } + withAnimation(.strongEaseOut) { + contentVisible = true + } + } + } + + private func scheduleCollapseAfterHoverExit() { + // 1.5s is long enough to cross a small pointer gap or return after an + // accidental exit, without leaving the expanded dashboard hanging. + let request = UUID() + collapseRequest = request + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + guard collapseRequest == request, !hovering else { return } + withAnimation(.easeOut(duration: 0.12)) { + contentVisible = false + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { + guard collapseRequest == request, !hovering else { return } + let target = restState + withAnimation(.closeMorph) { + model.setState(target) + } + if target == .peek { + withAnimation(.easeOut(duration: 0.18)) { + pillsVisible = true + } + } + } + } + } + private var accessibilityHintForState: String { switch model.state { case .compact: return alwaysShow.enabled - ? L10n.tr("Click to expand. Command-click to cycle visualization.") - : L10n.tr("Hover to peek usage. Click to expand. Command-click to cycle visualization.") - case .peek: return L10n.tr("Click to expand. Command-click to cycle visualization.") + ? L10n.tr("Hover or activate to expand. Move away to collapse.") + : L10n.tr("Hover or activate to expand. Move away to collapse.") + case .peek: return L10n.tr("Hover or activate to expand. Move away to collapse.") case .expanded: return ScreenPref.shared.screen == .overview ? L10n.tr("Swipe to change pages.")