aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-10 01:20:09 +0300
committerGitHub <noreply@github.com>2026-08-10 01:20:09 +0300
commite48d83067a5a9b05dfd0a82761068866ab2d294d (patch)
tree159b92f857f85e40037879c7f32da4957093597b /src/ui
parente2aa86a9e8b6445488ef11b2c0a8b4c22a6f79ce (diff)
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 <david@hey.com>
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/tabs.rs220
1 files changed, 219 insertions, 1 deletions
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<VisibleStatusSegment<'_>> {
+ 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<Rect> {
+ 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<Rect> {
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)]
@@ -439,6 +540,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();
let ws = Workspace::test_new("test");