aboutsummaryrefslogtreecommitdiff
path: root/src/ui/tabs.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/tabs.rs')
-rw-r--r--src/ui/tabs.rs356
1 files changed, 356 insertions, 0 deletions
diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs
new file mode 100644
index 00000000..400ee65f
--- /dev/null
+++ b/src/ui/tabs.rs
@@ -0,0 +1,356 @@
+use ratatui::{
+ layout::Rect,
+ style::{Modifier, Style},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::widgets::panel_contrast_fg;
+use crate::app::AppState;
+
+const MIN_TAB_WIDTH: u16 = 8;
+const NEW_TAB_WIDTH: u16 = 3;
+const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
+
+#[derive(Debug, Clone, 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 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 rects;
+ }
+
+ let mut x = area.x;
+ let right = area.x + area.width;
+ for idx in scroll..ws.tabs.len() {
+ if x >= right {
+ break;
+ }
+ let desired = tab_width(&ws.tabs[idx]);
+ let remaining = right.saturating_sub(x);
+ let width = desired.min(remaining).max(1);
+ rects[idx] = Rect::new(x, area.y, width, 1);
+ x = x.saturating_add(width + 1);
+ }
+ rects
+}
+
+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
+}
+
+fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 {
+ tab_hit_areas
+ .iter()
+ .rev()
+ .find(|rect| rect.width > 0)
+ .map(|rect| rect.x + rect.width)
+ .unwrap_or(fallback_x)
+}
+
+fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
+ (0..ws.tabs.len())
+ .find(|&scroll| {
+ layout_tab_hit_areas(ws, area, scroll)
+ .last()
+ .is_some_and(|rect| rect.width > 0)
+ })
+ .unwrap_or(0)
+}
+
+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 TabBarView::default();
+ }
+
+ let area_right = area.x + area.width;
+ 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 {
+ let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x);
+ let new_tab_hit_area = Rect::new(
+ new_tab_x,
+ area.y,
+ area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
+ 1,
+ );
+ 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 tab_area_x = left_hit_area.x + left_hit_area.width;
+ let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH);
+ let tab_area_right = area_right.saturating_sub(reserved_trailing_width);
+ let tab_area = Rect::new(
+ tab_area_x,
+ area.y,
+ tab_area_right.saturating_sub(tab_area_x),
+ area.height,
+ );
+
+ let max_scroll = max_tab_scroll(ws, tab_area);
+ let scroll = if follow_active {
+ centered_tab_scroll(ws, tab_area).min(max_scroll)
+ } else {
+ current_scroll.min(max_scroll)
+ };
+ let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll);
+ let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right);
+ let right_hit_area = Rect::new(
+ trailing_x,
+ area.y,
+ area_right
+ .saturating_sub(trailing_x)
+ .min(TAB_SCROLL_BUTTON_WIDTH),
+ 1,
+ );
+ let new_tab_x = right_hit_area.x + right_hit_area.width;
+ let new_tab_hit_area = Rect::new(
+ new_tab_x,
+ area.y,
+ area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
+ 1,
+ );
+
+ TabBarView {
+ scroll,
+ tab_hit_areas,
+ 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
+}
+
+pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let Some(active_ws_idx) = app.active else {
+ return;
+ };
+ let Some(ws) = app.workspaces.get(active_ws_idx) else {
+ return;
+ };
+
+ let p = &app.palette;
+
+ frame.render_widget(
+ Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
+ 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(panel_contrast_fg(p)).bg(p.accent);
+ if tab.is_auto_named() {
+ base.add_modifier(Modifier::DIM)
+ } else {
+ base.add_modifier(Modifier::BOLD)
+ }
+ } else if tab.is_auto_named() {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(p.surface0)
+ .add_modifier(Modifier::DIM)
+ } else {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ };
+ let width = rect.width as usize;
+ let name = tab.display_name();
+ let text = format!(" {:width$}", name, width = width.saturating_sub(1));
+ 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));
+ }
+ }
+}