diff options
Diffstat (limited to 'src/ui/tabs.rs')
| -rw-r--r-- | src/ui/tabs.rs | 356 |
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)); + } + } +} |
