feat: tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - Comprehensive README with feature documentation - Remove tracked files that belong in gitignore
This commit is contained in:
348
src-tauri/src/os_detection.rs
Normal file
348
src-tauri/src/os_detection.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, GetObjectW, BITMAP, BITMAPINFO,
|
||||
BITMAPINFOHEADER, DIB_RGB_COLORS, HBITMAP,
|
||||
};
|
||||
use windows::Win32::Storage::FileSystem::FILE_FLAGS_AND_ATTRIBUTES;
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO};
|
||||
use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_SMALLICON};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
DestroyIcon, EnumWindows, GetIconInfo, GetWindowTextLengthW, GetWindowTextW,
|
||||
GetWindowThreadProcessId, IsIconic, IsWindowVisible, ICONINFO,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct WindowInfo {
|
||||
pub exe_name: String,
|
||||
pub exe_path: String,
|
||||
pub title: String,
|
||||
pub display_name: String,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
pub fn get_system_idle_seconds() -> u64 {
|
||||
unsafe {
|
||||
let mut info = LASTINPUTINFO {
|
||||
cbSize: std::mem::size_of::<LASTINPUTINFO>() as u32,
|
||||
dwTime: 0,
|
||||
};
|
||||
if GetLastInputInfo(&mut info).as_bool() {
|
||||
let tick_count = windows::Win32::System::SystemInformation::GetTickCount();
|
||||
let idle_ms = tick_count.wrapping_sub(info.dwTime);
|
||||
(idle_ms / 1000) as u64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_process_exe_path(pid: u32) -> Option<String> {
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
|
||||
let mut buf = [0u16; 1024];
|
||||
let mut size = buf.len() as u32;
|
||||
QueryFullProcessImageNameW(handle, PROCESS_NAME_FORMAT(0), PWSTR(buf.as_mut_ptr()), &mut size).ok()?;
|
||||
let _ = windows::Win32::Foundation::CloseHandle(handle);
|
||||
let path = String::from_utf16_lossy(&buf[..size as usize]);
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_window_title(hwnd: HWND) -> String {
|
||||
unsafe {
|
||||
let len = GetWindowTextLengthW(hwnd);
|
||||
if len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let mut buf = vec![0u16; (len + 1) as usize];
|
||||
let copied = GetWindowTextW(hwnd, &mut buf);
|
||||
String::from_utf16_lossy(&buf[..copied as usize])
|
||||
}
|
||||
}
|
||||
|
||||
fn exe_name_from_path(path: &str) -> String {
|
||||
path.rsplit('\\').next().unwrap_or(path).to_string()
|
||||
}
|
||||
|
||||
fn display_name_from_exe(exe_name: &str) -> String {
|
||||
exe_name
|
||||
.strip_suffix(".exe")
|
||||
.or_else(|| exe_name.strip_suffix(".EXE"))
|
||||
.unwrap_or(exe_name)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
struct EnumState {
|
||||
windows: Vec<WindowInfo>,
|
||||
include_minimized: bool,
|
||||
}
|
||||
|
||||
unsafe extern "system" fn enum_windows_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let state = &mut *(lparam.0 as *mut EnumState);
|
||||
|
||||
if !IsWindowVisible(hwnd).as_bool() {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
if !state.include_minimized && IsIconic(hwnd).as_bool() {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
let title = get_window_title(hwnd);
|
||||
if title.is_empty() {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
let mut pid: u32 = 0;
|
||||
GetWindowThreadProcessId(hwnd, Some(&mut pid));
|
||||
if pid == 0 {
|
||||
return BOOL(1);
|
||||
}
|
||||
|
||||
if let Some(exe_path) = get_process_exe_path(pid) {
|
||||
let exe_name = exe_name_from_path(&exe_path);
|
||||
let display_name = display_name_from_exe(&exe_name);
|
||||
state.windows.push(WindowInfo {
|
||||
exe_name,
|
||||
exe_path,
|
||||
title,
|
||||
display_name,
|
||||
icon: None,
|
||||
});
|
||||
}
|
||||
|
||||
BOOL(1)
|
||||
}
|
||||
|
||||
pub fn enumerate_visible_windows() -> Vec<WindowInfo> {
|
||||
let mut state = EnumState {
|
||||
windows: Vec::new(),
|
||||
include_minimized: false,
|
||||
};
|
||||
unsafe {
|
||||
let _ = EnumWindows(
|
||||
Some(enum_windows_callback),
|
||||
LPARAM(&mut state as *mut EnumState as isize),
|
||||
);
|
||||
}
|
||||
state.windows
|
||||
}
|
||||
|
||||
pub fn enumerate_running_processes() -> Vec<WindowInfo> {
|
||||
let mut state = EnumState {
|
||||
windows: Vec::new(),
|
||||
include_minimized: true,
|
||||
};
|
||||
unsafe {
|
||||
let _ = EnumWindows(
|
||||
Some(enum_windows_callback),
|
||||
LPARAM(&mut state as *mut EnumState as isize),
|
||||
);
|
||||
}
|
||||
// Deduplicate by exe_path (case-insensitive)
|
||||
let mut seen = HashMap::new();
|
||||
let mut result = Vec::new();
|
||||
for w in state.windows {
|
||||
let key = w.exe_path.to_lowercase();
|
||||
if !seen.contains_key(&key) {
|
||||
seen.insert(key, true);
|
||||
result.push(w);
|
||||
}
|
||||
}
|
||||
result.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()));
|
||||
|
||||
// Extract icons for the deduplicated list
|
||||
for w in &mut result {
|
||||
w.icon = extract_icon_data_url(&w.exe_path);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// --- Icon extraction ---
|
||||
|
||||
fn extract_icon_data_url(exe_path: &str) -> Option<String> {
|
||||
unsafe {
|
||||
let wide: Vec<u16> = exe_path.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
let mut fi = SHFILEINFOW::default();
|
||||
let res = SHGetFileInfoW(
|
||||
PCWSTR(wide.as_ptr()),
|
||||
FILE_FLAGS_AND_ATTRIBUTES(0),
|
||||
Some(&mut fi),
|
||||
std::mem::size_of::<SHFILEINFOW>() as u32,
|
||||
SHGFI_ICON | SHGFI_SMALLICON,
|
||||
);
|
||||
if res == 0 || fi.hIcon.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hicon = fi.hIcon;
|
||||
let mut ii = ICONINFO::default();
|
||||
if GetIconInfo(hicon, &mut ii).is_err() {
|
||||
let _ = DestroyIcon(hicon);
|
||||
return None;
|
||||
}
|
||||
|
||||
let result = extract_icon_pixels(ii.hbmColor, ii.hbmMask).and_then(|(rgba, w, h)| {
|
||||
let png_bytes = encode_rgba_to_png(&rgba, w, h)?;
|
||||
Some(format!("data:image/png;base64,{}", base64_encode(&png_bytes)))
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
if !ii.hbmColor.is_invalid() {
|
||||
let _ = DeleteObject(ii.hbmColor);
|
||||
}
|
||||
if !ii.hbmMask.is_invalid() {
|
||||
let _ = DeleteObject(ii.hbmMask);
|
||||
}
|
||||
let _ = DestroyIcon(hicon);
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn extract_icon_pixels(
|
||||
hbm_color: HBITMAP,
|
||||
hbm_mask: HBITMAP,
|
||||
) -> Option<(Vec<u8>, u32, u32)> {
|
||||
if hbm_color.is_invalid() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut bm = BITMAP::default();
|
||||
if GetObjectW(
|
||||
hbm_color,
|
||||
std::mem::size_of::<BITMAP>() as i32,
|
||||
Some(&mut bm as *mut BITMAP as *mut std::ffi::c_void),
|
||||
) == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let w = bm.bmWidth as u32;
|
||||
let h = bm.bmHeight as u32;
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hdc = CreateCompatibleDC(None);
|
||||
|
||||
// Read color bitmap as 32-bit BGRA
|
||||
let mut bmi = make_bmi(w, h);
|
||||
let mut bgra = vec![0u8; (w * h * 4) as usize];
|
||||
let lines = GetDIBits(
|
||||
hdc,
|
||||
hbm_color,
|
||||
0,
|
||||
h,
|
||||
Some(bgra.as_mut_ptr() as *mut std::ffi::c_void),
|
||||
&mut bmi,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
if lines == 0 {
|
||||
let _ = DeleteDC(hdc);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if any pixel has a non-zero alpha
|
||||
let has_alpha = bgra.chunks_exact(4).any(|px| px[3] != 0);
|
||||
|
||||
if !has_alpha && !hbm_mask.is_invalid() {
|
||||
// Read the mask bitmap as 32-bit to determine transparency
|
||||
let mut mask_bmi = make_bmi(w, h);
|
||||
let mut mask = vec![0u8; (w * h * 4) as usize];
|
||||
GetDIBits(
|
||||
hdc,
|
||||
hbm_mask,
|
||||
0,
|
||||
h,
|
||||
Some(mask.as_mut_ptr() as *mut std::ffi::c_void),
|
||||
&mut mask_bmi,
|
||||
DIB_RGB_COLORS,
|
||||
);
|
||||
// Mask: black (0,0,0) = opaque, white = transparent
|
||||
for i in (0..bgra.len()).step_by(4) {
|
||||
bgra[i + 3] = if mask[i] == 0 && mask[i + 1] == 0 && mask[i + 2] == 0 {
|
||||
255
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
} else if !has_alpha {
|
||||
// No mask, assume fully opaque
|
||||
for px in bgra.chunks_exact_mut(4) {
|
||||
px[3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = DeleteDC(hdc);
|
||||
|
||||
// BGRA -> RGBA
|
||||
for px in bgra.chunks_exact_mut(4) {
|
||||
px.swap(0, 2);
|
||||
}
|
||||
|
||||
Some((bgra, w, h))
|
||||
}
|
||||
|
||||
fn make_bmi(w: u32, h: u32) -> BITMAPINFO {
|
||||
BITMAPINFO {
|
||||
bmiHeader: BITMAPINFOHEADER {
|
||||
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
||||
biWidth: w as i32,
|
||||
biHeight: -(h as i32), // top-down
|
||||
biPlanes: 1,
|
||||
biBitCount: 32,
|
||||
biCompression: 0, // BI_RGB
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_rgba_to_png(pixels: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut buf, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().ok()?;
|
||||
writer.write_image_data(pixels).ok()?;
|
||||
writer.finish().ok()?;
|
||||
}
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
fn base64_encode(data: &[u8]) -> String {
|
||||
const CHARS: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let b = [
|
||||
chunk[0],
|
||||
chunk.get(1).copied().unwrap_or(0),
|
||||
chunk.get(2).copied().unwrap_or(0),
|
||||
];
|
||||
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
|
||||
out.push(CHARS[((n >> 18) & 63) as usize] as char);
|
||||
out.push(CHARS[((n >> 12) & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 {
|
||||
CHARS[((n >> 6) & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
out.push(if chunk.len() > 2 {
|
||||
CHARS[(n & 63) as usize] as char
|
||||
} else {
|
||||
'='
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user