From 177eff5d7756469cbc2dfbda9339021240690b69 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:33:41 +0800 Subject: [PATCH] fix(switcher): let the pointer finish a Ctrl+Tab gesture the keyboard started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+Tab raises the panel and holds it up until Ctrl comes back up, which commits whatever is highlighted. That is right while the gesture is a keyboard one, and wrong the moment the user reaches for the mouse: letting go of Ctrl over the workspace list slammed the panel shut and picked a tab, so switching workspaces by hand — the thing the pointer was on its way to do — was unreachable. The panel now tracks where the pointer is, on the card at all and on the tab column specifically. A release with the pointer parked on the card but off the tab column drops the hold and leaves the panel up for the mouse to finish in; over the tab column it still commits, because that is the ordinary gesture. Both flags come from hover listeners, so they mean nothing until the mouse has actually moved since the panel came up, which is exactly the distinction wanted. Two macOS consequences of holding Ctrl, fixed with it. A held Ctrl turns every click into a right click, so reaching for the search box mid-gesture popped Cut/Copy/Paste instead of placing a caret — the rows already dodged this by dropping their own menus while the gesture is on, and the box has no menu worth keeping either (Cmd+V still pastes). And a tab row picked with the mouse arrives on the right button, so nothing between the row and the window may swallow that press first. Guarded by three tests that put the pointer on a computed point of the card rather than a hard-coded pixel: release over the workspace list keeps the panel up and picks nothing, release over the tab column still commits, and a Ctrl+click on a tab row mid-gesture picks the row under the pointer rather than the one the keyboard had reached. --- src/ui/switcher.rs | 184 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 178 insertions(+), 6 deletions(-) diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 9f318889..d57f1b09 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -313,6 +313,11 @@ pub(crate) struct Switcher { /// The modifiers held down when Ctrl+Tab opened the panel. Releasing them /// commits the highlighted tab, IDEA-style. hold: Option, + /// Where the pointer is: inside the card at all, and inside the tab column + /// specifically. Both are set by hover listeners, so they only mean + /// anything once the mouse has moved since the panel came up. + hover_card: bool, + hover_tabs: bool, left_scroll: gpui::ScrollHandle, right_scroll: gpui::ScrollHandle, /// Anchors on the two scrolls, worn by whichever row is selected. Both @@ -329,6 +334,14 @@ impl Switcher { fn text(&self, cx: &App) -> String { self.query.read(cx).value().trim().to_lowercase() } + + /// The pointer is parked in the card but off the tab column — on a + /// workspace row, the search box, a banner. Letting go of Ctrl there is + /// not a commit: the user is reaching for the mouse, and closing the panel + /// out from under them makes the workspace list unreachable by hand. + fn hover_keeps_open(&self) -> bool { + self.hover_card && !self.hover_tabs + } } /// Everything the panel needs for one frame: the groups (one per machine, @@ -437,9 +450,17 @@ impl Tty7App { remote_connect::register(cx); remote_connect::sweep_wsl(cx); let query = cx.new(|cx| { - InputState::new(window, cx).placeholder(crate::ui::i18n::t( - crate::ui::i18n::L10nKey::SearchWorkspacesAndMachines, - )) + InputState::new(window, cx) + .placeholder(crate::ui::i18n::t( + crate::ui::i18n::L10nKey::SearchWorkspacesAndMachines, + )) + // On macOS a held Ctrl turns every click into a right click, + // and the input answers a right click with Cut/Copy/Paste — + // so reaching for this box mid-Ctrl+Tab popped a menu instead + // of placing a caret. The rows already dodge this by dropping + // their own menus while the gesture is on; this box has no + // menu worth keeping either, and Cmd+V still pastes. + .context_menu(false) }); query.update(cx, |state, cx| state.focus(window, cx)); let subs = vec![cx.subscribe_in( @@ -469,6 +490,8 @@ impl Tty7App { right_sel: 0, mru, hold, + hover_card: false, + hover_tabs: false, left_scroll: left_scroll.clone(), right_scroll: right_scroll.clone(), left_anchor: gpui::ScrollAnchor::for_handle(left_scroll), @@ -601,9 +624,21 @@ impl Tty7App { let Some(hold) = self.switcher.as_ref().and_then(|sw| sw.hold) else { return; }; - if !now.modified() || !hold.is_subset_of(now) { - self.switcher_commit_hold(window, cx); + if now.modified() && hold.is_subset_of(now) { + return; } + // The pointer is already on the workspace list or the search box, so + // the release is the user's hand leaving the keyboard, not a pick. + // Drop the hold and leave the panel up for the mouse to finish in. + if self + .switcher + .as_ref() + .is_some_and(Switcher::hover_keeps_open) + { + self.switcher_release_hold(cx); + return; + } + self.switcher_commit_hold(window, cx); } /// Called when the modifier that raised the panel comes back up. @@ -1655,7 +1690,17 @@ impl Tty7App { this.close_switcher(window, cx) }), ) - .child(div().occlude().child(card)) + .child( + div() + .id("switcher-card") + .occlude() + .on_hover(cx.listener(|this, hovered: &bool, _window, _cx| { + if let Some(sw) = this.switcher.as_mut() { + sw.hover_card = *hovered; + } + })) + .child(card), + ) .into_any_element(), ) } @@ -1735,8 +1780,14 @@ impl Tty7App { ) .child( v_flex() + .id("switcher-tab-column") .flex_1() .min_w_0() + .on_hover(cx.listener(|this, hovered: &bool, _window, _cx| { + if let Some(sw) = this.switcher.as_mut() { + sw.hover_tabs = *hovered; + } + })) .child(crate::ui::scrollbar::with_vertical_scrollbar( "switcher-tabs-scrollbar", div() @@ -4046,6 +4097,127 @@ mod gpui_tests { }); } + /// One of the three places on the card a test wants to put the pointer. + #[derive(Clone, Copy)] + enum Spot { + Workspaces, + Tabs, + Search, + } + + /// Where that part of the card lands on screen. The card is centred and + /// its columns are laid out from `CARD_W` / `LEFT_W`, so the geometry is + /// worth recomputing here rather than hard-coding pixels that move with + /// the window size. + fn card_point(vcx: &mut gpui::VisualTestContext, spot: Spot) -> gpui::Point { + use gpui::{point, px}; + + let viewport = vcx.update(|window, _| window.viewport_size()); + let card_w = super::CARD_W + .min(viewport.width.as_f32() - 2. * super::CARD_MARGIN) + .max(320.); + let left_w = super::LEFT_W.min(card_w * 0.5); + let card_left = (viewport.width.as_f32() - card_w) / 2.; + let (dx, dy) = match spot { + // The search row is the first thing in the card; both columns + // start below it. + Spot::Search => (100., 20.), + Spot::Workspaces => (20., 60.), + // Past the tab column's own header row, onto its first tab. + Spot::Tabs => (left_w + 40., 42. + 6. + super::HOST_H + super::ROW_H / 2.), + }; + point(px(card_left + dx), px(super::CARD_TOP + dy)) + } + + /// Ctrl+Tab, then reach for the mouse: the pointer leaves the tab column + /// for the workspace list, and letting go of Ctrl there must not slam the + /// panel shut — switching workspaces by hand is exactly what the user is + /// in the middle of doing. + #[gpui::test] + fn releasing_ctrl_over_the_workspace_list_keeps_the_panel_up(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + vcx.simulate_modifiers_change(Modifiers::control()); + app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx)); + vcx.run_until_parked(); + + let at = card_point(&mut vcx, Spot::Workspaces); + vcx.simulate_mouse_move(at, None, Modifiers::control()); + vcx.simulate_modifiers_change(Modifiers::none()); + + app.update(cx, |app, _| { + let sw = app + .switcher + .as_ref() + .expect("the panel stays up for the mouse to finish in"); + assert!(sw.hold.is_none(), "the hold is spent, not re-armed"); + assert_eq!(app.active, 0, "the release picked nothing"); + }); + } + + /// The pointer over the tab column is the ordinary gesture: release still + /// commits. + #[gpui::test] + fn releasing_ctrl_over_the_tab_column_still_commits(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + vcx.simulate_modifiers_change(Modifiers::control()); + app.update_in(&mut vcx, |app, window, cx| app.tab_switch(true, window, cx)); + vcx.run_until_parked(); + + let at = card_point(&mut vcx, Spot::Tabs); + vcx.simulate_mouse_move(at, None, Modifiers::control()); + vcx.simulate_modifiers_change(Modifiers::none()); + + app.update(cx, |app, _| { + assert!(app.switcher.is_none(), "the panel comes down on release"); + assert_eq!(app.active, 1, "the highlighted tab is now the active one"); + }); + } + + /// macOS reports Ctrl+click as a right click, so a tab row picked with + /// the mouse mid-gesture arrives on the right button. The row takes that + /// press as the pick; nothing between it and the window may swallow it + /// first. + #[gpui::test] + fn ctrl_clicking_a_tab_row_mid_gesture_picks_it(cx: &mut TestAppContext) { + let (app, mut vcx, _streams) = harness_with_tabs(cx, 3); + vcx.simulate_modifiers_change(Modifiers::control()); + app.update_in(&mut vcx, |app, window, cx| { + app.tab_switch(true, window, cx); + // Two steps down, so the row the pointer lands on below is not + // the one the keyboard had already reached. + app.tab_switch(true, window, cx); + }); + vcx.run_until_parked(); + app.update(cx, |app, _| { + assert_eq!(app.switcher.as_ref().expect("up").right_sel, 2); + }); + + let at = card_point(&mut vcx, Spot::Tabs); + vcx.simulate_mouse_move(at, None, Modifiers::control()); + vcx.simulate_mouse_down(at, gpui::MouseButton::Right, Modifiers::control()); + + app.update(cx, |app, _| { + let sw = app.switcher.as_ref().expect("the panel stays up"); + assert_eq!( + sw.right_sel, 0, + "the row under the pointer took the press, not the keyboard's row 2" + ); + assert!( + sw.hold.is_some(), + "the gesture is still on until Ctrl is up" + ); + }); + + vcx.simulate_modifiers_change(Modifiers::none()); + app.update(cx, |app, _| { + assert!(app.switcher.is_none(), "release commits and closes"); + assert_eq!( + app.active, 0, + "the first row of a most-recently-used column is this very tab" + ); + }); + } + #[gpui::test] fn losing_focus_drops_the_hold_so_the_panel_cannot_hang(cx: &mut TestAppContext) { let (app, mut vcx, _streams) = harness_with_tabs(cx, 3);