Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Swipe horizontally on the panel (or use the indicator dots) to move between
**Usage**, **Cost**, and **Overview**.
- Move away to collapse it.
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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 仍能正常操作。
Expand Down
1 change: 1 addition & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"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 to show reset expiration details" = "Hover to show reset expiration details";
"How often to refresh." = "How often to refresh.";
"Percentages" = "Percentages";
Expand Down
1 change: 1 addition & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"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 to show reset expiration details" = "悬停查看重置到期详情";
"How often to refresh." = "刷新频率。";
"Percentages" = "百分比";
Expand Down
135 changes: 54 additions & 81 deletions Sources/Views/IslandRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -345,13 +273,58 @@ 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()
guard model.state != .expanded else { return }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore content when canceling an in-progress collapse

If the pointer returns during the 80 ms interval after contentVisible is set to false but before the shape shrinks, expandPanel() invalidates the collapse request and then returns here because the model is still .expanded. The second collapse callback is consequently canceled, but nothing makes the content visible again, leaving an expanded black panel with disabled content until the user exits and waits for another complete collapse. Restore contentVisible when canceling this phase instead of returning solely based on the model state.

Useful? React with 👍 / 👎.


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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}

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 to expand. Move away to collapse.")
: L10n.tr("Hover to expand. Move away to collapse.")
case .peek: return L10n.tr("Hover to expand. Move away to collapse.")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
case .expanded:
return ScreenPref.shared.screen == .overview
? L10n.tr("Swipe to change pages.")
Expand Down
Loading