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/ui.rs | |
| parent | e20be7bdae5c46ff922ee442b4fbd5d79c46cf99 (diff) | |
feat(tabs): add overflow scrolling and drag reorder
Diffstat (limited to 'src/ui.rs')
| -rw-r--r-- | src/ui.rs | 306 |
1 files changed, 283 insertions, 23 deletions
@@ -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"); |
