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