From fa2bec67d1402844046fd1a716e419a660461328 Mon Sep 17 00:00:00 2001 From: fix2it Date: Sun, 18 Oct 2026 22:37:08 +0300 Subject: [PATCH 1/2] fix(antigravity): read the Gemini pool from the quota summary reliably The summary picked the first group with windows when the Gemini group did not match, so the widget showed the "Claude and GPT models" quota under the Antigravity name. - The first endpoint that returns retrieveUserQuotaSummary is authoritative and returned at once; endpoints are no longer merged and fetchAvailableModels is skipped when the summary answered. - Groups are classified as Gemini or external (claude/gpt/3p-* buckets); an unrecognised group is used only when it is the sole group. - A summary without recognised windows (or with an empty group list) is "no limit data", not a failure. - The model-quota fallback uses Gemini models only, because per-model quotas do not say which window they belong to. - Send an explicit Antigravity User-Agent; try the sandbox endpoint last. - Absent windows are cached with `percentage: null` so external readers see "unknown" rather than 0% used. Co-Authored-By: Claude Opus 5.5 --- src/models.rs | 33 +++++- src/poller/antigravity.rs | 203 +++++++++++++++++++++++++++------- src/poller/tests.rs | 224 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 416 insertions(+), 44 deletions(-) diff --git a/src/models.rs b/src/models.rs index 3e11d9a7..1cddc13c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::providers::ProviderId; -#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct UsageSection { /// The provider reported this window, even if unused and without a reset time. pub available: bool, @@ -14,6 +14,21 @@ pub struct UsageSection { pub resets_at: Option, } +impl Serialize for UsageSection { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // An absent window carries no figure: external cache readers that only + // look at `percentage` must see "unknown", not 0% used. + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("available", &self.available)?; + map.serialize_entry("percentage", &self.available.then_some(self.percentage))?; + map.serialize_entry("resets_at", &self.resets_at)?; + map.end() + } +} + impl<'de> Deserialize<'de> for UsageSection { fn deserialize(deserializer: D) -> Result where @@ -22,17 +37,18 @@ impl<'de> Deserialize<'de> for UsageSection { #[derive(Deserialize)] struct StoredSection { available: Option, - percentage: f64, + percentage: Option, resets_at: Option, } let stored = StoredSection::deserialize(deserializer)?; + let percentage = stored.percentage.unwrap_or(0.0); Ok(Self { // Older caches lost the distinction between an idle window and an // absent one. Preserve evidence of presence until a fresh poll. available: stored .available - .unwrap_or(stored.resets_at.is_some() || stored.percentage != 0.0), - percentage: stored.percentage, + .unwrap_or(stored.resets_at.is_some() || percentage != 0.0), + percentage, resets_at: stored.resets_at, }) } @@ -464,4 +480,13 @@ mod tests { assert!(decoded.get(ProviderId::Antigravity).is_none()); assert!(decoded.get(ProviderId::OpenCode).is_none()); } + + #[test] + fn absent_window_is_cached_without_a_percentage() { + let section = UsageSection::default(); + let json = serde_json::to_value(§ion).unwrap(); + assert!(json["percentage"].is_null(), "readers must not see 0% for an absent window"); + let decoded: UsageSection = serde_json::from_value(json).unwrap(); + assert_eq!(decoded, section); + } } diff --git a/src/poller/antigravity.rs b/src/poller/antigravity.rs index 7b503ab9..9c427139 100644 --- a/src/poller/antigravity.rs +++ b/src/poller/antigravity.rs @@ -14,10 +14,11 @@ use crate::models::{UsageData, UsageSection}; const ANTIGRAVITY_CREDENTIAL_TARGET: &str = "gemini:antigravity"; const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; const EXPIRY_SKEW: Duration = Duration::from_secs(60); +const ANTIGRAVITY_USER_AGENT: &str = "antigravity/2.24.0"; const ANTIGRAVITY_ENDPOINTS: &[&str] = &[ "https://daily-cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com", "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", ]; #[derive(Deserialize)] @@ -44,17 +45,17 @@ struct AntigravityLoadResponse { } #[derive(Deserialize)] -struct AntigravityModelsResponse { - models: HashMap, +pub(super) struct AntigravityModelsResponse { + pub(super) models: HashMap, } #[derive(Deserialize)] -struct AntigravityModelInfo { +pub(super) struct AntigravityModelInfo { #[serde(rename = "quotaInfo")] - quota_info: Option, + pub(super) quota_info: Option, } -#[derive(Deserialize)] +#[derive(Clone, Deserialize)] pub(super) struct AntigravityQuotaInfo { #[serde(rename = "remainingFraction")] remaining_fraction: Option, @@ -314,17 +315,37 @@ pub(super) fn antigravity_credential_watch_signature() -> String { } pub(super) fn fetch_antigravity_usage(token: &str) -> Result { + select_antigravity_usage(ANTIGRAVITY_ENDPOINTS, |base_url| { + fetch_antigravity_usage_from_endpoint(base_url, token) + }) +} + +pub(super) fn select_antigravity_usage( + endpoints: &[&str], + mut fetch: impl FnMut(&str) -> Result<(UsageData, AntigravitySource), PollError>, +) -> Result { + let mut model_fallback: Option = None; let mut auth_error = false; let mut last_error = PollError::RequestFailed; - for base_url in ANTIGRAVITY_ENDPOINTS { - match fetch_antigravity_usage_from_endpoint(base_url, token) { - Ok(data) => return Ok(data), + for base_url in endpoints { + match fetch(base_url) { + // A quota summary names every window it has; it is authoritative, + // so later endpoints are neither asked nor merged. + Ok((data, AntigravitySource::Summary)) => return Ok(data), + // Model quotas do not name their window; keep them only as a last + // resort in case no endpoint answers with a summary. + Ok((data, AntigravitySource::Models)) => { + model_fallback.get_or_insert(data); + } Err(PollError::AuthRequired) => auth_error = true, Err(error) => last_error = error, } } + if let Some(data) = model_fallback { + return Ok(data); + } if auth_error { Err(PollError::AuthRequired) } else { @@ -332,35 +353,70 @@ pub(super) fn fetch_antigravity_usage(token: &str) -> Result Result { +) -> Result<(UsageData, AntigravitySource), PollError> { let project = fetch_antigravity_project(base_url, token)?; - if let Some(project) = project.as_deref() { + let summary_result = if let Some(project) = project.as_deref() { match fetch_antigravity_quota_summary(base_url, token, project) { - Ok(data) => return Ok(data), + Ok(data) => Some(data), Err(PollError::AuthRequired) => return Err(PollError::AuthRequired), - Err(error) => diagnose::log(format!( - "Antigravity retrieveUserQuotaSummary failed, falling back to model quota: {error:?}" - )), + Err(error) => { + diagnose::log(format!( + "Antigravity retrieveUserQuotaSummary failed: {error:?}" + )); + None + } } + } else { + None + }; + + // Per-model quotas do not say which window (5h or weekly) they belong to, + // so when the summary answered it is used alone and the models request is + // skipped. + if let Some(data) = summary_result { + return Ok((data, AntigravitySource::Summary)); + } + let models_result = fetch_antigravity_models_response(base_url, token, project.as_deref()); + + match models_result { + Ok(models) => antigravity_usage_from_models(&models) + .map(|data| (data, AntigravitySource::Models)) + .ok_or(PollError::RequestFailed), + Err(err) => Err(err), } +} - let session = fetch_antigravity_model_quota(base_url, token, project.as_deref())?; - let weekly = UsageSection::default(); - - Ok(UsageData { - limits: Vec::new(), - session, - weekly, - weekly_label: None, - monthly: None, - credits: None, - stale: false, +/// Fallback when the quota summary is unavailable: Gemini models only. +pub(super) fn antigravity_usage_from_models(models: &AntigravityModelsResponse) -> Option { + let gemini = best_gemini_model_quota(models); + // Claude/GPT model quotas do not say whether they are the 5h or the weekly + // window, so the external pool is left as "no limit data" here rather + // than guessed. + Some(UsageData { + session: gemini?, + ..Default::default() }) } +pub(super) fn best_gemini_model_quota(models: &AntigravityModelsResponse) -> Option { + best_antigravity_section(models.models.iter().filter_map(|(model, info)| { + if !model.starts_with("gemini") { + return None; + } + let quota = info.quota_info.as_ref().cloned()?; + antigravity_section_from_quota(quota) + })) +} + pub(super) fn fetch_antigravity_project( base_url: &str, token: &str, @@ -376,7 +432,7 @@ pub(super) fn fetch_antigravity_project( .post(&format!("{base_url}/v1internal:loadCodeAssist")) .header("Authorization", &format!("Bearer {token}")) .header("Content-Type", "application/json") - .header("User-Agent", "antigravity") + .header("User-Agent", ANTIGRAVITY_USER_AGENT) .send_json(&body) .and_then(super::check_http_status) { @@ -404,11 +460,11 @@ pub(super) fn fetch_antigravity_project( Ok(response.project.filter(|project| !project.is_empty())) } -pub(super) fn fetch_antigravity_model_quota( +pub(super) fn fetch_antigravity_models_response( base_url: &str, token: &str, project: Option<&str>, -) -> Result { +) -> Result { let agent = build_agent()?; let body = match project { Some(project) => serde_json::json!({ "project": project }), @@ -419,7 +475,7 @@ pub(super) fn fetch_antigravity_model_quota( .post(&format!("{base_url}/v1internal:fetchAvailableModels")) .header("Authorization", &format!("Bearer {token}")) .header("Content-Type", "application/json") - .header("User-Agent", "antigravity") + .header("User-Agent", ANTIGRAVITY_USER_AGENT) .send_json(&body) .and_then(super::check_http_status) { @@ -447,6 +503,17 @@ pub(super) fn fetch_antigravity_model_quota( } }; + Ok(response) +} + +#[allow(dead_code)] +pub(super) fn fetch_antigravity_model_quota( + base_url: &str, + token: &str, + project: Option<&str>, +) -> Result { + let response = fetch_antigravity_models_response(base_url, token, project)?; + best_antigravity_section(response.models.into_iter().filter_map(|(model, info)| { let quota = info.quota_info?; if !is_antigravity_display_model(&model) { @@ -469,7 +536,7 @@ pub(super) fn fetch_antigravity_quota_summary( .post(&format!("{base_url}/v1internal:retrieveUserQuotaSummary")) .header("Authorization", &format!("Bearer {token}")) .header("Content-Type", "application/json") - .header("User-Agent", "antigravity") + .header("User-Agent", ANTIGRAVITY_USER_AGENT) .send_json(&body) .and_then(super::check_http_status) { @@ -520,22 +587,77 @@ pub(super) fn antigravity_section_from_summary_bucket( pub(super) fn antigravity_usage_from_summary( response: AntigravityQuotaSummaryResponse, ) -> Option { - let mut fallback = None; - - for group in response.groups.unwrap_or_default() { + let mut gemini_usage = None; + let mut external_usage = None; + let mut other_groups = Vec::new(); + + // A summary that returns a group list (even an empty one) answered + // authoritatively: no recognised window means "no limit data", not a + // failure to be papered over with model quotas or another endpoint. + let answered = response.groups.is_some(); + let groups = response.groups.unwrap_or_default(); + for group in groups { let is_gemini = is_antigravity_gemini_summary_group(&group); + let is_external = is_antigravity_external_summary_group(&group); let usage = antigravity_usage_from_summary_group(group); - if is_gemini && usage.is_some() { - return usage; + if is_gemini && gemini_usage.is_none() { + gemini_usage = usage; + } else if is_external && external_usage.is_none() { + external_usage = usage; + } else if let Some(u) = usage { + other_groups.push(u); } + } - if fallback.is_none() { - fallback = usage; - } + // An unrecognised group is assigned only when it is the sole group with + // quota and no named pool exists; otherwise a position guess could show one + // pool's usage under the other's label. + if gemini_usage.is_none() && external_usage.is_none() && other_groups.len() == 1 { + gemini_usage = other_groups.pop(); } - fallback + // The external "Claude and GPT models" group is recognised only so it is + // never shown as the Gemini pool. + match gemini_usage { + Some(gemini) => Some(gemini), + None => answered.then(UsageData::default), + } +} + +pub(super) fn is_antigravity_external_summary_group(group: &AntigravityQuotaSummaryGroup) -> bool { + group + .display_name + .as_deref() + .is_some_and(|name| { + let n = name.to_ascii_lowercase(); + n.contains("claude") || n.contains("gpt") || n.contains("external") + }) + || group + .description + .as_deref() + .is_some_and(|description| { + let d = description.to_ascii_lowercase(); + d.contains("claude") || d.contains("gpt") || d.contains("external") + }) + || group.buckets.as_ref().is_some_and(|buckets| { + buckets.iter().any(|bucket| { + bucket + .bucket_id + .as_deref() + .is_some_and(|id| { + let id = id.to_ascii_lowercase(); + id.starts_with("claude-") || id.starts_with("gpt-") || id.starts_with("3p-") + }) + || bucket + .display_name + .as_deref() + .is_some_and(|name| { + let n = name.to_ascii_lowercase(); + n.contains("claude") || n.contains("gpt") + }) + }) + }) } pub(super) fn antigravity_usage_from_summary_group( @@ -826,3 +948,4 @@ mod auth_tests { assert_eq!(current.token.refresh_token.as_deref(), Some("refresh")); } } + diff --git a/src/poller/tests.rs b/src/poller/tests.rs index ff8d515d..866ff7e0 100644 --- a/src/poller/tests.rs +++ b/src/poller/tests.rs @@ -468,3 +468,227 @@ fn all_failed_providers_can_carry_their_previous_readings() { assert_eq!(claude.session.percentage, 21.0); assert!(claude.stale, "the carried reading must be marked stale"); } + +#[test] +fn antigravity_gemini_weekly_reset_projection_tests() { + use std::time::{Duration, SystemTime}; + + fn format_iso8601_for_test(time: SystemTime) -> String { + let secs = time + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + let days = secs / 86400; + let rem = secs % 86400; + let hours = rem / 3600; + let minutes = (rem % 3600) / 60; + let seconds = rem % 60; + let mut year = 1970; + let mut d = days; + loop { + let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let y_days = if leap { 366 } else { 365 }; + if d < y_days { + let mut month = 1; + let month_days = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + for &m_len in &month_days { + if d < m_len { + return format!( + "{year:04}-{month:02}-{:02}T{hours:02}:{minutes:02}:{seconds:02}Z", + d + 1 + ); + } + d -= m_len; + month += 1; + } + } + d -= y_days; + year += 1; + } + } + + let now = SystemTime::now(); + + // 1. A weekly reset that is close is reported as-is: the API's resetTime is + // authoritative and no "+6 days" projection is applied. + let reset_16h = now + Duration::from_secs(16 * 3600); + let reset_iso_16h = format_iso8601_for_test(reset_16h); + let bucket_json = serde_json::json!({ + "bucketId": "gemini-weekly", + "displayName": "Weekly Limit", + "window": "weekly", + "resetTime": reset_iso_16h, + "remainingFraction": 0.80 + }); + let bucket: antigravity::AntigravityQuotaSummaryBucket = + serde_json::from_value(bucket_json).unwrap(); + let section = antigravity::antigravity_section_from_summary_bucket(&bucket) + .expect("section exists"); + assert!((section.percentage - 20.0).abs() < 0.001); + let reported_reset = section.resets_at.expect("reset exists"); + let diff = reported_reset + .duration_since(reset_16h) + .unwrap_or_else(|e| e.duration()); + assert!(diff < Duration::from_secs(2), "close weekly reset must not be projected"); + + // 2. gemini-weekly with reset in the past (-1 hour) + let reset_past = now - Duration::from_secs(3600); + let reset_iso_past = format_iso8601_for_test(reset_past); + let bucket_past_json = serde_json::json!({ + "bucketId": "gemini-weekly", + "window": "weekly", + "resetTime": reset_iso_past, + "remainingFraction": 0.80 + }); + let bucket_past: antigravity::AntigravityQuotaSummaryBucket = + serde_json::from_value(bucket_past_json).unwrap(); + let section_past = antigravity::antigravity_section_from_summary_bucket(&bucket_past) + .expect("section exists"); + let past_reset = section_past.resets_at.expect("reset exists"); + let diff_past = past_reset + .duration_since(reset_past) + .unwrap_or_else(|e| e.duration()); + assert!(diff_past < Duration::from_secs(2), "Past reset must not have +6d added"); + + // 3. 3p-weekly with reset in 16 hours (MUST NOT have +6d added) + let bucket_3p_json = serde_json::json!({ + "bucketId": "3p-weekly", + "window": "weekly", + "resetTime": reset_iso_16h, + "remainingFraction": 0.50 + }); + let bucket_3p: antigravity::AntigravityQuotaSummaryBucket = + serde_json::from_value(bucket_3p_json).unwrap(); + let section_3p = antigravity::antigravity_section_from_summary_bucket(&bucket_3p) + .expect("section exists"); + let reset_3p = section_3p.resets_at.expect("reset exists"); + let diff_3p = reset_3p + .duration_since(reset_16h) + .unwrap_or_else(|e| e.duration()); + assert!(diff_3p < Duration::from_secs(2), "3p-weekly must not have +6d added"); +} + +#[test] +fn antigravity_models_quota_extracts_gemini_and_external_correctly() { + let models_json = r#"{ + "models": { + "gemini-2.5-flash": { + "quotaInfo": { + "remainingFraction": 0.66, + "resetTime": "2026-10-17T16:00:00Z" + } + }, + "gemini-3-flash": { + "quotaInfo": { + "remainingFraction": 0.80, + "resetTime": "2026-10-17T16:00:00Z" + } + }, + "claude-sonnet-4-6": { + "quotaInfo": { + "remainingFraction": 0.02, + "resetTime": "2099-10-22T18:00:00Z" + } + } + } + }"#; + let resp: antigravity::AntigravityModelsResponse = serde_json::from_str(models_json).unwrap(); + let gemini = antigravity::best_gemini_model_quota(&resp).expect("gemini quota exists"); + assert!((gemini.percentage - 34.0).abs() < 0.001); + +} + +#[test] +fn antigravity_first_summary_wins_and_model_quotas_are_only_a_last_resort() { + use antigravity::AntigravitySource::{Models, Summary}; + use std::cell::RefCell; + + let with_session = |percentage| UsageData { + session: UsageSection { + available: true, + percentage, + resets_at: None, + }, + ..Default::default() + }; + let endpoints = ["a", "b", "c"]; + + // A weekly-only summary is final: the next endpoint's model quota must not + // fill its 5h window, and later endpoints are not asked at all. + let asked = RefCell::new(Vec::new()); + let data = antigravity::select_antigravity_usage(&endpoints, |url| { + asked.borrow_mut().push(url.to_string()); + match url { + "a" => Err(PollError::RequestFailed), + "b" => Ok((UsageData::default(), Summary)), + _ => Ok((with_session(80.0), Models)), + } + }) + .unwrap(); + assert!(!data.session.available); + assert_eq!(*asked.borrow(), ["a", "b"]); + + // Without any summary the model quota is used. + let data = antigravity::select_antigravity_usage(&endpoints, |url| match url { + "a" => Ok((with_session(30.0), Models)), + _ => Err(PollError::RequestFailed), + }) + .unwrap(); + assert!((data.session.percentage - 30.0).abs() < 0.001); +} + +#[test] +fn antigravity_model_fallback_reports_only_gemini_models() { + let models: antigravity::AntigravityModelsResponse = serde_json::from_str( + r#"{"models":{ + "gemini-3-pro":{"quotaInfo":{"remainingFraction":0.9}}, + "claude-opus-4-6-thinking":{"quotaInfo":{"remainingFraction":0.1}}, + "gpt-oss-120b-medium":{"quotaInfo":{"remainingFraction":0.5}}, + "imagen-4":{"quotaInfo":{"remainingFraction":0.0}} + }}"#, + ) + .unwrap(); + let usage = antigravity::antigravity_usage_from_models(&models).expect("usage"); + assert!((usage.session.percentage - 10.0).abs() < 0.001, "Gemini pool must ignore Claude/GPT/image models"); +} + +#[test] +fn antigravity_summary_does_not_guess_pools_by_position() { + // An unnamed group with 3p-* buckets is the external pool, never Gemini. + let response: antigravity::AntigravityQuotaSummaryResponse = serde_json::from_str( + r#"{"groups":[{"buckets":[ + {"bucketId":"3p-weekly","window":"weekly","remainingFraction":0.4}, + {"bucketId":"3p-5h","window":"5h","remainingFraction":1} + ]}]}"#, + ) + .unwrap(); + let usage = antigravity::antigravity_usage_from_summary(response).expect("usage"); + assert!(!usage.session.available && !usage.weekly.available, "Gemini pool must stay empty"); +} + +#[test] +fn antigravity_summary_without_windows_is_an_answer_not_a_failure() { + let response: antigravity::AntigravityQuotaSummaryResponse = serde_json::from_str( + r#"{"groups":[{"displayName":"Gemini models","buckets":[]}]}"#, + ) + .unwrap(); + let usage = antigravity::antigravity_usage_from_summary(response).expect("authoritative"); + assert!(!usage.session.available && !usage.weekly.available); + + let empty: antigravity::AntigravityQuotaSummaryResponse = + serde_json::from_str(r#"{"groups":[]}"#).unwrap(); + assert!(antigravity::antigravity_usage_from_summary(empty).is_some()); +} From 8396ca514abad0aa58fb099b0e3f4bde948fbc2d Mon Sep 17 00:00:00 2001 From: fix2it Date: Sun, 18 Oct 2026 22:39:33 +0300 Subject: [PATCH 2/2] feat(antigravity): track the "Claude and GPT models" pool separately Antigravity has two quota groups: Gemini and the external "Claude and GPT models" group. Both are now polled and shown. - UsageData carries the external group as `external_pool`. - Cache keys: `antigravity_gemini`, `antigravity_external`, and the legacy `antigravity`, which mirrors the external pool for existing readers and is omitted when that pool is absent. - Settings and menus toggle each pool (command 67 external, 68 Gemini; 62 still toggles the whole provider). Re-saving settings keeps the per-pool choice, and a disabled provider is not re-enabled on load. - Compact Fluent Quad shows `antigravity.gemini.*` and `antigravity.external.*`, alternating on a shared crossfade timer when both are on. Other themes keep `antigravity.*` = Gemini. - A pool or window that was not reported shows "--" and an empty gauge, never 0% used; the tray tooltip lists both pools. Co-Authored-By: Claude Opus 5.5 --- src/app_settings.rs | 70 +- src/context_menu.rs | 15 +- src/models.rs | 111 ++- src/native_interop.rs | 2 + src/poller/antigravity.rs | 29 +- src/poller/copilot.rs | 1 + src/poller/cursor.rs | 1 + src/poller/grok.rs | 1 + src/poller/opencode.rs | 1 + src/poller/tests.rs | 42 ++ src/studio_app/studio_context_menu_editor.rs | 4 + src/studio_app/studio_core.rs | 5 + src/studio_app/tests.rs | 1 + src/theme_engine.rs | 103 ++- src/theme_engine/tests.rs | 186 +++++ src/theme_engine/theme_expression.rs | 25 +- src/themes/compact-fluent-quad.json | 716 ++++++++++++++++--- src/window.rs | 181 ++++- src/window/message_loop.rs | 154 ++++ src/window/placement_regression_tests.rs | 5 + src/window/window_context_menu.rs | 4 + 21 files changed, 1538 insertions(+), 119 deletions(-) diff --git a/src/app_settings.rs b/src/app_settings.rs index 4e325595..4ddbb981 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -54,6 +54,10 @@ pub struct SettingsFile { show_codex: bool, #[serde(default)] show_antigravity: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_antigravity_gemini: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_antigravity_external: Option, #[serde(default)] show_opencode: bool, #[serde(default)] @@ -127,6 +131,8 @@ impl Default for SettingsFile { show_claude_code: true, show_codex: false, show_antigravity: false, + show_antigravity_gemini: None, + show_antigravity_external: None, show_opencode: false, show_cursor: false, show_grok: false, @@ -158,6 +164,14 @@ impl SettingsFile { { self.poll_interval_ms = default_poll_interval(); } + if self.show_antigravity_gemini.is_none() { + self.show_antigravity_gemini = Some(self.show_antigravity); + } + if self.show_antigravity_external.is_none() { + self.show_antigravity_external = Some(self.show_antigravity); + } + self.show_antigravity = self.show_antigravity_gemini.unwrap_or(false) + || self.show_antigravity_external.unwrap_or(false); if self.enabled_providers().is_empty() { self.set_enabled_providers(ProviderSet::default()); } @@ -210,7 +224,10 @@ impl SettingsFile { match provider { ProviderId::Claude => self.show_claude_code, ProviderId::Codex => self.show_codex, - ProviderId::Antigravity => self.show_antigravity, + ProviderId::Antigravity => { + self.show_antigravity_gemini.unwrap_or(self.show_antigravity) + || self.show_antigravity_external.unwrap_or(self.show_antigravity) + } ProviderId::OpenCode => self.show_opencode, ProviderId::Cursor => self.show_cursor, ProviderId::Grok => self.show_grok, @@ -218,11 +235,41 @@ impl SettingsFile { } } + pub fn antigravity_gemini_enabled(&self) -> bool { + self.show_antigravity_gemini.unwrap_or(self.show_antigravity) + } + + pub fn antigravity_external_enabled(&self) -> bool { + self.show_antigravity_external.unwrap_or(self.show_antigravity) + } + + #[allow(dead_code)] + pub fn set_antigravity_gemini_enabled(&mut self, enabled: bool) { + self.show_antigravity_gemini = Some(enabled); + self.show_antigravity = enabled || self.antigravity_external_enabled(); + } + + #[allow(dead_code)] + pub fn set_antigravity_external_enabled(&mut self, enabled: bool) { + self.show_antigravity_external = Some(enabled); + self.show_antigravity = self.antigravity_gemini_enabled() || enabled; + } + pub fn set_provider_enabled(&mut self, provider: ProviderId, enabled: bool) { match provider { ProviderId::Claude => self.show_claude_code = enabled, ProviderId::Codex => self.show_codex = enabled, - ProviderId::Antigravity => self.show_antigravity = enabled, + ProviderId::Antigravity => { + // Re-saving an already enabled Antigravity keeps the user's + // per-pool choice; only an off/on transition resets both pools. + let was_enabled = + self.antigravity_gemini_enabled() || self.antigravity_external_enabled(); + if enabled != was_enabled { + self.show_antigravity_gemini = Some(enabled); + self.show_antigravity_external = Some(enabled); + } + self.show_antigravity = enabled; + } ProviderId::OpenCode => self.show_opencode = enabled, ProviderId::Cursor => self.show_cursor = enabled, ProviderId::Grok => self.show_grok = enabled, @@ -698,4 +745,23 @@ mod tests { assert_eq!(invalid.dashboard_width, None); assert_eq!(invalid.dashboard_height, None); } + + #[test] + fn resaving_enabled_providers_keeps_antigravity_pool_choice() { + let mut settings = SettingsFile::default(); + settings.set_provider_enabled(ProviderId::Antigravity, true); + settings.set_antigravity_gemini_enabled(false); + settings.set_enabled_providers(ProviderSet::from_enabled([ + ProviderId::Claude, + ProviderId::Antigravity, + ])); + assert!(!settings.antigravity_gemini_enabled()); + assert!(settings.antigravity_external_enabled()); + + settings.set_provider_enabled(ProviderId::Antigravity, false); + assert!(!settings.antigravity_gemini_enabled()); + assert!(!settings.antigravity_external_enabled()); + settings.set_provider_enabled(ProviderId::Antigravity, true); + assert!(settings.antigravity_gemini_enabled() && settings.antigravity_external_enabled()); + } } diff --git a/src/context_menu.rs b/src/context_menu.rs index 6743bf0a..bf87f113 100644 --- a/src/context_menu.rs +++ b/src/context_menu.rs @@ -64,6 +64,8 @@ pub enum ContextMenuAction { ToggleProvider { provider: ContextMenuProvider, }, + ToggleAntigravityGemini, + ToggleAntigravityExternal, ToggleStartup, ToggleTaskbarLock, ToggleWidget, @@ -385,11 +387,14 @@ pub fn classic_context_menu() -> ContextMenuDocument { }, ), ContextMenuItem::action( - "provider-antigravity", - "Antigravity", - Action::ToggleProvider { - provider: Provider::Antigravity, - }, + "provider-antigravity-gemini", + "Antigravity (Gemini)", + Action::ToggleAntigravityGemini, + ), + ContextMenuItem::action( + "provider-antigravity-external", + "Antigravity (Claude + GPT)", + Action::ToggleAntigravityExternal, ), ContextMenuItem::action( "provider-opencode", diff --git a/src/models.rs b/src/models.rs index 1cddc13c..ff713e80 100644 --- a/src/models.rs +++ b/src/models.rs @@ -86,6 +86,9 @@ pub struct UsageData { /// Additional API quotas, exposed only through opt-in theme bindings. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub limits: Vec, + /// Secondary quota pool for providers with multiple distinct allowances (e.g. Antigravity External Models). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_pool: Option>, /// True when this reading was carried over from an earlier poll because /// the provider failed this cycle. The figures are real, just not current. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -106,10 +109,15 @@ pub struct UsageLimit { impl UsageData { pub fn sections(&self) -> impl Iterator { + let external_sections: Box> = match &self.external_pool { + Some(pool) => Box::new(pool.sections()), + None => Box::new(std::iter::empty()), + }; [&self.session, &self.weekly] .into_iter() .chain(self.monthly.iter()) .chain(self.limits.iter().map(|limit| &limit.usage)) + .chain(external_sections) } } @@ -191,6 +199,11 @@ impl AppUsageData { self.providers.get(&provider) } + pub fn antigravity_external(&self) -> Option<&UsageData> { + self.get(ProviderId::Antigravity) + .and_then(|u| u.external_pool.as_deref()) + } + pub fn insert(&mut self, provider: ProviderId, usage: UsageData) -> Option { self.providers.insert(provider, usage) } @@ -313,7 +326,18 @@ impl Serialize for AppUsageData { { let mut map = serializer.serialize_map(None)?; for (provider, usage) in &self.providers { - map.serialize_entry(provider.descriptor().cache_key, usage)?; + if *provider == ProviderId::Antigravity { + map.serialize_entry("antigravity_gemini", usage)?; + if let Some(external) = &usage.external_pool { + map.serialize_entry("antigravity_external", external.as_ref())?; + // Legacy alias for limits.py compatibility (mirrors external pool) + map.serialize_entry("antigravity", external.as_ref())?; + } + // Without an external pool the legacy key is left out rather than + // filled with Gemini figures, so readers never mistake one pool for the other. + } else { + map.serialize_entry(provider.descriptor().cache_key, usage)?; + } } if !self.accounts.is_empty() { map.serialize_entry("accounts", &self.accounts)?; @@ -334,6 +358,11 @@ impl<'de> Deserialize<'de> for AppUsageData { .transpose() .map_err(serde::de::Error::custom)? .unwrap_or_default(); + + let gemini_val = values.remove("antigravity_gemini"); + let external_val = values.remove("antigravity_external"); + let legacy_agy_val = values.remove("antigravity"); + let mut data: Self = values .into_iter() .filter_map(|(key, usage)| { @@ -343,6 +372,23 @@ impl<'de> Deserialize<'de> for AppUsageData { .map(|provider| (provider, usage)) }) .collect(); + + let agy_usage = gemini_val + .and_then(|v| serde_json::from_value::>(v).ok().flatten()) + .or_else(|| { + legacy_agy_val + .and_then(|v| serde_json::from_value::>(v).ok().flatten()) + }); + + if let Some(mut agy) = agy_usage { + if let Some(ext) = external_val + .and_then(|v| serde_json::from_value::>(v).ok().flatten()) + { + agy.external_pool = Some(Box::new(ext)); + } + data.insert(ProviderId::Antigravity, agy); + } + data.accounts = accounts; Ok(data) } @@ -481,6 +527,69 @@ mod tests { assert!(decoded.get(ProviderId::OpenCode).is_none()); } + #[test] + fn usage_cache_antigravity_dual_pools_roundtrip_and_legacy_compatibility() { + let mut gemini_usage = UsageData { + session: UsageSection { + available: true, + percentage: 15.0, + resets_at: None, + }, + weekly: UsageSection { + available: true, + percentage: 30.0, + resets_at: None, + }, + ..Default::default() + }; + let external_usage = UsageData { + session: UsageSection { + available: true, + percentage: 60.0, + resets_at: None, + }, + weekly: UsageSection { + available: true, + percentage: 80.0, + resets_at: None, + }, + ..Default::default() + }; + gemini_usage.external_pool = Some(Box::new(external_usage)); + + let mut data = AppUsageData::default(); + data.insert(ProviderId::Antigravity, gemini_usage); + + let json = serde_json::to_value(&data).unwrap(); + // Check keys: antigravity_gemini, antigravity_external, and legacy antigravity mirror + assert_eq!(json["antigravity_gemini"]["session"]["percentage"], 15.0); + assert_eq!(json["antigravity_external"]["session"]["percentage"], 60.0); + assert_eq!(json["antigravity"]["session"]["percentage"], 60.0); + + // Deserialization round-trip + let decoded: AppUsageData = serde_json::from_value(json).unwrap(); + let agy = decoded.get(ProviderId::Antigravity).expect("Antigravity present"); + assert_eq!(agy.session.percentage, 15.0); + let ext = decoded.antigravity_external().expect("External pool present"); + assert_eq!(ext.session.percentage, 60.0); + assert_eq!(ext.weekly.percentage, 80.0); + + // Test section chaining: + let section_percentages: Vec = agy.sections().map(|s| s.percentage).collect(); + assert_eq!(section_percentages, vec![15.0, 30.0, 60.0, 80.0]); + } + + #[test] + fn usage_cache_omits_legacy_antigravity_key_without_external_pool() { + let mut data = AppUsageData::default(); + data.insert(ProviderId::Antigravity, UsageData::default()); + let json = serde_json::to_value(&data).unwrap(); + assert!(json.get("antigravity_gemini").is_some()); + // `antigravity` means the external pool; Gemini figures must never fill it. + assert!(json.get("antigravity").is_none()); + assert!(json.get("antigravity_external").is_none()); + } + #[test] fn absent_window_is_cached_without_a_percentage() { let section = UsageSection::default(); diff --git a/src/native_interop.rs b/src/native_interop.rs index 9032b7d9..ba589990 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -29,6 +29,8 @@ pub const TIMER_MOUSE_CLICK: usize = 6; pub const TIMER_TRAY_HOVER: usize = 7; pub const TIMER_CLOCK: usize = 8; pub const TIMER_TRAY_REPOSITION: usize = 9; +pub const TIMER_CROSSFADE_SCHEDULE: usize = 14; +pub const TIMER_CROSSFADE_ANIM: usize = 15; // Custom messages pub const WM_APP: u32 = 0x8000; diff --git a/src/poller/antigravity.rs b/src/poller/antigravity.rs index 9c427139..70a88a2c 100644 --- a/src/poller/antigravity.rs +++ b/src/poller/antigravity.rs @@ -330,8 +330,8 @@ pub(super) fn select_antigravity_usage( for base_url in endpoints { match fetch(base_url) { - // A quota summary names every window it has; it is authoritative, - // so later endpoints are neither asked nor merged. + // A quota summary names every window it has; it is authoritative + // for both pools, so later endpoints are neither asked nor merged. Ok((data, AntigravitySource::Summary)) => return Ok(data), // Model quotas do not name their window; keep them only as a last // resort in case no endpoint answers with a summary. @@ -380,8 +380,8 @@ pub(super) fn fetch_antigravity_usage_from_endpoint( }; // Per-model quotas do not say which window (5h or weekly) they belong to, - // so when the summary answered it is used alone and the models request is - // skipped. + // so when the summary answered, both pools are taken from it alone and the + // models request is skipped. if let Some(data) = summary_result { return Ok((data, AntigravitySource::Summary)); } @@ -395,7 +395,8 @@ pub(super) fn fetch_antigravity_usage_from_endpoint( } } -/// Fallback when the quota summary is unavailable: Gemini models only. +/// Fallback when the quota summary is unavailable: keep the two pools apart, +/// Gemini models in the primary pool and Claude/GPT models in the external one. pub(super) fn antigravity_usage_from_models(models: &AntigravityModelsResponse) -> Option { let gemini = best_gemini_model_quota(models); // Claude/GPT model quotas do not say whether they are the 5h or the weekly @@ -617,11 +618,19 @@ pub(super) fn antigravity_usage_from_summary( gemini_usage = other_groups.pop(); } - // The external "Claude and GPT models" group is recognised only so it is - // never shown as the Gemini pool. - match gemini_usage { - Some(gemini) => Some(gemini), - None => answered.then(UsageData::default), + if let Some(mut gemini) = gemini_usage { + if let Some(external) = external_usage { + gemini.external_pool = Some(Box::new(external)); + } + Some(gemini) + } else if let Some(external) = external_usage { + let mut gemini = UsageData::default(); + gemini.session.available = false; + gemini.weekly.available = false; + gemini.external_pool = Some(Box::new(external)); + Some(gemini) + } else { + answered.then(UsageData::default) } } diff --git a/src/poller/copilot.rs b/src/poller/copilot.rs index 44be2bc1..c9a6fe0b 100644 --- a/src/poller/copilot.rs +++ b/src/poller/copilot.rs @@ -262,6 +262,7 @@ fn copilot_usage_from_user(response: CopilotUserResponse) -> Option { monthly: Some(window), credits: None, stale: false, + ..Default::default() }) } diff --git a/src/poller/cursor.rs b/src/poller/cursor.rs index 040d71bd..bf1e514a 100644 --- a/src/poller/cursor.rs +++ b/src/poller/cursor.rs @@ -235,6 +235,7 @@ fn cursor_usage_from_summary(response: CursorUsageSummaryResponse) -> Option Option { monthly: monthly_period.then_some(window), credits: grok_credits(&config, response.on_demand_enabled), stale: false, + ..Default::default() }) } diff --git a/src/poller/opencode.rs b/src/poller/opencode.rs index b1f20dd6..7848b53e 100644 --- a/src/poller/opencode.rs +++ b/src/poller/opencode.rs @@ -126,6 +126,7 @@ fn poll_dashboard(credentials: &DashboardCredentials) -> Result UsageData { monthly: None, credits: None, stale: false, + ..Default::default() } } @@ -663,6 +664,8 @@ fn antigravity_model_fallback_reports_only_gemini_models() { .unwrap(); let usage = antigravity::antigravity_usage_from_models(&models).expect("usage"); assert!((usage.session.percentage - 10.0).abs() < 0.001, "Gemini pool must ignore Claude/GPT/image models"); + // Model quotas do not name their window, so the external pool is not guessed. + assert!(usage.external_pool.is_none()); } #[test] @@ -677,6 +680,8 @@ fn antigravity_summary_does_not_guess_pools_by_position() { .unwrap(); let usage = antigravity::antigravity_usage_from_summary(response).expect("usage"); assert!(!usage.session.available && !usage.weekly.available, "Gemini pool must stay empty"); + let external = usage.external_pool.expect("external pool"); + assert!((external.weekly.percentage - 60.0).abs() < 0.001); } #[test] @@ -687,8 +692,45 @@ fn antigravity_summary_without_windows_is_an_answer_not_a_failure() { .unwrap(); let usage = antigravity::antigravity_usage_from_summary(response).expect("authoritative"); assert!(!usage.session.available && !usage.weekly.available); + assert!(usage.external_pool.is_none()); let empty: antigravity::AntigravityQuotaSummaryResponse = serde_json::from_str(r#"{"groups":[]}"#).unwrap(); assert!(antigravity::antigravity_usage_from_summary(empty).is_some()); } + +#[test] +fn antigravity_extracts_both_gemini_and_external_groups_from_summary() { + let summary = serde_json::from_str( + r#"{ + "groups": [ + { + "displayName": "Claude and GPT models", + "buckets": [ + {"window": "5h", "remainingFraction": 0.4}, + {"window": "weekly", "remainingFraction": 0.2} + ] + }, + { + "displayName": "Gemini Models", + "buckets": [ + {"window": "5h", "remainingFraction": 0.85}, + {"window": "weekly", "remainingFraction": 0.70} + ] + } + ] + }"#, + ) + .unwrap(); + let data = super::antigravity::antigravity_usage_from_summary(summary).unwrap(); + assert!(data.session.available); + assert!((data.session.percentage - 15.0).abs() < 1e-4); + assert!(data.weekly.available); + assert!((data.weekly.percentage - 30.0).abs() < 1e-4); + + let ext = data.external_pool.as_deref().expect("external pool extracted"); + assert!(ext.session.available); + assert!((ext.session.percentage - 60.0).abs() < 1e-4); + assert!(ext.weekly.available); + assert!((ext.weekly.percentage - 80.0).abs() < 1e-4); +} diff --git a/src/studio_app/studio_context_menu_editor.rs b/src/studio_app/studio_context_menu_editor.rs index 0152a498..a6df389e 100644 --- a/src/studio_app/studio_context_menu_editor.rs +++ b/src/studio_app/studio_context_menu_editor.rs @@ -321,6 +321,8 @@ pub(super) fn context_menu_action_script(action: &ContextMenuAction) -> String { ContextMenuAction::ToggleProvider { provider } => { format!("toggle_provider({})", provider.descriptor().key) } + ContextMenuAction::ToggleAntigravityGemini => "toggle_antigravity_gemini()".into(), + ContextMenuAction::ToggleAntigravityExternal => "toggle_antigravity_external()".into(), ContextMenuAction::ToggleStartup => "toggle_startup()".into(), ContextMenuAction::ToggleTaskbarLock => "toggle_taskbar_lock()".into(), ContextMenuAction::ToggleWidget => "toggle_widget()".into(), @@ -352,6 +354,8 @@ pub(super) fn parse_context_menu_action_script(script: &str) -> Result ThemeRuntime { diff --git a/src/studio_app/tests.rs b/src/studio_app/tests.rs index aa09f549..0919d912 100644 --- a/src/studio_app/tests.rs +++ b/src/studio_app/tests.rs @@ -399,6 +399,7 @@ fn studio_preview_uses_cached_poll_failure_state_instead_of_stale_values() { monthly: None, credits: None, stale: false, + ..Default::default() }, )])); app.usage_poll_ok = false; diff --git a/src/theme_engine.rs b/src/theme_engine.rs index 44c2ce0a..61e029ec 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -1324,7 +1324,7 @@ pub struct DataContext { /// Runtime environment used by theme expressions and layout. Provider state is /// deliberately independent of polling availability so a temporary provider /// error never causes the widget to jump or resize. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct ThemeRuntime { providers: ProviderSet, pub poll_ok: bool, @@ -1336,6 +1336,9 @@ pub struct ThemeRuntime { pub countdown: bool, pub surface_nest: SurfaceNest, pub floating_card_opacity: u8, + pub antigravity_gemini_enabled: bool, + pub antigravity_external_enabled: bool, + pub crossfade_opacity: f64, host_width: u32, host_height: u32, } @@ -1350,6 +1353,9 @@ impl Default for ThemeRuntime { countdown: false, surface_nest: SurfaceNest::Taskbar, floating_card_opacity: 85, + antigravity_gemini_enabled: true, + antigravity_external_enabled: false, + crossfade_opacity: 1.0, host_width: default_canvas_width(), host_height: default_canvas_height(), } @@ -1372,6 +1378,7 @@ impl ThemeRuntime { } pub fn from_providers(providers: ProviderSet) -> Self { + let agy = providers.contains(ProviderId::Antigravity); Self { providers, poll_ok: true, @@ -1380,11 +1387,26 @@ impl ThemeRuntime { countdown: false, surface_nest: SurfaceNest::Taskbar, floating_card_opacity: 85, + antigravity_gemini_enabled: agy, + antigravity_external_enabled: false, + crossfade_opacity: 1.0, host_width: default_canvas_width(), host_height: default_canvas_height(), } } + pub fn with_antigravity_pools( + mut self, + gemini: bool, + external: bool, + crossfade_opacity: f64, + ) -> Self { + self.antigravity_gemini_enabled = gemini; + self.antigravity_external_enabled = external; + self.crossfade_opacity = crossfade_opacity; + self + } + pub fn with_nest(mut self, nest: SurfaceNest) -> Self { self.surface_nest = nest; self @@ -1509,6 +1531,20 @@ impl DataContext { ); } context.insert("display.countdown", runtime.countdown as u8 as f64); + context.insert( + "antigravity.gemini.enabled", + runtime.antigravity_gemini_enabled as u8 as f64, + ); + context.insert( + "antigravity.external.enabled", + runtime.antigravity_external_enabled as u8 as f64, + ); + let agy_dual_mode = runtime.antigravity_gemini_enabled && runtime.antigravity_external_enabled; + context.insert("antigravity.dual_mode", agy_dual_mode as u8 as f64); + context.insert( + "antigravity.crossfade_opacity", + runtime.crossfade_opacity.clamp(0.0, 1.0), + ); if let Some(data) = data { for account in &data.accounts { let key = format!( @@ -1551,6 +1587,22 @@ impl DataContext { runtime.countdown, ); } + if let Some(agy) = data.get(ProviderId::Antigravity) { + // Antigravity answered this poll; pool-level gaps are "no limit + // data" ("--"), not a provider error. + context.insert("antigravity.reported", 1.0); + context.insert_provider("antigravity.gemini", Some(agy), false, runtime.countdown); + // A missing external pool stays empty ("--"); never show Gemini figures in its place. + context.insert_provider( + "antigravity.external", + agy.external_pool.as_deref(), + false, + runtime.countdown, + ); + } else { + context.insert_provider("antigravity.gemini", None, false, runtime.countdown); + context.insert_provider("antigravity.external", None, false, runtime.countdown); + } let active = ProviderId::ALL .into_iter() .find_map(|provider| data.get(provider).map(|usage| (provider, usage))); @@ -1569,6 +1621,8 @@ impl DataContext { runtime.countdown, ); } + context.insert_provider("antigravity.gemini", None, false, runtime.countdown); + context.insert_provider("antigravity.external", None, false, runtime.countdown); context.insert_provider("active", None, false, runtime.countdown); } context @@ -1612,15 +1666,56 @@ impl DataContext { } else { five_hour }; + let (session_available, weekly_available) = usage + .map(|usage| { + ( + usage.session.available || use_codex_session_fallback, + usage.weekly.available, + ) + }) + .unwrap_or((false, false)); + self.insert( + &format!("{name}.session.available"), + session_available as u8 as f64, + ); + self.insert( + &format!("{name}.five_hour.available"), + usage.is_some_and(|usage| usage.session.available) as u8 as f64, + ); + self.insert( + &format!("{name}.weekly.available"), + weekly_available as u8 as f64, + ); self.insert(&format!("{name}.session.percentage"), session); self.insert(&format!("{name}.session.remaining"), 100.0 - session); - self.insert(&format!("{name}.session.display"), display(session)); + // An Antigravity pool window that was not reported draws an empty + // gauge in either usage direction instead of a full "remaining" one. + let shown_session = if name.starts_with("antigravity") && !(session_available) { + 0.0 + } else { + display(session) + }; + self.insert(&format!("{name}.session.display"), shown_session); self.insert(&format!("{name}.five_hour.percentage"), five_hour); self.insert(&format!("{name}.five_hour.remaining"), 100.0 - five_hour); - self.insert(&format!("{name}.five_hour.display"), display(five_hour)); + // An Antigravity pool window that was not reported draws an empty + // gauge in either usage direction instead of a full "remaining" one. + let shown_five_hour = if name.starts_with("antigravity") && !(usage.is_some_and(|usage| usage.session.available)) { + 0.0 + } else { + display(five_hour) + }; + self.insert(&format!("{name}.five_hour.display"), shown_five_hour); self.insert(&format!("{name}.weekly.percentage"), weekly); self.insert(&format!("{name}.weekly.remaining"), 100.0 - weekly); - self.insert(&format!("{name}.weekly.display"), display(weekly)); + // An Antigravity pool window that was not reported draws an empty + // gauge in either usage direction instead of a full "remaining" one. + let shown_weekly = if name.starts_with("antigravity") && !(weekly_available) { + 0.0 + } else { + display(weekly) + }; + self.insert(&format!("{name}.weekly.display"), shown_weekly); let monthly = usage.and_then(|usage| usage.monthly.as_ref()); self.insert_string(&format!("{name}.monthly.label"), "30d"); if let Some(monthly) = monthly { diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index f97ba5a1..2aefa358 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -704,6 +704,7 @@ fn usage_lines_handle_loading_errors_missing_resets_and_language() { monthly: None, credits: None, stale: false, + ..Default::default() }, )]); let ready = DataContext::from_usage_with_runtime( @@ -1014,6 +1015,7 @@ fn reset_stats_and_duration_formats_are_available_to_every_provider() { monthly: None, credits: None, stale: false, + ..Default::default() }, )]); let context = DataContext::from_usage(Some(&usage), &Canvas::default()); @@ -2177,6 +2179,13 @@ fn compact_fluent_quad_widget_respects_usage_direction() { remaining: 24.1, total: 40.83, }), + external_pool: (provider == ProviderId::Antigravity).then(|| { + Box::new(UsageData { + session: section.clone(), + weekly: section.clone(), + ..Default::default() + }) + }), ..Default::default() }, ) @@ -3236,3 +3245,180 @@ fn action_values_are_found_around_the_caret() { assert_eq!(at(r#"layer_actions("toggle(self, |render)")"#), None); assert_eq!(at(r#"layer_actions(|"set(self, x, 1)")"#), None); } + +#[test] +fn compact_fluent_quad_antigravity_dual_and_solo_modes() { + use crate::models::{UsageData, UsageSection}; + + let theme: ThemeDocument = + serde_json::from_str(include_str!("../themes/compact-fluent-quad.json")).unwrap(); + + let gemini_section = UsageSection { + available: true, + percentage: 30.0, + resets_at: None, + }; + let external_section = UsageSection { + available: true, + percentage: 70.0, + resets_at: None, + }; + + let mut usage = AppUsageData::default(); + usage.insert( + ProviderId::Antigravity, + UsageData { + session: gemini_section.clone(), + weekly: gemini_section.clone(), + external_pool: Some(Box::new(UsageData { + session: external_section.clone(), + weekly: external_section.clone(), + ..Default::default() + })), + ..Default::default() + }, + ); + + // 1. Solo Gemini + { + let runtime = ThemeRuntime::from_providers(ProviderSet::from_enabled([ProviderId::Antigravity])) + .with_antigravity_pools(true, false, 1.0); + let mut context = DataContext::from_usage_with_runtime(Some(&usage), &Canvas::default(), runtime); + context.insert("system.dark", 1.0); + assert_eq!(evaluate("antigravity.dual_mode", &context).unwrap(), 0.0); + assert_eq!(evaluate("antigravity.gemini.enabled", &context).unwrap(), 1.0); + assert_eq!(evaluate("antigravity.external.enabled", &context).unwrap(), 0.0); + + let find_render = |id: &str| { + theme.surfaces[0] + .children + .iter() + .find(|o| o.id == id) + .map(|o| evaluate(&o.render.0, &context).unwrap() != 0.0) + .unwrap_or(false) + }; + + assert!(find_render("antigravity-session-gemini-solo-dark")); + assert!(!find_render("antigravity-session-external-solo-dark")); + assert!(!find_render("antigravity-session-gemini-dual-dark")); + assert!(!find_render("antigravity-session-external-dual-dark")); + assert!(find_render("antigravity-session-gemini-value-dark")); + assert!(!find_render("antigravity-session-external-value-dark")); + } + + // 2. Solo External + { + let runtime = ThemeRuntime::from_providers(ProviderSet::from_enabled([ProviderId::Antigravity])) + .with_antigravity_pools(false, true, 1.0); + let mut context = DataContext::from_usage_with_runtime(Some(&usage), &Canvas::default(), runtime); + context.insert("system.dark", 1.0); + assert_eq!(evaluate("antigravity.dual_mode", &context).unwrap(), 0.0); + assert_eq!(evaluate("antigravity.gemini.enabled", &context).unwrap(), 0.0); + assert_eq!(evaluate("antigravity.external.enabled", &context).unwrap(), 1.0); + + let find_render = |id: &str| { + theme.surfaces[0] + .children + .iter() + .find(|o| o.id == id) + .map(|o| evaluate(&o.render.0, &context).unwrap() != 0.0) + .unwrap_or(false) + }; + + assert!(!find_render("antigravity-session-gemini-solo-dark")); + assert!(find_render("antigravity-session-external-solo-dark")); + assert!(!find_render("antigravity-session-gemini-dual-dark")); + assert!(!find_render("antigravity-session-external-dual-dark")); + assert!(!find_render("antigravity-session-gemini-value-dark")); + assert!(find_render("antigravity-session-external-value-dark")); + } + + // 3. Dual Mode Crossfade + { + // At opacity 1.0 (Gemini holding) + let runtime = ThemeRuntime::from_providers(ProviderSet::from_enabled([ProviderId::Antigravity])) + .with_antigravity_pools(true, true, 1.0); + let mut context = DataContext::from_usage_with_runtime(Some(&usage), &Canvas::default(), runtime); + context.insert("system.dark", 1.0); + assert_eq!(evaluate("antigravity.dual_mode", &context).unwrap(), 1.0); + + let find_render = |id: &str| { + theme.surfaces[0] + .children + .iter() + .find(|o| o.id == id) + .map(|o| evaluate(&o.render.0, &context).unwrap() != 0.0) + .unwrap_or(false) + }; + + assert!(!find_render("antigravity-session-gemini-solo-dark")); + assert!(!find_render("antigravity-session-external-solo-dark")); + assert!(find_render("antigravity-session-gemini-dual-dark")); + assert!(find_render("antigravity-session-external-dual-dark")); + assert!(find_render("antigravity-session-gemini-value-dark")); + assert!(!find_render("antigravity-session-external-value-dark")); + + // At opacity 0.5 (Mid-transition) + let runtime_mid = ThemeRuntime::from_providers(ProviderSet::from_enabled([ProviderId::Antigravity])) + .with_antigravity_pools(true, true, 0.5); + let mut context_mid = DataContext::from_usage_with_runtime(Some(&usage), &Canvas::default(), runtime_mid); + context_mid.insert("system.dark", 1.0); + let find_render_mid = |id: &str| { + theme.surfaces[0] + .children + .iter() + .find(|o| o.id == id) + .map(|o| evaluate(&o.render.0, &context_mid).unwrap() != 0.0) + .unwrap_or(false) + }; + assert!(find_render_mid("antigravity-session-gemini-value-dark")); + assert!(find_render_mid("antigravity-session-external-value-dark")); + + // At opacity 0.0 (External holding) + let runtime_ext = ThemeRuntime::from_providers(ProviderSet::from_enabled([ProviderId::Antigravity])) + .with_antigravity_pools(true, true, 0.0); + let mut context_ext = DataContext::from_usage_with_runtime(Some(&usage), &Canvas::default(), runtime_ext); + context_ext.insert("system.dark", 1.0); + let find_render_ext = |id: &str| { + theme.surfaces[0] + .children + .iter() + .find(|o| o.id == id) + .map(|o| evaluate(&o.render.0, &context_ext).unwrap() != 0.0) + .unwrap_or(false) + }; + assert!(!find_render_ext("antigravity-session-gemini-value-dark")); + assert!(find_render_ext("antigravity-session-external-value-dark")); + } +} + +#[test] +fn missing_antigravity_external_pool_draws_an_empty_gauge() { + use crate::models::{UsageData, UsageSection}; + + let usage = AppUsageData::from_iter([( + ProviderId::Antigravity, + UsageData { + session: UsageSection { + available: true, + percentage: 25.0, + resets_at: None, + }, + ..Default::default() + }, + )]); + let context = DataContext::from_usage_with_runtime( + Some(&usage), + &Canvas::default(), + ThemeRuntime::from_providers(ProviderSet::from_enabled([ProviderId::Antigravity])) + .with_countdown(true), + ); + let value = |expression: &str| { + evaluate(expression, &context) + .unwrap() + }; + assert_eq!(value("antigravity.gemini.session.display"), 75.0); + assert_eq!(value("antigravity.gemini.weekly.display"), 0.0); + assert_eq!(value("antigravity.external.session.display"), 0.0); +} + diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index 62bf59ca..d0841ff3 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -517,6 +517,8 @@ pub(super) fn format_usage_line(base: &str, context: &DataContext) -> Option Option Option 0.01)", "visibility": "100", "parent": "antigravity-provider", "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", - "y": "25", + "y": "9", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{antigravity.gemini.session.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#5EEAD4FF", + "opacity": "if(antigravity.dual_mode, antigravity.crossfade_opacity, 1.0)" + } + } + }, + { + "id": "antigravity-session-gemini-value-light", + "name": "light Antigravity Gemini session value", + "render": "(1 - system.dark) && antigravity.gemini.enabled && (!antigravity.dual_mode || antigravity.crossfade_opacity > 0.01)", + "visibility": "100", + "parent": "antigravity-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", "width": "62", "height": "13", "background": { @@ -1367,7 +1441,7 @@ "gap": "0", "content": { "type": "text", - "template": "{antigravity.weekly.display:usage_line}", + "template": "{antigravity.gemini.session.display:usage_line}", "font_family": "Segoe UI", "font_size": "12", "weight": "medium", @@ -1376,7 +1450,485 @@ "align": "left", "color": { "color": "#007A6CFF", + "opacity": "if(antigravity.dual_mode, antigravity.crossfade_opacity, 1.0)" + } + } + }, + { + "id": "antigravity-session-external-value-dark", + "name": "dark Antigravity External session value", + "render": "system.dark && antigravity.external.enabled && (!antigravity.dual_mode || (1.0 - antigravity.crossfade_opacity) > 0.01)", + "visibility": "100", + "parent": "antigravity-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{antigravity.external.session.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#98D0E0FF", + "opacity": "if(antigravity.dual_mode, 1.0 - antigravity.crossfade_opacity, 1.0)" + } + } + }, + { + "id": "antigravity-session-external-value-light", + "name": "light Antigravity External session value", + "render": "(1 - system.dark) && antigravity.external.enabled && (!antigravity.dual_mode || (1.0 - antigravity.crossfade_opacity) > 0.01)", + "visibility": "100", + "parent": "antigravity-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "9", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{antigravity.external.session.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#2D6F82FF", + "opacity": "if(antigravity.dual_mode, 1.0 - antigravity.crossfade_opacity, 1.0)" + } + } + }, + { + "id": "antigravity-weekly-gemini-solo-dark", + "name": "dark Antigravity Gemini solo weekly segments", + "render": "system.dark && antigravity.gemini.enabled && !antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.gemini.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#00D2B4FF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-gemini-solo-light", + "name": "light Antigravity Gemini solo weekly segments", + "render": "(1 - system.dark) && antigravity.gemini.enabled && !antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.gemini.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#00A896FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-external-solo-dark", + "name": "dark Antigravity External solo weekly segments", + "render": "system.dark && antigravity.external.enabled && !antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.external.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#6EB4C8FF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-external-solo-light", + "name": "light Antigravity External solo weekly segments", + "render": "(1 - system.dark) && antigravity.external.enabled && !antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "9", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.external.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#4A8B9EFF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-gemini-dual-dark", + "name": "dark Antigravity Gemini dual weekly segments", + "render": "system.dark && antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "4", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.gemini.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#00D2B4FF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-gemini-dual-light", + "name": "light Antigravity Gemini dual weekly segments", + "render": "(1 - system.dark) && antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "27", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "4", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.gemini.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#00A896FF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-external-dual-dark", + "name": "dark Antigravity External dual weekly segments", + "render": "system.dark && antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "32", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "4", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.external.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#6EB4C8FF", + "opacity": "1" + }, + "track": { + "color": "#444444FF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-external-dual-light", + "name": "light Antigravity External dual weekly segments", + "render": "(1 - system.dark) && antigravity.dual_mode", + "visibility": "100", + "parent": "antigravity-provider", + "y": "32", + "width": "ceil(10 / max(1, providers.count)) * 11 - 1", + "height": "4", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "progress", + "value": "antigravity.external.weekly.display", + "direction": "left_to_right", + "fill": { + "color": "#4A8B9EFF", + "opacity": "1" + }, + "track": { + "color": "#AAAAAAFF", + "opacity": "1" + }, + "corner_radius": "1.5", + "segments": 10, + "segments_expression": "ceil(10 / max(1, providers.count))", + "segment_gap": "1" + } + }, + { + "id": "antigravity-weekly-gemini-value-dark", + "name": "dark Antigravity Gemini weekly value", + "render": "system.dark && antigravity.gemini.enabled && (!antigravity.dual_mode || antigravity.crossfade_opacity > 0.01)", + "visibility": "100", + "parent": "antigravity-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{antigravity.gemini.weekly.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#5EEAD4FF", + "opacity": "if(antigravity.dual_mode, antigravity.crossfade_opacity, 1.0)" + } + } + }, + { + "id": "antigravity-weekly-gemini-value-light", + "name": "light Antigravity Gemini weekly value", + "render": "(1 - system.dark) && antigravity.gemini.enabled && (!antigravity.dual_mode || antigravity.crossfade_opacity > 0.01)", + "visibility": "100", + "parent": "antigravity-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{antigravity.gemini.weekly.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#007A6CFF", + "opacity": "if(antigravity.dual_mode, antigravity.crossfade_opacity, 1.0)" + } + } + }, + { + "id": "antigravity-weekly-external-value-dark", + "name": "dark Antigravity External weekly value", + "render": "system.dark && antigravity.external.enabled && (!antigravity.dual_mode || (1.0 - antigravity.crossfade_opacity) > 0.01)", + "visibility": "100", + "parent": "antigravity-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{antigravity.external.weekly.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#98D0E0FF", + "opacity": "if(antigravity.dual_mode, 1.0 - antigravity.crossfade_opacity, 1.0)" + } + } + }, + { + "id": "antigravity-weekly-external-value-light", + "name": "light Antigravity External weekly value", + "render": "(1 - system.dark) && antigravity.external.enabled && (!antigravity.dual_mode || (1.0 - antigravity.crossfade_opacity) > 0.01)", + "visibility": "100", + "parent": "antigravity-provider", + "x": "ceil(10 / max(1, providers.count)) * 11 - 1 + 4", + "y": "25", + "width": "62", + "height": "13", + "background": { + "type": "none" + }, + "border": null, + "corner_radius": "0", + "layout": "freeform", + "align": "start", + "gap": "0", + "content": { + "type": "text", + "template": "{antigravity.external.weekly.display:usage_line}", + "font_family": "Segoe UI", + "font_size": "12", + "weight": "medium", + "rendering": "clear_type", + "contrast": "1", + "align": "left", + "color": { + "color": "#2D6F82FF", + "opacity": "if(antigravity.dual_mode, 1.0 - antigravity.crossfade_opacity, 1.0)" } } }, diff --git a/src/window.rs b/src/window.rs index 3b617855..db350b53 100644 --- a/src/window.rs +++ b/src/window.rs @@ -29,11 +29,11 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::AppUsageData; use crate::native_interop::{ - self, TIMER_CLOCK, TIMER_COUNTDOWN, TIMER_MOUSE_CLICK, TIMER_POLL, TIMER_RESET_POLL, - TIMER_TRAY_HOVER, TIMER_TRAY_REPOSITION, TIMER_UPDATE_CHECK, TIMER_WINDOW_STATE, - WM_APP_DISABLE_DIAGNOSTICS, WM_APP_ENABLE_DIAGNOSTICS, WM_APP_OPEN_DASHBOARD, WM_APP_QUIT, - WM_APP_REFRESH_NOW, WM_APP_SETTINGS_UPDATED, WM_APP_TASKBAR_COLLISION, WM_APP_TRAY, - WM_APP_USAGE_UPDATED, + self, TIMER_CLOCK, TIMER_COUNTDOWN, TIMER_CROSSFADE_ANIM, TIMER_CROSSFADE_SCHEDULE, + TIMER_MOUSE_CLICK, TIMER_POLL, TIMER_RESET_POLL, TIMER_TRAY_HOVER, TIMER_TRAY_REPOSITION, + TIMER_UPDATE_CHECK, TIMER_WINDOW_STATE, WM_APP_DISABLE_DIAGNOSTICS, + WM_APP_ENABLE_DIAGNOSTICS, WM_APP_OPEN_DASHBOARD, WM_APP_QUIT, WM_APP_REFRESH_NOW, + WM_APP_SETTINGS_UPDATED, WM_APP_TASKBAR_COLLISION, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::poller; use crate::providers::{ProviderId, ProviderSet}; @@ -94,6 +94,11 @@ struct AppState { install_channel: InstallChannel, providers: ProviderSet, + pub(super) show_antigravity_gemini: bool, + pub(super) show_antigravity_external: bool, + crossfade_opacity: f64, + crossfade_anim_start: Option, + crossfade_target_opacity: f64, accounts: crate::accounts::AccountSettings, data: Option, @@ -205,6 +210,9 @@ const IDM_VERSION_ACTION: u16 = 31; const IDM_LANG_SYSTEM: u16 = 100; const IDM_LANG_FIRST: u16 = 101; const IDM_DASHBOARD: u16 = 71; +// 62 stays the whole-provider Antigravity toggle used by saved custom menus. +pub(super) const IDM_PROVIDER_ANTIGRAVITY_GEMINI: u16 = 68; +pub(super) const IDM_PROVIDER_ANTIGRAVITY_EXTERNAL: u16 = 67; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -620,6 +628,11 @@ fn theme_runtime_from_state(state: &AppState) -> ThemeRuntime { .with_countdown(state.usage_countdown) .with_nest(nest) .with_floating_card_opacity(opacity) + .with_antigravity_pools( + state.show_antigravity_gemini, + state.show_antigravity_external, + state.crossfade_opacity, + ) } /// A transient outage can keep presenting the last real reading while its @@ -836,6 +849,11 @@ fn save_state_settings() { .map(|language| language.code().to_string()); persisted.last_update_check_unix = s.last_update_check_unix; persisted.set_enabled_providers(s.providers); + // Pool flags are only meaningful while Antigravity is enabled; persisting + // them as on for a disabled provider would re-enable it on the next load. + let antigravity_on = s.providers.contains(ProviderId::Antigravity); + persisted.show_antigravity_gemini = Some(antigravity_on && s.show_antigravity_gemini); + persisted.show_antigravity_external = Some(antigravity_on && s.show_antigravity_external); persisted.custom_theme_enabled = s.custom_theme_enabled; persisted.active_theme_path = s .active_theme_path @@ -874,11 +892,23 @@ pub(super) fn toggle_taskbar_lock(hwnd: HWND) { diagnose::log(format!("taskbar lock toggled: {enabled}")); } +#[cfg(test)] fn tray_usage_summary_lines( data: &AppUsageData, providers: ProviderSet, language: LanguageId, countdown: bool, +) -> Vec { + tray_usage_summary_lines_with_pools(data, providers, language, countdown, true, true) +} + +fn tray_usage_summary_lines_with_pools( + data: &AppUsageData, + providers: ProviderSet, + language: LanguageId, + countdown: bool, + show_antigravity_gemini: bool, + show_antigravity_external: bool, ) -> Vec { let strings = language.strings(); let shown = |percentage: f64| { @@ -888,16 +918,75 @@ fn tray_usage_summary_lines( percentage } }; - providers - .iter() - .filter_map(|provider| { - let usage = data.get(provider)?; + let window_text = |section: &crate::models::UsageSection| { + if section.available { + format!("{:.0}%", shown(section.percentage)) + } else { + "--".to_string() + } + }; + let mut lines = Vec::new(); + for provider in providers.iter() { + if provider == ProviderId::Antigravity { + let base_name = language.text(provider.descriptor().display_name); + let acct = data.selected_account_name(provider); + if show_antigravity_gemini { + if let Some(usage) = data.get(provider) { + let weekly_label = usage + .weekly_label + .as_deref() + .unwrap_or(strings.weekly_window); + let label = match acct { + Some(name) => format!("{base_name} (Gemini, {name})"), + None => format!("{base_name} (Gemini)"), + }; + lines.push(format!( + "{} {}: {} | {}: {}", + label, + strings.session_window, + window_text(&usage.session), + weekly_label, + window_text(&usage.weekly), + )); + } + } + if show_antigravity_external { + if data.get(provider).is_some() && data.antigravity_external().is_none() { + let label = match acct { + Some(name) => format!("{base_name} (Claude/GPT, {name})"), + None => format!("{base_name} (Claude/GPT)"), + }; + lines.push(format!("{label}: --")); + } + if let Some(usage) = data.antigravity_external() { + let weekly_label = usage + .weekly_label + .as_deref() + .unwrap_or(strings.weekly_window); + let label = match acct { + Some(name) => format!("{base_name} (Claude/GPT, {name})"), + None => format!("{base_name} (Claude/GPT)"), + }; + lines.push(format!( + "{} {}: {} | {}: {}", + label, + strings.session_window, + window_text(&usage.session), + weekly_label, + window_text(&usage.weekly), + )); + } + } + } else { + let Some(usage) = data.get(provider) else { + continue; + }; let descriptor = provider.descriptor(); let weekly_label = usage .weekly_label .as_deref() .unwrap_or(strings.weekly_window); - Some(format!( + lines.push(format!( "{} {}: {:.0}% | {}: {:.0}%", match data.selected_account_name(provider) { Some(name) => format!("{} ({name})", language.text(descriptor.display_name)), @@ -907,9 +996,10 @@ fn tray_usage_summary_lines( shown(usage.session.percentage), weekly_label, shown(usage.weekly.percentage), - )) - }) - .collect() + )); + } + } + lines } fn tray_usage_summary_from_state() -> Option { @@ -927,11 +1017,13 @@ fn tray_usage_summary_from_state() -> Option { if !state.last_poll_ok { return None; } - let lines = tray_usage_summary_lines( + let lines = tray_usage_summary_lines_with_pools( state.data.as_ref()?, state.providers, state.language, state.usage_countdown, + state.show_antigravity_gemini, + state.show_antigravity_external, ); (!lines.is_empty()).then(|| lines.join("\n")) } @@ -2184,6 +2276,11 @@ pub fn run() { language, install_channel, providers: settings.enabled_providers(), + show_antigravity_gemini: settings.antigravity_gemini_enabled(), + show_antigravity_external: settings.antigravity_external_enabled(), + crossfade_opacity: 1.0, + crossfade_anim_start: None, + crossfade_target_opacity: 1.0, accounts: settings.accounts.clone(), data: None, poll_interval_ms: settings.poll_interval_ms, @@ -2253,6 +2350,17 @@ pub fn run() { render_layered(); schedule_countdown_timer(); schedule_clock_timer(); + let startup_dual_mode = { + let state = lock_state(); + state.as_ref().is_some_and(|s| { + s.show_antigravity_gemini + && s.show_antigravity_external + && s.providers.contains(ProviderId::Antigravity) + }) + }; + if startup_dual_mode { + arm_crossfade_schedule(hwnd); + } if open_dashboard_on_start { crate::dashboard::show(hwnd); @@ -2896,12 +3004,36 @@ fn check_language_change() { } } +pub(super) fn arm_crossfade_schedule(hwnd: HWND) { + let now_ms = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) => d.as_millis() as u64, + Err(_) => 0, + }; + let cycle_pos_ms = now_ms % 20_000; + // Cycle: [0..8000) Gemini hold, [8000..10000) Fade to External, + // [10000..18000) External hold, [18000..20000) Fade to Gemini + let delay_ms = if cycle_pos_ms < 8_000 { + 8_000 - cycle_pos_ms + } else if cycle_pos_ms < 10_000 { + 1 + } else if cycle_pos_ms < 18_000 { + 18_000 - cycle_pos_ms + } else { + 1 + }; + unsafe { + let _ = SetTimer(Some(hwnd), TIMER_CROSSFADE_SCHEDULE, delay_ms as u32, None); + } +} + fn reload_external_settings(hwnd: HWND) { let settings = load_settings(); let language_override = settings.language.as_deref().and_then(LanguageId::from_code); let theme_path = settings.active_theme_path.as_ref().map(PathBuf::from); let providers_changed; let lock_enabled; + let antigravity_changed; + let dual_mode; { let mut state = lock_state(); let Some(state) = state.as_mut() else { @@ -2909,6 +3041,12 @@ fn reload_external_settings(hwnd: HWND) { }; providers_changed = state.providers != settings.enabled_providers() || state.accounts != settings.accounts; + let gemini_enabled = settings.antigravity_gemini_enabled(); + let external_enabled = settings.antigravity_external_enabled(); + antigravity_changed = state.show_antigravity_gemini != gemini_enabled + || state.show_antigravity_external != external_enabled; + state.show_antigravity_gemini = gemini_enabled; + state.show_antigravity_external = external_enabled; state.accounts = settings.accounts.clone(); if let Some(data) = state.data.as_mut() { data.select_accounts(&settings.accounts); @@ -2916,6 +3054,18 @@ fn reload_external_settings(hwnd: HWND) { state.poll_interval_ms = settings.poll_interval_ms; state.providers = settings.enabled_providers(); state.usage_countdown = settings.usage_countdown; + dual_mode = state.show_antigravity_gemini + && state.show_antigravity_external + && state.providers.contains(ProviderId::Antigravity); + if !dual_mode { + unsafe { + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_SCHEDULE); + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_ANIM); + } + state.crossfade_opacity = 1.0; + state.crossfade_anim_start = None; + state.crossfade_target_opacity = 1.0; + } update_configured_placement( state, settings.placement_override, @@ -2927,6 +3077,9 @@ fn reload_external_settings(hwnd: HWND) { lock_enabled = state.lock_taskbar; apply_language_to_state(state, language_override); } + if dual_mode && (antigravity_changed || providers_changed) { + arm_crossfade_schedule(hwnd); + } unsafe { SetTimer(Some(hwnd), TIMER_POLL, settings.poll_interval_ms, None); } diff --git a/src/window/message_loop.rs b/src/window/message_loop.rs index dbac3d91..67def3b1 100644 --- a/src/window/message_loop.rs +++ b/src/window/message_loop.rs @@ -127,6 +127,73 @@ pub(super) unsafe extern "system" fn wnd_proc( position_at_taskbar(); render_layered(); } + TIMER_CROSSFADE_SCHEDULE => { + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_SCHEDULE); + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + if s.show_antigravity_gemini + && s.show_antigravity_external + && s.providers.contains(ProviderId::Antigravity) + { + s.crossfade_target_opacity = if s.crossfade_opacity > 0.5 { 0.0 } else { 1.0 }; + s.crossfade_anim_start = Some(Instant::now()); + unsafe { + let _ = SetTimer(Some(hwnd), TIMER_CROSSFADE_ANIM, 30, None); + } + } + } + } + TIMER_CROSSFADE_ANIM => { + let (completed, _op) = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + if !s.show_antigravity_gemini + || !s.show_antigravity_external + || !s.providers.contains(ProviderId::Antigravity) + { + (true, 1.0) + } else if let Some(start) = s.crossfade_anim_start { + let elapsed = start.elapsed().as_secs_f64(); + let progress = (elapsed / 2.0).clamp(0.0, 1.0); + // Smoothstep ease-in-out: 3*p^2 - 2*p^3 + let t = progress * progress * (3.0 - 2.0 * progress); + let start_op = if s.crossfade_target_opacity == 0.0 { 1.0 } else { 0.0 }; + let current_op = start_op + (s.crossfade_target_opacity - start_op) * t; + s.crossfade_opacity = current_op; + if progress >= 1.0 { + s.crossfade_opacity = s.crossfade_target_opacity; + s.crossfade_anim_start = None; + (true, s.crossfade_opacity) + } else { + (false, current_op) + } + } else { + (true, s.crossfade_opacity) + } + } else { + (true, 1.0) + } + }; + render_layered(); + if completed { + unsafe { + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_ANIM); + } + let dual_mode = { + let state = lock_state(); + state.as_ref().is_some_and(|s| { + s.show_antigravity_gemini + && s.show_antigravity_external + && s.providers.contains(ProviderId::Antigravity) + }) + }; + if dual_mode { + unsafe { + let _ = SetTimer(Some(hwnd), TIMER_CROSSFADE_SCHEDULE, 8_000, None); + } + } + } + } _ => {} } LRESULT(0) @@ -793,15 +860,102 @@ pub(super) unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(Some(hwnd), TIMER_POLL, new_interval, None); } + IDM_PROVIDER_ANTIGRAVITY_GEMINI | IDM_PROVIDER_ANTIGRAVITY_EXTERNAL => { + let mut should_reposition = false; + let mut dual_mode = false; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + let is_gemini = id == IDM_PROVIDER_ANTIGRAVITY_GEMINI; + let current = if is_gemini { + s.show_antigravity_gemini + } else { + s.show_antigravity_external + }; + let other = if is_gemini { + s.show_antigravity_external + } else { + s.show_antigravity_gemini + }; + let target = !current; + // Guard: cannot disable both if Antigravity is the only active provider + if !target + && !other + && s.providers.len() <= 1 + && s.providers.contains(ProviderId::Antigravity) + { + // Keep at least one provider active + } else { + if is_gemini { + s.show_antigravity_gemini = target; + } else { + s.show_antigravity_external = target; + } + if !target && !other { + s.providers.set(ProviderId::Antigravity, false); + should_reposition = true; + } else if !s.providers.contains(ProviderId::Antigravity) { + s.providers.set(ProviderId::Antigravity, true); + should_reposition = true; + } + dual_mode = s.show_antigravity_gemini + && s.show_antigravity_external + && s.providers.contains(ProviderId::Antigravity); + if !dual_mode { + unsafe { + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_SCHEDULE); + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_ANIM); + } + s.crossfade_opacity = 1.0; + s.crossfade_anim_start = None; + s.crossfade_target_opacity = 1.0; + } + } + } + } + if dual_mode { + arm_crossfade_schedule(hwnd); + } + save_state_settings(); + if should_reposition { + position_at_taskbar(); + } + render_layered(); + sync_tray_icon(hwnd); + request_poll(hwnd); + } id if ProviderId::from_native_menu_command_id(id).is_some() => { + let mut dual_mode = false; { let mut state = lock_state(); if let Some(s) = state.as_mut() { let provider = ProviderId::from_native_menu_command_id(id) .expect("provider menu command was matched above"); s.providers.toggle(provider); + if provider == ProviderId::Antigravity { + if s.providers.contains(ProviderId::Antigravity) { + if !s.show_antigravity_gemini && !s.show_antigravity_external { + s.show_antigravity_gemini = true; + s.show_antigravity_external = true; + } + } else { + unsafe { + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_SCHEDULE); + let _ = KillTimer(Some(hwnd), TIMER_CROSSFADE_ANIM); + } + s.crossfade_opacity = 1.0; + s.crossfade_anim_start = None; + s.crossfade_target_opacity = 1.0; + } + } + dual_mode = s.show_antigravity_gemini + && s.show_antigravity_external + && s.providers.contains(ProviderId::Antigravity); } } + if dual_mode { + arm_crossfade_schedule(hwnd); + } save_state_settings(); position_at_taskbar(); render_layered(); diff --git a/src/window/placement_regression_tests.rs b/src/window/placement_regression_tests.rs index d977254f..6f0727e2 100644 --- a/src/window/placement_regression_tests.rs +++ b/src/window/placement_regression_tests.rs @@ -12,6 +12,11 @@ fn state_for(theme: ThemeDocument, placement: PlacementOverride) -> AppState { language: LanguageId::English, install_channel: InstallChannel::Portable, providers: ProviderSet::default(), + show_antigravity_gemini: true, + show_antigravity_external: true, + crossfade_opacity: 1.0, + crossfade_anim_start: None, + crossfade_target_opacity: 1.0, accounts: Default::default(), data: None, poll_interval_ms: POLL_15_MIN, diff --git a/src/window/window_context_menu.rs b/src/window/window_context_menu.rs index cc784541..130c0985 100644 --- a/src/window/window_context_menu.rs +++ b/src/window/window_context_menu.rs @@ -145,6 +145,8 @@ pub(super) fn context_menu_action_flags( state.poll_interval_ms == seconds.saturating_mul(1_000) } ContextMenuAction::ToggleProvider { provider } => state.providers.contains(*provider), + ContextMenuAction::ToggleAntigravityGemini => state.show_antigravity_gemini, + ContextMenuAction::ToggleAntigravityExternal => state.show_antigravity_external, ContextMenuAction::ToggleStartup => is_startup_enabled(), ContextMenuAction::ToggleTaskbarLock => state.lock_taskbar, ContextMenuAction::ToggleWidget => state @@ -267,6 +269,8 @@ pub(super) fn execute_context_menu_action( ContextMenuAction::ToggleProvider { provider } => { Some(provider.descriptor().native_menu_command_id) } + ContextMenuAction::ToggleAntigravityGemini => Some(IDM_PROVIDER_ANTIGRAVITY_GEMINI), + ContextMenuAction::ToggleAntigravityExternal => Some(IDM_PROVIDER_ANTIGRAVITY_EXTERNAL), ContextMenuAction::ToggleStartup => Some(IDM_START_WITH_WINDOWS), ContextMenuAction::ToggleTaskbarLock => Some(IDM_LOCK_TASKBAR), ContextMenuAction::SetLanguage { language } => {