Files
GitLite/src-tauri/src/badge.rs
T
Christoph Brandau 399ba80550 feat(tauri): update Windows taskbar badge with combined count
The Windows overlay badge now renders a single combined number and
includes an additional “changes” component in the total. This makes
the badge more informative while keeping the icon compact and
legible across small sizes.

- Simplify badge rendering to one circle with centered text
- Extend the Tauri command and frontend call to pass changes count
2026-07-03 16:40:47 +02:00

187 lines
6.0 KiB
Rust

//! Renders the combined repository attention count as a taskbar badge and applies it as the
//! Windows taskbar overlay. Windows has no native numeric taskbar badge (unlike
//! macOS/Linux, which support `Window::set_badge_count`), so the numbers have to be drawn
//! into a plain RGBA icon ourselves and set via `Window::set_overlay_icon`.
use tauri::Manager;
use tauri::image::Image;
const GLYPH_WIDTH: usize = 3;
const GLYPH_HEIGHT: usize = 5;
/// A minimal 3x5 pixel-grid font, just enough to draw digits and "+" legibly at badge size.
fn glyph_rows(ch: char) -> [&'static str; GLYPH_HEIGHT] {
match ch {
'0' => ["111", "101", "101", "101", "111"],
'1' => ["010", "110", "010", "010", "111"],
'2' => ["111", "001", "111", "100", "111"],
'3' => ["111", "001", "111", "001", "111"],
'4' => ["101", "101", "111", "001", "001"],
'5' => ["111", "100", "111", "001", "111"],
'6' => ["111", "100", "111", "101", "111"],
'7' => ["111", "001", "010", "010", "010"],
'8' => ["111", "101", "111", "101", "111"],
'9' => ["111", "101", "111", "001", "111"],
'+' => ["000", "010", "111", "010", "000"],
_ => ["000", "000", "000", "000", "000"],
}
}
const BADGE_FILL: [u8; 4] = [224, 160, 64, 255];
const BADGE_BORDER: [u8; 4] = [176, 118, 40, 255];
const TEXT_FILL: [u8; 4] = [255, 255, 255, 255];
const TEXT_SHADOW: [u8; 4] = [10, 12, 24, 190];
const CIRCLE_BORDER_WIDTH: f32 = 3.0;
/// Caps the displayed text at three characters ("99+") so it always fits legibly.
fn cap_text(count: u32) -> String {
if count > 99 {
"99+".to_string()
} else {
count.to_string()
}
}
/// Draws a filled circle (with a slightly darker rim) centered at `(cx, cy)`.
fn draw_circle(
rgba: &mut [u8],
size: usize,
cx: f32,
cy: f32,
radius: f32,
fill: [u8; 4],
border: [u8; 4],
) {
for y in 0..size {
for x in 0..size {
let dx = x as f32 + 0.5 - cx;
let dy = y as f32 + 0.5 - cy;
let dist = (dx * dx + dy * dy).sqrt();
if dist <= radius {
let color = if dist >= radius - CIRCLE_BORDER_WIDTH {
border
} else {
fill
};
let idx = (y * size + x) * 4;
rgba[idx..idx + 4].copy_from_slice(&color);
}
}
}
}
fn draw_text_layer(
rgba: &mut [u8],
size: usize,
text: &str,
cx: f32,
cy: f32,
scale: usize,
offset: (isize, isize),
color: [u8; 4],
) {
let glyphs: Vec<[&'static str; GLYPH_HEIGHT]> = text.chars().map(glyph_rows).collect();
let glyph_px_w = GLYPH_WIDTH * scale;
let glyph_px_h = GLYPH_HEIGHT * scale;
let gap = scale.max(1);
let total_w = glyphs.len() * glyph_px_w + gap * glyphs.len().saturating_sub(1);
let start_x = (cx - total_w as f32 / 2.0).round() as isize + offset.0;
let start_y = (cy - glyph_px_h as f32 / 2.0).round() as isize + offset.1;
for (gi, rows) in glyphs.iter().enumerate() {
let glyph_x = start_x + (gi * (glyph_px_w + gap)) as isize;
for (gy, row) in rows.iter().enumerate() {
for (gx, pixel) in row.chars().enumerate() {
if pixel != '1' {
continue;
}
for py in 0..scale {
for px in 0..scale {
let x = glyph_x + (gx * scale + px) as isize;
let y = start_y + (gy * scale + py) as isize;
if x >= 0 && y >= 0 && (x as usize) < size && (y as usize) < size {
let idx = (y as usize * size + x as usize) * 4;
rgba[idx..idx + 4].copy_from_slice(&color);
}
}
}
}
}
}
}
/// Draws white `text` centered at `(cx, cy)`, scaling each font pixel up by `scale`.
fn draw_text(rgba: &mut [u8], size: usize, text: &str, cx: f32, cy: f32, scale: usize) {
for offset in [(-1, 0), (1, 0), (0, -1), (0, 1)] {
draw_text_layer(rgba, size, text, cx, cy, scale, offset, TEXT_SHADOW);
}
draw_text_layer(rgba, size, text, cx, cy, scale, (0, 0), TEXT_FILL);
}
fn text_scale(text: &str) -> usize {
match text.len() {
0 | 1 => 7,
2 => 5,
_ => 4,
}
}
/// Renders a single large badge with `count`. Returns `None` when there's nothing to show,
/// so the caller can clear the overlay icon.
fn render_badge_icon(count: u32) -> Option<Image<'static>> {
if count == 0 {
return None;
}
const SIZE: usize = 64;
const RADIUS: f32 = 29.0;
let mut rgba = vec![0u8; SIZE * SIZE * 4];
let center = SIZE as f32 / 2.0;
let text = cap_text(count);
draw_circle(
&mut rgba,
SIZE,
center,
center,
RADIUS,
BADGE_FILL,
BADGE_BORDER,
);
draw_text(&mut rgba, SIZE, &text, center, center, text_scale(&text));
Some(Image::new_owned(rgba, SIZE as u32, SIZE as u32))
}
/// Sets the taskbar badge to `ahead + behind + changes` (0 clears it). Windows-only: Windows
/// has no native numeric badge API, so this draws and applies a small overlay icon instead.
/// No-op on other platforms: non-Windows desktops should use `Window::set_badge_count`
/// for a real native badge instead, which this app doesn't currently wire up.
#[tauri::command]
pub fn set_sync_badge(
app: tauri::AppHandle,
ahead: u32,
behind: u32,
changes: u32,
) -> Result<(), String> {
let count = ahead.saturating_add(behind).saturating_add(changes);
#[cfg(target_os = "windows")]
{
let Some(window) = app.get_webview_window("main") else {
return Ok(());
};
let icon = render_badge_icon(count);
window
.set_overlay_icon(icon)
.map_err(|err| format!("Could not set taskbar badge: {err}"))?;
}
#[cfg(not(target_os = "windows"))]
{
let _ = (app, count);
}
Ok(())
}