From e48d83067a5a9b05dfd0a82761068866ab2d294d Mon Sep 17 00:00:00 2001 From: Can Celik Date: Mon, 10 Aug 2026 01:20:09 +0300 Subject: feat: add configurable tab bar status (#2586) * feat: show a zoom indicator in the desktop tab bar Reserve the right edge of the tab row for a ZOOM pill while the focused pane is zoomed, matching the accent style of the mode bars. The per-tab Z suffix stays; the pill makes the zoomed state visible at a glance like tmux's status-right flag. * feat: optionally show the hostname in the desktop tab bar Add ui.tab_bar_hostname to display the machine's hostname at the right edge of the tab row, like tmux's #h in status-right. The value resolves where the server renders, so remote sessions show the remote host. Off by default. * fix: strip control characters from the tab bar hostname * fix: hide the hostname when it would squeeze out the tab strip * feat: add configurable tab bar status * fix: harden tab bar status updates * fix: terminate tab bar status process trees * fix: disable status commands on unsupported platforms * fix: skip unchanged status command renders * fix: keep tab bar status opt-in by default --------- Co-authored-by: David Heinemeier Hansson --- src/ui/tabs.rs | 220 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 219 insertions(+), 1 deletion(-) (limited to 'src/ui') diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index 02289581..c63fc26f 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -12,6 +12,11 @@ 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"; +// The narrowest overflowing tab strip worth keeping interactive: one +// minimum-width tab, both scroll controls, and the new-tab control. +const MIN_TAB_STRIP_WIDTH: u16 = + MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2); #[derive(Debug, Clone, Default)] pub(crate) struct TabBarView { @@ -39,6 +44,70 @@ fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String } } +#[derive(Clone, Copy)] +struct VisibleStatusSegment<'a> { + text: &'a str, + accent: bool, +} + +fn visible_status_segments(app: &AppState) -> Vec> { + let zoomed = app + .active + .and_then(|index| app.workspaces.get(index)) + .is_some_and(|workspace| workspace.zoomed); + app.tab_bar_right + .iter() + .filter_map(|segment| match segment { + crate::app::state::TabBarStatusSegment::Zoom if zoomed => Some(VisibleStatusSegment { + text: ZOOM_INDICATOR, + accent: true, + }), + crate::app::state::TabBarStatusSegment::Text(Some(text)) + if display_width_u16(text) > 0 => + { + Some(VisibleStatusSegment { + text, + accent: false, + }) + } + crate::app::state::TabBarStatusSegment::Zoom + | crate::app::state::TabBarStatusSegment::Text(_) => None, + }) + .collect() +} + +fn tab_bar_status_width(app: &AppState) -> u16 { + let segments = visible_status_segments(app); + let content_width = segments.iter().fold(0_u16, |width, segment| { + width.saturating_add(display_width_u16(segment.text)) + }); + let separators = u16::try_from(segments.len().saturating_sub(1)).unwrap_or(u16::MAX); + content_width + .saturating_add(display_width_u16(&app.tab_bar_right_separator).saturating_mul(separators)) +} + +fn tab_bar_status_area(app: &AppState, area: Rect) -> Option { + let width = tab_bar_status_width(app); + if width == 0 { + return None; + } + let reserved = width.saturating_add(1); + (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH) + .then(|| Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)) +} + +// Tabs win over status decoration on narrow rows. The extra reserved cell is +// the gap between the interactive strip and the right-aligned status entries. +pub(crate) fn tab_bar_content_area(app: &AppState, area: Rect) -> Rect { + let reserved = tab_bar_status_area(app, area) + .map(|status| status.width.saturating_add(1)) + .unwrap_or(0); + Rect { + width: area.width.saturating_sub(reserved), + ..area + } +} + fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec { let mut rects = vec![Rect::default(); ws.tabs.len()]; if area.width == 0 || area.height == 0 { @@ -380,10 +449,12 @@ 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 = tab_bar_content_area(app, area); + let content_right = content.x + content.width; 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 +462,36 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) { .set_style(Style::default().fg(p.overlay0)); } } + + if let Some(status_area) = tab_bar_status_area(app, area) { + let segments = visible_status_segments(app); + let separator_width = display_width_u16(&app.tab_bar_right_separator); + let mut x = status_area.x; + for (index, segment) in segments.iter().enumerate() { + if index > 0 && separator_width > 0 { + let rect = Rect::new(x, area.y, separator_width, 1); + frame.render_widget( + Paragraph::new(app.tab_bar_right_separator.as_str()) + .style(Style::default().fg(p.overlay0).bg(p.panel_bg)), + rect, + ); + x = x.saturating_add(separator_width); + } + + let width = display_width_u16(segment.text); + let rect = Rect::new(x, area.y, width, 1); + let style = if segment.accent { + Style::default() + .fg(panel_contrast_fg(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.overlay1).bg(p.panel_bg) + }; + frame.render_widget(Paragraph::new(segment.text).style(style), rect); + x = x.saturating_add(width); + } + } } #[cfg(test)] @@ -438,6 +539,123 @@ mod tests { ); } + #[test] + fn tab_bar_renders_ordered_status_entries_with_separator() { + let mut app = AppState::test_new(); + let mut ws = Workspace::test_new("test"); + ws.tabs[0].zoomed = true; + app.tab_bar_right = vec![ + crate::app::state::TabBarStatusSegment::Zoom, + crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), + crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), + ]; + app.tab_bar_right_separator = " · ".into(); + + app.workspaces = vec![ws]; + app.active = Some(0); + app.view.tab_bar_rect = Rect::new(0, 0, 60, 1); + let content = tab_bar_content_area(&app, app.view.tab_bar_rect); + let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); + app.view.tab_hit_areas = view.tab_hit_areas.clone(); + + let backend = TestBackend::new(60, 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 · wintermute · 14:30"), + "tab row: {row:?}" + ); + let status_x = 60 - display_width_u16("ZOOM · wintermute · 14:30"); + assert_eq!(buffer[(status_x, 0)].style().bg, Some(app.palette.accent)); + for rect in &view.tab_hit_areas { + assert!(rect.x + rect.width <= content.x + content.width); + } + } + + #[test] + fn hidden_status_entries_do_not_leave_dangling_separators() { + let mut app = AppState::test_new(); + app.tab_bar_right = vec![ + crate::app::state::TabBarStatusSegment::Zoom, + crate::app::state::TabBarStatusSegment::Text(None), + crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())), + ]; + app.tab_bar_right_separator = " | ".into(); + app.workspaces = vec![Workspace::test_new("test")]; + app.active = Some(0); + app.view.tab_bar_rect = Rect::new(0, 0, 40, 1); + let content = tab_bar_content_area(&app, app.view.tab_bar_rect); + let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false); + app.view.tab_hit_areas = view.tab_hit_areas; + + let backend = TestBackend::new(40, 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.ends_with("wintermute"), "tab row: {row:?}"); + assert!(!row.contains(" | "), "tab row: {row:?}"); + } + + #[test] + fn status_reservation_keeps_a_minimum_width_tab_between_scroll_controls() { + let mut app = AppState::test_new(); + app.tab_bar_right = vec![crate::app::state::TabBarStatusSegment::Text(Some( + "x".into(), + ))]; + let mut workspace = Workspace::test_new("test"); + workspace.test_add_tab(None); + workspace.test_add_tab(None); + app.workspaces = vec![workspace]; + app.active = Some(0); + + let too_narrow = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 1, 1); + assert_eq!(tab_bar_content_area(&app, too_narrow), too_narrow); + + let wide_enough = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 2, 1); + let content = tab_bar_content_area(&app, wide_enough); + assert_eq!(content.width, MIN_TAB_STRIP_WIDTH); + let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, true); + assert!(view.tab_hit_areas[0].width >= MIN_TAB_WIDTH); + } + + #[test] + fn combined_status_entries_yield_to_tab_controls_on_narrow_rows() { + let mut app = AppState::test_new(); + app.tab_bar_right = vec![ + crate::app::state::TabBarStatusSegment::Text(Some( + "a-hostname-wider-than-the-whole-bar".into(), + )), + crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())), + ]; + app.workspaces = vec![Workspace::test_new("test")]; + app.active = Some(0); + app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); + + assert_eq!( + tab_bar_content_area(&app, app.view.tab_bar_rect), + app.view.tab_bar_rect + ); + assert_eq!(tab_bar_status_area(&app, app.view.tab_bar_rect), None); + + let view = compute_tab_bar_view( + &app.workspaces[0], + tab_bar_content_area(&app, app.view.tab_bar_rect), + 0, + true, + true, + ); + assert!(view.tab_hit_areas[0].width > 0); + assert!(view.new_tab_hit_area.width > 0); + } + #[test] fn active_auto_named_tab_keeps_readable_weight() { let mut app = AppState::test_new(); -- cgit v1.2.3