diff --git a/Cargo.toml b/Cargo.toml index 5dd8a1f..7ad633b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ features = [ "Win32_Security", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_HiDpi", + "Win32_System_Com", ] [build-dependencies] diff --git a/src/highlight.rs b/src/highlight.rs new file mode 100644 index 0000000..7cce0fc --- /dev/null +++ b/src/highlight.rs @@ -0,0 +1,811 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use windows::core::PCWSTR; +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::System::Com::*; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::Accessibility::*; +use windows::Win32::UI::WindowsAndMessaging::*; + +use crate::diagnose; +use crate::native_interop; + +/// Cache of UIA-derived occupants (XAML-rendered icons that have no HWND). +/// Populated by `spawn_uia_scan` on a worker thread; read at drag-start. +/// Stale by up to one drag — refreshed at startup and after each drag end. +static UIA_CACHE: Mutex> = Mutex::new(Vec::new()); + +/// Prevents overlapping UIA scans when the polling interval is shorter than +/// the scan duration. A spawn called while a worker is still running is a +/// no-op; the in-flight worker's results will land in the cache shortly. +static UIA_SCAN_IN_FLIGHT: AtomicBool = AtomicBool::new(false); + +/// Set by `--debug-render` at startup. When true, drag-start additionally +/// shows red-bordered overlays around every detected occupant with its +/// class/name + dimensions, on top of the regular open-region highlights. +pub static DEBUG_RENDER_ENABLED: AtomicBool = AtomicBool::new(false); + +const DEBUG_BORDER_WIDTH: i32 = 2; + +const OVERLAY_CLASS: &str = "ClaudeCodeUsageMonitorHighlight"; + +const FILL_ALPHA_DIM: u8 = 32; +const BORDER_ALPHA_DIM: u8 = 110; +const FILL_ALPHA_LIT: u8 = 96; +const BORDER_ALPHA_LIT: u8 = 220; +const BORDER_WIDTH: i32 = 2; + +#[derive(Clone, Copy, Debug)] +pub struct HighlightRegion { + pub rect: RECT, +} + +/// One detected occupant of the taskbar — either an HWND we found via +/// enumeration, or a UIA-derived XAML element. `label` carries the +/// element's class/name and is rendered when `--debug-render` is on. +#[derive(Clone, Debug)] +pub struct DebugRect { + pub rect: RECT, + pub label: String, +} + +pub fn cached_uia_occupants() -> Vec { + UIA_CACHE + .lock() + .map(|g| g.clone()) + .unwrap_or_default() +} + +pub fn register_overlay_class(hinstance: HINSTANCE) { + unsafe { + let class_name = native_interop::wide_str(OVERLAY_CLASS); + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(overlay_wnd_proc), + hInstance: hinstance, + hCursor: LoadCursorW(HINSTANCE::default(), IDC_ARROW).unwrap_or_default(), + hbrBackground: HBRUSH(std::ptr::null_mut()), + lpszClassName: PCWSTR::from_raw(class_name.as_ptr()), + ..Default::default() + }; + let atom = RegisterClassExW(&wc); + if atom == 0 { + diagnose::log("highlight: RegisterClassExW returned 0"); + } + } +} + +#[derive(Clone, Debug)] +struct LeafInfo { + rect: RECT, + label: String, +} + +/// Enumerate occupied leaf rects (with class-name labels) under the taskbar. +/// HWND-only — XAML-rendered icons must be supplied separately via +/// `cached_uia_occupants`. +pub fn compute_debug_rects(taskbar_hwnd: HWND, exclude_hwnds: &[HWND]) -> Vec { + let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { + Some(r) => r, + None => return Vec::new(), + }; + let mut leaves = Vec::new(); + collect_taskbar_occupants(taskbar_hwnd, taskbar_rect, exclude_hwnds, &mut leaves); + leaves + .into_iter() + .filter_map(|leaf| { + let left = leaf.rect.left.max(taskbar_rect.left); + let right = leaf.rect.right.min(taskbar_rect.right); + let top = leaf.rect.top.max(taskbar_rect.top); + let bottom = leaf.rect.bottom.min(taskbar_rect.bottom); + if right <= left || bottom <= top { + return None; + } + let w = leaf.rect.right - leaf.rect.left; + let h = leaf.rect.bottom - leaf.rect.top; + let label = format!("{} {}x{}", leaf.label, w, h); + Some(DebugRect { + rect: RECT { left, top, right, bottom }, + label, + }) + }) + .collect() +} + +/// Find every leaf HWND that visually occupies the taskbar area: +/// - descendants of Shell_TrayWnd (the taskbar's own tree) +/// - top-level siblings (e.g., Win11 `TaskbarFrame`) whose rect lies within +/// the taskbar bounds — these host the Start button and pinned apps on Win11. +fn collect_taskbar_occupants( + taskbar_hwnd: HWND, + taskbar_rect: RECT, + exclude: &[HWND], + out: &mut Vec, +) { + collect_leaves(taskbar_hwnd, taskbar_rect, exclude, out); + + struct EnumData<'a> { + taskbar_hwnd: HWND, + taskbar_rect: RECT, + exclude: &'a [HWND], + out: &'a mut Vec, + } + + unsafe extern "system" fn cb(hwnd: HWND, lparam: LPARAM) -> BOOL { + let data = &mut *(lparam.0 as *mut EnumData); + if hwnd == data.taskbar_hwnd { + return TRUE; + } + if data.exclude.iter().any(|&e| e == hwnd) { + return TRUE; + } + if !IsWindowVisible(hwnd).as_bool() { + return TRUE; + } + let mut rect = RECT::default(); + if GetWindowRect(hwnd, &mut rect).is_err() { + return TRUE; + } + let slack = 8; + let inside = rect.left >= data.taskbar_rect.left - slack + && rect.top >= data.taskbar_rect.top - slack + && rect.right <= data.taskbar_rect.right + slack + && rect.bottom <= data.taskbar_rect.bottom + slack; + if !inside { + return TRUE; + } + + if has_visible_child(hwnd) { + collect_leaves(hwnd, data.taskbar_rect, data.exclude, data.out); + } else { + out_push_leaf(data.out, hwnd, rect); + } + TRUE + } + + let mut data = EnumData { + taskbar_hwnd, + taskbar_rect, + exclude, + out, + }; + unsafe { + let _ = EnumWindows(Some(cb), LPARAM(&mut data as *mut _ as isize)); + } +} + +fn out_push_leaf(out: &mut Vec, hwnd: HWND, rect: RECT) { + if rect.right > rect.left && rect.bottom > rect.top { + out.push(LeafInfo { + rect, + label: get_class_name(hwnd), + }); + } +} + +fn collect_leaves(parent: HWND, taskbar_rect: RECT, exclude: &[HWND], out: &mut Vec) { + unsafe { + let mut hwnd = HWND::default(); + loop { + hwnd = match FindWindowExW(parent, hwnd, PCWSTR::null(), PCWSTR::null()) { + Ok(h) if h != HWND::default() => h, + _ => break, + }; + if exclude.iter().any(|&e| e == hwnd) { + continue; + } + + let mut rect = RECT::default(); + if GetWindowRect(hwnd, &mut rect).is_err() { + continue; + } + if rect.right <= rect.left || rect.bottom <= rect.top { + continue; + } + + let visible = IsWindowVisible(hwnd).as_bool(); + // Win11 keeps legacy HWNDs (e.g. `Start`) marked as invisible while + // their position still indicates where the XAML-rendered control + // sits. Trust the rect when it's well-formed and inside the taskbar. + let rect_inside_taskbar = rect.left >= taskbar_rect.left + && rect.right <= taskbar_rect.right + && rect.top >= taskbar_rect.top + && rect.bottom <= taskbar_rect.bottom; + if !visible && !rect_inside_taskbar { + continue; + } + + let class = get_class_name(hwnd); + + // XAML hosts on Win11 (DesktopWindowContentBridge etc.) contribute + // no useful HWND descendants. They're enumerated separately on a + // background thread via UI Automation; skip them here. + if is_xaml_host(&class) { + continue; + } + + // Only descend into actually-visible containers; invisible HWNDs + // can have stale or empty subtrees. + if visible && has_visible_child(hwnd) { + collect_leaves(hwnd, taskbar_rect, exclude, out); + } else { + out.push(LeafInfo { rect, label: class }); + } + } + } +} + +fn is_xaml_host(class: &str) -> bool { + class == "Windows.UI.Composition.DesktopWindowContentBridge" + || class.starts_with("Microsoft.UI.Content.") + || class == "Microsoft.UI.Composition.SwapChainPanel" +} + +/// Refresh the UIA cache on a fresh MTA worker. Fire-and-forget — when +/// it completes (typically <1s on Win11), `UIA_CACHE` holds the latest leaves +/// for the next drag to consume. Never modifies overlays directly, so it +/// can't disrupt an in-progress drag. +pub fn spawn_uia_scan(taskbar_hwnd: HWND, taskbar_rect: RECT) { + if UIA_SCAN_IN_FLIGHT.swap(true, Ordering::AcqRel) { + return; + } + let taskbar_addr = taskbar_hwnd.0 as isize; + std::thread::spawn(move || { + let taskbar_hwnd = HWND(taskbar_addr as *mut _); + + let mut raw_leaves: Vec = Vec::new(); + unsafe { + if CoInitializeEx(None, COINIT_MULTITHREADED).is_err() { + UIA_SCAN_IN_FLIGHT.store(false, Ordering::Release); + return; + } + let mut hosts = Vec::new(); + find_xaml_hosts(taskbar_hwnd, &mut hosts); + for host in hosts { + uia_walk_inner(host, taskbar_rect, &mut raw_leaves); + } + CoUninitialize(); + } + + let leaves: Vec = raw_leaves + .into_iter() + .filter_map(|leaf| { + let l = leaf.rect.left.max(taskbar_rect.left); + let r = leaf.rect.right.min(taskbar_rect.right); + let t = leaf.rect.top.max(taskbar_rect.top); + let b = leaf.rect.bottom.min(taskbar_rect.bottom); + if r <= l || b <= t { + return None; + } + let w = leaf.rect.right - leaf.rect.left; + let h = leaf.rect.bottom - leaf.rect.top; + Some(DebugRect { + rect: RECT { + left: l, + top: t, + right: r, + bottom: b, + }, + label: format!("{} {}x{}", leaf.label, w, h), + }) + }) + .collect(); + + if let Ok(mut cache) = UIA_CACHE.lock() { + *cache = leaves; + } + UIA_SCAN_IN_FLIGHT.store(false, Ordering::Release); + }); +} + +fn find_xaml_hosts(parent: HWND, out: &mut Vec) { + unsafe { + let mut hwnd = HWND::default(); + loop { + hwnd = match FindWindowExW(parent, hwnd, PCWSTR::null(), PCWSTR::null()) { + Ok(h) if h != HWND::default() => h, + _ => break, + }; + if !IsWindowVisible(hwnd).as_bool() { + continue; + } + let class = get_class_name(hwnd); + if is_xaml_host(&class) { + out.push(hwnd); + } else if has_visible_child(hwnd) { + find_xaml_hosts(hwnd, out); + } + } + } +} + +/// Compute open regions from an arbitrary list of occupied debug rects. +/// Used to recompute regions when async UIA results arrive. +pub fn open_regions_from_occupants( + taskbar_rect: RECT, + occupants: &[DebugRect], +) -> Vec { + let mut intervals: Vec<(i32, i32)> = occupants + .iter() + .filter_map(|d| { + let l = d.rect.left.max(taskbar_rect.left); + let r = d.rect.right.min(taskbar_rect.right); + if r > l { Some((l, r)) } else { None } + }) + .collect(); + + intervals.sort_by_key(|&(l, _)| l); + let mut merged: Vec<(i32, i32)> = Vec::new(); + for (l, r) in intervals { + if let Some(last) = merged.last_mut() { + if l <= last.1 { + last.1 = last.1.max(r); + continue; + } + } + merged.push((l, r)); + } + + let mut regions = Vec::new(); + let mut cursor = taskbar_rect.left; + for (l, r) in &merged { + if *l > cursor { + regions.push(HighlightRegion { + rect: RECT { + left: cursor, + top: taskbar_rect.top, + right: *l, + bottom: taskbar_rect.bottom, + }, + }); + } + cursor = cursor.max(*r); + } + if cursor < taskbar_rect.right { + regions.push(HighlightRegion { + rect: RECT { + left: cursor, + top: taskbar_rect.top, + right: taskbar_rect.right, + bottom: taskbar_rect.bottom, + }, + }); + } + regions +} + +unsafe fn uia_walk_inner(host: HWND, taskbar_rect: RECT, out: &mut Vec) { + let uia: IUIAutomation = match CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER) { + Ok(u) => u, + Err(error) => { + diagnose::log_error("uia: CoCreateInstance failed", error); + return; + } + }; + + let root = match uia.ElementFromHandle(host) { + Ok(e) => e, + Err(error) => { + diagnose::log_error("uia: ElementFromHandle failed", error); + return; + } + }; + + // Batch the whole subtree into a single RPC instead of per-node. + let cache = match uia.CreateCacheRequest() { + Ok(c) => c, + Err(error) => { + diagnose::log_error("uia: CreateCacheRequest failed", error); + return; + } + }; + let _ = cache.AddProperty(UIA_BoundingRectanglePropertyId); + let _ = cache.AddProperty(UIA_NamePropertyId); + let _ = cache.AddProperty(UIA_ClassNamePropertyId); + + let condition = match uia.CreateTrueCondition() { + Ok(c) => c, + Err(error) => { + diagnose::log_error("uia: CreateTrueCondition failed", error); + return; + } + }; + + let elements = match root.FindAllBuildCache(TreeScope_Descendants, &condition, &cache) { + Ok(a) => a, + Err(error) => { + diagnose::log_error("uia: FindAllBuildCache failed", error); + return; + } + }; + + let len = elements.Length().unwrap_or(0); + let tb_w = taskbar_rect.right - taskbar_rect.left; + for i in 0..len { + let elem = match elements.GetElement(i) { + Ok(e) => e, + Err(_) => continue, + }; + + let rect = match elem.CachedBoundingRectangle() { + Ok(r) => r, + Err(_) => continue, + }; + if rect.right <= rect.left || rect.bottom <= rect.top { + continue; + } + if rect.right <= taskbar_rect.left + || rect.left >= taskbar_rect.right + || rect.bottom <= taskbar_rect.top + || rect.top >= taskbar_rect.bottom + { + continue; + } + // Skip elements whose rect is essentially the entire taskbar — those + // are containers; their children already cover the actual content. + if rect.right - rect.left >= tb_w * 9 / 10 { + continue; + } + + let label = elem + .CachedName() + .ok() + .map(|b| b.to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| elem.CachedClassName().ok().map(|b| b.to_string())) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "uia".into()); + + out.push(LeafInfo { rect, label }); + } +} + +fn has_visible_child(parent: HWND) -> bool { + unsafe { + let mut hwnd = HWND::default(); + loop { + hwnd = match FindWindowExW(parent, hwnd, PCWSTR::null(), PCWSTR::null()) { + Ok(h) if h != HWND::default() => h, + _ => return false, + }; + if IsWindowVisible(hwnd).as_bool() { + return true; + } + } + } +} + +fn get_class_name(hwnd: HWND) -> String { + unsafe { + let mut buf = [0u16; 256]; + let len = GetClassNameW(hwnd, &mut buf); + if len > 0 { + String::from_utf16_lossy(&buf[..len as usize]) + } else { + String::new() + } + } +} + +/// Create a layered overlay window covering `rect` (screen coords), parented +/// into Shell_TrayWnd. Caller is responsible for painting + storing the HWND. +fn create_overlay(taskbar_hwnd: HWND, rect: &RECT) -> Option { + let w = rect.right - rect.left; + let h = rect.bottom - rect.top; + if w <= 0 || h <= 0 { + return None; + } + + unsafe { + let hinstance = HINSTANCE(GetModuleHandleW(PCWSTR::null()).ok()?.0); + let class_name = native_interop::wide_str(OVERLAY_CLASS); + let title = native_interop::wide_str(""); + + let hwnd = match CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE, + PCWSTR::from_raw(class_name.as_ptr()), + PCWSTR::from_raw(title.as_ptr()), + WS_POPUP, + rect.left, + rect.top, + w, + h, + HWND::default(), + HMENU::default(), + hinstance, + None, + ) { + Ok(h) => h, + Err(error) => { + diagnose::log_error("highlight: CreateWindowExW failed", error); + return None; + } + }; + + native_interop::embed_in_taskbar(hwnd, taskbar_hwnd); + + if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { + native_interop::move_window( + hwnd, + rect.left - taskbar_rect.left, + rect.top - taskbar_rect.top, + w, + h, + ); + } + + let _ = SetWindowPos( + hwnd, + HWND_TOP, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + + Some(hwnd) + } +} + +pub fn show_highlights(taskbar_hwnd: HWND, regions: &[HighlightRegion]) -> Vec { + let mut hwnds = Vec::with_capacity(regions.len()); + for region in regions { + if let Some(hwnd) = create_overlay(taskbar_hwnd, ®ion.rect) { + let w = region.rect.right - region.rect.left; + let h = region.rect.bottom - region.rect.top; + paint_white_highlight(hwnd, w, h, false); + hwnds.push(hwnd); + } + } + hwnds +} + +/// Re-render an existing highlight overlay with the dim or lit alpha pair. +/// Re-uses the same `UpdateLayeredWindow` primitive — no window churn. +pub fn repaint_highlight(hwnd: HWND, region: &HighlightRegion, lit: bool) { + let w = region.rect.right - region.rect.left; + let h = region.rect.bottom - region.rect.top; + paint_white_highlight(hwnd, w, h, lit); +} + +/// Diagnostic: create an overlay per occupant rect with a red border + label. +/// Caller appends the returned HWNDs into the same vec used for white +/// highlights so they tear down together at drag-end. +pub fn show_debug_rects(taskbar_hwnd: HWND, rects: &[DebugRect]) -> Vec { + let mut hwnds = Vec::with_capacity(rects.len()); + for r in rects { + if let Some(hwnd) = create_overlay(taskbar_hwnd, &r.rect) { + let w = r.rect.right - r.rect.left; + let h = r.rect.bottom - r.rect.top; + paint_debug_rect(hwnd, w, h, &r.label); + hwnds.push(hwnd); + } + } + hwnds +} + +pub fn hide_highlights(hwnds: &mut Vec) { + unsafe { + for h in hwnds.drain(..) { + let _ = DestroyWindow(h); + } + } +} + +fn paint_white_highlight(hwnd: HWND, width: i32, height: i32, lit: bool) { + paint_with(hwnd, width, height, move |pixel_data, w, h| { + let fill_alpha = if lit { FILL_ALPHA_LIT } else { FILL_ALPHA_DIM }; + let border_alpha = if lit { BORDER_ALPHA_LIT } else { BORDER_ALPHA_DIM }; + let fill = u32::from(fill_alpha) * 0x0101_0101; + let border = u32::from(border_alpha) * 0x0101_0101; + let bw = BORDER_WIDTH.min(w / 2).min(h / 2).max(0); + for y in 0..h { + let row = &mut pixel_data[(y * w) as usize..((y + 1) * w) as usize]; + let on_border_row = y < bw || y >= h - bw; + for (x, px) in row.iter_mut().enumerate() { + let on_border = + on_border_row || (x as i32) < bw || (x as i32) >= w - bw; + *px = if on_border { border } else { fill }; + } + } + }); +} + +fn paint_debug_rect(hwnd: HWND, width: i32, height: i32, label: &str) { + if width <= 0 || height <= 0 { + return; + } + unsafe { + let screen_dc = GetDC(hwnd); + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: 0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); + let mem_dc = CreateCompatibleDC(screen_dc); + let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) + .unwrap_or_default(); + if dib.is_invalid() || bits.is_null() { + let _ = DeleteDC(mem_dc); + ReleaseDC(hwnd, screen_dc); + return; + } + let old_bmp = SelectObject(mem_dc, dib); + let pixel_count = (width * height) as usize; + let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); + + // Render label via GDI on a still-zeroed DIB. Anything GDI touches + // becomes opaque red after the post-process pass below. + let _ = SetBkMode(mem_dc, TRANSPARENT); + let _ = SetTextColor(mem_dc, COLORREF(0x0000_00FF)); // BGR red + + let font_name = native_interop::wide_str("Segoe UI"); + let font = CreateFontW( + -11, + 0, + 0, + 0, + FW_BOLD.0 as i32, + 0, + 0, + 0, + DEFAULT_CHARSET.0 as u32, + OUT_TT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + NONANTIALIASED_QUALITY.0 as u32, + (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, + PCWSTR::from_raw(font_name.as_ptr()), + ); + let old_font = SelectObject(mem_dc, font); + + let mut text_wide: Vec = label.encode_utf16().collect(); + let mut text_rect = RECT { + left: DEBUG_BORDER_WIDTH + 2, + top: DEBUG_BORDER_WIDTH + 1, + right: width - DEBUG_BORDER_WIDTH - 2, + bottom: height - DEBUG_BORDER_WIDTH - 1, + }; + let _ = DrawTextW( + mem_dc, + &mut text_wide, + &mut text_rect, + DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX, + ); + + SelectObject(mem_dc, old_font); + let _ = DeleteObject(font); + + // Anything GDI touched becomes opaque (alpha = 255). Untouched pixels + // stay transparent. + for px in pixel_data.iter_mut() { + let rgb = *px & 0x00FF_FFFF; + if rgb != 0 { + *px = 0xFF00_0000 | rgb; + } + } + + // Stamp the red border directly with premultiplied red. + let red = 0xFFFF_0000u32; + let bw = DEBUG_BORDER_WIDTH.min(width / 2).min(height / 2).max(1); + for y in 0..height { + let row = &mut pixel_data[(y * width) as usize..((y + 1) * width) as usize]; + let on_border_row = y < bw || y >= height - bw; + for (x, px) in row.iter_mut().enumerate() { + let on_border = + on_border_row || (x as i32) < bw || (x as i32) >= width - bw; + if on_border { + *px = red; + } + } + } + + let pt_src = POINT { x: 0, y: 0 }; + let sz = SIZE { cx: width, cy: height }; + let blend = BLENDFUNCTION { + BlendOp: 0, + BlendFlags: 0, + SourceConstantAlpha: 255, + AlphaFormat: 1, + }; + let _ = UpdateLayeredWindow( + hwnd, + screen_dc, + None, + Some(&sz), + mem_dc, + Some(&pt_src), + COLORREF(0), + Some(&blend), + ULW_ALPHA, + ); + + SelectObject(mem_dc, old_bmp); + let _ = DeleteObject(dib); + let _ = DeleteDC(mem_dc); + ReleaseDC(hwnd, screen_dc); + } +} + +fn paint_with(hwnd: HWND, width: i32, height: i32, fill: F) +where + F: FnOnce(&mut [u32], i32, i32), +{ + if width <= 0 || height <= 0 { + return; + } + unsafe { + let screen_dc = GetDC(hwnd); + let bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: 0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); + let mem_dc = CreateCompatibleDC(screen_dc); + let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) + .unwrap_or_default(); + if dib.is_invalid() || bits.is_null() { + let _ = DeleteDC(mem_dc); + ReleaseDC(hwnd, screen_dc); + return; + } + let old_bmp = SelectObject(mem_dc, dib); + let pixel_count = (width * height) as usize; + let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count); + fill(pixel_data, width, height); + + let pt_src = POINT { x: 0, y: 0 }; + let sz = SIZE { cx: width, cy: height }; + let blend = BLENDFUNCTION { + BlendOp: 0, + BlendFlags: 0, + SourceConstantAlpha: 255, + AlphaFormat: 1, + }; + let _ = UpdateLayeredWindow( + hwnd, + screen_dc, + None, + Some(&sz), + mem_dc, + Some(&pt_src), + COLORREF(0), + Some(&blend), + ULW_ALPHA, + ); + SelectObject(mem_dc, old_bmp); + let _ = DeleteObject(dib); + let _ = DeleteDC(mem_dc); + ReleaseDC(hwnd, screen_dc); + } +} + +unsafe extern "system" fn overlay_wnd_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + DefWindowProcW(hwnd, msg, wparam, lparam) +} diff --git a/src/main.rs b/src/main.rs index 88c363e..ee717cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #![windows_subsystem = "windows"] mod diagnose; +mod highlight; mod localization; mod models; mod native_interop; @@ -13,6 +14,7 @@ mod window; fn main() { let args: Vec = std::env::args().collect(); let diagnose_enabled = args.iter().any(|arg| arg == "--diagnose"); + let debug_render_enabled = args.iter().any(|arg| arg == "--debug-render"); if diagnose_enabled { match diagnose::init() { Ok(path) => diagnose::log(format!( @@ -25,6 +27,10 @@ fn main() { } } } + if debug_render_enabled { + highlight::DEBUG_RENDER_ENABLED + .store(true, std::sync::atomic::Ordering::Relaxed); + } if let Some(exit_code) = updater::handle_cli_mode(&args) { if diagnose_enabled { diff --git a/src/native_interop.rs b/src/native_interop.rs index 9bccd18..27e516b 100644 --- a/src/native_interop.rs +++ b/src/native_interop.rs @@ -18,6 +18,7 @@ pub const TIMER_POLL: usize = 1; pub const TIMER_COUNTDOWN: usize = 2; pub const TIMER_RESET_POLL: usize = 3; pub const TIMER_UPDATE_CHECK: usize = 4; +pub const TIMER_LAYOUT_REFRESH: usize = 5; // Custom messages pub const WM_APP: u32 = 0x8000; diff --git a/src/window.rs b/src/window.rs index adee010..a709606 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,7 +20,8 @@ use crate::diagnose; use crate::localization::{self, LanguageId, Strings}; use crate::models::UsageData; use crate::native_interop::{ - self, Color, TIMER_COUNTDOWN, TIMER_POLL, TIMER_RESET_POLL, TIMER_UPDATE_CHECK, + self, Color, TIMER_COUNTDOWN, TIMER_LAYOUT_REFRESH, TIMER_POLL, TIMER_RESET_POLL, + TIMER_UPDATE_CHECK, WM_APP_TRAY, WM_APP_USAGE_UPDATED, }; use crate::tray_icon; @@ -76,6 +77,10 @@ struct AppState { dragging: bool, drag_start_mouse_x: i32, drag_start_offset: i32, + overlay_hwnds: Vec, + overlay_regions: Vec, + hovered_region: Option, + segment_w_design: i32, widget_visible: bool, } @@ -197,6 +202,8 @@ struct SettingsFile { last_update_check_unix: Option, #[serde(default = "default_widget_visible")] widget_visible: bool, + #[serde(default = "default_segment_w_design")] + segment_w_design: i32, } impl Default for SettingsFile { @@ -207,10 +214,15 @@ impl Default for SettingsFile { language: None, last_update_check_unix: None, widget_visible: true, + segment_w_design: default_segment_w_design(), } } } +fn default_segment_w_design() -> i32 { + DEFAULT_SEGMENT_W +} + fn default_poll_interval() -> u32 { POLL_15_MIN } @@ -248,6 +260,7 @@ fn save_state_settings() { .map(|language| language.code().to_string()), last_update_check_unix: s.last_update_check_unix, widget_visible: s.widget_visible, + segment_w_design: s.segment_w_design, }); } } @@ -717,7 +730,9 @@ fn set_startup_enabled(enable: bool) { } // Dimensions matching the C# version -const SEGMENT_W: i32 = 10; +const DEFAULT_SEGMENT_W: i32 = 10; +const MIN_SEGMENT_W: i32 = 5; +const MAX_SEGMENT_W: i32 = 12; const SEGMENT_H: i32 = 13; const SEGMENT_GAP: i32 = 1; const SEGMENT_COUNT: i32 = 10; @@ -732,12 +747,23 @@ const TEXT_WIDTH: i32 = 62; const RIGHT_MARGIN: i32 = 1; const WIDGET_HEIGHT: i32 = 46; -fn total_widget_width() -> i32 { +/// Sum of all design-pixel widths in the widget that aren't the bar segments. +/// Used by the snap math: given a target widget width, the bars can occupy +/// `widget_width - sc(FIXED_NON_BAR_DESIGN_WIDTH)`. +const FIXED_NON_BAR_DESIGN_WIDTH: i32 = LEFT_DIVIDER_W + + DIVIDER_RIGHT_MARGIN + + LABEL_WIDTH + + LABEL_RIGHT_MARGIN + + BAR_RIGHT_MARGIN + + TEXT_WIDTH + + RIGHT_MARGIN; + +fn total_widget_width(segment_w_design: i32) -> i32 { sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN) - + (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * SEGMENT_COUNT + + (sc(segment_w_design) + sc(SEGMENT_GAP)) * SEGMENT_COUNT - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) + sc(TEXT_WIDTH) @@ -795,6 +821,8 @@ pub fn run() { diagnose::log("RegisterClassExW returned 0"); } + crate::highlight::register_overlay_class(HINSTANCE(hinstance.0)); + let settings = load_settings(); let language_override = settings.language.as_deref().and_then(LanguageId::from_code); let language = localization::resolve_language(language_override); @@ -802,6 +830,9 @@ pub fn run() { // Create as layered popup (will be reparented into taskbar) let title = native_interop::wide_str(language.strings().window_title); + let initial_segment_w = settings + .segment_w_design + .clamp(MIN_SEGMENT_W, MAX_SEGMENT_W); let hwnd = CreateWindowExW( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_NOACTIVATE, PCWSTR::from_raw(class_name.as_ptr()), @@ -809,7 +840,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width(), + total_widget_width(initial_segment_w), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -870,6 +901,12 @@ pub fn run() { dragging: false, drag_start_mouse_x: 0, drag_start_offset: 0, + overlay_hwnds: Vec::new(), + overlay_regions: Vec::new(), + hovered_region: None, + segment_w_design: settings + .segment_w_design + .clamp(MIN_SEGMENT_W, MAX_SEGMENT_W), widget_visible: settings.widget_visible, }); } @@ -903,6 +940,19 @@ pub fn run() { diagnose::log("tray event hook could not be installed"); } } + + // Pre-warm the UIA occupant cache so the first drag has data. + if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { + crate::highlight::spawn_uia_scan(taskbar_hwnd, taskbar_rect); + } + + // Win11 pinned-app changes happen entirely in XAML and don't fire + // Win32 LOCATIONCHANGE events, so the tray-hook path can't see them. + // Poll periodically to catch those, refresh the UIA cache, and + // auto-resize the widget if its current region's width has changed. + // 500 ms feels responsive without saturating explorer.exe RPC; the + // in-flight guard in `spawn_uia_scan` prevents stacking. + SetTimer(hwnd, TIMER_LAYOUT_REFRESH, 500, None); } else { diagnose::log("taskbar not found; using fallback popup window"); } @@ -981,7 +1031,17 @@ pub fn run() { /// ClearType sub-pixel font rendering can be used for crisp, OS-native text. fn render_layered() { refresh_dpi(); - let (hwnd_val, is_dark, embedded, strings, session_pct, session_text, weekly_pct, weekly_text) = { + let ( + hwnd_val, + is_dark, + embedded, + strings, + session_pct, + session_text, + weekly_pct, + weekly_text, + segment_w_design, + ) = { let state = lock_state(); match state.as_ref() { Some(s) => ( @@ -993,6 +1053,7 @@ fn render_layered() { s.session_text.clone(), s.weekly_percent, s.weekly_text.clone(), + s.segment_w_design, ), None => return, } @@ -1008,7 +1069,7 @@ fn render_layered() { return; } - let width = total_widget_width(); + let width = total_widget_width(segment_w_design); let height = sc(WIDGET_HEIGHT); let accent = Color::from_hex("#D97757"); @@ -1075,6 +1136,7 @@ fn render_layered() { &session_text, weekly_pct, &weekly_text, + segment_w_design, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1138,6 +1200,7 @@ fn paint_content( session_text: &str, weekly_pct: f64, weekly_text: &str, + segment_w_design: i32, ) { unsafe { let client_rect = RECT { @@ -1223,6 +1286,7 @@ fn paint_content( session_text, accent, track, + segment_w_design, ); draw_row( hdc, @@ -1233,6 +1297,7 @@ fn paint_content( weekly_text, accent, track, + segment_w_design, ); SelectObject(hdc, old_font); @@ -1447,11 +1512,115 @@ fn update_display() { refresh_usage_texts(s); } +/// On drop: pick the largest segment width that lets the widget fit into +/// `region`, right-align the widget to the region's right edge, and re-render. +/// Align the widget's right edge to whichever side of the region the user is +/// likely "anchored to": region on the left half of the taskbar → left-align +/// (empty space sits to the widget's right); right half → right-align (empty +/// space sits to the widget's left). Returns the `tray_offset` value to use. +fn offset_for_region( + taskbar_rect: RECT, + tray_left: i32, + region: &crate::highlight::HighlightRegion, + widget_width: i32, +) -> i32 { + let taskbar_center = (taskbar_rect.left + taskbar_rect.right) / 2; + let region_center = (region.rect.left + region.rect.right) / 2; + let widget_right = if region_center < taskbar_center { + region.rect.left + widget_width + } else { + region.rect.right + }; + (tray_left - widget_right).max(0) +} + +fn snap_widget_to_region(taskbar_hwnd: HWND, region: &crate::highlight::HighlightRegion) { + let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { + Some(r) => r, + None => return, + }; + let region_w_physical = region.rect.right - region.rect.left; + if region_w_physical <= 0 { + return; + } + + let dpi = CURRENT_DPI.load(Ordering::Relaxed).max(1) as i32; + let region_w_design = region_w_physical * 96 / dpi; + let bars_design = + region_w_design - FIXED_NON_BAR_DESIGN_WIDTH - (SEGMENT_COUNT - 1) * SEGMENT_GAP; + let new_segment_w = (bars_design / SEGMENT_COUNT).clamp(MIN_SEGMENT_W, MAX_SEGMENT_W); + let new_widget_w_physical = total_widget_width(new_segment_w); + + let mut tray_left = taskbar_rect.right; + if let Some(tray_hwnd) = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") { + if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) { + tray_left = tray_rect.left; + } + } + let new_offset = offset_for_region(taskbar_rect, tray_left, region, new_widget_w_physical); + + let _ = new_widget_w_physical; + + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.segment_w_design = new_segment_w; + s.tray_offset = new_offset; + } + } + + position_at_taskbar(); + render_layered(); +} + +/// Hit-test the cursor's screen X against the stored drag-time regions and +/// update lit/dim painting when the hovered region changes. +fn update_hovered_region(cursor_x: i32) { + let (old_index, new_index, old_repaint, new_repaint) = { + let mut state = lock_state(); + let s = match state.as_mut() { + Some(s) => s, + None => return, + }; + let new_index = s + .overlay_regions + .iter() + .position(|r| cursor_x >= r.rect.left && cursor_x < r.rect.right); + if new_index == s.hovered_region { + return; + } + let old = s.hovered_region; + s.hovered_region = new_index; + + let old_repaint = old.and_then(|i| { + let region = *s.overlay_regions.get(i)?; + let hwnd = s.overlay_hwnds.get(i)?.to_hwnd(); + Some((hwnd, region)) + }); + let new_repaint = new_index.and_then(|i| { + let region = *s.overlay_regions.get(i)?; + let hwnd = s.overlay_hwnds.get(i)?.to_hwnd(); + Some((hwnd, region)) + }); + (old, new_index, old_repaint, new_repaint) + }; + + let _ = old_index; + let _ = new_index; + + if let Some((hwnd, region)) = old_repaint { + crate::highlight::repaint_highlight(hwnd, ®ion, false); + } + if let Some((hwnd, region)) = new_repaint { + crate::highlight::repaint_highlight(hwnd, ®ion, true); + } +} + fn position_at_taskbar() { refresh_dpi(); // Drop the app-state lock before any Win32 call that may synchronously // re-enter our window procedure. - let (hwnd, embedded, tray_offset, taskbar_hwnd) = { + let (hwnd, embedded, tray_offset, taskbar_hwnd, segment_w_design) = { let state = lock_state(); let s = match state.as_ref() { Some(s) => s, @@ -1471,7 +1640,13 @@ fn position_at_taskbar() { } }; - (s.hwnd.to_hwnd(), s.embedded, s.tray_offset, taskbar_hwnd) + ( + s.hwnd.to_hwnd(), + s.embedded, + s.tray_offset, + taskbar_hwnd, + s.segment_w_design, + ) }; let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { @@ -1493,7 +1668,7 @@ fn position_at_taskbar() { } } - let widget_width = total_widget_width(); + let widget_width = total_widget_width(segment_w_design); let widget_height = sc(WIDGET_HEIGHT); let y = compute_anchor_y(anchor_top, anchor_height, widget_height); @@ -1530,36 +1705,202 @@ unsafe extern "system" fn on_tray_location_changed( _thread: u32, _time: u32, ) { - static LAST_REPOSITION: Mutex> = Mutex::new(None); + static LAST_RUN: Mutex> = Mutex::new(None); - let is_tray = { + let (taskbar_hwnd, widget_hwnd, dragging) = { let state = lock_state(); - state - .as_ref() - .and_then(|s| s.tray_notify_hwnd) - .map(|h| h == hwnd) - .unwrap_or(false) + match state.as_ref() { + Some(s) => (s.taskbar_hwnd, s.hwnd.to_hwnd(), s.dragging), + None => return, + } + }; + if dragging { + return; + } + if hwnd == widget_hwnd { + return; + } + let taskbar_hwnd = match taskbar_hwnd { + Some(h) => h, + None => return, + }; + let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { + Some(r) => r, + None => return, }; - if is_tray { - let should_reposition = { - let mut last = LAST_REPOSITION.lock().unwrap_or_else(|e| e.into_inner()); - let now = std::time::Instant::now(); - if last - .map(|t| now.duration_since(t).as_millis() > 500) - .unwrap_or(true) - { - *last = Some(now); - true - } else { - false - } + // Only react to events for windows positioned inside the taskbar — that's + // the tray, task list, start, etc. Filters out the firehose of unrelated + // events on this thread. + let event_rect = match native_interop::get_window_rect_safe(hwnd) { + Some(r) => r, + None => return, + }; + let inside_taskbar = event_rect.left >= taskbar_rect.left + && event_rect.right <= taskbar_rect.right + && event_rect.top >= taskbar_rect.top + && event_rect.bottom <= taskbar_rect.bottom; + if !inside_taskbar { + return; + } + + // Debounce: many LOCATIONCHANGE events can fire in quick succession. + let should_run = { + let mut last = LAST_RUN.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + if last + .map(|t| now.duration_since(t).as_millis() > 500) + .unwrap_or(true) + { + *last = Some(now); + true + } else { + false + } + }; + if !should_run { + return; + } + + auto_resize_to_current_region(); + position_at_taskbar(); + render_layered(); + crate::highlight::spawn_uia_scan(taskbar_hwnd, taskbar_rect); +} + +/// Periodic tick: catches Win11 XAML pin/unpin changes that don't fire +/// Win32 LOCATIONCHANGE events. Also a safety net for any layout changes +/// the event hook misses. +fn layout_refresh_tick() { + let (taskbar_hwnd, dragging) = { + let state = lock_state(); + match state.as_ref() { + Some(s) => (s.taskbar_hwnd, s.dragging), + None => return, + } + }; + if dragging { + return; + } + let taskbar_hwnd = match taskbar_hwnd { + Some(h) => h, + None => return, + }; + + // 1. Spawn a UIA cache refresh so the next tick (and the next drag) sees + // fresh XAML occupants. + if let Some(taskbar_rect) = native_interop::get_taskbar_rect(taskbar_hwnd) { + crate::highlight::spawn_uia_scan(taskbar_hwnd, taskbar_rect); + } + + // 2. Re-evaluate the widget's region using the cached UIA + fresh HWND + // occupants. Only re-renders if the segment width actually changes. + auto_resize_to_current_region(); + position_at_taskbar(); + render_layered(); +} + +/// Recompute the widget's segment width to match whichever open region it +/// currently sits in. Called on layout-change events outside of an active drag. +fn auto_resize_to_current_region() { + let (taskbar_hwnd, widget_hwnd, current_seg) = { + let state = lock_state(); + let s = match state.as_ref() { + Some(s) => s, + None => return, }; - if should_reposition { - position_at_taskbar(); - render_layered(); + if s.dragging { + return; + } + match s.taskbar_hwnd { + Some(tb) => (tb, s.hwnd.to_hwnd(), s.segment_w_design), + None => return, + } + }; + + let taskbar_rect = match native_interop::get_taskbar_rect(taskbar_hwnd) { + Some(r) => r, + None => return, + }; + let widget_rect = match native_interop::get_window_rect_safe(widget_hwnd) { + Some(r) => r, + None => return, + }; + let widget_center_x = (widget_rect.left + widget_rect.right) / 2; + + let mut occupants = crate::highlight::compute_debug_rects(taskbar_hwnd, &[widget_hwnd]); + occupants.extend(crate::highlight::cached_uia_occupants()); + let regions = + crate::highlight::open_regions_from_occupants(taskbar_rect, &occupants); + + // A region must fit the widget at minimum segment width to be considered. + let min_widget_w = total_widget_width(MIN_SEGMENT_W); + + // Prefer the region the widget currently sits in; if that region no + // longer fits the widget (e.g., a new pinned app squeezed it), fall back + // to the nearest valid region by horizontal centroid distance. + let containing = regions + .iter() + .find(|r| widget_center_x >= r.rect.left && widget_center_x < r.rect.right) + .copied(); + let containing_fits = containing + .map(|r| (r.rect.right - r.rect.left) >= min_widget_w) + .unwrap_or(false); + + let region = if containing_fits { + containing.unwrap() + } else { + match regions + .iter() + .filter(|r| (r.rect.right - r.rect.left) >= min_widget_w) + .min_by_key(|r| { + let center = (r.rect.left + r.rect.right) / 2; + (center - widget_center_x).abs() + }) + .copied() + { + Some(r) => r, + None => return, + } + }; + + let region_w_physical = region.rect.right - region.rect.left; + if region_w_physical <= 0 { + return; + } + let dpi = CURRENT_DPI.load(Ordering::Relaxed).max(1) as i32; + let region_w_design = region_w_physical * 96 / dpi; + let bars_design = + region_w_design - FIXED_NON_BAR_DESIGN_WIDTH - (SEGMENT_COUNT - 1) * SEGMENT_GAP; + let new_seg = (bars_design / SEGMENT_COUNT).clamp(MIN_SEGMENT_W, MAX_SEGMENT_W); + let new_widget_w = total_widget_width(new_seg); + + let mut tray_left = taskbar_rect.right; + if let Some(tray_hwnd) = native_interop::find_child_window(taskbar_hwnd, "TrayNotifyWnd") { + if let Some(tray_rect) = native_interop::get_window_rect_safe(tray_hwnd) { + tray_left = tray_rect.left; } } + let new_offset = offset_for_region(taskbar_rect, tray_left, ®ion, new_widget_w); + + let current_offset = { + let state = lock_state(); + state.as_ref().map(|s| s.tray_offset).unwrap_or(0) + }; + if new_seg == current_seg && new_offset == current_offset { + return; + } + + let _ = widget_hwnd; + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.segment_w_design = new_seg; + s.tray_offset = new_offset; + } + } + position_at_taskbar(); + render_layered(); } /// Main window procedure @@ -1670,6 +2011,9 @@ unsafe extern "system" fn wnd_proc( TIMER_UPDATE_CHECK => { begin_update_check(hwnd, false); } + TIMER_LAYOUT_REFRESH => { + layout_refresh_tick(); + } _ => {} } LRESULT(0) @@ -1692,10 +2036,10 @@ unsafe extern "system" fn wnd_proc( let state = lock_state(); state.as_ref().map(|s| s.dragging).unwrap_or(false) }; - // Always show resize cursor while dragging or when hovering divider zone + // Always show move cursor while dragging or when hovering divider zone let hit_test = (lparam.0 & 0xFFFF) as u16; if is_dragging { - let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); + let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEALL).unwrap_or_default(); SetCursor(cursor); return LRESULT(1); } @@ -1705,7 +2049,7 @@ unsafe extern "system" fn wnd_proc( let _ = GetCursorPos(&mut pt); let _ = ScreenToClient(hwnd, &mut pt); if pt.x < sc(DIVIDER_HIT_ZONE) { - let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEWE).unwrap_or_default(); + let cursor = LoadCursorW(HINSTANCE::default(), IDC_SIZEALL).unwrap_or_default(); SetCursor(cursor); return LRESULT(1); } @@ -1717,13 +2061,61 @@ unsafe extern "system" fn wnd_proc( if client_x < sc(DIVIDER_HIT_ZONE) { let mut pt = POINT::default(); let _ = GetCursorPos(&mut pt); - let mut state = lock_state(); - if let Some(s) = state.as_mut() { - s.dragging = true; - s.drag_start_mouse_x = pt.x; - s.drag_start_offset = s.tray_offset; - } + let taskbar_hwnd = { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.dragging = true; + s.drag_start_mouse_x = pt.x; + s.drag_start_offset = s.tray_offset; + s.taskbar_hwnd + } else { + None + } + }; SetCapture(hwnd); + if let Some(taskbar_hwnd) = taskbar_hwnd { + if let Some(taskbar_rect) = + native_interop::get_taskbar_rect(taskbar_hwnd) + { + // Combine fresh legacy HWND occupants with the cached + // UIA occupants (refreshed at startup and after each + // drag end). + let mut occupants = + crate::highlight::compute_debug_rects(taskbar_hwnd, &[hwnd]); + occupants.extend(crate::highlight::cached_uia_occupants()); + + let mut regions = crate::highlight::open_regions_from_occupants( + taskbar_rect, + &occupants, + ); + + // A region is only a valid snap target if the widget + // can fit inside it at the minimum allowed segment + // width (5 design-px per progress-bar rectangle). + let min_widget_w = total_widget_width(MIN_SEGMENT_W); + regions.retain(|r| (r.rect.right - r.rect.left) >= min_widget_w); + + let mut overlay_hwnds = + crate::highlight::show_highlights(taskbar_hwnd, ®ions); + if crate::highlight::DEBUG_RENDER_ENABLED + .load(std::sync::atomic::Ordering::Relaxed) + { + overlay_hwnds.extend(crate::highlight::show_debug_rects( + taskbar_hwnd, + &occupants, + )); + } + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.overlay_hwnds = overlay_hwnds + .into_iter() + .map(SendHwnd::from_hwnd) + .collect(); + s.overlay_regions = regions; + s.hovered_region = None; + } + } + } } LRESULT(0) } @@ -1767,7 +2159,7 @@ unsafe extern "system" fn wnd_proc( tray_left = tray_rect.left; } } - let widget_width = total_widget_width(); + let widget_width = total_widget_width(s.segment_w_design); let max_offset = tray_left - taskbar_rect.left - widget_width; if new_offset > max_offset { new_offset = max_offset; @@ -1819,27 +2211,76 @@ unsafe extern "system" fn wnd_proc( native_interop::move_window(hwnd_val, x, y, widget_width, widget_height); } } + + update_hovered_region(pt.x); } LRESULT(0) } WM_LBUTTONUP => { - let was_dragging = { + let (was_dragging, overlays, taskbar_hwnd, snap_target, revert_offset, had_regions) = { let mut state = lock_state(); if let Some(s) = state.as_mut() { if s.dragging { s.dragging = false; - let offset = s.tray_offset; - Some(offset) + let drained: Vec = s + .overlay_hwnds + .drain(..) + .map(|h| h.to_hwnd()) + .collect(); + let snap = s + .hovered_region + .and_then(|i| s.overlay_regions.get(i).copied()); + let had = !s.overlay_regions.is_empty(); + s.overlay_regions.clear(); + s.hovered_region = None; + ( + true, + drained, + s.taskbar_hwnd, + snap, + s.drag_start_offset, + had, + ) } else { - None + (false, Vec::new(), None, None, 0, false) } } else { - None + (false, Vec::new(), None, None, 0, false) } }; - if was_dragging.is_some() { + if was_dragging { let _ = ReleaseCapture(); + let mut overlays = overlays; + crate::highlight::hide_highlights(&mut overlays); + + if let (Some(taskbar_hwnd), Some(region)) = (taskbar_hwnd, snap_target) { + snap_widget_to_region(taskbar_hwnd, ®ion); + } else if had_regions { + // Regions existed but the user released over none of them + // — revert to where the drag started. + { + let mut state = lock_state(); + if let Some(s) = state.as_mut() { + s.tray_offset = revert_offset; + } + } + position_at_taskbar(); + render_layered(); + } + // No regions at all → keep the dragged position (legacy + // free-form behavior). `tray_offset` was already updated on + // every WM_MOUSEMOVE. + save_state_settings(); + // Refresh the UIA cache off the UI thread so the next drag + // sees current pinned/running app positions. + if let Some(taskbar_hwnd) = taskbar_hwnd { + if let Some(taskbar_rect) = + native_interop::get_taskbar_rect(taskbar_hwnd) + { + crate::highlight::spawn_uia_scan(taskbar_hwnd, taskbar_rect); + } + } } LRESULT(0) } @@ -2201,7 +2642,7 @@ fn show_context_menu(hwnd: HWND) { /// Paint for non-embedded fallback (normal WM_PAINT path) fn paint(hdc: HDC, hwnd: HWND) { - let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text) = { + let (is_dark, strings, session_pct, session_text, weekly_pct, weekly_text, segment_w_design) = { let state = lock_state(); match state.as_ref() { Some(s) => ( @@ -2211,6 +2652,7 @@ fn paint(hdc: HDC, hwnd: HWND) { s.session_text.clone(), s.weekly_percent, s.weekly_text.clone(), + s.segment_w_design, ), None => return, } @@ -2261,6 +2703,7 @@ fn paint(hdc: HDC, hwnd: HWND) { &session_text, weekly_pct, &weekly_text, + segment_w_design, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -2280,8 +2723,9 @@ fn draw_row( text: &str, accent: &Color, track: &Color, + segment_w_design: i32, ) { - let seg_w = sc(SEGMENT_W); + let seg_w = sc(segment_w_design); let seg_h = sc(SEGMENT_H); let seg_gap = sc(SEGMENT_GAP); let corner_r = sc(CORNER_RADIUS);