用 Rust 和 Tauri 2 实现动态托盘电量图标

在设计电池电量实时显示的程序 percentage-rust 时,需要在 Tauri 2 中,将动态的文本显示到系统托盘图标上。网上没有现成的资料,于是把自己实现的方法分享出来,以供参考。

最近打算学 Rust,想找个项目做牵引。这个项目灵感来源于 Percentage,它是一个基于 C++的、在 Windows 的系统托盘中显示电量的程序。系统自带的电池图标只能看个大概,若要查看具体电量还需要把鼠标悬停在图标上。更要命的是,如果因电量不足开启了省电模式,那个叶子图标会把剩余的电量挡住,完全不知道还剩下多少。Percentage 则通过把电池电量百分比以数字形式实时显示在任务栏中,解决了这个痛点,让人瞟一眼就知道当前剩余电量。
编写过程中,为了实现“将获取到的电池状态绘制为图标、更新到系统托盘”这个功能,搜索了许多资料。然而,Tauri 2 的社区内容尚不是特别完善,官方教程的示例也只是提到了使用静态 icon 文件作为托盘图标的方法,说白了没地方直接抄,于是自己研究并实现了这个功能。
思路为:先生成字符串,然后调整字体大小使文字不超出图标宽度,接着计算坐标使文字居中,再渲染得到一个 tauri::image::Image 对象,最后将这个对象通过 tauri::tray::TrayIcon() 函数更新为当前图标。
核心踩坑点有两个。其一是要使用 ascent 而不是 height 作为字符高度,否则数字位置会偏高。其二是要将图像编码为 ICO 格式存储到二进制 buffer 中,再用 buffer 初始化 tauri::image::Image,而不是直接使用 image::RgbaImage,否则 Tauri 不认。

字体参数计算

首先,使用 ab_glyph 计算文本尺寸,参考其 文档 可知,字体的几个主要尺寸如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
         ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
| .:x++++== |
| .#+ |
| :@ =++=++x=: |
ascent | +# x: +x x+ |
| =# #: :#:---:#: | height
| -@- #: .#--:-- |
| =#:-.-==#: #x+===:. |
baseline ____________ .-::-. .. #: .:@. |
| #+--..-=#. |
descent | -::=::- |
____________________________________
| | | | line_gap
| | h_advance | ‾
^
h_side_bearing

其中,宽度是 h_advance 很显然。但对于高度,由于程序中只考虑数字和几个符号,如果使用 height 会导致字符位置偏上,所以应该使用 ascent。最终实现如下:

1
2
3
4
5
6
fn measure_text(&self, text: &str, scale: PxScale) -> (f32, f32) {
let scaled_font = self.font.as_scaled(scale);
let width = text.chars().map(|c| scaled_font.h_advance(scaled_font.glyph_id(c))).sum();
let height = scaled_font.ascent();
(width, height)
}

函数中的 scale 是通过二分法确定的,用二分法尝试不同的 scale,逐步逼近(但不超过)画布的宽度。这样做的原因是,字体中不同字符的宽度略有区别,如果写死宽度,当显示较窄的字符时,会显得图标过于空旷。
确定最终的 scale 之后,再调用 measure_text() 得到宽高,与画布尺寸做运算,即可得到要使字符串居中所需的 x、y 坐标。

图标绘制与更新

在绘制图标的函数中,首先创建一个 RgbaImage 对象,用之前计算得到的 x, y, scale 绘制到画布,再将画布编码为 ICO 格式的二进制 buffer,用这个二进制 buffer 初始化 tauri::image::Image 对象,作为返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn render_icon(&self, x: i32, y: i32, scale: PxScale, text: &str) -> Result<Image<'static>> {
let mut img = RgbaImage::new(BatteryIconGenerator::SIZE, BatteryIconGenerator::SIZE);
draw_text_mut(&mut img, Rgba([0, 0, 0, 255]), x, y, scale, &self.font, &text);

let mut icon_data = Cursor::new(Vec::new());
img.write_to(&mut icon_data, image::ImageFormat::Ico)
.context("Failed to encode icon to ICO")?;

let icon_image = Image::from_bytes(&icon_data.into_inner())
.context("Failed to create Tauri image")?
.to_owned();

Ok(icon_image)
}

主程序中,生成一个线程,每隔一秒调用 battery 库获取电池充电状态和电量百分比。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn spawn_battery_monitor(tx: Sender<(u32, State)>) {
thread::spawn(move || {
let manager = Manager::new().expect("Failed to initialize battery manager");
let mut last_battery_info: Option<(u32, State)> = None;

loop {
if let Ok(batteries) = manager.batteries() {
if let Some(battery) = batteries.flatten().next() {
let percentage = (battery.state_of_charge().value * 100.0).round() as u32;
let state = battery.state();

if Some((percentage, state)) != last_battery_info {
last_battery_info = Some((percentage, state));

tx.blocking_send((percentage, state))
.expect("Failed to send battery info");
}
}
}
thread::sleep(Duration::from_secs(1));
}
});
}

它绑定一组 rx、tx,其中 rx 用于初始化一个异步函数。

1
2
3
let (tx, rx) = channel(1);
spawn_battery_monitor(tx);
spawn_tray_updater(tray, rx);

当电池状态更新,rx 收到消息,就生成 icon 并通过 set_icon() 函数更新图标,实现实时电量显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn spawn_tray_updater(tray: Arc<Mutex<TrayIcon>>, mut rx: Receiver<(u32, State)>) {
async_runtime::spawn(async move {
let icon_generator = BatteryIconGenerator::new().unwrap();
while let Some((percentage, state)) = rx.recv().await {
if let Ok(icon) = icon_generator.generate_icon(percentage, state == State::Charging).await {
let tooltip = match state {
State::Charging => format!("Charging: {}%", percentage),
State::Discharging => format!("Discharging: {}%", percentage),
State::Full => format!("Full"),
_ => format!("Unhandled state: {}%", percentage),
};

let tray = tray.lock().await;
if let Err(e) = tray.set_icon(Some(icon)) {
eprintln!("Failed to update tray icon: {}", e);
}
if let Err(e) = tray.set_tooltip(Some(&tooltip)) {
eprintln!("Failed to update tray tooltip: {}", e);
}
}
}
});
}