aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-10 14:15:23 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-10 14:16:25 +0300
commit831bb66c0cda103f79b2418389cd6528c03ade56 (patch)
tree0ae176496c492a035f3eb6fb9f8eab13bd397ca1 /src
parente20be7bdae5c46ff922ee442b4fbd5d79c46cf99 (diff)
feat(tabs): add overflow scrolling and drag reorder
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs56
-rw-r--r--src/app/input.rs328
-rw-r--r--src/app/mod.rs5
-rw-r--r--src/app/state.rs22
-rw-r--r--src/ui.rs306
-rw-r--r--src/workspace.rs46
6 files changed, 733 insertions, 30 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 97537ef5..da5c8698 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -126,6 +126,8 @@ impl AppState {
if let Some(ws) = self.workspaces.get_mut(idx) {
ws.switch_tab(ws.active_tab);
}
+ self.tab_scroll_follow_active = true;
+ self.refresh_tab_bar_view();
}
}
@@ -163,6 +165,8 @@ impl AppState {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
ws.switch_tab(idx);
self.mark_session_dirty();
+ self.tab_scroll_follow_active = true;
+ self.refresh_tab_bar_view();
}
}
@@ -215,6 +219,28 @@ impl AppState {
self.ensure_workspace_visible(self.selected);
}
+ pub fn scroll_tabs_left(&mut self) {
+ self.tab_scroll_follow_active = false;
+ self.tab_scroll = self.tab_scroll.saturating_sub(1);
+ self.refresh_tab_bar_view();
+ }
+
+ pub fn scroll_tabs_right(&mut self) {
+ self.tab_scroll_follow_active = false;
+ self.tab_scroll = self.tab_scroll.saturating_add(1);
+ self.refresh_tab_bar_view();
+ }
+
+ pub fn move_tab(&mut self, source_idx: usize, insert_idx: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if ws.move_tab(source_idx, insert_idx) {
+ self.mark_session_dirty();
+ self.tab_scroll_follow_active = true;
+ self.refresh_tab_bar_view();
+ }
+ }
+ }
+
pub fn next_tab(&mut self) {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
if !ws.tabs.is_empty() {
@@ -249,6 +275,8 @@ impl AppState {
self.active = None;
self.selected = 0;
self.workspace_scroll = 0;
+ self.tab_scroll = 0;
+ self.tab_scroll_follow_active = true;
} else {
if self.selected >= self.workspaces.len() {
self.selected = self.workspaces.len() - 1;
@@ -258,8 +286,34 @@ impl AppState {
.workspace_scroll
.min(self.workspaces.len().saturating_sub(1));
self.ensure_workspace_visible(self.selected);
+ self.tab_scroll_follow_active = true;
+ self.refresh_tab_bar_view();
}
}
+
+ fn refresh_tab_bar_view(&mut self) {
+ let area = self.view.tab_bar_rect;
+ let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else {
+ self.tab_scroll = 0;
+ self.view.tab_hit_areas.clear();
+ self.view.tab_scroll_left_hit_area = ratatui::layout::Rect::default();
+ self.view.tab_scroll_right_hit_area = ratatui::layout::Rect::default();
+ self.view.new_tab_hit_area = ratatui::layout::Rect::default();
+ return;
+ };
+
+ let layout = crate::ui::compute_tab_bar_view(
+ ws,
+ area,
+ self.tab_scroll,
+ self.tab_scroll_follow_active,
+ );
+ self.tab_scroll = layout.scroll;
+ self.view.tab_hit_areas = layout.tab_hit_areas;
+ self.view.tab_scroll_left_hit_area = layout.scroll_left_hit_area;
+ self.view.tab_scroll_right_hit_area = layout.scroll_right_hit_area;
+ self.view.new_tab_hit_area = layout.new_tab_hit_area;
+ }
}
// ---------------------------------------------------------------------------
@@ -352,6 +406,8 @@ impl AppState {
}
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
ws.close_active_tab();
+ self.tab_scroll_follow_active = true;
+ self.refresh_tab_bar_view();
}
}
}
diff --git a/src/app/input.rs b/src/app/input.rs
index b2412046..af7a5e29 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -25,10 +25,11 @@ enum WheelRouting {
}
const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
+const TAB_DRAG_THRESHOLD: u16 = 1;
use super::state::{
key_matches, AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState,
- DragTarget, MenuListState, Mode, WorkspacePressState,
+ DragTarget, MenuListState, Mode, TabPressState, WorkspacePressState,
};
use super::App;
@@ -2051,9 +2052,23 @@ impl AppState {
}
}
- if let Some(tab_idx) = self.tab_at(mouse.column, mouse.row) {
- self.switch_tab(tab_idx);
- self.mode = Mode::Terminal;
+ 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) {
@@ -2203,6 +2218,7 @@ impl AppState {
}
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);
@@ -2215,6 +2231,18 @@ impl AppState {
},
});
}
+ } 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,
+ },
+ });
+ }
}
}
@@ -2223,9 +2251,19 @@ impl AppState {
}) = &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::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)
@@ -2289,6 +2327,7 @@ impl AppState {
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 {
@@ -2303,12 +2342,14 @@ impl AppState {
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:
@@ -2319,6 +2360,19 @@ impl AppState {
}) => {
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 {
@@ -2326,6 +2380,13 @@ impl AppState {
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;
@@ -2345,7 +2406,13 @@ impl AppState {
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
- if !self.scroll_selection_with_wheel(mouse) {
+ if self.on_tab_bar(mouse.column, mouse.row) {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_tabs_left(),
+ MouseEventKind::ScrollDown => self.scroll_tabs_right(),
+ _ => {}
+ }
+ } else if !self.scroll_selection_with_wheel(mouse) {
self.selection = None;
self.handle_terminal_wheel(mouse);
}
@@ -2519,7 +2586,8 @@ impl AppState {
.iter()
.enumerate()
.find_map(|(idx, area)| {
- (row >= area.y
+ (area.width > 0
+ && row >= area.y
&& row < area.y + area.height
&& col >= area.x
&& col < area.x + area.width)
@@ -2527,6 +2595,93 @@ impl AppState {
})
}
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ 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)
+ }
+
fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
let area = self.view.new_tab_hit_area;
area.width > 0
@@ -4408,6 +4563,165 @@ mod tests {
}
#[test]
+ fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(Some("logs"));
+ ws.test_add_tab(Some("review"));
+ ws.test_add_tab(Some("ops"));
+ ws.test_add_tab(Some("notes"));
+ 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, 52, 20));
+
+ let right = app.state.view.tab_scroll_right_hit_area;
+ assert!(right.width > 0);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ right.x + 1,
+ right.y,
+ ));
+
+ assert_eq!(app.state.tab_scroll, 1);
+ assert!(!app.state.tab_scroll_follow_active);
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ assert_eq!(app.state.view.tab_hit_areas[0].width, 0);
+ assert!(app.state.workspaces[0].tabs[0].custom_name.is_none());
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].custom_name.as_deref(),
+ Some("logs")
+ );
+ }
+
+ #[test]
+ fn clicking_rightmost_visible_tab_recenters_it_when_tabs_overflow() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ for name in [
+ "one", "two", "three", "four", "five", "six", "seven", "eight",
+ ] {
+ ws.test_add_tab(Some(name));
+ }
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.tab_scroll = 2;
+ app.state.tab_scroll_follow_active = false;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
+
+ let clicked_idx = app
+ .state
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .rev()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx)
+ .expect("rightmost visible tab");
+ let target = app.state.view.tab_hit_areas[clicked_idx];
+ let first_visible_before = app
+ .state
+ .view
+ .tab_hit_areas
+ .iter()
+ .position(|rect| rect.width > 0)
+ .expect("first visible tab before click");
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ target.x + 1,
+ target.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ target.x + 1,
+ target.y,
+ ));
+
+ let last_visible_after = app
+ .state
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .rev()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx)
+ .expect("rightmost visible tab after click");
+
+ assert_eq!(app.state.workspaces[0].active_tab, clicked_idx);
+ assert!(app.state.tab_scroll > 2);
+ assert!(app.state.view.tab_hit_areas[clicked_idx].width > 0);
+ assert!(last_visible_after > clicked_idx);
+ assert!(app
+ .state
+ .view
+ .tab_hit_areas
+ .iter()
+ .position(|rect| rect.width > 0)
+ .is_some_and(|idx| idx > first_visible_before));
+ }
+
+ #[test]
+ fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(Some("foo"));
+ ws.test_add_tab(None);
+ let moved_root = ws.tabs[0].root_pane;
+ 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 source = app.state.view.tab_hit_areas[0];
+ let last = app.state.view.tab_hit_areas[2];
+ let drop_col = last.x + last.width;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ source.x + 1,
+ source.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ drop_col,
+ source.y,
+ ));
+ assert!(matches!(
+ app.state.drag.as_ref().map(|drag| &drag.target),
+ Some(DragTarget::TabReorder {
+ ws_idx: 0,
+ source_tab_idx: 0,
+ insert_idx: Some(3),
+ })
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ drop_col,
+ source.y,
+ ));
+
+ let labels: Vec<_> = app.state.workspaces[0]
+ .tabs
+ .iter()
+ .map(|tab| tab.display_name())
+ .collect();
+ assert_eq!(labels, vec!["foo", "2", "3"]);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].custom_name.as_deref(),
+ Some("foo")
+ );
+ assert!(app.state.workspaces[0].tabs[1].custom_name.is_none());
+ assert!(app.state.workspaces[0].tabs[2].custom_name.is_none());
+ assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root);
+ assert_eq!(app.state.workspaces[0].active_tab, 2);
+ }
+
+ #[test]
fn top_drop_slot_is_distinct_from_gap_below_first_workspace() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 9393521a..3e79ce05 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -255,11 +255,15 @@ impl App {
keybind_help: state::KeybindHelpState { scroll: 0 },
workspace_scroll: 0,
agent_panel_scroll: 0,
+ tab_scroll: 0,
+ tab_scroll_follow_active: true,
view: state::ViewState {
sidebar_rect: Rect::default(),
workspace_card_areas: Vec::new(),
tab_bar_rect: Rect::default(),
tab_hit_areas: Vec::new(),
+ tab_scroll_left_hit_area: Rect::default(),
+ tab_scroll_right_hit_area: Rect::default(),
new_tab_hit_area: Rect::default(),
terminal_area: Rect::default(),
pane_infos: Vec::new(),
@@ -267,6 +271,7 @@ impl App {
},
drag: None,
workspace_press: None,
+ tab_press: None,
selection: None,
context_menu: None,
update_available: None,
diff --git a/src/app/state.rs b/src/app/state.rs
index 008d45c8..c4b79cc3 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -334,6 +334,8 @@ pub struct ViewState {
pub workspace_card_areas: Vec<WorkspaceCardArea>,
pub tab_bar_rect: Rect,
pub tab_hit_areas: Vec<Rect>,
+ pub tab_scroll_left_hit_area: Rect,
+ pub tab_scroll_right_hit_area: Rect,
pub new_tab_hit_area: Rect,
pub terminal_area: Rect,
pub pane_infos: Vec<PaneInfo>,
@@ -473,6 +475,11 @@ pub(crate) enum DragTarget {
source_ws_idx: usize,
insert_idx: Option<usize>,
},
+ TabReorder {
+ ws_idx: usize,
+ source_tab_idx: usize,
+ insert_idx: Option<usize>,
+ },
WorkspaceListScrollbar {
grab_row_offset: u16,
},
@@ -509,6 +516,13 @@ pub(crate) struct WorkspacePressState {
pub start_row: u16,
}
+pub(crate) struct TabPressState {
+ pub ws_idx: usize,
+ pub tab_idx: usize,
+ pub start_col: u16,
+ pub start_row: u16,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuKind {
Workspace { ws_idx: usize },
@@ -586,10 +600,13 @@ pub struct AppState {
pub keybind_help: KeybindHelpState,
pub workspace_scroll: usize,
pub agent_panel_scroll: usize,
+ pub tab_scroll: usize,
+ pub tab_scroll_follow_active: bool,
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
pub(crate) drag: Option<DragState>,
pub(crate) workspace_press: Option<WorkspacePressState>,
+ pub(crate) tab_press: Option<TabPressState>,
pub selection: Option<Selection>,
pub context_menu: Option<ContextMenuState>,
// Notifications
@@ -700,11 +717,15 @@ impl AppState {
keybind_help: KeybindHelpState { scroll: 0 },
workspace_scroll: 0,
agent_panel_scroll: 0,
+ tab_scroll: 0,
+ tab_scroll_follow_active: true,
view: ViewState {
sidebar_rect: Rect::default(),
workspace_card_areas: Vec::new(),
tab_bar_rect: Rect::default(),
tab_hit_areas: Vec::new(),
+ tab_scroll_left_hit_area: Rect::default(),
+ tab_scroll_right_hit_area: Rect::default(),
new_tab_hit_area: Rect::default(),
terminal_area: Rect::default(),
pane_infos: Vec::new(),
@@ -712,6 +733,7 @@ impl AppState {
},
drag: None,
workspace_press: None,
+ tab_press: None,
selection: None,
context_menu: None,
update_available: None,
diff --git a/src/ui.rs b/src/ui.rs
index b1b5f699..4401d231 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -261,12 +261,19 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
compute_workspace_card_areas(app, sidebar_area)
};
- let tab_hit_areas = app
+ let tab_bar_view = app
.active
.and_then(|i| app.workspaces.get(i))
- .map(|ws| compute_tab_hit_areas(ws, tab_bar_rect))
+ .map(|ws| {
+ compute_tab_bar_view(
+ ws,
+ tab_bar_rect,
+ app.tab_scroll,
+ app.tab_scroll_follow_active,
+ )
+ })
.unwrap_or_default();
- let new_tab_hit_area = compute_new_tab_hit_area(&tab_hit_areas, tab_bar_rect);
+ app.tab_scroll = tab_bar_view.scroll;
let split_borders = app
.active
@@ -280,8 +287,10 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
sidebar_rect: sidebar_area,
workspace_card_areas,
tab_bar_rect,
- tab_hit_areas,
- new_tab_hit_area,
+ tab_hit_areas: tab_bar_view.tab_hit_areas,
+ tab_scroll_left_hit_area: tab_bar_view.scroll_left_hit_area,
+ tab_scroll_right_hit_area: tab_bar_view.scroll_right_hit_area,
+ new_tab_hit_area: tab_bar_view.new_tab_hit_area,
terminal_area,
pane_infos,
split_borders,
@@ -336,9 +345,19 @@ pub fn render(app: &AppState, frame: &mut Frame) {
const MIN_TAB_WIDTH: u16 = 8;
const NEW_TAB_WIDTH: u16 = 3;
+const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
const AGENT_PANEL_HEADER_ROWS: u16 = 3;
+#[derive(Default)]
+pub(crate) struct TabBarView {
+ pub scroll: usize,
+ pub tab_hit_areas: Vec<Rect>,
+ pub scroll_left_hit_area: Rect,
+ pub scroll_right_hit_area: Rect,
+ pub new_tab_hit_area: Rect,
+}
+
fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
if ws.branch().is_some() {
2
@@ -501,47 +520,176 @@ pub(crate) fn compute_workspace_card_areas(
cards
}
-fn compute_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect) -> Vec<Rect> {
+fn tab_width(tab: &crate::workspace::Tab) -> u16 {
+ (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH)
+}
+
+fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> {
+ let mut rects = vec![Rect::default(); ws.tabs.len()];
if area.width == 0 || area.height == 0 {
- return Vec::new();
+ return rects;
}
let mut x = area.x;
- let mut rects = Vec::new();
let right = area.x + area.width;
- for tab in &ws.tabs {
- if x >= right.saturating_sub(NEW_TAB_WIDTH) {
+ for idx in scroll..ws.tabs.len() {
+ if x >= right {
break;
}
- let desired = (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH);
- let remaining = right.saturating_sub(NEW_TAB_WIDTH).saturating_sub(x);
+ let desired = tab_width(&ws.tabs[idx]);
+ let remaining = right.saturating_sub(x);
let width = desired.min(remaining).max(1);
- rects.push(Rect::new(x, area.y, width, 1));
+ rects[idx] = Rect::new(x, area.y, width, 1);
x = x.saturating_add(width + 1);
}
rects
}
-fn compute_new_tab_hit_area(tab_hit_areas: &[Rect], area: Rect) -> Rect {
+fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
+ let mut best_scroll = ws.active_tab;
+ let mut best_distance = u16::MAX;
+ let viewport_center = area.x.saturating_mul(2).saturating_add(area.width);
+
+ for scroll in 0..=ws.active_tab {
+ let rects = layout_tab_hit_areas(ws, area, scroll);
+ let Some(active_rect) = rects.get(ws.active_tab).copied() else {
+ continue;
+ };
+ if active_rect.width == 0 {
+ continue;
+ }
+
+ let active_center = active_rect
+ .x
+ .saturating_mul(2)
+ .saturating_add(active_rect.width);
+ let distance = active_center.abs_diff(viewport_center);
+ if distance <= best_distance {
+ best_distance = distance;
+ best_scroll = scroll;
+ }
+ }
+
+ best_scroll
+}
+
+pub(crate) fn compute_tab_bar_view(
+ ws: &crate::workspace::Workspace,
+ area: Rect,
+ current_scroll: usize,
+ follow_active: bool,
+) -> TabBarView {
if area.width == 0 || area.height == 0 {
- return Rect::default();
+ return TabBarView::default();
}
- let x = tab_hit_areas
- .last()
- .map(|rect| rect.x + rect.width + 1)
- .unwrap_or(area.x)
- .min(area.x + area.width.saturating_sub(NEW_TAB_WIDTH));
- Rect::new(x, area.y, NEW_TAB_WIDTH.min(area.width), 1)
+
+ let new_tab_hit_area = Rect::new(
+ area.x + area.width.saturating_sub(NEW_TAB_WIDTH),
+ area.y,
+ NEW_TAB_WIDTH.min(area.width),
+ 1,
+ );
+
+ let all_tabs_area = Rect::new(
+ area.x,
+ area.y,
+ area.width.saturating_sub(NEW_TAB_WIDTH),
+ area.height,
+ );
+ let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0);
+ let overflow = all_tabs.iter().any(|rect| rect.width == 0);
+ if !overflow {
+ return TabBarView {
+ scroll: 0,
+ tab_hit_areas: all_tabs,
+ scroll_left_hit_area: Rect::default(),
+ scroll_right_hit_area: Rect::default(),
+ new_tab_hit_area,
+ };
+ }
+
+ let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1);
+ let right_hit_area = Rect::new(
+ new_tab_hit_area.x.saturating_sub(TAB_SCROLL_BUTTON_WIDTH),
+ area.y,
+ TAB_SCROLL_BUTTON_WIDTH.min(new_tab_hit_area.x.saturating_sub(area.x)),
+ 1,
+ );
+ let tab_area_x = left_hit_area.x + left_hit_area.width;
+ let tab_area_right = right_hit_area.x;
+ let tab_area = Rect::new(
+ tab_area_x,
+ area.y,
+ tab_area_right.saturating_sub(tab_area_x),
+ area.height,
+ );
+
+ let scroll = if follow_active {
+ centered_tab_scroll(ws, tab_area)
+ } else {
+ current_scroll.min(ws.tabs.len().saturating_sub(1))
+ };
+
+ TabBarView {
+ scroll,
+ tab_hit_areas: layout_tab_hit_areas(ws, tab_area, scroll),
+ scroll_left_hit_area: left_hit_area,
+ scroll_right_hit_area: right_hit_area,
+ new_tab_hit_area,
+ }
+}
+
+fn tab_drop_indicator_x(
+ app: &AppState,
+ ws: &crate::workspace::Workspace,
+ insert_idx: usize,
+) -> Option<u16> {
+ let visible_tabs = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .filter(|(_, rect)| rect.width > 0);
+ let first_visible = visible_tabs.clone().next()?;
+ let last_visible = visible_tabs.last().unwrap_or(first_visible);
+
+ if insert_idx == 0 {
+ return Some(if first_visible.0 == 0 {
+ first_visible.1.x
+ } else {
+ app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width
+ });
+ }
+
+ if let Some((_, rect)) = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find(|(idx, rect)| *idx == insert_idx && rect.width > 0)
+ {
+ return Some(rect.x.saturating_sub(1));
+ }
+
+ if insert_idx >= ws.tabs.len() {
+ return Some(if last_visible.0 + 1 >= ws.tabs.len() {
+ last_visible.1.x + last_visible.1.width
+ } else {
+ app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
+ });
+ }
+
+ None
}
fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
- let Some(ws_idx) = app.active else {
+ let Some(active_ws_idx) = app.active else {
return;
};
- let Some(ws) = app.workspaces.get(ws_idx) else {
+ let Some(ws) = app.workspaces.get(active_ws_idx) else {
return;
};
@@ -552,10 +700,62 @@ fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
area,
);
+ let first_visible_idx = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx);
+ let last_visible_idx = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .rev()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx);
+ let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0;
+ let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0
+ && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len());
+
+ if app.view.tab_scroll_left_hit_area.width > 0 {
+ let style = if can_scroll_left {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ } else {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(p.surface0)
+ .add_modifier(Modifier::DIM)
+ };
+ frame.render_widget(
+ Paragraph::new(" < ").style(style),
+ app.view.tab_scroll_left_hit_area,
+ );
+ }
+
+ if app.view.tab_scroll_right_hit_area.width > 0 {
+ let style = if can_scroll_right {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ } else {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(p.surface0)
+ .add_modifier(Modifier::DIM)
+ };
+ frame.render_widget(
+ Paragraph::new(" > ").style(style),
+ app.view.tab_scroll_right_hit_area,
+ );
+ }
+
for (idx, tab) in ws.tabs.iter().enumerate() {
let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
break;
};
+ if rect.width == 0 {
+ continue;
+ }
let active = idx == ws.active_tab;
let style = if active {
let base = Style::default().fg(p.panel_bg).bg(p.accent);
@@ -578,12 +778,47 @@ fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
frame.render_widget(Paragraph::new(text).style(style), rect);
}
+ if let Some(crate::app::state::DragState {
+ target:
+ crate::app::state::DragTarget::TabReorder {
+ ws_idx,
+ insert_idx: Some(insert_idx),
+ ..
+ },
+ }) = &app.drag
+ {
+ if *ws_idx == active_ws_idx {
+ if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) {
+ frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)]
+ .set_symbol("│")
+ .set_style(Style::default().fg(p.accent));
+ }
+ }
+ }
+
if app.view.new_tab_hit_area.width > 0 {
frame.render_widget(
Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
app.view.new_tab_hit_area,
);
}
+
+ if first_visible_idx.is_some_and(|idx| idx > 0) {
+ let x = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width;
+ if x < area.x + area.width {
+ frame.buffer_mut()[(x, area.y)]
+ .set_symbol("…")
+ .set_style(Style::default().fg(p.overlay0));
+ }
+ }
+ if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
+ let x = app.view.tab_scroll_right_hit_area.x.saturating_sub(1);
+ if x >= area.x && x < area.x + area.width {
+ frame.buffer_mut()[(x, area.y)]
+ .set_symbol("…")
+ .set_style(Style::default().fg(p.overlay0));
+ }
+ }
}
/// Compute pane layout info and resize pane runtimes to match.
@@ -3178,6 +3413,31 @@ mod tests {
}
#[test]
+ fn tab_bar_shows_scroll_controls_when_tabs_overflow() {
+ let mut app = crate::app::state::AppState::test_new();
+ let mut ws = Workspace::test_new("test");
+ for name in ["alpha", "beta", "gamma", "delta"] {
+ ws.test_add_tab(Some(name));
+ }
+
+ app.workspaces = vec![ws];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+ app.tab_scroll_follow_active = false;
+ app.tab_scroll = 2;
+
+ compute_view(&mut app, Rect::new(0, 0, 44, 20));
+
+ assert!(app.view.tab_scroll_left_hit_area.width > 0);
+ assert!(app.view.tab_scroll_right_hit_area.width > 0);
+ assert_eq!(app.view.tab_hit_areas[0].width, 0);
+ assert_eq!(app.view.tab_hit_areas[1].width, 0);
+ assert!(app.view.tab_hit_areas[2].width > 0);
+ assert!(app.view.new_tab_hit_area.width > 0);
+ }
+
+ #[test]
fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() {
let mut app = crate::app::state::AppState::test_new();
let mut first = Workspace::test_new("one");
diff --git a/src/workspace.rs b/src/workspace.rs
index 5fabe47f..09fa382d 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -399,6 +399,32 @@ impl Workspace {
true
}
+ pub fn move_tab(&mut self, source_idx: usize, insert_idx: usize) -> bool {
+ if source_idx >= self.tabs.len() || insert_idx > self.tabs.len() {
+ return false;
+ }
+
+ let target_idx = if source_idx < insert_idx {
+ insert_idx.saturating_sub(1)
+ } else {
+ insert_idx
+ }
+ .min(self.tabs.len().saturating_sub(1));
+
+ if source_idx == target_idx {
+ return false;
+ }
+
+ let active_root_pane = self.tabs.get(self.active_tab).map(|tab| tab.root_pane);
+ let tab = self.tabs.remove(source_idx);
+ self.tabs.insert(target_idx, tab);
+ self.renumber_tabs();
+ self.active_tab = active_root_pane
+ .and_then(|root_pane| self.tabs.iter().position(|tab| tab.root_pane == root_pane))
+ .unwrap_or(target_idx);
+ true
+ }
+
pub fn close_active_tab(&mut self) -> bool {
self.close_tab(self.active_tab)
}
@@ -963,6 +989,26 @@ mod tests {
}
#[test]
+ fn moving_tab_keeps_active_identity_and_renumbers_auto_tabs() {
+ let mut ws = Workspace::test_new("test");
+ let moved_root = ws.tabs[0].root_pane;
+ ws.test_add_tab(Some("foo"));
+ let final_auto_idx = ws.test_add_tab(None);
+ let active_root = ws.tabs[final_auto_idx].root_pane;
+ ws.switch_tab(final_auto_idx);
+
+ assert!(ws.move_tab(0, ws.tabs.len()));
+
+ let labels: Vec<_> = ws.tabs.iter().map(|tab| tab.display_name()).collect();
+ assert_eq!(labels, vec!["foo", "2", "3"]);
+ assert_eq!(ws.tabs[0].custom_name.as_deref(), Some("foo"));
+ assert!(ws.tabs[1].custom_name.is_none());
+ assert!(ws.tabs[2].custom_name.is_none());
+ assert_eq!(ws.tabs[2].root_pane, moved_root);
+ assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root);
+ }
+
+ #[test]
fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() {
let mut ws = Workspace::test_new("test");
ws.tabs[0].set_custom_name("main".into());