From 02fe7d7659e37d9f05eed80af382d1dcdccb6876 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Fri, 31 Jul 2026 18:15:55 +0300 Subject: feat: support bottom tab bar placement (#2118) * feat: support bottom tab bar placement * fix: preserve mouse cleanup under bottom mode bar refs #2117 --- src/app/input/mouse.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++- src/app/mod.rs | 2 ++ src/app/state.rs | 6 +++- 3 files changed, 82 insertions(+), 2 deletions(-) (limited to 'src/app') diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 56b9b676..2c32c95a 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -483,6 +483,10 @@ impl AppState { } } + if self.mode_bar_covers_tab_row(mouse.column, mouse.row) { + return None; + } + if self.on_tab_scroll_left_button(mouse.column, mouse.row) { self.scroll_tabs_left(); return None; @@ -912,6 +916,9 @@ impl AppState { } } + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + if self.mode_bar_covers_tab_row(mouse.column, mouse.row) => {} + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if self.on_tab_bar(mouse.column, mouse.row) => { @@ -1063,7 +1070,8 @@ impl AppState { } MouseEventKind::Down(MouseButton::Right) - if self.tab_at(mouse.column, mouse.row).is_some() => + if !self.mode_bar_covers_tab_row(mouse.column, mouse.row) + && 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)) @@ -1272,6 +1280,15 @@ impl AppState { }) } + fn mode_bar_covers_tab_row(&self, col: u16, row: u16) -> bool { + self.tab_bar_position == crate::config::TabBarPositionConfig::Bottom + && matches!( + self.mode, + Mode::Navigate | Mode::Prefix | Mode::Copy | Mode::Resize + ) + && self.on_tab_bar(col, row) + } + pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool { let area = self.view.tab_bar_rect; area.width > 0 @@ -3392,6 +3409,63 @@ mod tests { assert_eq!(app.state.workspaces[0].active_tab, 0); } + #[test] + fn bottom_mode_bar_consumes_hidden_tab_mouse_actions() { + 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::Prefix; + app.state.tab_bar_position = crate::config::TabBarPositionConfig::Bottom; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let second_tab = app.state.view.tab_hit_areas[1]; + let new_tab = app.state.view.new_tab_hit_area; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + second_tab.x, + second_tab.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Up(MouseButton::Left), + second_tab.x, + second_tab.y, + )); + app.handle_mouse(mouse( + MouseEventKind::ScrollDown, + second_tab.x, + second_tab.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + second_tab.x, + second_tab.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + new_tab.x, + new_tab.y, + )); + + app.state.drag = Some(DragState { + target: DragTarget::SidebarDivider, + }); + app.handle_mouse(mouse( + MouseEventKind::Up(MouseButton::Left), + second_tab.x, + second_tab.y, + )); + + 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.drag.is_none()); + } + #[test] fn right_click_inactive_tab_opens_menu_without_switching_tabs() { let mut app = app_for_mouse_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 2530a05b..f4bc5008 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -642,6 +642,7 @@ impl App { pane_gaps: config.ui.pane_gaps, show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, + tab_bar_position: config.ui.tab_bar_position, pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), @@ -1450,6 +1451,7 @@ impl App { self.state.show_agent_labels_on_pane_borders = config.ui.show_agent_labels_on_pane_borders; self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; + self.state.tab_bar_position = config.ui.tab_bar_position; self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); self.state.sidebar_agents = config.ui.sidebar.agents.clone(); diff --git a/src/app/state.rs b/src/app/state.rs index a1c5afc1..975144b4 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,4 +1,6 @@ -use crate::config::{Keybinds, NewTerminalCwdConfig, SoundConfig, ToastConfig, ToastDelivery}; +use crate::config::{ + Keybinds, NewTerminalCwdConfig, SoundConfig, TabBarPositionConfig, ToastConfig, ToastDelivery, +}; use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use ratatui::style::Color; @@ -1525,6 +1527,7 @@ pub struct AppState { pub pane_gaps: bool, pub show_agent_labels_on_pane_borders: bool, pub hide_tab_bar_when_single_tab: bool, + pub tab_bar_position: TabBarPositionConfig, pub pane_history_persistence: bool, /// Expose the focused pane's cursor anchor to the outer terminal even when /// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`. @@ -1899,6 +1902,7 @@ impl AppState { pane_gaps: false, show_agent_labels_on_pane_borders: false, hide_tab_bar_when_single_tab: false, + tab_bar_position: TabBarPositionConfig::Top, pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, cjk_ime_agent_filter_configured: false, -- cgit v1.2.3