diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index dba64077b6..89e83fb8d4 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- The desktop tab bar now shows a right-aligned ZOOM indicator while the focused pane is zoomed. - Optional `keys.resize_pane_left`, `keys.resize_pane_down`, `keys.resize_pane_up`, and `keys.resize_pane_right` bindings now resize the focused pane in one keystroke without entering resize mode. - Devin CLI, Cursor Agent CLI, MastraCode, Hermes Agent, and Grok CLI integrations now install and run natively on Windows. - Panes can now route normal right-click gestures to mouse-reporting applications through the pane menu, `herdr pane input`, `pane.input.set`, or the `pane split --right-click pane` launch option. diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 02289581fa..a73f51352b 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -12,6 +12,7 @@ use crate::app::AppState; const MIN_TAB_WIDTH: u16 = 8; const NEW_TAB_WIDTH: u16 = 3; const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; +const ZOOM_INDICATOR: &str = " ZOOM "; #[derive(Debug, Clone, Default)] pub(crate) struct TabBarView { @@ -22,6 +23,14 @@ pub(crate) struct TabBarView { pub new_tab_hit_area: Rect, } +fn zoom_indicator_width(ws: &crate::workspace::Workspace) -> u16 { + if ws.zoomed { + display_width_u16(ZOOM_INDICATOR).saturating_add(1) + } else { + 0 + } +} + fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 { display_width_u16(&tab_chrome_label(ws, tab_idx)) .saturating_add(4) @@ -118,6 +127,16 @@ pub(crate) fn compute_tab_bar_view( return TabBarView::default(); } + // Reserve the right edge for the zoom indicator so tabs and trailing + // controls never render underneath it. + let area = Rect { + width: area.width.saturating_sub(zoom_indicator_width(ws)), + ..area + }; + if area.width == 0 { + return TabBarView::default(); + } + if !mouse_chrome { let max_scroll = max_tab_scroll(ws, area); let scroll = if follow_active { @@ -380,10 +399,11 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { } } if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) { + let content_right = area.x + area.width.saturating_sub(zoom_indicator_width(ws)); let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 { app.view.tab_scroll_right_hit_area.x.saturating_sub(1) } else { - area.x + area.width.saturating_sub(1) + content_right.saturating_sub(1) }; if x >= area.x && x < area.x + area.width { frame.buffer_mut()[(x, area.y)] @@ -391,6 +411,20 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { .set_style(Style::default().fg(p.overlay0)); } } + + if ws.zoomed { + let width = display_width_u16(ZOOM_INDICATOR).min(area.width); + let rect = Rect::new(area.x + area.width - width, area.y, width, 1); + frame.render_widget( + Paragraph::new(ZOOM_INDICATOR).style( + Style::default() + .fg(panel_contrast_fg(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ), + rect, + ); + } } #[cfg(test)] @@ -438,6 +472,57 @@ mod tests { ); } + #[test] + fn tab_bar_shows_zoom_indicator_at_right_edge_when_active_tab_is_zoomed() { + let mut app = AppState::test_new(); + let mut ws = Workspace::test_new("test"); + ws.tabs[0].zoomed = true; + + app.workspaces = vec![ws]; + app.active = Some(0); + app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); + let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); + app.view.tab_hit_areas = view.tab_hit_areas.clone(); + + let backend = TestBackend::new(30, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0); + assert!(row.ends_with(" ZOOM"), "tab row: {row:?}"); + assert_eq!(buffer[(28, 0)].style().bg, Some(app.palette.accent)); + + // Tabs never render underneath the reserved indicator area. + let indicator_x = 30 - display_width_u16(ZOOM_INDICATOR) - 1; + for rect in &view.tab_hit_areas { + assert!(rect.x + rect.width <= indicator_x, "tab overlaps indicator"); + } + } + + #[test] + fn tab_bar_omits_zoom_indicator_when_active_tab_is_not_zoomed() { + let mut app = AppState::test_new(); + let ws = Workspace::test_new("test"); + + app.workspaces = vec![ws]; + app.active = Some(0); + app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); + let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); + app.view.tab_hit_areas = view.tab_hit_areas; + + let backend = TestBackend::new(30, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) + .unwrap(); + + let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); + assert!(!row.contains("ZOOM"), "tab row: {row:?}"); + } + #[test] fn active_auto_named_tab_keeps_readable_weight() { let mut app = AppState::test_new();