diff --git a/apps/desktop-tauri/src-tauri/src/floatbar/taskbar.rs b/apps/desktop-tauri/src-tauri/src/floatbar/taskbar.rs index 52577287b..3ad21440f 100644 --- a/apps/desktop-tauri/src-tauri/src/floatbar/taskbar.rs +++ b/apps/desktop-tauri/src-tauri/src/floatbar/taskbar.rs @@ -19,6 +19,9 @@ pub struct TaskbarLayout { pub struct TaskbarLandmarks { pub widgets: Option, pub start: Option, + /// The `TrayNotifyWnd` notification-area rect, when found. Anchor point + /// for the taskbar widget's Start-to-tray fallback lane. + pub tray: Option, } impl TaskbarLayout { @@ -197,13 +200,18 @@ unsafe fn layout_for_taskbar(hwnd: isize, primary: bool) -> Option() as isize, ); + let mut landmarks = TaskbarLandmarks::default(); + // Reserve the notification area when this taskbar exposes one. Some // Windows versions omit TrayNotifyWnd from secondary taskbars; child // enumeration still captures the controls that are actually present. + // Also promote it to a landmark: it's the anchor for the taskbar + // widget's Start-to-tray fallback lane. let tray_class = wide("TrayNotifyWnd"); let tray = FindWindowExW(hwnd, 0, tray_class.as_ptr(), std::ptr::null()); if let Some(tray_rect) = window_rect(tray) { context.obstacles.push(tray_rect); + landmarks.tray = Some(tray_rect); } // Windows 11 renders Widgets, Start, Search, and app buttons as XAML. @@ -212,7 +220,6 @@ unsafe fn layout_for_taskbar(hwnd: isize, primary: bool) -> Option landmarks.widgets = Some(button.bounds), diff --git a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs index 097f43ada..5bd47a95d 100644 --- a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs +++ b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs @@ -444,6 +444,41 @@ fn child_placement( } } +/// Largest fully-empty sub-gap of `[lane_left, lane_right]` that is at +/// least `minimum_width` wide, after removing `obstacles` that fall inside +/// the lane. `obstacles` is expected to already be filtered to the taskbar +/// band (top/bottom) by the caller, since that filter doesn't depend on the +/// lane. Shared by `placement_outcome`'s two lanes so both use the exact +/// same gap-scan policy. +fn best_gap( + lane_left: i32, + lane_right: i32, + obstacles: &[crate::floatbar::placement::Rect], + minimum_width: i32, +) -> Option<(i32, i32)> { + let mut obstacles = obstacles + .iter() + .copied() + .filter(|rect| rect.right > lane_left && rect.left < lane_right) + .collect::>(); + obstacles.sort_by_key(|rect| (rect.left, rect.right)); + + let mut gap_left = lane_left; + let mut gaps = Vec::new(); + for obstacle in obstacles { + let obstacle_left = obstacle.left.max(lane_left); + if obstacle_left.saturating_sub(gap_left) >= minimum_width { + gaps.push((gap_left, obstacle_left)); + } + gap_left = gap_left.max(obstacle.right.saturating_add(8)); + } + if lane_right.saturating_sub(gap_left) >= minimum_width { + gaps.push((gap_left, lane_right)); + } + gaps.into_iter() + .max_by_key(|(left, right)| right.saturating_sub(*left)) +} + fn placement_outcome( layout: &TaskbarLayout, landmarks: TaskbarLandmarks, @@ -467,15 +502,25 @@ fn placement_outcome( return PlacementOutcome::TransientLandmarks; } - let lane_left = if let Some(widgets) = landmarks.widgets { - if !overlaps_taskbar_band(widgets) || widgets.right >= start.left { + // Lane 1's left boundary. `None` means lane 1 doesn't exist: with + // "Taskbar alignment = Left" and Windows Widgets enabled, Windows + // renders the Widgets entry by the tray, so UIA reports it at or right + // of Start permanently — that is real geometry, not a mid-animation + // state, and treating it as transient froze a stale widget in place + // while never consulting lane 2. + let lane1_left = if let Some(widgets) = landmarks.widgets { + if !overlaps_taskbar_band(widgets) { return PlacementOutcome::TransientLandmarks; } - widgets.right.saturating_add(8) + if widgets.right >= start.left { + None + } else { + Some(widgets.right.saturating_add(8)) + } } else { - bounds.left.saturating_add(8) + Some(bounds.left.saturating_add(8)) }; - let lane_right = start.left.saturating_sub(8); + let lane1_right = start.left.saturating_sub(8); let Ok(provider_count) = i32::try_from(provider_count) else { return PlacementOutcome::VerifiedNoFit; }; @@ -486,37 +531,54 @@ fn placement_outcome( let minimum_width = provider_count.saturating_mul(72); // UI Automation can expose Search, Task View, or pinned-app buttons in - // the apparent Widgets-to-Start lane. Never cover one: use only a fully - // empty sub-gap and hide the proof if no verified gap can fit. - let mut obstacles = layout + // either lane. Never cover one: use only a fully empty sub-gap and hide + // the widget if no verified gap can fit in either lane. + let mut band_obstacles = layout .obstacles .iter() .copied() - .filter(|rect| { - rect.top < bounds.bottom - && rect.bottom > bounds.top - && rect.right > lane_left - && rect.left < lane_right - }) + .filter(|rect| rect.top < bounds.bottom && rect.bottom > bounds.top) .collect::>(); - obstacles.sort_by_key(|rect| (rect.left, rect.right)); + if lane1_left.is_none() + && let Some(widgets) = landmarks.widgets + { + // The Widgets entry sits in lane 2's territory when it isn't a + // lane-1 boundary — it's a real control there; never cover it. + band_obstacles.push(widgets); + } - let mut gap_left = lane_left; - let mut gaps = Vec::new(); - for obstacle in obstacles { - let obstacle_left = obstacle.left.max(lane_left); - if obstacle_left.saturating_sub(gap_left) >= minimum_width { - gaps.push((gap_left, obstacle_left)); + // Lane 1 (Widgets→Start), exactly today's policy, always preferred. + let gap = match lane1_left + .and_then(|lane1_left| best_gap(lane1_left, lane1_right, &band_obstacles, minimum_width)) + { + Some(gap) => Some(gap), + None => { + // No qualifying gap in lane 1 — e.g. Start pinned at the + // taskbar's left edge (stock "Taskbar alignment = Left", or + // Windhawk's "Start button always on left"). Try a second lane + // between Start and the tray, with the same obstacle + // verification. + let lane2_left = start.right.saturating_add(8); + let lane2_right = match landmarks.tray { + // A tray rect off the taskbar band is stale (mid layout or + // DPI change) — wait for a clean pass instead of letting a + // garbage rect widen the lane over the tray's true + // position. Same policy as an off-band Start or Widgets. + Some(tray) if !overlaps_taskbar_band(tray) => { + return PlacementOutcome::TransientLandmarks; + } + Some(tray) => tray.left.saturating_sub(8), + // Secondary taskbars can omit TrayNotifyWnd. + None => bounds.right.saturating_sub(8), + }; + if lane2_right <= lane2_left { + None + } else { + best_gap(lane2_left, lane2_right, &band_obstacles, minimum_width) + } } - gap_left = gap_left.max(obstacle.right.saturating_add(8)); - } - if lane_right.saturating_sub(gap_left) >= minimum_width { - gaps.push((gap_left, lane_right)); - } - let Some((gap_left, gap_right)) = gaps - .into_iter() - .max_by_key(|(left, right)| right.saturating_sub(*left)) - else { + }; + let Some((gap_left, gap_right)) = gap else { return PlacementOutcome::VerifiedNoFit; }; let available_width = gap_right.saturating_sub(gap_left); @@ -2057,6 +2119,7 @@ mod tests { right: -992, bottom: 1080, }), + tray: None, }, primary: false, ..layout(secondary_bounds, Vec::new()) @@ -2080,6 +2143,7 @@ mod tests { TaskbarLandmarks { widgets: Some(widgets), start: Some(start), + tray: None, } } @@ -2333,6 +2397,7 @@ mod tests { right: 848, bottom: 1080, }), + tray: None, }, 3, ) @@ -2417,6 +2482,376 @@ mod tests { assert_eq!(placement.width, 312); } + /// Left-aligned-Start fixture shared by the lane-2 fallback tests: + /// Start pinned at the taskbar's left edge (lane 1's right edge goes + /// negative), one icon-row obstacle at 56..700, tray as given. + fn left_aligned_taskbar(tray: Option) -> (TaskbarLayout, TaskbarLandmarks) { + let taskbar = layout( + Rect { + left: 0, + top: 1032, + right: 1920, + bottom: 1080, + }, + vec![Rect { + left: 56, + top: 1032, + right: 700, + bottom: 1080, + }], + ); + let landmarks = TaskbarLandmarks { + widgets: None, + start: Some(Rect { + left: 0, + top: 1032, + right: 48, + bottom: 1080, + }), + tray, + }; + (taskbar, landmarks) + } + + /// This is the test that closes #261: stock Windows 11 "Taskbar + /// alignment = Left" (or Windhawk's "Start button always on left") pins + /// Start at the taskbar's left edge, starving lane 1 (its right edge + /// goes negative). The widget now falls into lane 2, between Start and + /// the tray, centered in the verified gap after the last icon. + #[test] + fn left_aligned_start_falls_back_to_the_tray_lane() { + let (taskbar, landmarks) = left_aligned_taskbar(Some(Rect { + left: 1700, + top: 1032, + right: 1920, + bottom: 1080, + })); + + let placement = + child_placement(&taskbar, landmarks, 3).expect("lane 2 should fit after the icons"); + assert_eq!(placement.x, 1044); + assert_eq!(placement.width, 312); + } + + /// Same left-aligned-Start scenario, but the taskbar has no + /// `TrayNotifyWnd` (secondary taskbars can omit it) — lane 2's right + /// edge falls back to the taskbar's own right edge. + #[test] + fn missing_tray_landmark_falls_back_to_the_taskbar_right_edge() { + let (taskbar, landmarks) = left_aligned_taskbar(None); + + let placement = + child_placement(&taskbar, landmarks, 3).expect("lane 2 should fit after the icons"); + assert_eq!(placement.x, 1154); + assert_eq!(placement.width, 312); + } + + /// A tray rect present but off the taskbar band is stale (mid layout or + /// DPI change). Treating it as "missing" would widen lane 2 over the + /// tray's true position, so it reports transient instead — the same + /// policy as an off-band Start or Widgets rect — and the next watchdog + /// pass retries with fresh rects. + #[test] + fn tray_rect_failing_the_band_check_is_transient() { + // Off-band: entirely above the taskbar's top edge. + let (taskbar, landmarks) = left_aligned_taskbar(Some(Rect { + left: 1700, + top: 0, + right: 1920, + bottom: 40, + })); + + assert_eq!( + placement_outcome(&taskbar, landmarks, 3), + PlacementOutcome::TransientLandmarks + ); + } + + /// Stock Windows 11 with "Taskbar alignment = Left" AND Windows Widgets + /// enabled (the default): Windows renders the Widgets entry by the + /// tray, so UIA reports it right of Start — permanent geometry, not a + /// mid-animation state. The old guard returned TransientLandmarks here + /// forever, freezing a stale widget in place. Now lane 1 is simply + /// absent, lane 2 places the widget, and the Widgets entry joins the + /// obstacle set so the verified gap ends before it. + #[test] + fn widgets_rendered_by_the_tray_becomes_a_lane_two_obstacle() { + let (taskbar, mut landmarks) = left_aligned_taskbar(Some(Rect { + left: 1700, + top: 1032, + right: 1920, + bottom: 1080, + })); + landmarks.widgets = Some(Rect { + left: 1600, + top: 1032, + right: 1690, + bottom: 1080, + }); + + let placement = child_placement(&taskbar, landmarks, 3) + .expect("lane 2 should fit between the icons and the Widgets entry"); + // Largest verified gap is (708, 1600): after the icon row, ending at + // the Widgets entry — not at the tray. Centered 312px within it. + assert_eq!(placement.x, 998); + assert_eq!(placement.width, 312); + assert!(placement.x + placement.width <= 1600); + } + + /// An off-band Widgets rect is still stale-landmark territory — the + /// transient policy is unchanged for genuinely garbage rects. + #[test] + fn off_band_widgets_rect_is_still_transient() { + let (taskbar, mut landmarks) = left_aligned_taskbar(None); + landmarks.widgets = Some(Rect { + left: 0, + top: 0, + right: 48, + bottom: 40, + }); + + assert_eq!( + placement_outcome(&taskbar, landmarks, 3), + PlacementOutcome::TransientLandmarks + ); + } + + /// Centered alignment (today's common case): lane 1 has ample room and a + /// tray landmark is also present with room to spare in lane 2. Lane 1 + /// must still win — placement is pixel-identical to a world with no + /// tray landmark at all. + #[test] + fn centered_alignment_still_prefers_lane_one_even_when_lane_two_would_fit() { + let taskbar = layout( + Rect { + left: 0, + top: 1032, + right: 1920, + bottom: 1080, + }, + vec![], + ); + let landmarks = TaskbarLandmarks { + widgets: Some(Rect { + left: 0, + top: 1032, + right: 160, + bottom: 1080, + }), + start: Some(Rect { + left: 960, + top: 1032, + right: 1008, + bottom: 1080, + }), + tray: Some(Rect { + left: 1700, + top: 1032, + right: 1920, + bottom: 1080, + }), + }; + + let placement = child_placement(&taskbar, landmarks, 3).expect("lane 1 has plenty of room"); + assert_eq!(placement.x, 404); + assert_eq!(placement.width, 312); + } + + /// Lane 1 has a non-negative but too-small gap (a crowded centered + /// taskbar, not the left-aligned #261 case) — falls through to lane 2. + #[test] + fn lane_one_too_small_but_non_negative_falls_through_to_lane_two() { + let taskbar = layout( + Rect { + left: 0, + top: 1032, + right: 1920, + bottom: 1080, + }, + vec![], + ); + let landmarks = TaskbarLandmarks { + widgets: Some(Rect { + left: 0, + top: 1032, + right: 780, + bottom: 1080, + }), + start: Some(Rect { + left: 800, + top: 1032, + right: 848, + bottom: 1080, + }), + tray: Some(Rect { + left: 1700, + top: 1032, + right: 1920, + bottom: 1080, + }), + }; + + // Lane 1 is (788, 792) -- 4px wide, well under the 216px minimum for + // 3 providers -- non-negative but too small, unlike the left-aligned + // #261 case where lane 1's right edge goes negative. + let placement = child_placement(&taskbar, landmarks, 3).expect("lane 2 should fit"); + assert_eq!(placement.x, 1118); + assert_eq!(placement.width, 312); + } + + /// Lane 2 fully obstructed (an icon row spans the whole taskbar) → + /// neither lane has a verified gap, so the widget hides. + #[test] + fn fully_obstructed_lane_two_still_reports_verified_no_fit() { + let taskbar = layout( + Rect { + left: 0, + top: 1032, + right: 1920, + bottom: 1080, + }, + vec![Rect { + left: 0, + top: 1032, + right: 1920, + bottom: 1080, + }], + ); + let landmarks = TaskbarLandmarks { + widgets: None, + start: Some(Rect { + left: 0, + top: 1032, + right: 48, + bottom: 1080, + }), + tray: Some(Rect { + left: 1700, + top: 1032, + right: 1920, + bottom: 1080, + }), + }; + + assert_eq!( + placement_outcome(&taskbar, landmarks, 3), + PlacementOutcome::VerifiedNoFit + ); + } + + /// A vertical taskbar rejects placement before either lane (or the tray + /// landmark) is ever consulted. + #[test] + fn vertical_taskbar_early_return_is_unaffected_by_a_tray_landmark() { + let taskbar = layout( + Rect { + left: 0, + top: 0, + right: 48, + bottom: 1080, + }, + vec![], + ); + let landmarks = TaskbarLandmarks { + widgets: Some(Rect { + left: 0, + top: 0, + right: 48, + bottom: 60, + }), + start: Some(Rect { + left: 0, + top: 500, + right: 48, + bottom: 548, + }), + tray: Some(Rect { + left: 0, + top: 1000, + right: 48, + bottom: 1080, + }), + }; + + assert_eq!( + placement_outcome(&taskbar, landmarks, 3), + PlacementOutcome::VerifiedNoFit + ); + } + + /// Multi-monitor: both taskbars are left-aligned (lane 2), and each must + /// anchor against its own tray rect, not the other's. + #[test] + fn multi_monitor_lane_two_uses_each_taskbars_own_tray_rect() { + let primary = TaskbarLayout { + window_handle: 1, + landmarks: TaskbarLandmarks { + widgets: None, + start: Some(Rect { + left: 0, + top: 1392, + right: 48, + bottom: 1440, + }), + tray: Some(Rect { + left: 2400, + top: 1392, + right: 2560, + bottom: 1440, + }), + }, + ..layout( + Rect { + left: 0, + top: 1392, + right: 2560, + bottom: 1440, + }, + Vec::new(), + ) + }; + let secondary_bounds = Rect { + left: -1920, + top: 1032, + right: 0, + bottom: 1080, + }; + let secondary = TaskbarLayout { + window_handle: 2, + primary: false, + landmarks: TaskbarLandmarks { + widgets: None, + start: Some(Rect { + left: -1920, + top: 1032, + right: -1872, + bottom: 1080, + }), + tray: Some(Rect { + left: -260, + top: 1032, + right: 0, + bottom: 1080, + }), + }, + ..layout(secondary_bounds, Vec::new()) + }; + + let (discovered, rejected, placements) = taskbar_placements(&[primary, secondary], true, 3); + assert_eq!(discovered, vec![1, 2]); + assert!(rejected.is_empty()); + assert_eq!(placements.len(), 2); + assert_eq!(placements[0].0, 1); + assert_eq!(placements[0].1.x, 1068); + assert_eq!(placements[1].0, 2); + assert_eq!(placements[1].1.x, 698); + assert!( + placements + .iter() + .all(|(_, placement)| placement.width == 312 && placement.height == 48) + ); + } + fn rate_window(used: f64, minutes: Option) -> crate::commands::RateWindowSnapshot { crate::commands::RateWindowSnapshot { used_percent: used, diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx index 551400447..20ab4d919 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx @@ -256,7 +256,7 @@ describe("FloatBar settings", () => { it.each<[TaskbarWidgetStatus, string]>([ [ { kind: "noFit" }, - "Hidden: no free space on the taskbar between Widgets and Start.", + "Hidden: no free space on the taskbar.", ], [ { kind: "waitingLandmarks" }, @@ -297,13 +297,13 @@ describe("FloatBar settings", () => { resolveSecond({ kind: "noFit" }); expect(await screen.findByRole("status")).toHaveTextContent( - "Hidden: no free space on the taskbar between Widgets and Start.", + "Hidden: no free space on the taskbar.", ); resolveFirst({ kind: "active", taskbars: 3 }); await act(async () => {}); expect(screen.getByRole("status")).toHaveTextContent( - "Hidden: no free space on the taskbar between Widgets and Start.", + "Hidden: no free space on the taskbar.", ); }); diff --git a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx index 51db55db4..0c53c8390 100644 --- a/apps/desktop-tauri/src/floatbar/SettingsSection.tsx +++ b/apps/desktop-tauri/src/floatbar/SettingsSection.tsx @@ -44,7 +44,7 @@ function taskbarWidgetStatusMessage(status: TaskbarWidgetStatus | null): string case "active": return `Shown on ${status.taskbars} taskbar${status.taskbars === 1 ? "" : "s"}.`; case "noFit": - return "Hidden: no free space on the taskbar between Widgets and Start."; + return "Hidden: no free space on the taskbar."; case "waitingLandmarks": return "Waiting for taskbar landmarks (Start button not found). A taskbar mod may be interfering."; case "noProviders":