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 3e11d9a7..ff713e80 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, }) } @@ -70,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")] @@ -90,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) } } @@ -175,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) } @@ -297,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)?; @@ -318,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)| { @@ -327,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) } @@ -464,4 +526,76 @@ mod tests { assert!(decoded.get(ProviderId::Antigravity).is_none()); 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(); + 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/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 7b503ab9..70a88a2c 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 + // 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. + 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,71 @@ 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, both pools are taken from it 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: 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 + // 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 +433,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 +461,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 +476,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 +504,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 +537,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 +588,85 @@ 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); } + } + + // 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(); + } - if fallback.is_none() { - fallback = usage; + 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) } +} - fallback +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 +957,4 @@ mod auth_tests { assert_eq!(current.token.refresh_token.as_deref(), Some("refresh")); } } + 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() } } @@ -468,3 +469,268 @@ 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"); + // Model quotas do not name their window, so the external pool is not guessed. + assert!(usage.external_pool.is_none()); +} + +#[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"); + let external = usage.external_pool.expect("external pool"); + assert!((external.weekly.percentage - 60.0).abs() < 0.001); +} + +#[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); + 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 } => {