From 015a96e8c26390b3b6ffe8c246982b2bc8c3bc40 Mon Sep 17 00:00:00 2001 From: Diogo Chaves Date: Wed, 12 Aug 2026 10:30:40 -0300 Subject: [PATCH 1/4] Add an auto second lane so left-aligned Start doesn't hide the widget Stock Windows 11's "Taskbar alignment = Left" (and Windhawk's "Start button always on left") pin Start at the taskbar's left edge, which starves the only lane placement_outcome knew about (Widgets->Start) and silently hides the widget. Factor the gap scan into a shared best_gap helper and try a second lane, Start->tray, whenever lane 1 has no qualifying gap. Lane 1 still wins whenever it fits, so default placement is unchanged. Promote the already-discovered TrayNotifyWnd rect to TaskbarLandmarks.tray as the new lane's right-edge landmark, with a bounds.right - 8 fallback for secondary taskbars that omit it or when the tray rect fails the taskbar-band check. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/floatbar/taskbar.rs | 9 +- .../src-tauri/src/taskbar_widget.rs | 446 ++++++++++++++++-- 2 files changed, 427 insertions(+), 28 deletions(-) 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..240a4c448 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,7 +502,7 @@ fn placement_outcome( return PlacementOutcome::TransientLandmarks; } - let lane_left = if let Some(widgets) = landmarks.widgets { + let lane1_left = if let Some(widgets) = landmarks.widgets { if !overlaps_taskbar_band(widgets) || widgets.right >= start.left { return PlacementOutcome::TransientLandmarks; } @@ -475,7 +510,7 @@ fn placement_outcome( } else { 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 +521,34 @@ 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 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)); - 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 = best_gap(lane1_left, lane1_right, &band_obstacles, minimum_width).or_else(|| { + // 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 = landmarks + .tray + .filter(|tray| overlaps_taskbar_band(*tray)) + .map(|tray| tray.left.saturating_sub(8)) + // Secondary taskbars can omit TrayNotifyWnd. + .unwrap_or_else(|| bounds.right.saturating_sub(8)); + if lane2_right <= lane2_left { + return None; } - 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 { + best_gap(lane2_left, lane2_right, &band_obstacles, minimum_width) + }); + let Some((gap_left, gap_right)) = gap else { return PlacementOutcome::VerifiedNoFit; }; let available_width = gap_right.saturating_sub(gap_left); @@ -2057,6 +2089,7 @@ mod tests { right: -992, bottom: 1080, }), + tray: None, }, primary: false, ..layout(secondary_bounds, Vec::new()) @@ -2080,6 +2113,7 @@ mod tests { TaskbarLandmarks { widgets: Some(widgets), start: Some(start), + tray: None, } } @@ -2333,6 +2367,7 @@ mod tests { right: 848, bottom: 1080, }), + tray: None, }, 3, ) @@ -2417,6 +2452,363 @@ mod tests { assert_eq!(placement.width, 312); } + /// 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 = 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: 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 = 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: 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 (stale, or copied from a + /// differently-positioned taskbar) must be treated exactly like a + /// missing tray: fall back to the taskbar's own right edge. + #[test] + fn tray_rect_failing_the_band_check_is_treated_as_missing() { + 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, + }), + // Off-band: entirely above the taskbar's top edge. + tray: Some(Rect { + left: 1700, + top: 0, + right: 1920, + bottom: 40, + }), + }; + + 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); + } + + /// 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, From bcc89bbdc3a7b1214dc22fda8f4db420c61a79d3 Mon Sep 17 00:00:00 2001 From: Diogo Chaves Date: Wed, 12 Aug 2026 13:53:56 -0300 Subject: [PATCH 2/4] Treat an off-band tray rect as transient and pin the Widgets guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An off-band tray rect is stale (mid layout or DPI change); letting it widen lane 2 to the taskbar's right edge could place the widget over the tray's true position, so report TransientLandmarks and retry on the next watchdog pass — the same policy as an off-band Start or Widgets rect. Also add a characterization test pinning the pre-existing sanity guard (Widgets at or right of Start stays transient, no lane-2 fallback) so a future change to it is a conscious one, and fold the three left-aligned lane-2 fixtures into one helper. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/taskbar_widget.rs | 182 +++++++++--------- 1 file changed, 92 insertions(+), 90 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs index 240a4c448..9243590be 100644 --- a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs +++ b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs @@ -531,23 +531,34 @@ fn placement_outcome( .collect::>(); // Lane 1 (Widgets→Start), exactly today's policy, always preferred. - let gap = best_gap(lane1_left, lane1_right, &band_obstacles, minimum_width).or_else(|| { - // 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 = landmarks - .tray - .filter(|tray| overlaps_taskbar_band(*tray)) - .map(|tray| tray.left.saturating_sub(8)) - // Secondary taskbars can omit TrayNotifyWnd. - .unwrap_or_else(|| bounds.right.saturating_sub(8)); - if lane2_right <= lane2_left { - return None; + let gap = match 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) + } } - best_gap(lane2_left, lane2_right, &band_obstacles, minimum_width) - }); + }; let Some((gap_left, gap_right)) = gap else { return PlacementOutcome::VerifiedNoFit; }; @@ -2452,13 +2463,10 @@ mod tests { assert_eq!(placement.width, 312); } - /// 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() { + /// 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, @@ -2481,13 +2489,24 @@ mod tests { right: 48, bottom: 1080, }), - tray: Some(Rect { - left: 1700, - top: 1032, - right: 1920, - 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"); @@ -2500,30 +2519,7 @@ mod tests { /// edge falls back to the taskbar's own right edge. #[test] fn missing_tray_landmark_falls_back_to_the_taskbar_right_edge() { - 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: None, - }; + let (taskbar, landmarks) = left_aligned_taskbar(None); let placement = child_placement(&taskbar, landmarks, 3).expect("lane 2 should fit after the icons"); @@ -2531,46 +2527,52 @@ mod tests { assert_eq!(placement.width, 312); } - /// A tray rect present but off the taskbar band (stale, or copied from a - /// differently-positioned taskbar) must be treated exactly like a - /// missing tray: fall back to the taskbar's own right edge. + /// 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_treated_as_missing() { - let taskbar = layout( - Rect { - left: 0, - top: 1032, - right: 1920, - bottom: 1080, - }, - vec![Rect { - left: 56, - top: 1032, - right: 700, - bottom: 1080, - }], + 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 ); - let landmarks = TaskbarLandmarks { - widgets: None, - start: Some(Rect { - left: 0, - top: 1032, - right: 48, - bottom: 1080, - }), - // Off-band: entirely above the taskbar's top edge. - tray: Some(Rect { - left: 1700, - top: 0, - right: 1920, - bottom: 40, - }), - }; + } - 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); + /// Documents a deliberately-kept pre-existing corner: when UIA reports + /// the Widgets button at or right of Start, the sanity guard still + /// treats the geometry as transient and lane 2 is never consulted — + /// even when it has a verified gap. Changing that guard would alter + /// lane-1 semantics and is out of scope for the #261 fix; this test + /// pins the current behavior so a future change is a conscious one. + #[test] + fn widgets_reported_right_of_start_stays_transient_without_fallback() { + let (taskbar, mut landmarks) = left_aligned_taskbar(Some(Rect { + left: 1700, + top: 1032, + right: 1920, + bottom: 1080, + })); + landmarks.widgets = Some(Rect { + left: 56, + top: 1032, + right: 104, + bottom: 1080, + }); + + assert_eq!( + placement_outcome(&taskbar, landmarks, 3), + PlacementOutcome::TransientLandmarks + ); } /// Centered alignment (today's common case): lane 1 has ample room and a From 0d6d8983d92515f0665da88940fcdf2c0a7379d0 Mon Sep 17 00:00:00 2001 From: Diogo Chaves Date: Wed, 12 Aug 2026 14:23:45 -0300 Subject: [PATCH 3/4] Treat a tray-side Widgets entry as lane-2 geometry, not transient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With "Taskbar alignment = Left" and Windows Widgets enabled (the stock default), Windows renders the Widgets entry by the tray, so UIA reports it at or right of Start permanently. The sanity guard read that as a mid-animation state and returned TransientLandmarks on every watchdog pass, which froze a stale widget in place (transient misses preserve the existing window) and never consulted the fallback lane — reproduced on real hardware during #261 verification. Lane 1 is now simply absent in that geometry, lane 2 places the widget, and the Widgets rect joins the obstacle set so the verified gap can never cover it. Off-band Widgets rects keep the transient policy. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/taskbar_widget.rs | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs index 9243590be..5bd47a95d 100644 --- a/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs +++ b/apps/desktop-tauri/src-tauri/src/taskbar_widget.rs @@ -502,13 +502,23 @@ fn placement_outcome( return PlacementOutcome::TransientLandmarks; } + // 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) || widgets.right >= start.left { + 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 lane1_right = start.left.saturating_sub(8); let Ok(provider_count) = i32::try_from(provider_count) else { @@ -523,15 +533,24 @@ fn placement_outcome( // UI Automation can expose Search, Task View, or pinned-app buttons in // 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 band_obstacles = layout + let mut band_obstacles = layout .obstacles .iter() .copied() .filter(|rect| rect.top < bounds.bottom && rect.bottom > bounds.top) .collect::>(); + 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); + } // Lane 1 (Widgets→Start), exactly today's policy, always preferred. - let gap = match best_gap(lane1_left, lane1_right, &band_obstacles, minimum_width) { + 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 @@ -2548,14 +2567,15 @@ mod tests { ); } - /// Documents a deliberately-kept pre-existing corner: when UIA reports - /// the Widgets button at or right of Start, the sanity guard still - /// treats the geometry as transient and lane 2 is never consulted — - /// even when it has a verified gap. Changing that guard would alter - /// lane-1 semantics and is out of scope for the #261 fix; this test - /// pins the current behavior so a future change is a conscious one. + /// 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_reported_right_of_start_stays_transient_without_fallback() { + fn widgets_rendered_by_the_tray_becomes_a_lane_two_obstacle() { let (taskbar, mut landmarks) = left_aligned_taskbar(Some(Rect { left: 1700, top: 1032, @@ -2563,12 +2583,33 @@ mod tests { bottom: 1080, })); landmarks.widgets = Some(Rect { - left: 56, + left: 1600, top: 1032, - right: 104, + 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 From ffbdf1e419a59b700d720aafc3aaf74d308fdb94 Mon Sep 17 00:00:00 2001 From: tsouth89 Date: Wed, 12 Aug 2026 17:56:29 -0400 Subject: [PATCH 4/4] Generalize hidden-widget status to cover both taskbar lanes --- apps/desktop-tauri/src/floatbar/SettingsSection.test.tsx | 6 +++--- apps/desktop-tauri/src/floatbar/SettingsSection.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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":