diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 7a9bac83..5bdc5cc2 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added `ui.tab_bar_position = "bottom"` to place the desktop tab row below terminal panes. + ### Changed - Agent status indicators now use the same static workspace marks across the sidebar, navigator, and mobile views, eliminating continuous spinner rendering while agents work. - Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0. diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index bd908765..92f5efa0 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -256,6 +256,8 @@ Color values accept hex, named colors, `rgb(r,g,b)`, or reset aliases like `rese The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference](/docs/config-reference/) for sizing, collapsed mode, Agent panel ordering, mouse behavior, pane borders, and other presentation settings. +Set `tab_bar_position = "bottom"` under `[ui]` to place the desktop tab row below the terminal panes. Prefix, Navigate, Copy, and Resize mode bars temporarily replace the bottom tab row while active. The default is `"top"`. + ### Sidebar row layouts The expanded desktop sidebar renders each inner array in `rows` as one line. These are the complete default layouts: diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 14549a22..82d24946 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -670,6 +670,16 @@ "default": "false", "description": "Hide the tab row when the workspace has one tab." }, + { + "key": "ui.tab_bar_position", + "type": "enum", + "default": "\"top\"", + "description": "Place the desktop tab row above or below the terminal panes.", + "values": [ + "top", + "bottom" + ] + }, { "key": "ui.agent_panel_sort", "type": "enum", 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, 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) } @@ -793,6 +811,35 @@ mod tests { assert_eq!(app.view.terminal_area, Rect::new(0, 2, 80, 18)); } + #[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(); @@ -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();