diff options
| author | Michael Hackner <mhackner@gmail.com> | 2026-08-13 08:00:32 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-13 18:00:32 +0300 |
| commit | f76d7df549e95e1699651f59916e9a38f0f58985 (patch) | |
| tree | c8e50a75fd32e68d63b8e0386de7e791cb727fb4 /src | |
| parent | b7dca4bb2a2cf488407ae051b40782f03fcabc62 (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.rs | 5 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 603 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 9 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 2 | ||||
| -rw-r--r-- | src/app/mod.rs | 4 | ||||
| -rw-r--r-- | src/app/state.rs | 21 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 1 |
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)), }, |
