Skip to content
Open
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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ features = [
"Win32_Globalization",
"Win32_Graphics_Gdi",
"Win32_System_LibraryLoader",
"Win32_System_Time",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Accessibility",
Expand Down
90 changes: 87 additions & 3 deletions src/poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde::Deserialize;
use serde::{Deserialize, Serialize};
use std::os::windows::process::CommandExt;
use windows::Win32::Foundation::{FILETIME, SYSTEMTIME};
use windows::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToTzSpecificLocalTime};

use crate::diagnose;
use crate::localization::Strings;
Expand Down Expand Up @@ -43,6 +45,23 @@ pub enum CredentialWatchMode {

pub type CredentialWatchSnapshot = Vec<String>;

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ResetTimeDisplay {
#[default]
Relative,
Clock,
}

impl ResetTimeDisplay {
pub fn toggled(self) -> Self {
match self {
Self::Relative => Self::Clock,
Self::Clock => Self::Relative,
}
}
}

#[derive(Deserialize)]
struct UsageResponse {
five_hour: Option<UsageBucket>,
Expand Down Expand Up @@ -1522,16 +1541,32 @@ fn is_leap(y: u64) -> bool {
}

/// Format a usage section as "X% · Yh" style text
pub fn format_line(section: &UsageSection, strings: Strings) -> String {
pub fn format_line(
section: &UsageSection,
strings: Strings,
reset_time_display: ResetTimeDisplay,
) -> String {
let pct = format!("{:.0}%", section.percentage);
let cd = format_countdown(section.resets_at, strings);
let cd = format_reset_time(section.resets_at, strings, reset_time_display);
if cd.is_empty() {
pct
} else {
format!("{pct} \u{00b7} {cd}")
}
}

fn format_reset_time(
resets_at: Option<SystemTime>,
strings: Strings,
reset_time_display: ResetTimeDisplay,
) -> String {
match reset_time_display {
ResetTimeDisplay::Relative => format_countdown(resets_at, strings),
ResetTimeDisplay::Clock => format_clock_time(resets_at)
.unwrap_or_else(|| format_countdown(resets_at, strings)),
}
}

fn format_countdown(resets_at: Option<SystemTime>, strings: Strings) -> String {
let reset = match resets_at {
Some(t) => t,
Expand All @@ -1546,6 +1581,48 @@ fn format_countdown(resets_at: Option<SystemTime>, strings: Strings) -> String {
format_countdown_from_secs(remaining.as_secs(), strings)
}

fn format_clock_time(resets_at: Option<SystemTime>) -> Option<String> {
let reset = resets_at?;
reset.duration_since(SystemTime::now()).ok()?;

let local_time = local_system_time(reset)?;
Some(format_clock_time_parts(local_time.wHour, local_time.wMinute))
}

fn format_clock_time_parts(hour_24: u16, minute: u16) -> String {
let hour_12 = match hour_24 % 12 {
0 => 12,
hour => hour,
};
format!("{hour_12}:{minute:02}")
}

fn local_system_time(time: SystemTime) -> Option<SYSTEMTIME> {
const WINDOWS_EPOCH_OFFSET_SECS: u64 = 11_644_473_600;
const WINDOWS_TICKS_PER_SEC: u64 = 10_000_000;

let duration = time.duration_since(UNIX_EPOCH).ok()?;
let ticks = duration
.as_secs()
.checked_add(WINDOWS_EPOCH_OFFSET_SECS)?
.checked_mul(WINDOWS_TICKS_PER_SEC)?
.checked_add(u64::from(duration.subsec_nanos() / 100))?;

let file_time = FILETIME {
dwLowDateTime: ticks as u32,
dwHighDateTime: (ticks >> 32) as u32,
};

let mut utc_time = SYSTEMTIME::default();
let mut local_time = SYSTEMTIME::default();
unsafe {
FileTimeToSystemTime(&file_time, &mut utc_time).ok()?;
SystemTimeToTzSpecificLocalTime(None, &utc_time, &mut local_time).ok()?;
}

Some(local_time)
}

/// Calculate how long until the display text would change
pub fn time_until_display_change(resets_at: Option<SystemTime>) -> Option<Duration> {
let reset = resets_at?;
Expand Down Expand Up @@ -1614,6 +1691,13 @@ mod tests {
}
}

#[test]
fn clock_display_uses_twelve_hour_time_without_suffix() {
assert_eq!(format_clock_time_parts(14, 20), "2:20");
assert_eq!(format_clock_time_parts(0, 20), "12:20");
assert_eq!(format_clock_time_parts(12, 5), "12:05");
}

#[test]
fn claude_failure_does_not_block_codex_when_both_are_enabled() {
let data = poll_with(
Expand Down
117 changes: 111 additions & 6 deletions src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ struct AppState {
show_claude_code: bool,
show_codex: bool,
show_antigravity: bool,
reset_time_display: poller::ResetTimeDisplay,

data: Option<AppUsageData>,

Expand Down Expand Up @@ -316,6 +317,8 @@ struct SettingsFile {
show_codex: bool,
#[serde(default = "default_show_antigravity")]
show_antigravity: bool,
#[serde(default)]
reset_time_display: poller::ResetTimeDisplay,
}

impl Default for SettingsFile {
Expand All @@ -330,6 +333,7 @@ impl Default for SettingsFile {
show_claude_code: true,
show_codex: false,
show_antigravity: false,
reset_time_display: poller::ResetTimeDisplay::default(),
}
}
}
Expand Down Expand Up @@ -391,6 +395,7 @@ fn save_state_settings() {
show_claude_code: s.show_claude_code,
show_codex: s.show_codex,
show_antigravity: s.show_antigravity,
reset_time_display: s.reset_time_display,
});
}
}
Expand Down Expand Up @@ -640,33 +645,36 @@ fn refresh_usage_texts(state: &mut AppState) {
}

let strings = state.language.strings();
let reset_time_display = state.reset_time_display;
let Some(data) = state.data.as_ref() else {
return;
};

if let Some(claude_code) = data.claude_code.as_ref() {
state.session_text = poller::format_line(&claude_code.session, strings);
state.weekly_text = poller::format_line(&claude_code.weekly, strings);
state.session_text =
poller::format_line(&claude_code.session, strings, reset_time_display);
state.weekly_text = poller::format_line(&claude_code.weekly, strings, reset_time_display);
} else if state.show_claude_code {
state.session_text = "!".to_string();
state.weekly_text = "!".to_string();
}

if let Some(codex) = data.codex.as_ref() {
state.codex_session_text = poller::format_line(&codex.session, strings);
state.codex_weekly_text = poller::format_line(&codex.weekly, strings);
state.codex_session_text = poller::format_line(&codex.session, strings, reset_time_display);
state.codex_weekly_text = poller::format_line(&codex.weekly, strings, reset_time_display);
} else if state.show_codex {
state.codex_session_text = "!".to_string();
state.codex_weekly_text = "!".to_string();
}

if let Some(antigravity) = data.antigravity.as_ref() {
state.antigravity_session_text = poller::format_line(&antigravity.session, strings);
state.antigravity_session_text =
poller::format_line(&antigravity.session, strings, reset_time_display);
state.antigravity_weekly_text =
if antigravity.weekly.resets_at.is_none() && antigravity.weekly.percentage == 0.0 {
"--".to_string()
} else {
poller::format_line(&antigravity.weekly, strings)
poller::format_line(&antigravity.weekly, strings, reset_time_display)
};
} else if state.show_antigravity {
state.antigravity_session_text = "!".to_string();
Expand Down Expand Up @@ -1079,6 +1087,74 @@ fn cursor_is_on_drag_handle(hwnd: HWND) -> bool {
}
}

fn cursor_is_on_reset_time_toggle(hwnd: HWND) -> bool {
unsafe {
let mut pt = POINT::default();
if GetCursorPos(&mut pt).is_err() || !ScreenToClient(hwnd, &mut pt).as_bool() {
return false;
}

let state = lock_state();
state
.as_ref()
.is_some_and(|s| is_reset_time_toggle_point(s, pt.x, pt.y))
}
}

fn is_reset_time_toggle_point(state: &AppState, client_x: i32, client_y: i32) -> bool {
let height = sc(WIDGET_HEIGHT);
let content_x = sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN);
let row2_y = height - sc(5) - sc(SEGMENT_H);
let row1_y = row2_y - sc(10) - sc(SEGMENT_H);
let active_models = active_model_count(
state.show_claude_code,
state.show_codex,
state.show_antigravity,
);
let segment_count = row_bar_segment_count(active_models);

reset_text_hit_row(state, content_x, row1_y, segment_count, client_x, client_y)
|| reset_text_hit_row(state, content_x, row2_y, segment_count, client_x, client_y)
}

fn reset_text_hit_row(
state: &AppState,
x: i32,
y: i32,
segment_count: i32,
client_x: i32,
client_y: i32,
) -> bool {
if client_y < y || client_y >= y + sc(SEGMENT_H) {
return false;
}

let mut model_x = x + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN);
for visible in [
state.show_claude_code,
state.show_codex,
state.show_antigravity,
] {
if visible {
if reset_text_hit_model(model_x, segment_count, client_x) {
return true;
}
model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN);
}
}

false
}

fn reset_text_hit_model(bar_x: i32, segment_count: i32, client_x: i32) -> bool {
let text_x = bar_x
+ segment_count * (sc(SEGMENT_W) + sc(SEGMENT_GAP))
- sc(SEGMENT_GAP)
+ sc(BAR_RIGHT_MARGIN);

client_x >= text_x && client_x < text_x + sc(TEXT_WIDTH)
}

fn active_model_count(show_claude_code: bool, show_codex: bool, show_antigravity: bool) -> i32 {
(show_claude_code as i32 + show_codex as i32 + show_antigravity as i32).max(1)
}
Expand Down Expand Up @@ -1310,6 +1386,7 @@ pub fn run() {
show_claude_code: settings.show_claude_code,
show_codex: settings.show_codex,
show_antigravity: settings.show_antigravity,
reset_time_display: settings.reset_time_display,
data: None,
poll_interval_ms: settings.poll_interval_ms,
retry_count: 0,
Expand Down Expand Up @@ -2338,6 +2415,11 @@ unsafe extern "system" fn wnd_proc(
SetCursor(cursor);
return LRESULT(1);
}
if cursor_is_on_reset_time_toggle(hwnd) {
let cursor = LoadCursorW(HINSTANCE::default(), IDC_HAND).unwrap_or_default();
SetCursor(cursor);
return LRESULT(1);
}
DefWindowProcW(hwnd, msg, wparam, lparam)
}
WM_LBUTTONDOWN => {
Expand Down Expand Up @@ -2456,6 +2538,29 @@ unsafe extern "system" fn wnd_proc(
LRESULT(0)
}
WM_LBUTTONUP => {
let client_x = (lparam.0 & 0xFFFF) as i16 as i32;
let client_y = ((lparam.0 >> 16) & 0xFFFF) as i16 as i32;
let toggled_reset_time = {
let mut state = lock_state();
if let Some(s) = state.as_mut() {
if !s.dragging && is_reset_time_toggle_point(s, client_x, client_y) {
s.reset_time_display = s.reset_time_display.toggled();
refresh_usage_texts(s);
true
} else {
false
}
} else {
false
}
};
if toggled_reset_time {
save_state_settings();
schedule_countdown_timer();
render_layered();
return LRESULT(0);
}

let mut pt = POINT::default();
let _ = GetCursorPos(&mut pt);
let drag_result = {
Expand Down