mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
feat: support bottom tab bar placement (#2118)
* feat: support bottom tab bar placement * fix: preserve mouse cleanup under bottom mode bar refs #2117
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
+75
-1
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user