aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMichael Hackner <mhackner@gmail.com>2026-08-13 08:00:32 -0700
committerGitHub <noreply@github.com>2026-08-13 18:00:32 +0300
commitf76d7df549e95e1699651f59916e9a38f0f58985 (patch)
treec8e50a75fd32e68d63b8e0386de7e791cb727fb4 /src
parentb7dca4bb2a2cf488407ae051b40782f03fcabc62 (diff)
fix: keep tab and sidebar clicks when the terminal reports a stray drag (#2736)
* fix: keep tab and sidebar clicks when the terminal reports a stray drag This works around a Ghostty bug that causes herdr tabs to be unclickable in fullscreen mode. Ghostty on macOS in native fullscreen emits one motion report after a fresh press in the strip where the menu bar reveals, with the row shifted to near the bottom of the grid. The cause is upstream: macOS delivers one mouse event whose y is nearly a window height away from the real pointer, and Ghostty reports it as it arrives (iTerm2 and Terminal.app report the same clicks correctly). Herdr treated the stray report as a real drag and lost the click two ways: it opened a tab reorder with no drop target, making the release a no-op, and it handed the release to the pane under the bogus coordinates. Now, a press on the tab bar or sidebar owns the gesture until release, and a tab drag only starts once the pointer is over a real drop target. refs #796 * fix: scope chrome mouse gestures to their input source * fix: preserve chrome drag input source * fix: preserve concurrent chrome presses --------- Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs5
-rw-r--r--src/app/input/mouse.rs603
-rw-r--r--src/app/input/sidebar.rs9
-rw-r--r--src/app/input/terminal.rs2
-rw-r--r--src/app/mod.rs4
-rw-r--r--src/app/state.rs21
-rw-r--r--src/ui/sidebar.rs1
7 files changed, 580 insertions, 65 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 3202ae26..45124107 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -392,7 +392,10 @@ impl App {
let previous_agent_panel_sort = self.state.agent_panel_sort;
let previous_settings_section = self.state.settings.section;
if !handled_pane_double_click {
- if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) {
+ if let Some(action) =
+ self.state
+ .handle_mouse(&mut self.terminal_runtimes, source_id, mouse)
+ {
match action {
MouseAction::NewWorkspace => {
self.begin_tui_workspace_create("tui.mouse.workspace.create")
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 8d8988f9..3066c2a4 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -101,6 +101,7 @@ impl AppState {
pub(super) fn handle_mouse(
&mut self,
terminal_runtimes: &mut TerminalRuntimeRegistry,
+ source_id: crate::app::InputSourceId,
mouse: MouseEvent,
) -> Option<MouseAction> {
if self.mode == Mode::Onboarding {
@@ -190,7 +191,7 @@ impl AppState {
&& mouse.row >= sidebar.y
&& mouse.row < sidebar.y + sidebar.height;
- if self.handle_right_click_passthrough(terminal_runtimes, mouse, in_sidebar) {
+ if self.handle_right_click_passthrough(terminal_runtimes, source_id, mouse, in_sidebar) {
return None;
}
@@ -224,7 +225,7 @@ impl AppState {
MouseEventKind::Down(MouseButton::Left) => {
self.selection = None;
self.selection_autoscroll = None;
- self.workspace_press = None;
+ self.clear_chrome_press(source_id);
if self.mode == Mode::ConfirmClose {
let popup = self.confirm_close_rect();
@@ -498,12 +499,15 @@ impl AppState {
if let (Some(ws_idx), Some(tab_idx)) =
(self.active, self.tab_at(mouse.column, mouse.row))
{
- self.tab_press = Some(TabPressState {
- ws_idx,
- tab_idx,
- start_col: mouse.column,
- start_row: mouse.row,
- });
+ self.tab_presses.insert(
+ source_id,
+ TabPressState {
+ ws_idx,
+ tab_idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ },
+ );
return None;
}
if self.on_new_tab_button(mouse.column, mouse.row) {
@@ -585,11 +589,14 @@ impl AppState {
}
if let Some(idx) = self.workspace_at_row(mouse.row) {
- self.workspace_press = Some(WorkspacePressState {
- ws_idx: idx,
- start_col: mouse.column,
- start_row: mouse.row,
- });
+ self.workspace_presses.insert(
+ source_id,
+ WorkspacePressState {
+ ws_idx: idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ },
+ );
return None;
}
@@ -667,7 +674,9 @@ impl AppState {
return None;
}
- if self.drag.is_none() {
+ if (self.drag.is_none() || self.chrome_drag_owned_by_other(source_id))
+ && !self.chrome_press_pending(source_id)
+ {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) {
self.selection = None;
@@ -680,27 +689,37 @@ impl AppState {
let workspace_drop_target = self.workspace_drop_target_at_row(mouse.row);
let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row);
if self.drag.is_none() {
- if let Some(press) = &self.workspace_press {
+ if let Some(press) = self.workspace_presses.get(&source_id) {
let delta_col = mouse.column.abs_diff(press.start_col);
let delta_row = mouse.row.abs_diff(press.start_row);
let can_reorder = self.workspaces.get(press.ws_idx).is_some_and(|ws| {
ws.worktree_space()
.is_none_or(|space| !space.is_linked_worktree)
});
- if can_reorder && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD {
+ if workspace_drop_target.is_some()
+ && can_reorder
+ && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD
+ {
self.drag = Some(DragState {
target: DragTarget::WorkspaceReorder {
+ source_id,
source_ws_idx: press.ws_idx,
drop_target: workspace_drop_target,
},
});
}
- } else if let Some(press) = &self.tab_press {
+ } else if let Some(press) = self.tab_presses.get(&source_id) {
let delta_col = mouse.column.abs_diff(press.start_col);
let delta_row = mouse.row.abs_diff(press.start_row);
- if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD {
+ // Require a real drop target before opening a reorder,
+ // so a report from off the tab bar cannot start a drag
+ // that has nowhere to land.
+ if tab_drop_index.is_some()
+ && delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD
+ {
self.drag = Some(DragState {
target: DragTarget::TabReorder {
+ source_id,
ws_idx: press.ws_idx,
source_tab_idx: press.tab_idx,
insert_idx: tab_drop_index,
@@ -711,18 +730,28 @@ impl AppState {
}
if let Some(DragState {
- target: DragTarget::WorkspaceReorder { drop_target, .. },
+ target:
+ DragTarget::WorkspaceReorder {
+ source_id: drag_source_id,
+ drop_target,
+ ..
+ },
}) = &mut self.drag
{
- *drop_target = workspace_drop_target;
+ if *drag_source_id == source_id {
+ *drop_target = workspace_drop_target;
+ }
} else if let Some(DragState {
target:
DragTarget::TabReorder {
- ws_idx, insert_idx, ..
+ source_id: drag_source_id,
+ ws_idx,
+ insert_idx,
+ ..
},
}) = &mut self.drag
{
- if self.active == Some(*ws_idx) {
+ if *drag_source_id == source_id && self.active == Some(*ws_idx) {
*insert_idx = tab_drop_index;
}
} else if let Some(drag) = &self.drag {
@@ -807,8 +836,7 @@ impl AppState {
let was_click = selection.was_just_click();
let was_finalized = selection.is_finalized();
- self.workspace_press = None;
- self.tab_press = None;
+ self.clear_chrome_press(source_id);
self.drag = None;
self.selection_autoscroll = None;
if was_click {
@@ -823,27 +851,32 @@ impl AppState {
return None;
}
- if self.drag.is_none() {
+ let foreign_chrome_drag = self.chrome_drag_owned_by_other(source_id);
+ if (self.drag.is_none() || foreign_chrome_drag)
+ && !self.chrome_press_pending(source_id)
+ {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) {
self.selection = None;
self.selection_autoscroll = None;
- self.workspace_press = None;
- self.tab_press = None;
- self.drag = None;
return None;
}
}
}
- let workspace_press = self.workspace_press.take();
- let tab_press = self.tab_press.take();
+ let workspace_press = self.workspace_presses.remove(&source_id);
+ let tab_press = self.tab_presses.remove(&source_id);
+ if foreign_chrome_drag {
+ return self.chrome_press_action(workspace_press, tab_press);
+ }
+
match self.drag.take() {
Some(DragState {
target:
DragTarget::WorkspaceReorder {
source_ws_idx,
drop_target: Some(drop_target),
+ ..
},
}) => {
if let Some(params) =
@@ -877,6 +910,7 @@ impl AppState {
ws_idx,
source_tab_idx,
insert_idx: Some(insert_idx),
+ ..
},
}) => {
if self.active == Some(ws_idx) {
@@ -889,22 +923,7 @@ impl AppState {
}
}
Some(_) => {}
- None => {
- if let Some(press) = workspace_press {
- self.mode = Mode::Terminal;
- return Some(MouseAction::FocusWorkspace {
- ws_idx: press.ws_idx,
- });
- }
- if let Some(press) = tab_press {
- if self.active == Some(press.ws_idx) {
- self.mode = Mode::Terminal;
- return Some(MouseAction::FocusTab {
- tab_idx: press.tab_idx,
- });
- }
- }
- }
+ None => return self.chrome_press_action(workspace_press, tab_press),
}
}
@@ -1017,8 +1036,7 @@ impl AppState {
}
MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
- self.workspace_press = None;
- self.tab_press = None;
+ self.clear_chrome_press(source_id);
if self
.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
.is_some()
@@ -1441,6 +1459,70 @@ impl AppState {
.or_else(|| self.pane_frame_at(col, row))
}
+ fn chrome_press_pending(&self, source_id: crate::app::InputSourceId) -> bool {
+ self.tab_presses.contains_key(&source_id) || self.workspace_presses.contains_key(&source_id)
+ }
+
+ fn chrome_drag_owned_by_other(&self, source_id: crate::app::InputSourceId) -> bool {
+ self.drag.as_ref().is_some_and(|drag| {
+ matches!(
+ drag.target,
+ DragTarget::WorkspaceReorder {
+ source_id: drag_source_id,
+ ..
+ } | DragTarget::TabReorder {
+ source_id: drag_source_id,
+ ..
+ } if drag_source_id != source_id
+ )
+ })
+ }
+
+ fn chrome_press_action(
+ &mut self,
+ workspace_press: Option<WorkspacePressState>,
+ tab_press: Option<TabPressState>,
+ ) -> Option<MouseAction> {
+ if let Some(press) = workspace_press {
+ self.mode = Mode::Terminal;
+ return Some(MouseAction::FocusWorkspace {
+ ws_idx: press.ws_idx,
+ });
+ }
+ if let Some(press) = tab_press {
+ if self.active == Some(press.ws_idx) {
+ self.mode = Mode::Terminal;
+ return Some(MouseAction::FocusTab {
+ tab_idx: press.tab_idx,
+ });
+ }
+ }
+ None
+ }
+
+ pub(crate) fn clear_chrome_gesture(&mut self, source_id: crate::app::InputSourceId) {
+ if self.drag.as_ref().is_some_and(|drag| {
+ matches!(
+ drag.target,
+ DragTarget::WorkspaceReorder {
+ source_id: drag_source_id,
+ ..
+ } | DragTarget::TabReorder {
+ source_id: drag_source_id,
+ ..
+ } if drag_source_id == source_id
+ )
+ }) {
+ self.drag = None;
+ }
+ self.clear_chrome_press(source_id);
+ }
+
+ fn clear_chrome_press(&mut self, source_id: crate::app::InputSourceId) {
+ self.tab_presses.remove(&source_id);
+ self.workspace_presses.remove(&source_id);
+ }
+
fn mouse_pane_focus_action(&self, pane_id: crate::layout::PaneId) -> Option<MouseAction> {
let ws_idx = self.active?;
(self
@@ -1537,6 +1619,7 @@ impl AppState {
fn handle_right_click_passthrough(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
+ source_id: crate::app::InputSourceId,
mouse: MouseEvent,
in_sidebar: bool,
) -> bool {
@@ -1596,8 +1679,7 @@ impl AppState {
self.selection = None;
self.selection_autoscroll = None;
- self.workspace_press = None;
- self.tab_press = None;
+ self.clear_chrome_press(source_id);
self.drag = None;
self.context_menu = None;
self.right_click_passthrough = Some(RightClickPassthroughGesture {
@@ -1927,6 +2009,425 @@ mod tests {
workspace::Workspace,
};
+ #[test]
+ fn tab_click_survives_stray_drag_report_off_the_tab_bar() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(None);
+ ws.active_tab = 1;
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let area = Rect::new(0, 0, 106, 20);
+ crate::ui::compute_view(&mut app.state, area);
+
+ let first_tab = app.state.view.tab_hit_areas[0];
+ let press_col = first_tab.x + 1;
+ let stray_row = area.height - 1;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ press_col,
+ first_tab.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ press_col,
+ stray_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ press_col,
+ stray_row,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ }
+
+ #[test]
+ fn workspace_click_survives_stray_drag_report_off_the_workspace_list() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("second")];
+ app.state.active = Some(1);
+ app.state.selected = 1;
+ let area = Rect::new(0, 0, 106, 20);
+ crate::ui::compute_view(&mut app.state, area);
+
+ let first_workspace = app.state.view.workspace_card_areas[0];
+ let press_col = first_workspace.rect.x + 1;
+ let press_row = first_workspace.rect.y;
+ let stray_row = area.height - 1;
+ assert!(app.state.workspace_drop_target_at_row(stray_row).is_none());
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ press_col,
+ press_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ press_col,
+ stray_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ press_col,
+ stray_row,
+ ));
+
+ assert_eq!(app.state.active, Some(0));
+ }
+
+ #[test]
+ fn concurrent_input_sources_keep_their_tab_clicks() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(None);
+ ws.test_add_tab(None);
+ ws.active_tab = 2;
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let first_tab = app.state.view.tab_hit_areas[0];
+ let second_tab = app.state.view.tab_hit_areas[1];
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ first_tab.x + 1,
+ first_tab.y,
+ ),
+ );
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ second_tab.x + 1,
+ second_tab.y,
+ ),
+ );
+
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ first_tab.x + 1,
+ first_tab.y,
+ ),
+ );
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ second_tab.x + 1,
+ second_tab.y,
+ ),
+ );
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+ }
+
+ #[test]
+ fn concurrent_input_sources_keep_their_workspace_clicks() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ Workspace::test_new("first"),
+ Workspace::test_new("second"),
+ Workspace::test_new("third"),
+ ];
+ app.state.active = Some(2);
+ app.state.selected = 2;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let first = app.state.view.workspace_card_areas[0].rect;
+ let second = app.state.view.workspace_card_areas[1].rect;
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ first.x + 1,
+ first.y,
+ ),
+ );
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ second.x + 1,
+ second.y,
+ ),
+ );
+
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(MouseEventKind::Up(MouseButton::Left), first.x + 1, first.y),
+ );
+ assert_eq!(app.state.active, Some(0));
+
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ second.x + 1,
+ second.y,
+ ),
+ );
+ assert_eq!(app.state.active, Some(1));
+ }
+
+ #[test]
+ fn tab_click_completes_while_other_source_reorders() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(None);
+ ws.test_add_tab(None);
+ ws.active_tab = 2;
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let first_tab = app.state.view.tab_hit_areas[0];
+ let second_tab = app.state.view.tab_hit_areas[1];
+ let last_tab = app.state.view.tab_hit_areas[2];
+ let drop_col = last_tab.x + last_tab.width;
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ first_tab.x + 1,
+ first_tab.y,
+ ),
+ );
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ drop_col,
+ first_tab.y,
+ ),
+ );
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ second_tab.x + 1,
+ second_tab.y,
+ ),
+ );
+
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ second_tab.x + 1,
+ second_tab.y,
+ ),
+ );
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+ assert!(matches!(
+ app.state.drag.as_ref().map(|drag| &drag.target),
+ Some(DragTarget::TabReorder { source_id: 41, .. })
+ ));
+
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(MouseEventKind::Up(MouseButton::Left), drop_col, first_tab.y),
+ );
+ assert!(app.state.drag.is_none());
+ }
+
+ #[test]
+ fn releasing_input_source_clears_only_its_pending_tab_click() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(None);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let first_tab = app.state.view.tab_hit_areas[0];
+ let second_tab = app.state.view.tab_hit_areas[1];
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ first_tab.x + 1,
+ first_tab.y,
+ ),
+ );
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ second_tab.x + 1,
+ second_tab.y,
+ ),
+ );
+
+ app.clear_input_source(41);
+
+ assert!(!app.state.tab_presses.contains_key(&41));
+ assert!(app.state.tab_presses.contains_key(&42));
+ }
+
+ #[tokio::test]
+ async fn other_input_source_pane_gesture_is_not_swallowed_by_chrome_press() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(None);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let area = Rect::new(0, 0, 106, 20);
+ crate::ui::compute_view(&mut app.state, area);
+
+ let info = app.state.view.pane_infos[0].clone();
+ let pane_id = info.id;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 0,
+ b"\x1b[?1000h\x1b[?1006h",
+ 4,
+ );
+ app.state.insert_test_runtime(pane_id, runtime);
+ crate::ui::compute_view(&mut app.state, area);
+
+ let first_tab = app.state.view.tab_hit_areas[0];
+ let pane_col = info.inner_rect.x;
+ let pane_row = info.inner_rect.y;
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ first_tab.x + 1,
+ first_tab.y,
+ ),
+ );
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(MouseEventKind::Down(MouseButton::Left), pane_col, pane_row),
+ );
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(MouseEventKind::Up(MouseButton::Left), pane_col, pane_row),
+ );
+
+ assert_eq!(
+ input_rx.try_recv().expect("other source mouse down"),
+ Bytes::from_static(b"\x1b[<0;1;1M")
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("other source mouse up"),
+ Bytes::from_static(b"\x1b[<0;1;1m")
+ );
+ }
+
+ #[test]
+ fn other_input_source_cannot_release_tab_reorder() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(Some("second"));
+ ws.test_add_tab(Some("third"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let source = app.state.view.tab_hit_areas[0];
+ let target = app.state.view.tab_hit_areas[2];
+ let drop_col = target.x + target.width;
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ source.x + 1,
+ source.y,
+ ),
+ );
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(MouseEventKind::Drag(MouseButton::Left), drop_col, source.y),
+ );
+ app.handle_mouse_from_input_source(
+ 42,
+ mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y),
+ );
+
+ assert!(app.state.drag.is_some());
+ assert_eq!(app.state.workspaces[0].tabs[0].custom_name, None);
+
+ app.handle_mouse_from_input_source(
+ 41,
+ mouse(MouseEventKind::Up(MouseButton::Left), drop_col, source.y),
+ );
+
+ assert!(app.state.drag.is_none());
+ assert_eq!(app.state.workspaces[0].tabs[2].custom_name.as_deref(), None);
+ }
+
+ #[tokio::test]
+ async fn tab_click_survives_stray_drag_report_into_a_mouse_reporting_pane() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(None);
+ ws.active_tab = 1;
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let area = Rect::new(0, 0, 106, 20);
+ crate::ui::compute_view(&mut app.state, area);
+
+ let info = app
+ .state
+ .view
+ .pane_infos
+ .first()
+ .cloned()
+ .expect("visible pane");
+ app.state.insert_test_runtime(
+ info.id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
+ info.inner_rect.width.max(1),
+ info.inner_rect.height.max(1),
+ b"\x1b[?1002h",
+ ),
+ );
+ crate::ui::compute_view(&mut app.state, area);
+
+ let first_tab = app.state.view.tab_hit_areas[0];
+ let press_col = first_tab.x + 1;
+ let stray_row = info.inner_rect.bottom().saturating_sub(1);
+ assert!(
+ app.state.pane_mouse_target(press_col, stray_row).is_some(),
+ "stray coordinates must land on the pane for this to test anything"
+ );
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ press_col,
+ first_tab.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ press_col,
+ stray_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ press_col,
+ stray_row,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ }
+
fn mark_worktree_space_member(workspace: &mut Workspace, ws_idx: usize, key: &str) {
workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
key: key.into(),
@@ -3667,7 +4168,7 @@ mod tests {
assert_eq!(app.state.workspaces[0].active_tab, 0);
assert_eq!(app.state.workspaces[0].tabs.len(), 2);
assert!(app.state.context_menu.is_none());
- assert!(app.state.tab_press.is_none());
+ assert!(app.state.tab_presses.is_empty());
assert!(app.state.drag.is_none());
}
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 1c3a3b58..0923eef6 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -1200,12 +1200,12 @@ mod tests {
target_row,
));
assert_eq!(app.state.active, Some(0));
- assert!(app.state.workspace_press.is_some());
+ assert_eq!(app.state.workspace_presses.len(), 1);
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
assert_eq!(app.state.active, Some(1));
assert_eq!(app.state.selected, 1);
- assert!(app.state.workspace_press.is_none());
+ assert!(app.state.workspace_presses.is_empty());
let snapshot = capture_snapshot(&app.state);
assert_eq!(snapshot.active, Some(1));
assert_eq!(snapshot.selected, 1);
@@ -1272,7 +1272,7 @@ mod tests {
));
assert_eq!(app.state.active, None);
- assert!(app.state.workspace_press.is_none());
+ assert!(app.state.workspace_presses.is_empty());
assert!(app.state.collapsed_space_keys.contains("repo-key"));
app.handle_mouse(mouse(
@@ -1361,6 +1361,7 @@ mod tests {
Some(DragTarget::WorkspaceReorder {
source_ws_idx: 1,
drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(0)),
+ ..
})
));
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
@@ -1496,6 +1497,7 @@ mod tests {
ws_idx: 0,
source_tab_idx: 0,
insert_idx: Some(3),
+ ..
})
));
app.handle_mouse(mouse(
@@ -1721,6 +1723,7 @@ mod tests {
Some(DragTarget::WorkspaceReorder {
source_ws_idx: 0,
drop_target: Some(crate::app::state::WorkspaceDropTarget::End),
+ ..
})
));
app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index bc9b2a59..12d09e6a 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -358,6 +358,7 @@ impl App {
pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
// Pending URL clicks survive this call; see clear_input_source.
+ self.state.clear_chrome_gesture(source_id);
for pressed in self.take_pressed_keys_for_source(source_id) {
let release = pressed
.key
@@ -368,6 +369,7 @@ impl App {
pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
// Pending URL clicks survive this call; see clear_input_source.
+ self.state.clear_chrome_gesture(source_id);
for pressed in self.take_pressed_keys_for_source(source_id) {
let release = pressed
.key
diff --git a/src/app/mod.rs b/src/app/mod.rs
index d3d79954..ef33c693 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -602,8 +602,8 @@ impl App {
split_borders: Vec::new(),
},
drag: None,
- workspace_press: None,
- tab_press: None,
+ workspace_presses: HashMap::new(),
+ tab_presses: HashMap::new(),
selection: None,
selection_autoscroll: None,
context_menu: None,
diff --git a/src/app/state.rs b/src/app/state.rs
index cbecef00..cb687363 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1089,10 +1089,12 @@ pub(crate) enum WorkspaceDropTarget {
pub(crate) enum DragTarget {
WorkspaceReorder {
+ source_id: crate::app::InputSourceId,
source_ws_idx: usize,
drop_target: Option<WorkspaceDropTarget>,
},
TabReorder {
+ source_id: crate::app::InputSourceId,
ws_idx: usize,
source_tab_idx: usize,
insert_idx: Option<usize>,
@@ -1382,8 +1384,9 @@ pub struct AppState {
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
pub(crate) drag: Option<DragState>,
- pub(crate) workspace_press: Option<WorkspacePressState>,
- pub(crate) tab_press: Option<TabPressState>,
+ pub(crate) workspace_presses:
+ std::collections::HashMap<crate::app::InputSourceId, WorkspacePressState>,
+ pub(crate) tab_presses: std::collections::HashMap<crate::app::InputSourceId, TabPressState>,
pub selection: Option<Selection>,
pub selection_autoscroll: Option<SelectionAutoscroll>,
pub context_menu: Option<ContextMenuState>,
@@ -1758,8 +1761,8 @@ impl AppState {
split_borders: Vec::new(),
},
drag: None,
- workspace_press: None,
- tab_press: None,
+ workspace_presses: std::collections::HashMap::new(),
+ tab_presses: std::collections::HashMap::new(),
selection: None,
selection_autoscroll: None,
context_menu: None,
@@ -1955,11 +1958,11 @@ impl AppState {
"empty app state must not keep drag state"
);
assert!(
- self.workspace_press.is_none(),
+ self.workspace_presses.is_empty(),
"empty app state must not keep workspace press state"
);
assert!(
- self.tab_press.is_none(),
+ self.tab_presses.is_empty(),
"empty app state must not keep tab press state"
);
assert!(
@@ -2126,6 +2129,7 @@ impl AppState {
DragTarget::WorkspaceReorder {
source_ws_idx,
drop_target,
+ ..
} => {
assert_workspace_index(*source_ws_idx, "workspace drag source");
if let Some(WorkspaceDropTarget::Before(ws_idx)) = drop_target {
@@ -2136,6 +2140,7 @@ impl AppState {
ws_idx,
source_tab_idx,
insert_idx,
+ ..
} => {
assert_tab_index(*ws_idx, *source_tab_idx, "tab drag source");
if let Some(insert_idx) = insert_idx {
@@ -2154,10 +2159,10 @@ impl AppState {
_ => {}
}
}
- if let Some(press) = &self.workspace_press {
+ for press in self.workspace_presses.values() {
assert_workspace_index(press.ws_idx, "workspace press");
}
- if let Some(press) = &self.tab_press {
+ for press in self.tab_presses.values() {
assert_tab_index(press.ws_idx, press.tab_idx, "tab press");
}
if let Some(menu) = &self.context_menu {
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index c6533880..bc294cfe 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -2748,6 +2748,7 @@ rows = [[{ token = "git_status", fg = "#123456" }]]
assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y);
app.drag = Some(crate::app::state::DragState {
target: crate::app::state::DragTarget::WorkspaceReorder {
+ source_id: 0,
source_ws_idx: 0,
drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(2)),
},