From 81ec07a9aa01d9d5773187c7ad285482f1bf1525 Mon Sep 17 00:00:00 2001 From: Armel van Ravels Date: Mon, 12 Oct 2026 16:21:10 +0200 Subject: [PATCH] Improve tooltip --- src/waybar.rs | 179 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 133 insertions(+), 46 deletions(-) diff --git a/src/waybar.rs b/src/waybar.rs index 2a4945f..96286a2 100644 --- a/src/waybar.rs +++ b/src/waybar.rs @@ -7,17 +7,18 @@ use crate::model::{Provider, ProviderState, UsageSnapshot, UsageWindow}; pub struct WaybarView { pub text: String, pub tooltip: String, + pub tooltip_markup: String, pub class: String, } pub fn render(snapshot: &UsageSnapshot, now: DateTime, enabled: &[Provider]) -> WaybarView { let mut parts = Vec::new(); - let mut tooltip = Vec::new(); + let mut blocks = Vec::new(); let mut worst = "unknown"; let mut severity = level_rank("unknown"); let mut any = false; if snapshot.is_demo { - tooltip.push("DEMO".to_string()); + blocks.push(("DEMO".to_string(), "DEMO".to_string())); } for provider in Provider::ALL { if !enabled.contains(&provider) { @@ -43,21 +44,24 @@ pub fn render(snapshot: &UsageSnapshot, now: DateTime, enabled: &[Provider] severity = level_rank(provider_level); worst = provider_level; } - tooltip.push(provider_block(provider, &state, snapshot.show_used_percentage, now)); + blocks.push(provider_block(provider, &state, snapshot.show_used_percentage, now)); } if !any { + let tooltip = "Every provider is disabled.".to_string(); return WaybarView { text: "AI usage off".to_string(), - tooltip: "Every provider is disabled.".to_string(), + tooltip: tooltip.clone(), + tooltip_markup: tooltip, class: "unknown".to_string(), }; } - if tooltip.iter().all(|line| line.contains("Awaiting first update")) && parts.iter().all(|part| part.ends_with("—")) { + if blocks.iter().all(|(plain, _)| plain.contains("Awaiting first update")) && parts.iter().all(|part| part.ends_with("—")) { worst = "unknown"; } WaybarView { text: parts.join(" "), - tooltip: tooltip.join("\n"), + tooltip: blocks.iter().map(|(plain, _)| plain.as_str()).collect::>().join("\n\n"), + tooltip_markup: blocks.iter().map(|(_, markup)| markup.as_str()).collect::>().join("\n\n"), class: worst.to_string(), } } @@ -65,7 +69,7 @@ pub fn render(snapshot: &UsageSnapshot, now: DateTime, enabled: &[Provider] pub fn json(view: &WaybarView) -> String { serde_json::json!({ "text": view.text, - "tooltip": view.tooltip, + "tooltip": view.tooltip_markup, "class": view.class, }) .to_string() @@ -96,60 +100,137 @@ fn displayed_window(provider: Provider, state: &ProviderState) -> Option) -> String { - let mut lines = vec![provider.display_name().to_string()]; - if state.is_stale(now) { - lines.push("Stale".to_string()); - } +fn provider_block(provider: Provider, state: &ProviderState, show_used: bool, now: DateTime) -> (String, String) { + let usage = state.last_successful_usage.as_ref(); + let fetched = usage.map(|usage| formatting::clock(Some(usage.fetched_at))); + let mut plain = vec![header_plain(provider.display_name(), state.is_stale(now), fetched.as_deref())]; + let mut markup = vec![header_markup(provider.display_name(), state.is_stale(now), fetched.as_deref())]; if let Some(message) = &state.message { - lines.push(message.clone()); + plain.push(format!(" {message}")); + markup.push(format!(" {}", pango_escape(message))); } - let Some(usage) = &state.last_successful_usage else { - lines.push(state.status.label().to_string()); - return lines.join("\n"); + let Some(usage) = usage else { + let label = state.status.label(); + plain.push(format!(" {label}")); + markup.push(format!(" {}", pango_escape(label))); + return (plain.join("\n"), markup.join("\n")); }; if provider == Provider::Cursor { - for pool in usage.display_billing_pools() { - lines.push(format!( - "{} {}", - pool.name, - formatting::percentage(Some(&pool.window), show_used) - )); - } - if usage.display_billing_pools().is_empty() { + let pools = usage.display_billing_pools(); + if pools.is_empty() { if let Some(window) = &usage.billing_window { - lines.push(format!("Included {}", formatting::percentage(Some(window), show_used))); + let (row_plain, row_markup) = metric_row("Included", Some(window), show_used, None); + plain.push(row_plain); + markup.push(row_markup); + } + } else { + for pool in pools { + let (row_plain, row_markup) = metric_row(&pool.name, Some(&pool.window), show_used, None); + plain.push(row_plain); + markup.push(row_markup); } } - lines.push( - usage - .billing_reset_label - .as_ref() - .map(|label| format!("Resets {label}")) - .unwrap_or_else(|| "Reset unknown".to_string()), - ); + if let Some(label) = &usage.billing_reset_label { + let line = format!("Resets {label}"); + plain.push(format!(" {line}")); + markup.push(format!(" {}", pango_escape(&line))); + } for metric in usage.display_additional_usage() { - lines.push(format!("{}: {}", metric.name, metric.value)); + let line = format!("{}: {}", metric.name, metric.value); + plain.push(format!(" {line}")); + markup.push(format!(" {}", pango_escape(&line))); } } else { - lines.push(window_line("5h", usage.short_window.as_ref(), show_used, now)); - lines.push(window_line("Week", usage.weekly_window.as_ref(), show_used, now)); + for (label, window) in [("5h", usage.short_window.as_ref()), ("Week", usage.weekly_window.as_ref())] { + let reset = reset_clause(window, now); + let (row_plain, row_markup) = metric_row(label, window, show_used, reset.as_deref()); + plain.push(row_plain); + markup.push(row_markup); + } for metric in &usage.additional_usage { - lines.push(format!("{}: {}", metric.name, metric.value)); + let line = format!("{}: {}", metric.name, metric.value); + plain.push(format!(" {line}")); + markup.push(format!(" {}", pango_escape(&line))); } } - lines.push(format!("Updated {}", formatting::clock(Some(usage.fetched_at)))); - lines.join("\n") + (plain.join("\n"), markup.join("\n")) } -fn window_line(label: &str, window: Option<&UsageWindow>, show_used: bool, now: DateTime) -> String { - let percent = formatting::percentage(window, show_used); - let reset = window - .and_then(|window| window.reset_at) - .map(|date| formatting::reset(Some(date), now, true)) - .unwrap_or_else(|| "—".to_string()); - let relative = formatting::relative_reset(window.and_then(|window| window.reset_at), now); - format!("{label} {percent} {reset} {relative}") +fn header_plain(name: &str, stale: bool, fetched: Option<&str>) -> String { + let suffix = header_suffix(stale, fetched); + if suffix.is_empty() { + name.to_string() + } else { + format!("{name} {suffix}") + } +} + +fn header_markup(name: &str, stale: bool, fetched: Option<&str>) -> String { + let suffix = header_suffix(stale, fetched); + if suffix.is_empty() { + format!("{}", pango_escape(name)) + } else { + format!( + "{} {}", + pango_escape(name), + pango_escape(&suffix) + ) + } +} + +fn header_suffix(stale: bool, fetched: Option<&str>) -> String { + let mut parts = Vec::new(); + if stale { + parts.push("stale"); + } + if let Some(fetched) = fetched { + parts.push(fetched); + } + parts.join(" · ") +} + +fn metric_row(label: &str, window: Option<&UsageWindow>, show_used: bool, reset: Option<&str>) -> (String, String) { + let percent = match window { + Some(window) => formatting::percentage(Some(window), show_used), + None => "—".to_string(), + }; + let color = level_color(level(window.and_then(|window| window.remaining_percentage))); + let padded = format!("{label:<16}"); + let mut plain = format!(" {padded}{percent}"); + let mut markup = format!( + " {}{}", + pango_escape(&padded), + pango_escape(&percent) + ); + if let Some(reset) = reset { + plain.push_str(" "); + plain.push_str(reset); + markup.push_str(" "); + markup.push_str(&pango_escape(reset)); + markup.push_str(""); + } + (plain, markup) +} + +fn reset_clause(window: Option<&UsageWindow>, now: DateTime) -> Option { + let date = window.and_then(|window| window.reset_at)?; + let absolute = formatting::reset(Some(date), now, true); + let relative = formatting::relative_reset(Some(date), now); + let relative = relative.strip_prefix("Reset ").unwrap_or(relative.as_str()); + Some(format!("{absolute} · {relative}")) +} + +fn level_color(class: &str) -> &'static str { + match class { + "ok" => "#a6e3a1", + "warning" => "#fab387", + "critical" => "#f38ba8", + _ => "#cdd6f4", + } +} + +fn pango_escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">") } pub fn status_text(snapshot: &UsageSnapshot, active: bool, now: DateTime, enabled: &[Provider]) -> String { @@ -186,6 +267,12 @@ mod tests { assert_eq!(view.class, "warning"); assert!(view.tooltip.contains("Cursor models")); assert!(view.tooltip.contains("Resets Oct 30")); + assert!(view.tooltip.contains("\n\n")); + assert!(!view.tooltip.contains("Reset unknown")); + let encoded = json(&view); + assert!(encoded.contains("foreground=\\\"#fab387\\\"")); + assert!(encoded.contains("Cursor")); + assert!(!view.tooltip.contains("