diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-10 14:15:23 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-10 14:16:25 +0300 |
| commit | 831bb66c0cda103f79b2418389cd6528c03ade56 (patch) | |
| tree | 0ae176496c492a035f3eb6fb9f8eab13bd397ca1 /src | |
| parent | e20be7bdae5c46ff922ee442b4fbd5d79c46cf99 (diff) | |
feat(tabs): add overflow scrolling and drag reorder
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 56 | ||||
| -rw-r--r-- | src/app/input.rs | 328 | ||||
| -rw-r--r-- | src/app/mod.rs | 5 | ||||
| -rw-r--r-- | src/app/state.rs | 22 | ||||
| -rw-r--r-- | src/ui.rs | 306 | ||||
| -rw-r--r-- | src/workspace.rs | 46 |
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, @@ -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()); |
