diff options
| author | akbash <akbash@herdr.dev> | 2026-09-15 04:20:59 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-15 03:20:59 +0200 |
| commit | 052779c4159ed851ae78ed1271130811986d1166 (patch) | |
| tree | 7b3cb372966549abf5cb41a96952997eda1304c9 /src/client/shell | |
| parent | 32503f81f7f22552d86f048afb3de179f44e049a (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.rs | 56 | ||||
| -rw-r--r-- | src/client/shell/tests/chrome_context.rs | 98 |
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(); |
