aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-15 04:20:59 +0300
committerGitHub <noreply@github.com>2026-09-15 03:20:59 +0200
commit052779c4159ed851ae78ed1271130811986d1166 (patch)
tree7b3cb372966549abf5cb41a96952997eda1304c9 /src/client/shell
parent32503f81f7f22552d86f048afb3de179f44e049a (diff)
fix: reveal the full last tab in overflowing tab strips (#4152)
refs #4151 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Diffstat (limited to 'src/client/shell')
-rw-r--r--src/client/shell/tabs.rs56
-rw-r--r--src/client/shell/tests/chrome_context.rs98
2 files changed, 137 insertions, 17 deletions
diff --git a/src/client/shell/tabs.rs b/src/client/shell/tabs.rs
index 9e9901ff..3fdcf7dc 100644
--- a/src/client/shell/tabs.rs
+++ b/src/client/shell/tabs.rs
@@ -134,7 +134,7 @@ pub(crate) fn render_tab_bar(
if overflow && mouse_chrome {
hits.tab_scroll_right = Rect::new(tab_right, area.y, TAB_SCROLL_BUTTON_WIDTH, 1);
- let can_scroll_right = last_visible.is_some_and(|index| index + 1 < tabs.len());
+ let can_scroll_right = *tab_scroll < max_scroll;
put_text(
buffer,
hits.tab_scroll_right.x,
@@ -353,25 +353,22 @@ fn centered_tab_scroll(focused: usize, widths: &[u16], available: u16) -> usize
}
fn max_tab_scroll(widths: &[u16], available: u16) -> usize {
- (0..widths.len())
- .find(|start| last_visible_tab(*start, widths, available) == widths.len().checked_sub(1))
- .unwrap_or(0)
-}
-
-fn last_visible_tab(start: usize, widths: &[u16], available: u16) -> Option<usize> {
- let mut remaining = available;
- let mut last = None;
- for (index, width) in widths.iter().copied().enumerate().skip(start) {
- if remaining == 0 {
- break;
- }
- last = Some(index);
- if width >= remaining {
+ let Some((&last, preceding)) = widths.split_last() else {
+ return 0;
+ };
+ let mut start = preceding.len();
+ let mut used = u32::from(last);
+ // Keep the longest fully visible suffix, not merely a sliver of the last tab.
+ // An oversized last tab must still be reachable at the start of the strip.
+ for width in preceding.iter().rev() {
+ let required = used + 1 + u32::from(*width);
+ if required > u32::from(available) {
break;
}
- remaining = remaining.saturating_sub(width.saturating_add(1));
+ used = required;
+ start -= 1;
}
- last
+ start
}
fn tab_label(tab: &ClientShellTab) -> String {
@@ -381,3 +378,28 @@ fn tab_label(tab: &ClientShellTab) -> String {
tab.label.clone()
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::max_tab_scroll;
+
+ #[test]
+ fn trailing_scroll_limit_accounts_for_full_widths_and_separators() {
+ for (widths, available, expected) in [
+ (&[][..], 0, 0),
+ (&[8, 13][..], 0, 1),
+ (&[8, 13][..], 1, 1),
+ (&[8, 13][..], 12, 1),
+ (&[8, 13][..], 21, 1),
+ (&[8, 13][..], 22, 0),
+ (&[8, 13][..], 30, 0),
+ (&[8, u16::MAX][..], u16::MAX, 1),
+ ] {
+ assert_eq!(
+ max_tab_scroll(widths, available),
+ expected,
+ "widths={widths:?}, available={available}"
+ );
+ }
+ }
+}
diff --git a/src/client/shell/tests/chrome_context.rs b/src/client/shell/tests/chrome_context.rs
index 5747bb97..ed21bc8a 100644
--- a/src/client/shell/tests/chrome_context.rs
+++ b/src/client/shell/tests/chrome_context.rs
@@ -47,6 +47,104 @@ fn tab_overflow_controls_scroll_the_client_owned_tab_bar() {
}
#[test]
+fn focused_last_overflow_tab_shows_its_full_label() {
+ let mut projected = snapshot();
+ let labels = [
+ "1",
+ "Laiza Portfolio Site",
+ "linkedin posts",
+ "update cv",
+ "nvim",
+ "brother",
+ "day organiser",
+ "nvim test",
+ ];
+ projected.tabs = labels
+ .iter()
+ .enumerate()
+ .map(|(index, label)| ClientShellTab {
+ tab_id: format!("tab_{}", index + 1),
+ workspace_id: "ws_1".into(),
+ number: index + 1,
+ label: (*label).into(),
+ custom_label: index > 0,
+ zoomed: false,
+ focused: index == 7,
+ agent_status: AgentStatus::Idle,
+ })
+ .collect();
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ for number in [8, 7, 8] {
+ let tab_id = format!("tab_{number}");
+ projected.focused_tab_id = Some(tab_id.clone());
+ projected.workspaces[0].active_tab_id = tab_id.clone();
+ projected.panes[0].tab_id = tab_id.clone();
+ for tab in &mut projected.tabs {
+ tab.focused = tab.tab_id == tab_id;
+ }
+ state.set_snapshot(Box::new(projected.clone()));
+ state.set_pane_surface(surface());
+ let frame = state
+ .compose(133, 20)
+ .expect("reporter's overflowing strip");
+ assert_eq!(
+ state.hits.new_tab.right() - state.hits.tab_scroll_left.x,
+ 107
+ );
+ let rect = state
+ .hits
+ .tabs
+ .iter()
+ .find(|(_, id)| id == &tab_id)
+ .expect("focused tab")
+ .0;
+ let text = (rect.x..rect.right())
+ .map(|x| {
+ frame.cells[(rect.y * frame.width + x) as usize]
+ .symbol
+ .as_str()
+ })
+ .collect::<String>();
+ assert!(
+ text.contains(labels[number - 1]),
+ "focused tab rendered as {text:?}, rect={rect:?}"
+ );
+ }
+
+ // Manual scrolling must be able to reveal the rest of a partially drawn last tab.
+ let end_scroll = state.tab_scroll;
+ let left = state.hits.tab_scroll_left;
+ state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: left.x + 1,
+ row: left.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ let frame = state.compose(133, 20).expect("manual scroll left");
+ assert_eq!(state.tab_scroll, end_scroll - 1);
+ let right = state.hits.tab_scroll_right;
+ assert_eq!(
+ frame.cells[(right.y * frame.width + right.x + 1) as usize].fg,
+ crate::protocol::color_to_u32(state.config.palette.overlay1),
+ "right arrow stays enabled while the final tab is clipped"
+ );
+ state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
+ kind: MouseEventKind::Down(MouseButton::Left),
+ column: right.x + 1,
+ row: right.y,
+ modifiers: KeyModifiers::empty(),
+ })]);
+ let frame = state.compose(133, 20).expect("manual scroll right");
+ assert_eq!(state.tab_scroll, end_scroll);
+ assert!(frame_rows(&frame)[0].contains("nvim test"));
+ assert_eq!(
+ frame.cells[(right.y * frame.width + right.x + 1) as usize].fg,
+ crate::protocol::color_to_u32(state.config.palette.overlay0),
+ "right arrow dims at the useful scroll limit"
+ );
+}
+
+#[test]
fn focused_workspace_change_reveals_new_workspace_in_full_sidebar() {
let mut initial = snapshot();
let template = initial.workspaces[0].clone();