Skip to content

Commit 5587905

Browse files
committed
source code
1 parent 7ef75f4 commit 5587905

File tree

5 files changed

+378
-3
lines changed

5 files changed

+378
-3
lines changed

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(cargo build:*)"
5+
]
6+
}
7+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod models;
66
mod native_interop;
77
mod poller;
88
mod theme;
9+
mod tray_icon;
910
mod updater;
1011
mod window;
1112

src/native_interop.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub const TIMER_UPDATE_CHECK: usize = 4;
2222
// Custom messages
2323
pub const WM_APP: u32 = 0x8000;
2424
pub const WM_APP_USAGE_UPDATED: u32 = WM_APP + 1;
25+
pub const WM_APP_TRAY: u32 = WM_APP + 3;
2526

2627
/// Get the taskbar window handle
2728
pub fn find_taskbar() -> Option<HWND> {

src/tray_icon.rs

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
use windows::Win32::Foundation::*;
2+
use windows::Win32::Graphics::Gdi::*;
3+
use windows::Win32::UI::Shell::{
4+
NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
5+
Shell_NotifyIconW,
6+
};
7+
use windows::Win32::UI::WindowsAndMessaging::*;
8+
use windows::core::PCWSTR;
9+
10+
use crate::native_interop::{self, Color, WM_APP_TRAY};
11+
12+
const TRAY_ICON_ID: u32 = 1;
13+
14+
/// Menu item ID for toggling widget visibility (used by window.rs context menu).
15+
pub const IDM_TOGGLE_WIDGET: u16 = 50;
16+
17+
/// Actions the tray message handler can request from the main window.
18+
pub enum TrayAction {
19+
None,
20+
ToggleWidget,
21+
ShowContextMenu,
22+
}
23+
24+
/// Create a rounded-rectangle tray icon badge showing the usage percentage.
25+
/// `percent` = None means "no data" (gray "?"), Some(p) is the usage level.
26+
pub fn create_icon(percent: Option<f64>) -> HICON {
27+
let size = 64_i32;
28+
let margin = 4_i32;
29+
let radius = 14_i32;
30+
let outline = 2_i32;
31+
32+
let (fill, outline_col, text_col) = match percent {
33+
None => (
34+
Color::from_hex("#6c757d"),
35+
Color::from_hex("#495057"),
36+
Color::from_hex("#FFFFFF"),
37+
),
38+
Some(p) if p < 50.0 => (
39+
Color::from_hex("#28a745"),
40+
Color::from_hex("#1e7e34"),
41+
Color::from_hex("#FFFFFF"),
42+
),
43+
Some(p) if p < 75.0 => (
44+
Color::from_hex("#ffc107"),
45+
Color::from_hex("#e0a800"),
46+
Color::from_hex("#1a1a1a"),
47+
),
48+
Some(p) if p < 90.0 => (
49+
Color::from_hex("#fd7e14"),
50+
Color::from_hex("#d9650a"),
51+
Color::from_hex("#FFFFFF"),
52+
),
53+
_ => (
54+
Color::from_hex("#dc3545"),
55+
Color::from_hex("#bd2130"),
56+
Color::from_hex("#FFFFFF"),
57+
),
58+
};
59+
60+
let display_text = match percent {
61+
None => "?".to_string(),
62+
Some(p) => format!("{}", p as u32),
63+
};
64+
65+
let font_h = match display_text.len() {
66+
1 => -50,
67+
2 => -42,
68+
_ => -30,
69+
};
70+
71+
unsafe {
72+
let screen_dc = GetDC(HWND::default());
73+
let mem_dc = CreateCompatibleDC(screen_dc);
74+
75+
let bmi = BITMAPINFO {
76+
bmiHeader: BITMAPINFOHEADER {
77+
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
78+
biWidth: size,
79+
biHeight: -size,
80+
biPlanes: 1,
81+
biBitCount: 32,
82+
biCompression: 0,
83+
..Default::default()
84+
},
85+
..Default::default()
86+
};
87+
88+
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
89+
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
90+
.unwrap_or_default();
91+
92+
if dib.is_invalid() {
93+
let _ = DeleteDC(mem_dc);
94+
ReleaseDC(HWND::default(), screen_dc);
95+
return HICON::default();
96+
}
97+
98+
let old_bmp = SelectObject(mem_dc, dib);
99+
100+
// Zero-fill (transparent background)
101+
let pixel_data =
102+
std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize);
103+
for px in pixel_data.iter_mut() {
104+
*px = 0;
105+
}
106+
107+
// Draw rounded rectangle badge
108+
let null_pen = GetStockObject(NULL_PEN);
109+
let old_pen = SelectObject(mem_dc, null_pen);
110+
111+
// Outer rounded rect = outline colour
112+
let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref()));
113+
let old_brush = SelectObject(mem_dc, br_outline);
114+
let _ = RoundRect(
115+
mem_dc,
116+
margin,
117+
margin,
118+
size - margin + 1,
119+
size - margin + 1,
120+
radius * 2,
121+
radius * 2,
122+
);
123+
124+
// Inner rounded rect = fill colour
125+
let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref()));
126+
SelectObject(mem_dc, br_fill);
127+
let _ = RoundRect(
128+
mem_dc,
129+
margin + outline,
130+
margin + outline,
131+
size - margin - outline + 1,
132+
size - margin - outline + 1,
133+
(radius - 1) * 2,
134+
(radius - 1) * 2,
135+
);
136+
137+
SelectObject(mem_dc, old_brush);
138+
SelectObject(mem_dc, old_pen);
139+
let _ = DeleteObject(br_outline);
140+
let _ = DeleteObject(br_fill);
141+
142+
// Draw centered percentage text
143+
let font_name = native_interop::wide_str("Arial Bold");
144+
let font = CreateFontW(
145+
font_h,
146+
0,
147+
0,
148+
0,
149+
FW_BOLD.0 as i32,
150+
0,
151+
0,
152+
0,
153+
DEFAULT_CHARSET.0 as u32,
154+
OUT_TT_PRECIS.0 as u32,
155+
CLIP_DEFAULT_PRECIS.0 as u32,
156+
ANTIALIASED_QUALITY.0 as u32,
157+
(DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32,
158+
PCWSTR::from_raw(font_name.as_ptr()),
159+
);
160+
let old_font = SelectObject(mem_dc, font);
161+
let _ = SetBkMode(mem_dc, TRANSPARENT);
162+
let _ = SetTextColor(mem_dc, COLORREF(text_col.to_colorref()));
163+
164+
let mut text_rect = RECT {
165+
left: margin,
166+
top: margin,
167+
right: size - margin,
168+
bottom: size - margin,
169+
};
170+
let mut text_wide: Vec<u16> = display_text.encode_utf16().collect();
171+
let _ = DrawTextW(
172+
mem_dc,
173+
&mut text_wide,
174+
&mut text_rect,
175+
DT_CENTER | DT_VCENTER | DT_SINGLELINE,
176+
);
177+
178+
SelectObject(mem_dc, old_font);
179+
let _ = DeleteObject(font);
180+
181+
// Set alpha: non-zero BGR pixel -> fully opaque; background stays transparent
182+
for px in pixel_data.iter_mut() {
183+
if *px != 0 {
184+
*px = (*px & 0x00FF_FFFF) | 0xFF00_0000;
185+
}
186+
}
187+
188+
// Monochrome mask (per-pixel alpha from colour bitmap)
189+
let mask_bytes = vec![0u8; ((size * size + 7) / 8) as usize];
190+
let mask_bmp = CreateBitmap(
191+
size,
192+
size,
193+
1,
194+
1,
195+
Some(mask_bytes.as_ptr() as *const std::ffi::c_void),
196+
);
197+
198+
let icon_info = ICONINFO {
199+
fIcon: TRUE,
200+
xHotspot: 0,
201+
yHotspot: 0,
202+
hbmMask: mask_bmp,
203+
hbmColor: dib,
204+
};
205+
let hicon = CreateIconIndirect(&icon_info).unwrap_or_default();
206+
207+
let _ = DeleteObject(mask_bmp);
208+
SelectObject(mem_dc, old_bmp);
209+
let _ = DeleteObject(dib);
210+
let _ = DeleteDC(mem_dc);
211+
ReleaseDC(HWND::default(), screen_dc);
212+
213+
hicon
214+
}
215+
}
216+
217+
/// Register the tray icon with the shell.
218+
pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
219+
let hicon = create_icon(percent);
220+
unsafe {
221+
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
222+
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
223+
nid.hWnd = hwnd;
224+
nid.uID = TRAY_ICON_ID;
225+
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
226+
nid.uCallbackMessage = WM_APP_TRAY;
227+
nid.hIcon = hicon;
228+
copy_to_tip(tooltip, &mut nid.szTip);
229+
let _ = Shell_NotifyIconW(NIM_ADD, &nid);
230+
if !hicon.is_invalid() {
231+
let _ = DestroyIcon(hicon);
232+
}
233+
}
234+
}
235+
236+
/// Update the tray icon colour and tooltip to reflect current usage.
237+
pub fn update(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
238+
let hicon = create_icon(percent);
239+
unsafe {
240+
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
241+
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
242+
nid.hWnd = hwnd;
243+
nid.uID = TRAY_ICON_ID;
244+
nid.uFlags = NIF_ICON | NIF_TIP;
245+
nid.hIcon = hicon;
246+
copy_to_tip(tooltip, &mut nid.szTip);
247+
let _ = Shell_NotifyIconW(NIM_MODIFY, &nid);
248+
if !hicon.is_invalid() {
249+
let _ = DestroyIcon(hicon);
250+
}
251+
}
252+
}
253+
254+
/// Remove the tray icon from the shell.
255+
pub fn remove(hwnd: HWND) {
256+
unsafe {
257+
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
258+
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
259+
nid.hWnd = hwnd;
260+
nid.uID = TRAY_ICON_ID;
261+
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
262+
}
263+
}
264+
265+
/// Interpret a tray callback message and return the action to take.
266+
pub fn handle_message(lparam: LPARAM) -> TrayAction {
267+
let mouse_msg = lparam.0 as u32;
268+
match mouse_msg {
269+
WM_LBUTTONUP => TrayAction::ToggleWidget,
270+
WM_RBUTTONUP => TrayAction::ShowContextMenu,
271+
_ => TrayAction::None,
272+
}
273+
}
274+
275+
/// Copy a string into the fixed-size szTip field (max 127 chars + null).
276+
fn copy_to_tip(s: &str, tip: &mut [u16; 128]) {
277+
let wide: Vec<u16> = s.encode_utf16().collect();
278+
let mut len = wide.len().min(127);
279+
// Don't leave a lone high surrogate at the truncation point
280+
if len > 0 && (0xD800..=0xDBFF).contains(&wide[len - 1]) {
281+
len -= 1;
282+
}
283+
tip[..len].copy_from_slice(&wide[..len]);
284+
tip[len] = 0;
285+
}

0 commit comments

Comments
 (0)