use ratatui::{ layout::Rect, style::{Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; use super::scrollbar::{render_pane_scrollbar, should_show_scrollbar}; use super::widgets::panel_contrast_fg; use crate::app::state::Palette; use crate::app::{AppState, Mode}; use crate::layout::PaneInfo; use crate::terminal::TerminalRuntime; pub(crate) fn pane_is_scrolled_back(rt: &TerminalRuntime) -> bool { rt.scroll_metrics() .is_some_and(|metrics| metrics.offset_from_bottom > 0) } fn truncate_label(text: &str, max_width: usize) -> String { let len = text.chars().count(); if len <= max_width { return text.to_string(); } if max_width == 0 { return String::new(); } if max_width == 1 { return "…".to_string(); } let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect(); format!("{prefix}…") } fn pane_border_title(label: &str, pane_width: u16) -> Option { let label = label.trim(); if label.is_empty() || pane_width <= 4 { return None; } let max_label_width = pane_width.saturating_sub(4) as usize; Some(format!(" {} ", truncate_label(label, max_label_width))) } fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { if pane_inner.width <= 4 { return pane_inner; } Rect::new( pane_inner.x, pane_inner.y, pane_inner.width.saturating_sub(1), pane_inner.height, ) } fn pane_inner_rect(area: Rect, framed: bool) -> Rect { if framed { Block::default().borders(Borders::ALL).inner(area) } else { area } } fn runtime_for_tab_pane<'a>( app: &'a AppState, tab: &'a crate::workspace::Tab, pane_id: crate::layout::PaneId, ) -> Option<(&'a crate::terminal::TerminalId, &'a TerminalRuntime)> { let terminal_id = tab.terminal_id(pane_id)?; #[cfg(test)] if let Some(runtime) = tab.runtimes.get(&pane_id) { return Some((terminal_id, runtime)); } app.terminal_runtimes .get(terminal_id) .map(|runtime| (terminal_id, runtime)) } fn stable_scrollbar_gutter(rt: &TerminalRuntime, pane_inner: Rect) -> (Rect, Option) { let inner_rect = stable_terminal_inner_rect(pane_inner); if inner_rect == pane_inner { return (inner_rect, None); } let gutter = Rect::new( pane_inner.x + pane_inner.width.saturating_sub(1), pane_inner.y, 1, pane_inner.height, ); let scrollbar_rect = rt .scroll_metrics() .filter(|metrics| should_show_scrollbar(*metrics)) .map(|_| gutter); (inner_rect, scrollbar_rect) } /// Resize every visible runtime in a tab to the geometry it would receive if the tab were selected. pub(super) fn resize_tab_panes( app: &AppState, tab: &crate::workspace::Tab, area: Rect, cell_size: crate::kitty_graphics::HostCellSize, ) { let multi_pane = tab.layout.pane_count() > 1; if tab.zoomed { let focused_id = tab.layout.focused(); if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, focused_id) { let pane_inner = pane_inner_rect(area, multi_pane); let inner_rect = stable_terminal_inner_rect(pane_inner); if !app.direct_attach_resize_locks.contains(terminal_id) { rt.resize( inner_rect.height, inner_rect.width, cell_size.width_px, cell_size.height_px, ); } } return; } for info in tab.layout.panes(area) { let pane_inner = if multi_pane { Block::default().borders(Borders::ALL).inner(info.rect) } else { area }; if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, tab, info.id) { let inner_rect = stable_terminal_inner_rect(pane_inner); if !app.direct_attach_resize_locks.contains(terminal_id) { rt.resize( inner_rect.height, inner_rect.width, cell_size.width_px, cell_size.height_px, ); } } } } /// Compute pane layout info and optionally resize pane runtimes to match. pub(super) fn compute_pane_infos( app: &AppState, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> Vec { let Some(ws_idx) = app.active else { return Vec::new(); }; let Some(ws) = app.workspaces.get(ws_idx) else { return Vec::new(); }; let multi_pane = ws.layout.pane_count() > 1; let terminal_active = app.mode == Mode::Terminal; if ws.zoomed { let focused_id = ws.layout.focused(); let pane_inner = pane_inner_rect(area, multi_pane); let mut inner_rect = pane_inner; let mut scrollbar_rect = None; if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, focused_id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner); if resize_panes && ws.terminal_id(focused_id).is_some_and(|terminal_id| { !app.direct_attach_resize_locks.contains(terminal_id) }) { rt.resize( inner_rect.height, inner_rect.width, cell_size.width_px, cell_size.height_px, ); } } return vec![PaneInfo { id: focused_id, rect: area, inner_rect, scrollbar_rect, is_focused: true, }]; } let mut pane_infos = ws.layout.panes(area); for info in &mut pane_infos { let pane_inner = if multi_pane { let border_set = if info.is_focused && terminal_active { ratatui::symbols::border::THICK } else { ratatui::symbols::border::PLAIN }; let block = Block::default() .borders(Borders::ALL) .border_set(border_set); block.inner(info.rect) } else { area }; let mut inner_rect = pane_inner; let mut scrollbar_rect = None; if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) { (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner); if resize_panes && ws.terminal_id(info.id).is_some_and(|terminal_id| { !app.direct_attach_resize_locks.contains(terminal_id) }) { rt.resize( inner_rect.height, inner_rect.width, cell_size.width_px, cell_size.height_px, ); } } info.inner_rect = inner_rect; info.scrollbar_rect = scrollbar_rect; } pane_infos } pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { let Some(ws_idx) = app.active else { render_empty(app, frame, area); return; }; let Some(ws) = app.workspaces.get(ws_idx) else { render_empty(app, frame, area); return; }; let multi_pane = ws.layout.pane_count() > 1; let terminal_active = app.mode == Mode::Terminal; for info in &app.view.pane_infos { if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) { if multi_pane { let (border_style, border_set) = if info.is_focused && terminal_active { ( Style::default().fg(app.palette.accent), ratatui::symbols::border::THICK, ) } else if info.is_focused { ( Style::default().fg(app.palette.accent), ratatui::symbols::border::PLAIN, ) } else { ( Style::default().fg(app.palette.overlay0), ratatui::symbols::border::PLAIN, ) }; let mut block = Block::default() .borders(Borders::ALL) .border_style(border_style) .border_set(border_set); if let Some(title) = ws .pane_state(info.id) .and_then(|pane| app.terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| { terminal.border_label(app.show_agent_labels_on_pane_borders) }) .and_then(|label| pane_border_title(label, info.rect.width)) { block = block.title(Line::from(Span::styled(title, border_style))); } frame.render_widget(block, info.rect); } let show_cursor = info.is_focused && terminal_active && !pane_is_scrolled_back(rt); rt.render(frame, info.inner_rect, show_cursor); render_pane_scrollbar(app, frame, info, rt); let should_dim = !info.is_focused && multi_pane && !terminal_active; if should_dim { let inner = info.inner_rect; let buf = frame.buffer_mut(); for y in inner.y..inner.y + inner.height { for x in inner.x..inner.x + inner.width { let cell = &mut buf[(x, y)]; cell.set_style(cell.style().add_modifier(Modifier::DIM)); } } } render_selection_highlight( &app.selection, frame, info.id, info.inner_rect, rt.scroll_metrics(), &app.palette, ); } } } fn render_selection_highlight( selection: &Option, frame: &mut Frame, pane_id: crate::layout::PaneId, inner: Rect, scroll_metrics: Option, p: &Palette, ) { if let Some(sel) = selection { if sel.is_visible() && sel.pane_id == pane_id { let buf = frame.buffer_mut(); for y in 0..inner.height { for x in 0..inner.width { if sel.contains(y, x, scroll_metrics) { let cell = &mut buf[(inner.x + x, inner.y + y)]; cell.set_style(Style::default().fg(panel_contrast_fg(p)).bg(p.blue)); } } } } } } fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; let lines = vec![ Line::from(""), Line::from(""), Line::from(Span::styled( " No workspaces yet", Style::default().fg(p.overlay0), )), Line::from(""), Line::from(Span::styled( " A workspace is one project context.", Style::default().fg(p.overlay1), )), Line::from(Span::styled( " Its root pane (top-left) sets the default repo or folder name.", Style::default().fg(p.overlay1), )), Line::from(""), Line::from(vec![ Span::styled(" Press ", Style::default().fg(p.overlay0)), Span::styled( app.keybinds.new_workspace_label.to_string(), Style::default().fg(p.accent).add_modifier(Modifier::BOLD), ), Span::styled(" to create one", Style::default().fg(p.overlay0)), ]), ]; frame.render_widget( Paragraph::new(lines).block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(p.surface_dim)), ), area, ); } #[cfg(test)] mod tests { use super::*; use crate::terminal::TerminalRuntime; use crate::workspace::Workspace; #[test] fn pane_border_title_trims_and_truncates() { assert_eq!( pane_border_title(" claude ", 20).as_deref(), Some(" claude ") ); assert_eq!(pane_border_title("", 20), None); assert_eq!(pane_border_title("abcdef", 8).as_deref(), Some(" abc… ")); assert_eq!(pane_border_title("abcdef", 4), None); } #[tokio::test] async fn pane_scrollbar_gutter_is_reserved_before_scrollback_exists() { let mut app = AppState::test_new(); let mut workspace = Workspace::test_new("test"); let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, TerminalRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"), ); app.workspaces = vec![workspace]; app.active = Some(0); let area = Rect::new(10, 3, 40, 8); let infos = compute_pane_infos( &app, area, false, crate::kitty_graphics::HostCellSize::default(), ); let info = &infos[0]; assert_eq!(info.rect, area); assert_eq!(info.scrollbar_rect, None); assert_eq!(info.inner_rect, Rect::new(10, 3, 39, 8)); } #[tokio::test] async fn zoomed_pane_scrollbar_gutter_is_reserved_before_scrollback_exists() { let mut app = AppState::test_new(); let mut workspace = Workspace::test_new("test"); workspace.zoomed = true; let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, TerminalRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"), ); app.workspaces = vec![workspace]; app.active = Some(0); let area = Rect::new(10, 3, 40, 8); let infos = compute_pane_infos( &app, area, false, crate::kitty_graphics::HostCellSize::default(), ); let info = &infos[0]; assert_eq!(info.rect, area); assert_eq!(info.scrollbar_rect, None); assert_eq!(info.inner_rect, Rect::new(10, 3, 39, 8)); } #[tokio::test] async fn zoomed_multi_pane_keeps_border_space() { let mut app = AppState::test_new(); let mut workspace = Workspace::test_new("test"); let focused_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); workspace.zoomed = true; workspace.tabs[0].runtimes.insert( focused_pane, TerminalRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"), ); app.workspaces = vec![workspace]; app.active = Some(0); let area = Rect::new(10, 3, 40, 8); let infos = compute_pane_infos( &app, area, false, crate::kitty_graphics::HostCellSize::default(), ); let info = &infos[0]; assert_eq!(info.id, focused_pane); assert_eq!(info.rect, area); assert_eq!(info.scrollbar_rect, None); assert_eq!(info.inner_rect, Rect::new(11, 4, 37, 6)); } #[tokio::test] async fn tiny_pane_does_not_reserve_scrollbar_gutter() { let mut app = AppState::test_new(); let mut workspace = Workspace::test_new("test"); let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, TerminalRuntime::test_with_scrollback_bytes(4, 8, 1024, b"ready\n"), ); app.workspaces = vec![workspace]; app.active = Some(0); let area = Rect::new(10, 3, 4, 8); let infos = compute_pane_infos( &app, area, false, crate::kitty_graphics::HostCellSize::default(), ); let info = &infos[0]; assert_eq!(info.rect, area); assert_eq!(info.scrollbar_rect, None); assert_eq!(info.inner_rect, area); } #[tokio::test] async fn pane_scrollbar_reserves_last_column_from_terminal_area() { let mut app = AppState::test_new(); let mut workspace = Workspace::test_new("test"); let root_pane = workspace.tabs[0].root_pane; workspace.tabs[0].runtimes.insert( root_pane, TerminalRuntime::test_with_scrollback_bytes( 40, 8, 1024, b"one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n", ), ); app.workspaces = vec![workspace]; app.active = Some(0); let area = Rect::new(10, 3, 40, 8); let infos = compute_pane_infos( &app, area, false, crate::kitty_graphics::HostCellSize::default(), ); let info = &infos[0]; assert_eq!(info.rect, area); assert_eq!(info.scrollbar_rect, Some(Rect::new(49, 3, 1, 8))); assert_eq!(info.inner_rect, Rect::new(10, 3, 39, 8)); } }