Improve tooltip

This commit is contained in:
Armel van Ravels committed 2026-10-12 16:21:10 +02:00
1 parent c312d83c7a
commit 81ec07a9aa
1 file changed
+133 -46
+133 -46
View File
@@ -7,17 +7,18 @@ use crate::model::{Provider, ProviderState, UsageSnapshot, UsageWindow};
pub struct WaybarView { pub struct WaybarView {
pub text: String, pub text: String,
pub tooltip: String, pub tooltip: String,
pub tooltip_markup: String,
pub class: String, pub class: String,
} }
pub fn render(snapshot: &UsageSnapshot, now: DateTime<Utc>, enabled: &[Provider]) -> WaybarView { pub fn render(snapshot: &UsageSnapshot, now: DateTime<Utc>, enabled: &[Provider]) -> WaybarView {
let mut parts = Vec::new(); let mut parts = Vec::new();
let mut tooltip = Vec::new(); let mut blocks = Vec::new();
let mut worst = "unknown"; let mut worst = "unknown";
let mut severity = level_rank("unknown"); let mut severity = level_rank("unknown");
let mut any = false; let mut any = false;
if snapshot.is_demo { if snapshot.is_demo {
tooltip.push("DEMO".to_string()); blocks.push(("DEMO".to_string(), "<b>DEMO</b>".to_string()));
} }
for provider in Provider::ALL { for provider in Provider::ALL {
if !enabled.contains(&provider) { if !enabled.contains(&provider) {
@@ -43,21 +44,24 @@ pub fn render(snapshot: &UsageSnapshot, now: DateTime<Utc>, enabled: &[Provider]
severity = level_rank(provider_level); severity = level_rank(provider_level);
worst = 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 { if !any {
let tooltip = "Every provider is disabled.".to_string();
return WaybarView { return WaybarView {
text: "AI usage off".to_string(), text: "AI usage off".to_string(),
tooltip: "Every provider is disabled.".to_string(), tooltip: tooltip.clone(),
tooltip_markup: tooltip,
class: "unknown".to_string(), 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"; worst = "unknown";
} }
WaybarView { WaybarView {
text: parts.join(" "), text: parts.join(" "),
tooltip: tooltip.join("\n"), tooltip: blocks.iter().map(|(plain, _)| plain.as_str()).collect::<Vec<_>>().join("\n\n"),
tooltip_markup: blocks.iter().map(|(_, markup)| markup.as_str()).collect::<Vec<_>>().join("\n\n"),
class: worst.to_string(), class: worst.to_string(),
} }
} }
@@ -65,7 +69,7 @@ pub fn render(snapshot: &UsageSnapshot, now: DateTime<Utc>, enabled: &[Provider]
pub fn json(view: &WaybarView) -> String { pub fn json(view: &WaybarView) -> String {
serde_json::json!({ serde_json::json!({
"text": view.text, "text": view.text,
"tooltip": view.tooltip, "tooltip": view.tooltip_markup,
"class": view.class, "class": view.class,
}) })
.to_string() .to_string()
@@ -96,60 +100,137 @@ fn displayed_window(provider: Provider, state: &ProviderState) -> Option<UsageWi
} }
} }
fn provider_block(provider: Provider, state: &ProviderState, show_used: bool, now: DateTime<Utc>) -> String { fn provider_block(provider: Provider, state: &ProviderState, show_used: bool, now: DateTime<Utc>) -> (String, String) {
let mut lines = vec![provider.display_name().to_string()]; let usage = state.last_successful_usage.as_ref();
if state.is_stale(now) { let fetched = usage.map(|usage| formatting::clock(Some(usage.fetched_at)));
lines.push("Stale".to_string()); 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 { 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 { let Some(usage) = usage else {
lines.push(state.status.label().to_string()); let label = state.status.label();
return lines.join("\n"); plain.push(format!(" {label}"));
markup.push(format!(" {}", pango_escape(label)));
return (plain.join("\n"), markup.join("\n"));
}; };
if provider == Provider::Cursor { if provider == Provider::Cursor {
for pool in usage.display_billing_pools() { let pools = usage.display_billing_pools();
lines.push(format!( if pools.is_empty() {
"{} {}",
pool.name,
formatting::percentage(Some(&pool.window), show_used)
));
}
if usage.display_billing_pools().is_empty() {
if let Some(window) = &usage.billing_window { 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( if let Some(label) = &usage.billing_reset_label {
usage let line = format!("Resets {label}");
.billing_reset_label plain.push(format!(" {line}"));
.as_ref() markup.push(format!(" <span foreground=\"#6c7086\">{}</span>", pango_escape(&line)));
.map(|label| format!("Resets {label}")) }
.unwrap_or_else(|| "Reset unknown".to_string()),
);
for metric in usage.display_additional_usage() { 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 { } else {
lines.push(window_line("5h", usage.short_window.as_ref(), show_used, now)); for (label, window) in [("5h", usage.short_window.as_ref()), ("Week", usage.weekly_window.as_ref())] {
lines.push(window_line("Week", usage.weekly_window.as_ref(), show_used, now)); 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 { 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)))); (plain.join("\n"), markup.join("\n"))
lines.join("\n")
} }
fn window_line(label: &str, window: Option<&UsageWindow>, show_used: bool, now: DateTime<Utc>) -> String { fn header_plain(name: &str, stale: bool, fetched: Option<&str>) -> String {
let percent = formatting::percentage(window, show_used); let suffix = header_suffix(stale, fetched);
let reset = window if suffix.is_empty() {
.and_then(|window| window.reset_at) name.to_string()
.map(|date| formatting::reset(Some(date), now, true)) } else {
.unwrap_or_else(|| "—".to_string()); format!("{name} {suffix}")
let relative = formatting::relative_reset(window.and_then(|window| window.reset_at), now); }
format!("{label} {percent} {reset} {relative}") }
fn header_markup(name: &str, stale: bool, fetched: Option<&str>) -> String {
let suffix = header_suffix(stale, fetched);
if suffix.is_empty() {
format!("<b>{}</b>", pango_escape(name))
} else {
format!(
"<b>{}</b> <span foreground=\"#6c7086\">{}</span>",
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!(
" {}<span foreground=\"{color}\">{}</span>",
pango_escape(&padded),
pango_escape(&percent)
);
if let Some(reset) = reset {
plain.push_str(" ");
plain.push_str(reset);
markup.push_str(" <span foreground=\"#6c7086\">");
markup.push_str(&pango_escape(reset));
markup.push_str("</span>");
}
(plain, markup)
}
fn reset_clause(window: Option<&UsageWindow>, now: DateTime<Utc>) -> Option<String> {
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
} }
pub fn status_text(snapshot: &UsageSnapshot, active: bool, now: DateTime<Utc>, enabled: &[Provider]) -> String { pub fn status_text(snapshot: &UsageSnapshot, active: bool, now: DateTime<Utc>, enabled: &[Provider]) -> String {
@@ -186,6 +267,12 @@ mod tests {
assert_eq!(view.class, "warning"); assert_eq!(view.class, "warning");
assert!(view.tooltip.contains("Cursor models")); assert!(view.tooltip.contains("Cursor models"));
assert!(view.tooltip.contains("Resets Oct 30")); 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("<b>Cursor</b>"));
assert!(!view.tooltip.contains("<span"));
} }
#[test] #[test]