Files
herdr/src/app/input/mouse.rs
T

1914 lines
71 KiB
Rust

use bytes::Bytes;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Direction, Rect};
use tracing::warn;
use crate::{
app::state::{
AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
MenuListState, Mode, TabPressState, ViewLayout, WorkspacePressState,
},
layout::{PaneInfo, SplitBorder},
selection::Selection,
};
#[cfg(test)]
use super::WheelRouting;
use super::{
modal::{
apply_context_menu_action, apply_global_menu_action, apply_rename_action,
confirm_close_accept, confirm_close_cancel, global_menu_actions, leave_modal,
modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction,
},
settings::SettingsAction,
ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD,
};
impl AppState {
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
if self.mode == Mode::Onboarding {
self.handle_onboarding_mouse(mouse);
return None;
}
if self.clickable_toast_at(mouse.column, mouse.row)
&& matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
{
self.focus_toast_target();
return None;
}
if self.clickable_toast_at(mouse.column, mouse.row)
&& matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left))
{
return None;
}
if self.mode == Mode::Settings {
return self.handle_settings_mouse(mouse);
}
let launcher_enabled = self.view.layout != ViewLayout::Mobile
&& !self.sidebar_collapsed
&& matches!(
self.mode,
Mode::Terminal
| Mode::Navigate
| Mode::Resize
| Mode::GlobalMenu
| Mode::KeybindHelp
);
let launcher = self.global_launcher_rect();
let launcher_hit = launcher_enabled
&& mouse.column >= launcher.x
&& mouse.column < launcher.x + launcher.width
&& mouse.row >= launcher.y
&& mouse.row < launcher.y + launcher.height;
if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu {
let actions = global_menu_actions(self);
let hovered = self
.global_menu_item_at(mouse.column, mouse.row)
.and_then(|action| actions.iter().position(|item| *item == action));
self.global_menu.hover(hovered);
return None;
}
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit {
if self.mode == Mode::GlobalMenu {
leave_modal(self);
} else {
open_global_menu(self);
}
return None;
}
if self.mode == Mode::GlobalMenu {
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) {
apply_global_menu_action(self, action);
} else {
leave_modal(self);
}
}
return None;
}
if self.mode == Mode::KeybindHelp {
return None;
}
if self.view.layout == ViewLayout::Mobile && self.handle_mobile_mouse(mouse) {
return None;
}
let sidebar = self.view.sidebar_rect;
let in_sidebar = mouse.column >= sidebar.x
&& mouse.column < sidebar.x + sidebar.width
&& mouse.row >= sidebar.y
&& mouse.row < sidebar.y + sidebar.height;
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
self.selection = None;
self.workspace_press = None;
if self.mode == Mode::ConfirmClose {
let popup = self.confirm_close_rect();
let inner = Rect::new(
popup.x + 1,
popup.y + 1,
popup.width.saturating_sub(2),
popup.height.saturating_sub(2),
);
let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner);
match modal_action_from_buttons(
mouse.column,
mouse.row,
&[
(confirm, ModalAction::Confirm),
(cancel, ModalAction::Cancel),
],
) {
Some(ModalAction::Confirm) => confirm_close_accept(self),
Some(ModalAction::Cancel) | None => confirm_close_cancel(self),
_ => {}
}
return None;
}
if matches!(
self.mode,
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane
) {
let action = self
.rename_modal_inner()
.map(crate::ui::rename_button_rects)
.and_then(|(save, clear, cancel)| {
modal_action_from_buttons(
mouse.column,
mouse.row,
&[
(save, ModalAction::Save),
(clear, ModalAction::Clear),
(cancel, ModalAction::Cancel),
],
)
})
.unwrap_or(ModalAction::Cancel);
apply_rename_action(self, action);
return None;
}
if self.mode == Mode::ContextMenu {
let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
if let Some(menu) = self.context_menu.take() {
if let Some(idx) = item_idx {
apply_context_menu_action(self, menu, idx);
} else {
leave_modal(self);
}
}
return None;
}
if self.on_sidebar_divider(mouse.column, mouse.row) {
self.drag = Some(DragState {
target: DragTarget::SidebarDivider,
});
self.set_manual_sidebar_width(mouse.column);
return None;
}
if self.on_sidebar_section_divider(mouse.column, mouse.row) {
self.drag = Some(DragState {
target: DragTarget::SidebarSectionDivider,
});
self.set_sidebar_section_split(mouse.row);
return None;
}
if !in_sidebar {
if let Some(border) = self.find_border_at(mouse.column, mouse.row) {
self.drag = Some(DragState {
target: DragTarget::PaneSplit {
path: border.path.clone(),
direction: border.direction,
area: border.area,
},
});
return None;
}
if let Some((pane_id, target)) =
self.scrollbar_target_at(mouse.column, mouse.row)
{
self.focus_pane(pane_id);
match target {
ScrollbarClickTarget::Thumb { grab_row_offset } => {
self.drag = Some(DragState {
target: DragTarget::PaneScrollbar {
pane_id,
grab_row_offset,
},
});
}
ScrollbarClickTarget::Track { offset_from_bottom } => {
self.set_pane_scroll_offset(pane_id, offset_from_bottom);
}
}
if self.mode != Mode::Terminal {
self.mode = Mode::Terminal;
}
return None;
}
}
if self.on_tab_scroll_left_button(mouse.column, mouse.row) {
self.scroll_tabs_left();
return None;
}
if self.on_tab_scroll_right_button(mouse.column, mouse.row) {
self.scroll_tabs_right();
return None;
}
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,
});
return None;
}
if self.on_new_tab_button(mouse.column, mouse.row) {
open_new_tab_dialog(self);
return None;
}
if in_sidebar {
if self.sidebar_collapsed {
if self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) {
self.sidebar_collapsed = false;
return None;
}
if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) {
self.switch_workspace(idx);
self.mode = Mode::Terminal;
return None;
}
if let Some((ws_idx, tab_idx, pane_id)) =
self.collapsed_agent_detail_target_at(mouse.row)
{
self.switch_workspace(ws_idx);
self.switch_tab(tab_idx);
self.focus_pane(pane_id);
self.mode = Mode::Terminal;
}
return None;
}
let new_button = self.sidebar_new_button_rect();
let on_new_button = mouse.row >= new_button.y
&& mouse.row < new_button.y + new_button.height
&& mouse.column >= new_button.x
&& mouse.column < new_button.x + new_button.width;
if on_new_button {
self.request_new_workspace = true;
return None;
}
if let Some(target) =
self.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
{
match target {
ScrollbarClickTarget::Thumb { grab_row_offset } => {
self.drag = Some(DragState {
target: DragTarget::WorkspaceListScrollbar { grab_row_offset },
});
}
ScrollbarClickTarget::Track { offset_from_bottom } => {
self.set_workspace_list_offset_from_bottom(offset_from_bottom);
}
}
return None;
}
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,
});
return None;
}
if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) {
self.agent_panel_scope = match self.agent_panel_scope {
AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces,
AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
};
self.agent_panel_scroll = 0;
self.mark_session_dirty();
return None;
}
if let Some(target) =
self.agent_panel_scrollbar_target_at(mouse.column, mouse.row)
{
match target {
ScrollbarClickTarget::Thumb { grab_row_offset } => {
self.drag = Some(DragState {
target: DragTarget::AgentPanelScrollbar { grab_row_offset },
});
}
ScrollbarClickTarget::Track { offset_from_bottom } => {
self.set_agent_panel_offset_from_bottom(offset_from_bottom);
}
}
return None;
}
if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row)
{
self.switch_workspace(ws_idx);
self.switch_tab(tab_idx);
self.focus_pane(pane_id);
self.mode = Mode::Terminal;
return None;
}
} else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
self.focus_pane(info.id);
if self.mode != Mode::Terminal {
self.mode = Mode::Terminal;
}
if self.forward_pane_mouse_button(&info, mouse) {
self.selection = None;
return None;
}
let (row, col) = (
mouse.row - info.inner_rect.y,
mouse.column - info.inner_rect.x,
);
self.selection = Some(Selection::anchor(
info.id,
row,
col,
self.pane_scroll_metrics(info.id),
));
} else if let Some(info) = self.view.pane_infos.iter().find(|p| {
mouse.column >= p.rect.x
&& mouse.column < p.rect.x + p.rect.width
&& mouse.row >= p.rect.y
&& mouse.row < p.rect.y + p.rect.height
}) {
let id = info.id;
self.focus_pane(id);
if self.mode != Mode::Terminal {
self.mode = Mode::Terminal;
}
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if self.selection.is_some() {
self.update_selection_drag(mouse.column, mouse.row);
return None;
}
if self.drag.is_none() {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
if self.forward_pane_mouse_button(&info, mouse) {
self.selection = None;
return None;
}
}
}
let workspace_drop_index = self.workspace_drop_index_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 {
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) >= WORKSPACE_DRAG_THRESHOLD {
self.drag = Some(DragState {
target: DragTarget::WorkspaceReorder {
source_ws_idx: press.ws_idx,
insert_idx: workspace_drop_index,
},
});
}
} else if let Some(press) = &self.tab_press {
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 {
self.drag = Some(DragState {
target: DragTarget::TabReorder {
ws_idx: press.ws_idx,
source_tab_idx: press.tab_idx,
insert_idx: tab_drop_index,
},
});
}
}
}
if let Some(DragState {
target: DragTarget::WorkspaceReorder { insert_idx, .. },
}) = &mut self.drag
{
*insert_idx = workspace_drop_index;
} else if let Some(DragState {
target:
DragTarget::TabReorder {
ws_idx, insert_idx, ..
},
}) = &mut self.drag
{
if self.active == Some(*ws_idx) {
*insert_idx = tab_drop_index;
}
} else if let Some(drag) = &self.drag {
match &drag.target {
DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {}
DragTarget::WorkspaceListScrollbar { grab_row_offset } => {
if let Some(offset_from_bottom) =
self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset)
{
self.set_workspace_list_offset_from_bottom(offset_from_bottom);
}
}
DragTarget::AgentPanelScrollbar { grab_row_offset } => {
if let Some(offset_from_bottom) =
self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset)
{
self.set_agent_panel_offset_from_bottom(offset_from_bottom);
}
}
DragTarget::PaneSplit {
path,
direction,
area,
} => {
let ratio = match direction {
Direction::Horizontal => {
(mouse.column.saturating_sub(area.x)) as f32
/ area.width.max(1) as f32
}
Direction::Vertical => {
(mouse.row.saturating_sub(area.y)) as f32
/ area.height.max(1) as f32
}
};
let ratio = ratio.clamp(0.1, 0.9);
let path = path.clone();
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
ws.layout.set_ratio_at(&path, ratio);
self.mark_session_dirty();
}
}
DragTarget::PaneScrollbar {
pane_id,
grab_row_offset,
} => {
if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row(
*pane_id,
mouse.row,
*grab_row_offset,
) {
self.set_pane_scroll_offset(*pane_id, offset_from_bottom);
}
}
DragTarget::SidebarDivider => {
self.set_manual_sidebar_width(mouse.column);
}
DragTarget::SidebarSectionDivider => {
self.set_sidebar_section_split(mouse.row);
}
DragTarget::ReleaseNotesScrollbar { .. }
| DragTarget::KeybindHelpScrollbar { .. } => {}
}
}
}
MouseEventKind::Up(MouseButton::Left) => {
if self.selection.is_some() {
self.workspace_press = None;
self.tab_press = None;
self.drag = None;
let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
if was_click {
self.selection = None;
} else {
self.copy_selection();
}
return None;
}
if self.drag.is_none() {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
if self.forward_pane_mouse_button(&info, mouse) {
self.selection = 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();
match self.drag.take() {
Some(DragState {
target:
DragTarget::WorkspaceReorder {
source_ws_idx,
insert_idx: Some(insert_idx),
},
}) => {
self.move_workspace(source_ws_idx, insert_idx);
}
Some(DragState {
target:
DragTarget::TabReorder {
ws_idx,
source_tab_idx,
insert_idx: Some(insert_idx),
},
}) => {
if self.active == Some(ws_idx) {
self.move_tab(source_tab_idx, insert_idx);
self.mode = Mode::Terminal;
}
}
Some(_) => {}
None => {
if let Some(press) = workspace_press {
self.switch_workspace(press.ws_idx);
self.mode = Mode::Terminal;
return None;
}
if let Some(press) = tab_press {
if self.active == Some(press.ws_idx) {
self.switch_tab(press.tab_idx);
self.mode = Mode::Terminal;
return None;
}
}
let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
if was_click {
self.selection = None;
} else {
self.copy_selection();
}
}
}
}
MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle)
if !in_sidebar =>
{
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
let _ = self.forward_pane_mouse_button(&info, mouse);
}
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
if self.on_tab_bar(mouse.column, mouse.row) =>
{
match mouse.kind {
MouseEventKind::ScrollUp => self.previous_tab(),
MouseEventKind::ScrollDown => self.next_tab(),
_ => {}
}
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
if !in_sidebar && self.scroll_selection_with_wheel(mouse) => {}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
self.selection = None;
self.handle_terminal_wheel(mouse);
}
MouseEventKind::ScrollUp if in_sidebar => {
let agent_area = self.agent_panel_rect();
let over_agent_panel = agent_area != Rect::default()
&& mouse.row >= agent_area.y
&& mouse.row < agent_area.y + agent_area.height;
if over_agent_panel {
if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
self, agent_area,
)) {
self.scroll_agent_panel(-1);
}
} else if crate::ui::should_show_scrollbar(
crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
) {
self.scroll_workspace_list(-1);
} else if self.selected > 0 {
self.selected -= 1;
self.ensure_workspace_visible(self.selected);
}
}
MouseEventKind::ScrollDown if in_sidebar => {
let agent_area = self.agent_panel_rect();
let over_agent_panel = agent_area != Rect::default()
&& mouse.row >= agent_area.y
&& mouse.row < agent_area.y + agent_area.height;
if over_agent_panel {
if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
self, agent_area,
)) {
self.scroll_agent_panel(1);
}
} else if crate::ui::should_show_scrollbar(
crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
) {
self.scroll_workspace_list(1);
} else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
self.selected += 1;
self.ensure_workspace_visible(self.selected);
}
}
MouseEventKind::Moved if self.mode == Mode::ContextMenu => {
let hovered = self.context_menu_item_at(mouse.column, mouse.row);
if let Some(menu) = &mut self.context_menu {
menu.list.hover(hovered);
}
}
MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
if self
.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
.is_some()
{
return None;
}
if let Some(idx) = self.workspace_at_row(mouse.row) {
self.selected = idx;
self.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Workspace { ws_idx: idx },
x: mouse.column,
y: mouse.row,
list: MenuListState::new(0),
});
self.mode = Mode::ContextMenu;
}
}
MouseEventKind::Down(MouseButton::Right)
if self.tab_at(mouse.column, mouse.row).is_some() =>
{
if let (Some(ws_idx), Some(tab_idx)) =
(self.active, self.tab_at(mouse.column, mouse.row))
{
self.switch_tab(tab_idx);
self.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Tab { ws_idx, tab_idx },
x: mouse.column,
y: mouse.row,
list: MenuListState::new(0),
});
self.mode = Mode::ContextMenu;
}
}
MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
self.focus_pane(info.id);
let has_manual_label = self
.active
.and_then(|ws_idx| self.workspaces.get(ws_idx))
.and_then(|ws| ws.pane_state(info.id))
.and_then(|pane| pane.manual_label.as_ref())
.is_some();
self.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Pane {
pane_id: info.id,
has_manual_label,
},
x: mouse.column,
y: mouse.row,
list: MenuListState::new(0),
});
self.mode = Mode::ContextMenu;
}
}
_ => {}
}
None
}
fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> bool {
if self.mode == Mode::Navigate {
match mouse.kind {
MouseEventKind::ScrollUp => {
self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1);
return true;
}
MouseEventKind::ScrollDown => {
self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1);
return true;
}
MouseEventKind::Down(MouseButton::Left) => {}
_ => return true,
}
} else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return false;
}
if self.mode != Mode::Navigate {
if !matches!(self.mode, Mode::Terminal | Mode::Resize) {
return false;
}
if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) {
self.mobile_switcher_scroll = 0;
self.mode = Mode::Navigate;
return true;
}
return false;
}
let areas = crate::ui::mobile_switcher_areas(self);
if rect_contains(areas.close, mouse.column, mouse.row) {
self.mode = Mode::Terminal;
return true;
}
match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) {
Some(crate::ui::MobileSwitcherTarget::NewWorkspace) => {
self.request_new_workspace = true;
}
Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => {
self.switch_workspace(ws_idx);
self.mode = Mode::Terminal;
}
Some(crate::ui::MobileSwitcherTarget::NewTab) => {
open_new_tab_dialog(self);
}
Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => {
self.switch_tab(tab_idx);
self.mode = Mode::Terminal;
}
Some(crate::ui::MobileSwitcherTarget::Agent {
ws_idx,
tab_idx,
pane_id,
}) => {
self.switch_workspace(ws_idx);
self.switch_tab(tab_idx);
self.focus_pane(pane_id);
self.mode = Mode::Terminal;
}
Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => {
let actions = global_menu_actions(self);
if let Some(action) = actions.get(action_idx).copied() {
apply_global_menu_action(self, action);
}
}
None => {}
}
true
}
fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) {
let max_scroll = crate::ui::mobile_switcher_max_scroll(self);
apply_scroll(
&mut self.mobile_switcher_scroll,
delta.saturating_mul(2),
max_scroll,
);
}
pub(super) fn screen_rect(&self) -> Rect {
let sidebar = self.view.sidebar_rect;
let terminal = self.view.terminal_area;
let x = sidebar.x.min(terminal.x);
let y = sidebar.y.min(terminal.y);
let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width);
let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height);
Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
}
pub(crate) fn context_menu_rect(&self) -> Option<Rect> {
let menu = self.context_menu.as_ref()?;
let screen = self.screen_rect();
let max_item_w = menu
.items()
.iter()
.map(|item| item.len() as u16)
.max()
.unwrap_or(0);
let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1));
let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1));
let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w));
let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h));
Some(Rect::new(x, y, menu_w, menu_h))
}
pub(crate) fn confirm_close_rect(&self) -> Rect {
crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default()
}
fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
let menu_rect = self.context_menu_rect()?;
let inner_x = menu_rect.x + 1;
let inner_y = menu_rect.y + 1;
let inner_w = menu_rect.width.saturating_sub(2);
let inner_h = menu_rect.height.saturating_sub(2);
let item_count = self
.context_menu
.as_ref()
.map(|menu| menu.items().len() as u16)
.unwrap_or(0);
if col >= inner_x
&& col < inner_x + inner_w
&& row >= inner_y
&& row < inner_y + inner_h.min(item_count)
{
Some((row - inner_y) as usize)
} else {
None
}
}
pub(super) fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
self.view
.tab_hit_areas
.iter()
.enumerate()
.find_map(|(idx, area)| {
(area.width > 0
&& row >= area.y
&& row < area.y + area.height
&& col >= area.x
&& col < area.x + area.width)
.then_some(idx)
})
}
pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool {
let area = self.view.tab_bar_rect;
area.width > 0
&& row >= area.y
&& row < area.y + area.height
&& col >= area.x
&& col < area.x + area.width
}
pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool {
let area = self.view.tab_scroll_left_hit_area;
area.width > 0
&& row >= area.y
&& row < area.y + area.height
&& col >= area.x
&& col < area.x + area.width
}
pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool {
let area = self.view.tab_scroll_right_hit_area;
area.width > 0
&& row >= area.y
&& row < area.y + area.height
&& col >= area.x
&& col < area.x + area.width
}
pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> {
if !self.on_tab_bar(col, row) {
return None;
}
let visible_tabs: Vec<_> = self
.view
.tab_hit_areas
.iter()
.enumerate()
.filter(|(_, rect)| rect.width > 0)
.collect();
let (first_idx, first_rect) = *visible_tabs.first()?;
let (last_idx, last_rect) = *visible_tabs.last()?;
if self.on_tab_scroll_left_button(col, row) {
return Some(0);
}
if self.on_tab_scroll_right_button(col, row) {
return self
.active
.and_then(|idx| self.workspaces.get(idx))
.map(|ws| ws.tabs.len());
}
let left_edge = if first_idx == 0 {
first_rect.x
} else {
self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width
};
let right_edge = if self
.active
.and_then(|idx| self.workspaces.get(idx))
.is_some_and(|ws| last_idx + 1 >= ws.tabs.len())
{
last_rect.x + last_rect.width
} else {
self.view.tab_scroll_right_hit_area.x.saturating_sub(1)
};
if col <= left_edge {
return Some(first_idx);
}
if col >= right_edge {
return Some(last_idx + 1);
}
for (idx, rect) in visible_tabs {
let midpoint = rect.x + rect.width / 2;
if col < midpoint {
return Some(idx);
}
if col < rect.x + rect.width {
return Some(idx + 1);
}
}
Some(last_idx + 1)
}
pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
let area = self.view.new_tab_hit_area;
area.width > 0
&& row >= area.y
&& row < area.y + area.height
&& col >= area.x
&& col < area.x + area.width
}
pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> {
self.view.split_borders.iter().find(|b| match b.direction {
Direction::Horizontal => {
(col as i32 - b.pos as i32).unsigned_abs() <= 1
&& row >= b.area.y
&& row < b.area.y + b.area.height
}
Direction::Vertical => {
(row as i32 - b.pos as i32).unsigned_abs() <= 1
&& col >= b.area.x
&& col < b.area.x + b.area.width
}
})
}
pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
self.view.pane_infos.iter().find(|p| {
col >= p.inner_rect.x
&& col < p.inner_rect.x + p.inner_rect.width
&& row >= p.inner_rect.y
&& row < p.inner_rect.y + p.inner_rect.height
})
}
pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> {
self.pane_at(col, row)
.or_else(|| self.pane_frame_at(col, row))
}
pub(super) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> {
self.view.pane_infos.iter().find(|info| info.id == pane_id)
}
pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
self.view.pane_infos.iter().find(|p| {
col >= p.rect.x
&& col < p.rect.x + p.rect.width
&& row >= p.rect.y
&& row < p.rect.y + p.rect.height
})
}
pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
if ws.layout.focused() != pane_id {
ws.layout.focus_pane(pane_id);
self.mark_session_dirty();
}
}
}
fn clickable_toast_at(&self, col: u16, row: u16) -> bool {
self.toast
.as_ref()
.is_some_and(|toast| toast.target.is_some())
&& rect_contains(self.view.toast_hit_area, col, row)
}
pub(crate) fn focus_toast_target(&mut self) {
let Some(target) = self.toast.as_ref().and_then(|toast| toast.target.clone()) else {
return;
};
let Some(ws_idx) = self
.workspaces
.iter()
.position(|workspace| workspace.id == target.workspace_id)
else {
return;
};
let Some(tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else {
return;
};
self.switch_workspace(ws_idx);
self.switch_tab(tab_idx);
self.focus_pane(target.pane_id);
self.toast = None;
self.mode = Mode::Terminal;
}
pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
if let Some(rt) = ws.runtimes.get(&pane_id) {
rt.scroll_up(lines);
}
}
}
pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
if let Some(rt) = ws.runtimes.get(&pane_id) {
rt.scroll_down(lines);
}
}
}
pub(super) fn pane_scroll_metrics(
&self,
pane_id: crate::layout::PaneId,
) -> Option<crate::pane::ScrollMetrics> {
self.active
.and_then(|i| self.workspaces.get(i))
.and_then(|ws| ws.runtime(pane_id))
.and_then(crate::pane::PaneRuntime::scroll_metrics)
}
pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
const LINES_PER_NOTCH: usize = 3;
if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
self.focus_pane(info.id);
if self.forward_pane_wheel(&info, mouse) {
return;
}
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
_ => {}
}
return;
}
if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
self.focus_pane(info.id);
match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
_ => {}
}
return;
}
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
if let Some(rt) = ws.focused_runtime() {
match mouse.kind {
MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
_ => {}
}
}
}
}
pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
return false;
};
let Some(rt) = ws.runtimes.get(&info.id) else {
return false;
};
let column = mouse.column.saturating_sub(info.inner_rect.x);
let row = mouse.row.saturating_sub(info.inner_rect.y);
let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else {
return false;
};
rt.scroll_reset();
if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event");
}
true
}
pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
return false;
};
let Some(rt) = ws.runtimes.get(&info.id) else {
return false;
};
match rt.wheel_routing() {
Some(crate::pane::WheelRouting::HostScroll) | None => false,
Some(crate::pane::WheelRouting::MouseReport) => {
rt.scroll_reset();
let column = mouse.column.saturating_sub(info.inner_rect.x);
let row = mouse.row.saturating_sub(info.inner_rect.y);
let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers)
else {
warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
return true;
};
if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event");
}
true
}
Some(crate::pane::WheelRouting::AlternateScroll) => {
rt.scroll_reset();
let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else {
return true;
};
if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key");
}
true
}
}
}
pub(super) fn set_pane_scroll_offset(
&self,
pane_id: crate::layout::PaneId,
offset_from_bottom: usize,
) {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
if let Some(rt) = ws.runtimes.get(&pane_id) {
rt.set_scroll_offset_from_bottom(offset_from_bottom);
}
}
}
pub(super) fn scrollbar_target_at(
&self,
col: u16,
row: u16,
) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
let ws = self.active.and_then(|i| self.workspaces.get(i))?;
let info = self.view.pane_infos.iter().find(|info| {
crate::ui::pane_scrollbar_rect(info).is_some_and(|track| {
col >= track.x
&& col < track.x + track.width
&& row >= track.y
&& row < track.y + track.height
})
})?;
let rt = ws.runtimes.get(&info.id)?;
let metrics = rt.scroll_metrics()?;
if metrics.max_offset_from_bottom == 0 {
return None;
}
let track = crate::ui::pane_scrollbar_rect(info)?;
if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset }))
} else {
Some((
info.id,
ScrollbarClickTarget::Track {
offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
},
))
}
}
pub(super) fn scrollbar_offset_for_pane_row(
&self,
pane_id: crate::layout::PaneId,
row: u16,
grab_row_offset: u16,
) -> Option<usize> {
let ws = self.active.and_then(|i| self.workspaces.get(i))?;
let info = self
.view
.pane_infos
.iter()
.find(|info| info.id == pane_id)?;
let track = crate::ui::pane_scrollbar_rect(info)?;
let rt = ws.runtimes.get(&pane_id)?;
let metrics = rt.scroll_metrics()?;
if metrics.max_offset_from_bottom == 0 {
return None;
}
Some(crate::ui::scrollbar_offset_from_drag_row(
metrics,
track,
row,
grab_row_offset,
))
}
}
#[cfg(test)]
pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting {
if input_state.mouse_protocol_mode.reporting_enabled() {
WheelRouting::MouseReport
} else if input_state.alternate_screen && input_state.mouse_alternate_scroll {
WheelRouting::AlternateScroll
} else {
WheelRouting::HostScroll
}
}
fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
rect.width > 0
&& rect.height > 0
&& col >= rect.x
&& col < rect.x + rect.width
&& row >= rect.y
&& row < rect.y + rect.height
}
fn apply_scroll(scroll: &mut usize, delta: i16, max_scroll: usize) {
if delta.is_negative() {
*scroll = scroll.saturating_sub(delta.unsigned_abs() as usize);
} else {
*scroll = scroll.saturating_add(delta as usize).min(max_scroll);
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::{Direction, Rect};
use super::super::{
app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, root_layout_ratio,
};
use super::*;
use crate::{
app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout},
detect::{Agent, AgentState},
workspace::Workspace,
};
#[test]
fn hovering_context_menu_updates_highlight() {
let mut app = app_for_mouse_test();
app.state.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Workspace { ws_idx: 0 },
x: 2,
y: 2,
list: MenuListState::new(0),
});
app.state.mode = Mode::ContextMenu;
let menu = app.state.context_menu_rect().unwrap();
app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1);
}
#[test]
fn clicking_agent_toast_focuses_target_pane() {
let mut app = app_for_mouse_test();
let active = Workspace::test_new("active");
let mut background = Workspace::test_new("background");
let first_pane = background.tabs[0].root_pane;
let target_pane = background.test_split(Direction::Horizontal);
background.tabs[0].layout.focus_pane(first_pane);
app.state.workspaces = vec![active, background];
app.state.active = Some(0);
app.state.selected = 0;
app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
app.state.workspaces[1]
.panes
.get_mut(&target_pane)
.unwrap()
.state = AgentState::Working;
app.state
.handle_app_event(crate::events::AppEvent::StateChanged {
pane_id: target_pane,
agent: Some(Agent::Pi),
state: AgentState::Idle,
});
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let hit = app.state.view.toast_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
hit.x + 1,
hit.y + 1,
));
assert_eq!(app.state.active, Some(1));
assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane));
assert!(app.state.toast.is_none());
assert_eq!(app.state.mode, Mode::Terminal);
}
#[test]
fn clicking_confirm_close_accepts_workspace_close() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
app.state.active = Some(0);
app.state.selected = 1;
app.state.mode = Mode::ConfirmClose;
let popup = app.state.confirm_close_rect();
let inner = Rect::new(
popup.x + 1,
popup.y + 1,
popup.width.saturating_sub(2),
popup.height.saturating_sub(2),
);
let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
confirm.x,
confirm.y,
));
assert_eq!(app.state.workspaces.len(), 1);
assert_eq!(app.state.mode, Mode::Terminal);
}
#[test]
fn clicking_confirm_close_accepts_after_workspace_context_menu_close() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Workspace { ws_idx: 1 },
x: 2,
y: 2,
list: MenuListState::new(1),
});
app.state.mode = Mode::ContextMenu;
handle_context_menu_key(
&mut app.state,
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
);
assert_eq!(app.state.mode, Mode::ConfirmClose);
assert_eq!(app.state.selected, 1);
let popup = app.state.confirm_close_rect();
let inner = Rect::new(
popup.x + 1,
popup.y + 1,
popup.width.saturating_sub(2),
popup.height.saturating_sub(2),
);
let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
confirm.x + 1,
confirm.y,
));
assert_eq!(app.state.workspaces.len(), 1);
assert_eq!(app.state.workspaces[0].display_name(), "a");
}
#[test]
fn dragging_pane_split_updates_captured_layout_ratio() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("test")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.workspaces[0].test_split(Direction::Horizontal);
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let border = app.state.view.split_borders[0].clone();
let before = capture_snapshot(&app.state);
let drag_row = border.area.y.saturating_add(1);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
border.pos,
drag_row,
));
app.handle_mouse(mouse(
MouseEventKind::Drag(MouseButton::Left),
border.pos.saturating_add(6),
drag_row,
));
let after = capture_snapshot(&app.state);
assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after));
}
#[tokio::test]
async fn dragging_vertical_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let first_pane = ws.tabs[0].root_pane;
let second_pane = ws.test_split(Direction::Vertical);
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 pane_infos = app.state.view.pane_infos.clone();
let first_info = pane_infos
.iter()
.find(|info| info.id == first_pane)
.expect("first pane info")
.clone();
let second_info = pane_infos
.iter()
.find(|info| info.id == second_pane)
.expect("second pane info")
.clone();
app.state.workspaces[0].tabs[0].runtimes.insert(
first_pane,
crate::pane::PaneRuntime::test_with_screen_bytes(
first_info.inner_rect.width.max(1),
first_info.inner_rect.height.max(1),
b"\x1b[?1002h",
),
);
app.state.workspaces[0].tabs[0].runtimes.insert(
second_pane,
crate::pane::PaneRuntime::test_with_screen_bytes(
second_info.inner_rect.width.max(1),
second_info.inner_rect.height.max(1),
b"\x1b[?1002h",
),
);
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let border = app
.state
.view
.split_borders
.iter()
.find(|border| border.direction == Direction::Vertical)
.expect("vertical split border")
.clone();
let before = capture_snapshot(&app.state);
let drag_col = border.area.x.saturating_add(1);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
drag_col,
border.pos,
));
app.handle_mouse(mouse(
MouseEventKind::Drag(MouseButton::Left),
drag_col,
border.pos.saturating_add(4),
));
let after = capture_snapshot(&app.state);
assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after));
}
#[tokio::test]
async fn dragging_horizontal_pane_split_still_resizes_when_pane_mouse_reporting_is_enabled() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let first_pane = ws.tabs[0].root_pane;
let second_pane = ws.test_split(Direction::Horizontal);
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 pane_infos = app.state.view.pane_infos.clone();
let first_info = pane_infos
.iter()
.find(|info| info.id == first_pane)
.expect("first pane info")
.clone();
let second_info = pane_infos
.iter()
.find(|info| info.id == second_pane)
.expect("second pane info")
.clone();
app.state.workspaces[0].tabs[0].runtimes.insert(
first_pane,
crate::pane::PaneRuntime::test_with_screen_bytes(
first_info.inner_rect.width.max(1),
first_info.inner_rect.height.max(1),
b"\x1b[?1002h",
),
);
app.state.workspaces[0].tabs[0].runtimes.insert(
second_pane,
crate::pane::PaneRuntime::test_with_screen_bytes(
second_info.inner_rect.width.max(1),
second_info.inner_rect.height.max(1),
b"\x1b[?1002h",
),
);
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let border = app
.state
.view
.split_borders
.iter()
.find(|border| border.direction == Direction::Horizontal)
.expect("horizontal split border")
.clone();
let before = capture_snapshot(&app.state);
let drag_row = border.area.y.saturating_add(1);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
border.pos,
drag_row,
));
app.handle_mouse(mouse(
MouseEventKind::Drag(MouseButton::Left),
border.pos.saturating_add(6),
drag_row,
));
let after = capture_snapshot(&app.state);
assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after));
}
#[test]
fn wheel_routing_prefers_mouse_reporting() {
let input_state = crate::pane::InputState {
alternate_screen: true,
application_cursor: false,
bracketed_paste: false,
focus_reporting: false,
mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion,
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
mouse_alternate_scroll: true,
};
assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport);
}
#[test]
fn wheel_over_tab_bar_switches_tabs() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("one");
ws.test_add_tab(Some("two"));
ws.test_add_tab(Some("three"));
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let tab_bar = app.state.view.tab_bar_rect;
app.handle_mouse(mouse(MouseEventKind::ScrollDown, tab_bar.x + 1, tab_bar.y));
assert_eq!(app.state.workspaces[0].active_tab, 1);
app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y));
assert_eq!(app.state.workspaces[0].active_tab, 0);
app.handle_mouse(mouse(MouseEventKind::ScrollUp, tab_bar.x + 1, tab_bar.y));
assert_eq!(app.state.workspaces[0].active_tab, 2);
app.handle_mouse(mouse(
MouseEventKind::ScrollDown,
tab_bar.x + tab_bar.width.saturating_sub(1),
tab_bar.y,
));
assert_eq!(app.state.workspaces[0].active_tab, 0);
}
#[test]
fn wheel_over_overflowing_tab_bar_switches_tabs() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("one");
ws.tabs[0].set_custom_name("very-long-one".into());
ws.test_add_tab(Some("very-long-two"));
ws.test_add_tab(Some("very-long-three"));
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 65, 20));
assert!(app.state.view.tab_scroll_right_hit_area.width > 0);
let tab_bar = app.state.view.tab_bar_rect;
app.handle_mouse(mouse(
MouseEventKind::ScrollDown,
tab_bar.x + tab_bar.width.saturating_sub(2),
tab_bar.y,
));
assert_eq!(app.state.workspaces[0].active_tab, 1);
app.handle_mouse(mouse(
MouseEventKind::ScrollDown,
tab_bar.x + tab_bar.width.saturating_sub(2),
tab_bar.y,
));
assert_eq!(app.state.workspaces[0].active_tab, 2);
}
#[test]
fn wheel_outside_tab_bar_does_not_switch_tabs() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("one");
ws.test_add_tab(Some("two"));
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
let terminal = app.state.view.terminal_area;
app.handle_mouse(mouse(
MouseEventKind::ScrollDown,
terminal.x + 1,
terminal.y + 1,
));
assert_eq!(app.state.workspaces[0].active_tab, 0);
}
#[test]
fn mobile_switch_button_opens_switcher_and_workspace_row_switches_workspace() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
assert_eq!(app.state.view.layout, ViewLayout::Mobile);
let switch = app.state.view.mobile_menu_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
switch.x + 1,
switch.y + 1,
));
assert_eq!(app.state.mode, Mode::Navigate);
let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
viewport.x + 2,
viewport.y + 4,
));
assert_eq!(app.state.active, Some(1));
assert_eq!(app.state.mode, Mode::Terminal);
}
#[test]
fn mobile_workspace_panel_scroll_reaches_extra_workspaces() {
let mut app = app_for_mouse_test();
app.state.workspaces = (0..12)
.map(|idx| Workspace::test_new(&format!("ws-{idx}")))
.collect();
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
let switch = app.state.view.mobile_menu_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
switch.x + 1,
switch.y + 1,
));
assert_eq!(app.state.mode, Mode::Navigate);
let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
app.handle_mouse(mouse(
MouseEventKind::ScrollDown,
viewport.x + 2,
viewport.y,
));
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
assert_eq!(app.state.mobile_switcher_scroll, 2);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
viewport.x + 2,
viewport.y + 2,
));
assert_eq!(app.state.active, Some(1));
assert_eq!(app.state.mode, Mode::Terminal);
}
#[test]
fn mobile_global_scroll_reaches_tabs_and_switches_tab() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("one");
ws.test_add_tab(Some("two"));
ws.test_add_tab(Some("three"));
ws.test_add_tab(Some("four"));
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 12));
let switch = app.state.view.mobile_menu_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
switch.x + 1,
switch.y + 1,
));
let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
app.handle_mouse(mouse(
MouseEventKind::ScrollDown,
viewport.x + 2,
viewport.y,
));
app.handle_mouse(mouse(
MouseEventKind::ScrollDown,
viewport.x + 2,
viewport.y,
));
assert_eq!(app.state.mobile_switcher_scroll, 4);
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
viewport.x + 2,
viewport.y + 4,
));
assert_eq!(app.state.workspaces[0].active_tab, 2);
}
#[test]
fn mobile_switcher_action_rows_create_workspace_and_open_tab_dialog() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("one");
ws.test_add_tab(Some("logs"));
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
let switch = app.state.view.mobile_menu_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
switch.x + 1,
switch.y + 1,
));
let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
viewport.x + 2,
viewport.y + 1,
));
assert!(app.state.request_new_workspace);
app.state.request_new_workspace = false;
app.state.mode = Mode::Navigate;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
viewport.x + 2,
viewport.y + 5,
));
assert_eq!(app.state.mode, Mode::RenameTab);
assert!(app.state.creating_new_tab);
}
#[test]
fn mobile_switcher_swallows_non_left_mouse_events() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("one")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
let switch = app.state.view.mobile_menu_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
switch.x + 1,
switch.y + 1,
));
assert_eq!(app.state.mode, Mode::Navigate);
let viewport = crate::ui::mobile_switcher_areas(&app.state).viewport;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Right),
viewport.x + 2,
viewport.y + 2,
));
assert_eq!(app.state.mode, Mode::Navigate);
assert!(app.state.context_menu.is_none());
}
#[test]
fn mobile_switch_button_does_not_bypass_rename_modal() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("one")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::RenameTab;
app.state.creating_new_tab = true;
app.state.name_input = "new tab".into();
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
let switch = app.state.view.mobile_menu_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
switch.x + 1,
switch.y + 1,
));
assert_eq!(app.state.mode, Mode::Terminal);
assert!(!app.state.creating_new_tab);
assert!(!app.state.request_new_tab);
}
#[test]
fn mobile_switcher_close_returns_to_terminal() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("one")];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 44, 20));
let switch = app.state.view.mobile_menu_hit_area;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
switch.x + 1,
switch.y + 1,
));
assert_eq!(app.state.mode, Mode::Navigate);
let close = crate::ui::mobile_switcher_areas(&app.state).close;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
close.x + 1,
close.y,
));
assert_eq!(app.state.mode, Mode::Terminal);
}
#[test]
fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() {
let input_state = crate::pane::InputState {
alternate_screen: true,
application_cursor: false,
bracketed_paste: false,
focus_reporting: false,
mouse_protocol_mode: crate::input::MouseProtocolMode::None,
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
mouse_alternate_scroll: true,
};
assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll);
}
#[test]
fn wheel_routing_falls_back_to_host_scrollback() {
let input_state = crate::pane::InputState {
alternate_screen: false,
application_cursor: false,
bracketed_paste: false,
focus_reporting: false,
mouse_protocol_mode: crate::input::MouseProtocolMode::None,
mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
mouse_alternate_scroll: true,
};
assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll);
}
}