aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-07-31 18:15:55 +0300
committerGitHub <noreply@github.com>2026-07-31 18:15:55 +0300
commit02fe7d7659e37d9f05eed80af382d1dcdccb6876 (patch)
tree0ca1ad4e912b36eb3e3294501cc73ac8a0d9466f /src
parentb1d14fe984c92ab4a81438ba07c7d94b464d0d3a (diff)
feat: support bottom tab bar placement (#2118)
* feat: support bottom tab bar placement * fix: preserve mouse cleanup under bottom mode bar refs #2117
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mouse.rs76
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/app/state.rs6
-rw-r--r--src/config.rs4
-rw-r--r--src/config/model.rs17
-rw-r--r--src/main.rs3
-rw-r--r--src/ui.rs85
7 files changed, 182 insertions, 11 deletions
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;
@@ -913,6 +917,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) =>
{
match mouse.kind {
@@ -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
@@ -3393,6 +3410,63 @@ mod tests {
}
#[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();
let mut ws = Workspace::test_new("one");
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,
diff --git a/src/config.rs b/src/config.rs
index f07f66ce..f3fc170a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -21,8 +21,8 @@ pub use self::{
model::{
validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport,
ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig,
- SidebarCollapsedModeConfig, ToastClipboardPosition, ToastConfig, ToastDelivery,
- ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
+ SidebarCollapsedModeConfig, TabBarPositionConfig, ToastClipboardPosition, ToastConfig,
+ ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS,
},
sidebar::{
AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle,
diff --git a/src/config/model.rs b/src/config/model.rs
index 72c81051..b1f0ebc3 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -772,6 +772,14 @@ pub struct WorktreesConfig {
pub directory: String,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum TabBarPositionConfig {
+ #[default]
+ Top,
+ Bottom,
+}
+
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct UiConfig {
@@ -812,6 +820,8 @@ pub struct UiConfig {
pub show_agent_labels_on_pane_borders: bool,
/// Hide the tab row when the workspace has one tab. Default: false.
pub hide_tab_bar_when_single_tab: bool,
+ /// Desktop tab row placement. Default: top.
+ pub tab_bar_position: TabBarPositionConfig,
/// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
pub agent_panel_sort: AgentPanelSortConfig,
/// Expanded sidebar row composition.
@@ -1014,6 +1024,7 @@ impl Default for UiConfig {
pane_gaps: true,
show_agent_labels_on_pane_borders: false,
hide_tab_bar_when_single_tab: false,
+ tab_bar_position: TabBarPositionConfig::Top,
agent_panel_sort: AgentPanelSortConfig::Spaces,
sidebar: SidebarConfig::default(),
accent: "cyan".into(),
@@ -1245,6 +1256,10 @@ agent_panel_scope = "current"
assert!(default_config.ui.pane_gaps);
assert!(!default_config.ui.show_agent_labels_on_pane_borders);
assert!(!default_config.ui.hide_tab_bar_when_single_tab);
+ assert_eq!(
+ default_config.ui.tab_bar_position,
+ TabBarPositionConfig::Top
+ );
let toml = r#"
[ui]
@@ -1252,12 +1267,14 @@ pane_borders = false
pane_gaps = true
show_agent_labels_on_pane_borders = true
hide_tab_bar_when_single_tab = true
+tab_bar_position = "bottom"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(!config.ui.pane_borders);
assert!(config.ui.pane_gaps);
assert!(config.ui.show_agent_labels_on_pane_borders);
assert!(config.ui.hide_tab_bar_when_single_tab);
+ assert_eq!(config.ui.tab_bar_position, TabBarPositionConfig::Bottom);
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 098693f1..8fc51d6b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -315,6 +315,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# New tabs can still be created with the configured keybinding.
# hide_tab_bar_when_single_tab = false
+# Desktop tab row placement: "top" or "bottom".
+# tab_bar_position = "top"
+
# Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue).
# "workspaces" is accepted as an alias for "spaces".
# agent_panel_sort = "spaces"
diff --git a/src/ui.rs b/src/ui.rs
index bf67d9fd..60143fe4 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -195,9 +195,18 @@ fn desktop_tab_bar_and_terminal_area(
) -> (Rect, Rect) {
let hide_single_tab_bar = app.hide_tab_bar_when_single_tab && ws.tabs.len() == 1;
if !hide_single_tab_bar && main_area.height > 1 {
- let [tab_bar_rect, terminal_area] =
- Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
- (tab_bar_rect, terminal_area)
+ match app.tab_bar_position {
+ crate::config::TabBarPositionConfig::Top => {
+ let [tab_bar_rect, terminal_area] =
+ Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
+ (tab_bar_rect, terminal_area)
+ }
+ crate::config::TabBarPositionConfig::Bottom => {
+ let [terminal_area, tab_bar_rect] =
+ Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(main_area);
+ (tab_bar_rect, terminal_area)
+ }
+ }
} else {
(Rect::default(), main_area)
}
@@ -410,6 +419,15 @@ pub fn render_with_runtime_registry(
render_notifications(app, frame, terminal_area);
render_popup_pane(app, terminal_runtimes, frame, terminal_area);
+ let mode_bar_area = if app.view.layout == ViewLayout::Desktop
+ && app.tab_bar_position == crate::config::TabBarPositionConfig::Bottom
+ && tab_bar_area.height > 0
+ {
+ tab_bar_area
+ } else {
+ terminal_area
+ };
+
match app.mode {
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
@@ -417,10 +435,10 @@ pub fn render_with_runtime_registry(
Mode::Navigate if app.view.layout == ViewLayout::Mobile => {
render_mobile_panel(app, terminal_runtimes, frame, frame.area())
}
- Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
- Mode::Prefix => render_prefix_overlay(app, frame, terminal_area),
- Mode::Copy => render_copy_mode_overlay(app, frame, terminal_area),
- Mode::Resize => render_resize_overlay(app, frame, terminal_area),
+ Mode::Navigate => render_navigate_overlay(app, frame, mode_bar_area),
+ Mode::Prefix => render_prefix_overlay(app, frame, mode_bar_area),
+ Mode::Copy => render_copy_mode_overlay(app, frame, mode_bar_area),
+ Mode::Resize => render_resize_overlay(app, frame, mode_bar_area),
Mode::ConfirmClose => {
render_confirm_close_overlay(app, terminal_runtimes, frame, terminal_area)
}
@@ -794,6 +812,35 @@ mod tests {
}
#[test]
+ fn desktop_tab_bar_position_controls_geometry_and_mode_bar_placement() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Prefix;
+
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+ assert_eq!(app.view.tab_bar_rect, Rect::new(26, 0, 54, 1));
+ assert_eq!(app.view.terminal_area, Rect::new(26, 1, 54, 19));
+
+ app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+ assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 19));
+ assert_eq!(app.view.tab_bar_rect, Rect::new(26, 19, 54, 1));
+ assert!(app.view.tab_hit_areas.iter().all(|rect| rect.y == 19));
+ assert_eq!(app.view.new_tab_hit_area.y, 19);
+
+ let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
+ terminal.draw(|frame| render(&app, frame)).unwrap();
+ let mode_row = buffer_row_text(
+ terminal.backend().buffer(),
+ app.view.tab_bar_rect,
+ app.view.tab_bar_rect.y,
+ );
+ assert!(mode_row.contains("PREFIX"), "{mode_row}");
+ }
+
+ #[test]
fn hide_tab_bar_when_single_tab_toggles_geometry_with_tab_count() {
let mut app = crate::app::state::AppState::test_new();
app.hide_tab_bar_when_single_tab = true;
@@ -827,6 +874,30 @@ mod tests {
assert_eq!(app.view.new_tab_hit_area, Rect::default());
}
+ #[test]
+ fn bottom_tab_bar_still_hides_when_single_tab() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.hide_tab_bar_when_single_tab = true;
+ app.tab_bar_position = crate::config::TabBarPositionConfig::Bottom;
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Prefix;
+
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+ assert_eq!(app.view.tab_bar_rect, Rect::default());
+ assert_eq!(app.view.terminal_area, Rect::new(26, 0, 54, 20));
+
+ let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
+ terminal.draw(|frame| render(&app, frame)).unwrap();
+ let mode_row = buffer_row_text(
+ terminal.backend().buffer(),
+ app.view.terminal_area,
+ app.view.terminal_area.y + app.view.terminal_area.height - 1,
+ );
+ assert!(mode_row.contains("PREFIX"), "{mode_row}");
+ }
+
#[tokio::test]
async fn hide_tab_bar_when_single_tab_resizes_background_tabs_per_workspace() {
let mut app = crate::app::state::AppState::test_new();