From 302fb725f4273b542907d327d6a9721b4dbc3650 Mon Sep 17 00:00:00 2001 From: h-east Date: Fri, 24 Apr 2026 08:41:38 +0900 Subject: [PATCH] tabpanel: scrollbar completion, follow curtab, right-edge layout Problem: Several issues around the tabpanel scrollbar: 1. :set tabpanelopt= completion did not offer "scroll" and "scrollbar". 2. gt/gT and other tab switches did not update the scrollbar thumb; the current tab could move outside the visible panel range without the view following. 3. When tpl_scroll_offset was at its maximum, the thumb's bottom did not reach the last screen row due to integer truncation in thumb_top (e.g. 31 tabs on 24 rows + :tablast left a one-row gap). 4. For align:right the scrollbar was drawn on the panel's left edge (adjacent to the buffer area), which breaks the common convention that a vertical scrollbar sits on the right. Solution: - Add "scroll" and "scrollbar" to the 'tabpanelopt' expansion list. Cover the completion in test_options.vim and extend util/gen_opt_test.vim with the new valid/invalid values; drop the now-redundant acceptance test from test_tabpanel.vim. - In draw_tabpanel(), remember the last-drawn curtab and, when it changes, adjust tpl_scroll_offset so curtab_row falls inside [offset, offset + Rows). Mouse wheel and drag leave curtab unchanged, so the user's chosen offset is preserved. - In draw_tabpanel_scrollbar(), compute thumb_top as (Rows - thumb_height) * tpl_scroll_offset / (tpl_total_rows - Rows), mirroring the mapping already used by tabpanel_drag_scrollbar(). This guarantees the thumb's bottom reaches the last row at the maximum offset. - In draw_tabpanel(), place the scrollbar at the tabpanel's right edge for both align:left and align:right (previously align:right put it on the panel's left edge next to the vertical separator). For align:right this means the scrollbar now sits at the screen's right edge. - Update :h tabpanel-scroll to describe the new, align- independent placement. - Add Test_tabpanel_scrollbar_follows_curtab() and Test_tabpanel_scrollbar_reaches_bottom() to exercise the regressions fixed by items 2 and 3. Co-Authored-By: Claude Opus 4.7 (1M context) --- runtime/doc/tabpage.txt | 16 +++-- src/optionstr.c | 2 +- src/tabpanel.c | 71 +++++++++++++++---- src/testdir/test_options.vim | 6 ++ src/testdir/test_tabpanel.vim | 110 ++++++++++++++++++++++++------ src/testdir/util/gen_opt_test.vim | 6 +- 6 files changed, 168 insertions(+), 43 deletions(-) diff --git a/runtime/doc/tabpage.txt b/runtime/doc/tabpage.txt index 3130c185f87a7b..acb1d67908497d 100644 --- a/runtime/doc/tabpage.txt +++ b/runtime/doc/tabpage.txt @@ -500,12 +500,16 @@ To additionally show a vertical scrollbar indicating the current scroll position, use "scrollbar": > :set tabpanelopt+=scrollbar -The "scrollbar" value implies "scroll". A one-column scrollbar is reserved at -the edge of the tabpanel; clicking on the scrollbar column moves the thumb to -the click position, and the thumb can be dragged to scroll continuously. - -When "vert" is combined with "scrollbar", the scrollbar is drawn next to the -vertical separator, on the panel side. +The "scrollbar" value implies "scroll". A one-column scrollbar is always +reserved at the right edge of the tabpanel, regardless of 'align'. For +|'tabpanelopt'|=align:left this is the edge adjacent to the buffer windows; +for align:right it is the right edge of the screen. Clicking on the +scrollbar column moves the thumb to the click position, and the thumb can +be dragged to scroll continuously. + +When "vert" is combined with "scrollbar", the vertical separator is drawn +at the tabpanel's boundary with the buffer area and the scrollbar stays at +the tabpanel's right edge. The scrollbar uses the |hl-PmenuSbar| highlight group for the track and |hl-PmenuThumb| for the thumb. diff --git a/src/optionstr.c b/src/optionstr.c index ab7c5ffff16ddc..488284c18e4e29 100644 --- a/src/optionstr.c +++ b/src/optionstr.c @@ -30,7 +30,7 @@ static char *(p_briopt_values[]) = {"shift:", "min:", "sbr", "list:", "column:", #endif #if defined(FEAT_TABPANEL) // Note: Keep this in sync with tabpanelopt_changed() -static char *(p_tplo_values[]) = {"align:", "columns:", "vert", NULL}; +static char *(p_tplo_values[]) = {"align:", "columns:", "scroll", "scrollbar", "vert", NULL}; static char *(p_tplo_align_values[]) = {"left", "right", NULL}; #endif #if defined(FEAT_DIFF) diff --git a/src/tabpanel.c b/src/tabpanel.c index 07be48e44c7c02..800597e1df3371 100644 --- a/src/tabpanel.c +++ b/src/tabpanel.c @@ -48,6 +48,7 @@ static bool tpl_scrollbar = false; static int tpl_scroll_offset = 0; static int tpl_total_rows = 0; static int tpl_scrollbar_col = -1; // screen column of scrollbar, -1 if none +static tabpage_T *tpl_last_curtab = NULL; // last curtab seen by draw_tabpanel typedef struct { win_T *wp; @@ -264,6 +265,30 @@ tabpanel_append_click_regions( } } +/* + * Ensure the current tab is visible by adjusting tpl_scroll_offset when + * the selected tab has changed since the previous redraw. Mouse wheel or + * scrollbar drag operations leave curtab unchanged, so the user's chosen + * offset is preserved in those cases. + */ + static void +follow_curtab_if_needed(int curtab_row) +{ + if (!tpl_scroll || Rows <= 0 || curtab == tpl_last_curtab) + return; + + if (curtab_row < tpl_scroll_offset) + tpl_scroll_offset = curtab_row; + else if (curtab_row >= tpl_scroll_offset + Rows) + tpl_scroll_offset = curtab_row - Rows + 1; + + if (tpl_scroll_offset < 0) + tpl_scroll_offset = 0; + if (tpl_total_rows > Rows + && tpl_scroll_offset > tpl_total_rows - Rows) + tpl_scroll_offset = tpl_total_rows - Rows; +} + /* * draw the tabpanel. */ @@ -293,30 +318,36 @@ draw_tabpanel(void) int sb_len = tpl_scrollbar ? SCROLL_LEN : 0; int sb_screen_col = -1; + // The scrollbar is always placed at the right edge of the tabpanel, + // regardless of 'align'. The vertical separator sits at the panel's + // boundary with the buffer area (left edge for align:right, right edge + // for align:left). if (tpl_is_vert) { if (is_right) { - // draw main contents in tabpanel - do_by_tplmode(TPLMODE_GET_CURTAB_ROW, VERT_LEN + sb_len, - maxwidth - VERT_LEN, &curtab_row, NULL); - do_by_tplmode(TPLMODE_REDRAW, VERT_LEN + sb_len, maxwidth, + // Panel on the right: vert at panel's left edge, scrollbar at + // panel's right edge (= screen's right edge). + do_by_tplmode(TPLMODE_GET_CURTAB_ROW, VERT_LEN, + maxwidth - sb_len, &curtab_row, NULL); + follow_curtab_if_needed(curtab_row); + do_by_tplmode(TPLMODE_REDRAW, VERT_LEN, maxwidth - sb_len, &curtab_row, NULL); - // draw vert separator in tabpanel for (vsrow = 0; vsrow < Rows; vsrow++) screen_putchar(curwin->w_fill_chars.tpl_vert, vsrow, topframe->fr_width, vs_attr); if (tpl_scrollbar) - sb_screen_col = topframe->fr_width + VERT_LEN; + sb_screen_col = topframe->fr_width + maxwidth - SCROLL_LEN; } else { - // draw main contents in tabpanel + // Panel on the left: scrollbar just left of vert, vert at + // panel's right edge (boundary with buffer). do_by_tplmode(TPLMODE_GET_CURTAB_ROW, 0, maxwidth - VERT_LEN - sb_len, &curtab_row, NULL); + follow_curtab_if_needed(curtab_row); do_by_tplmode(TPLMODE_REDRAW, 0, maxwidth - VERT_LEN - sb_len, &curtab_row, NULL); - // draw vert separator in tabpanel for (vsrow = 0; vsrow < Rows; vsrow++) screen_putchar(curwin->w_fill_chars.tpl_vert, vsrow, maxwidth - VERT_LEN, vs_attr); @@ -328,16 +359,20 @@ draw_tabpanel(void) { if (is_right) { - do_by_tplmode(TPLMODE_GET_CURTAB_ROW, sb_len, maxwidth, + // Panel on the right, no vert: scrollbar at screen's right edge. + do_by_tplmode(TPLMODE_GET_CURTAB_ROW, 0, maxwidth - sb_len, + &curtab_row, NULL); + follow_curtab_if_needed(curtab_row); + do_by_tplmode(TPLMODE_REDRAW, 0, maxwidth - sb_len, &curtab_row, NULL); - do_by_tplmode(TPLMODE_REDRAW, sb_len, maxwidth, &curtab_row, NULL); if (tpl_scrollbar) - sb_screen_col = topframe->fr_width; + sb_screen_col = topframe->fr_width + maxwidth - SCROLL_LEN; } else { do_by_tplmode(TPLMODE_GET_CURTAB_ROW, 0, maxwidth - sb_len, &curtab_row, NULL); + follow_curtab_if_needed(curtab_row); do_by_tplmode(TPLMODE_REDRAW, 0, maxwidth - sb_len, &curtab_row, NULL); if (tpl_scrollbar) @@ -354,6 +389,7 @@ draw_tabpanel(void) // A user function may reset KeyTyped, restore it. KeyTyped = saved_KeyTyped; + tpl_last_curtab = curtab; redraw_tabpanel = FALSE; } @@ -761,10 +797,21 @@ draw_tabpanel_scrollbar(int screen_col) if (tpl_total_rows > Rows && Rows > 0) { + int max_offset = tpl_total_rows - Rows; + int track_range; + thumb_height = Rows * Rows / tpl_total_rows; if (thumb_height < 1) thumb_height = 1; - thumb_top = Rows * tpl_scroll_offset / tpl_total_rows; + + // Map tpl_scroll_offset onto the track: at offset 0 the thumb's top + // is at row 0, at the maximum offset its bottom reaches the last + // row. This is the exact inverse of tabpanel_drag_scrollbar(). + track_range = Rows - thumb_height; + if (track_range > 0 && max_offset > 0) + thumb_top = track_range * tpl_scroll_offset / max_offset; + else + thumb_top = 0; if (thumb_top + thumb_height > Rows) thumb_top = Rows - thumb_height; if (thumb_top < 0) diff --git a/src/testdir/test_options.vim b/src/testdir/test_options.vim index 76a1bff344ee10..bfd8528943a235 100644 --- a/src/testdir/test_options.vim +++ b/src/testdir/test_options.vim @@ -593,6 +593,12 @@ func Test_set_completion_string_values() if exists('+tabclose') call assert_equal('left uselast', join(sort(getcompletion('set tabclose=', 'cmdline'))), ' ') endif + if has('tabpanel') + call assert_equal(['align:', 'columns:', 'scroll', 'scrollbar', 'vert'], + \ getcompletion('set tabpanelopt=', 'cmdline')) + call assert_equal(['left', 'right'], + \ getcompletion('set tabpanelopt=align:', 'cmdline')) + endif if exists('+termwintype') call assert_equal('conpty', getcompletion('set termwintype=', 'cmdline')[1]) endif diff --git a/src/testdir/test_tabpanel.vim b/src/testdir/test_tabpanel.vim index 427360aeda388f..aed096e185faa1 100644 --- a/src/testdir/test_tabpanel.vim +++ b/src/testdir/test_tabpanel.vim @@ -962,28 +962,6 @@ func Test_tabpanel_large_columns() call assert_fails(':set tabpanelopt=columns:-1', 'E474:') endfunc -func Test_tabpanel_scrollopt_accepted() - " 'scroll' / 'scrollbar' must be accepted in 'tabpanelopt'. - set tabpanelopt=scroll - call assert_equal('scroll', &tabpanelopt) - set tabpanelopt=scrollbar - call assert_equal('scrollbar', &tabpanelopt) - - " Combination with other values. - set tabpanelopt=align:right,scroll - call assert_equal('align:right,scroll', &tabpanelopt) - set tabpanelopt=columns:15,vert,scrollbar - call assert_equal('columns:15,vert,scrollbar', &tabpanelopt) - set tabpanelopt=align:right,columns:12,vert,scrollbar - call assert_equal('align:right,columns:12,vert,scrollbar', &tabpanelopt) - - " Unknown values must still fail. - call assert_fails(':set tabpanelopt=scrol', 'E474:') - call assert_fails(':set tabpanelopt=scrollbarx', 'E474:') - - call s:reset() -endfunc - func Test_tabpanel_scroll_many_tabs() let save_lines = &lines let save_showtabpanel = &showtabpanel @@ -1023,6 +1001,94 @@ func Test_tabpanel_scroll_many_tabs() let &lines = save_lines endfunc +" The scrollbar thumb must follow the current tab when it is moved by +" gt/gT/:tabnext/:tablast, so that the selected tab is always visible. +func Test_tabpanel_scrollbar_follows_curtab() + let save_lines = &lines + let save_columns = &columns + let save_showtabpanel = &showtabpanel + let save_tabpanelopt = &tabpanelopt + + set lines=10 columns=40 + set showtabpanel=2 tabpanelopt=scrollbar,columns:8 + for i in range(49) + tabnew + endfor + let sb_col = 8 + + " With curtab at the top of the list, row 1 shows the thumb and the + " last row shows the track. Record the two attrs for comparison. + tabfirst + redraw + let attr_thumb = screenattr(1, sb_col) + let attr_track = screenattr(&lines, sb_col) + call assert_notequal(attr_thumb, attr_track) + + " Jump to a tab far outside the visible range: thumb must leave the top. + 30tabnext + redraw + call assert_equal(attr_track, screenattr(1, sb_col)) + + " Back to the first tab: thumb returns to the top. + tabfirst + redraw + call assert_equal(attr_thumb, screenattr(1, sb_col)) + call assert_equal(attr_track, screenattr(&lines, sb_col)) + + " gT from the first tab wraps to the last: thumb moves to the bottom. + normal! gT + redraw + call assert_equal(attr_track, screenattr(1, sb_col)) + call assert_equal(attr_thumb, screenattr(&lines, sb_col)) + + " gt from the last tab wraps to the first: thumb returns to the top. + normal! gt + redraw + call assert_equal(attr_thumb, screenattr(1, sb_col)) + call assert_equal(attr_track, screenattr(&lines, sb_col)) + + %bwipeout! + let &tabpanelopt = save_tabpanelopt + let &showtabpanel = save_showtabpanel + let &lines = save_lines + let &columns = save_columns +endfunc + +" With 31 tabs on 24 rows, :tablast must place the scrollbar thumb's +" bottom at the last screen row. Before the fix, integer truncation in +" thumb_top left a one-row gap at the bottom. +func Test_tabpanel_scrollbar_reaches_bottom() + let save_lines = &lines + let save_columns = &columns + let save_showtabpanel = &showtabpanel + let save_tabpanelopt = &tabpanelopt + + set lines=24 columns=40 + set showtabpanel=2 tabpanelopt=scrollbar,columns:8 + for i in range(30) + tabnew + endfor + let sb_col = 8 + + " Identify the thumb attr while the thumb is at the top. + tabfirst + redraw + let attr_thumb = screenattr(1, sb_col) + let attr_track = screenattr(&lines, sb_col) + call assert_notequal(attr_thumb, attr_track) + + " :tablast must push the thumb all the way to the bottom. + tablast + redraw + call assert_equal(attr_thumb, screenattr(&lines, sb_col)) + + %bwipeout! + let &tabpanelopt = save_tabpanelopt + let &showtabpanel = save_showtabpanel + let &lines = save_lines + let &columns = save_columns +endfunc + func Test_tabpanel_variable_height() let save_lines = &lines diff --git a/src/testdir/util/gen_opt_test.vim b/src/testdir/util/gen_opt_test.vim index bc54d272d8db4a..d95fae89728211 100644 --- a/src/testdir/util/gen_opt_test.vim +++ b/src/testdir/util/gen_opt_test.vim @@ -330,9 +330,11 @@ let test_values = { \ 'tabline': [['', 'xxx'], ['%$', '%{', '%{%', '%{%}', '%(', '%)']], \ 'tabpanel': [['', 'aaa', 'bbb'], []], \ 'tabpanelopt': [['', 'align:left', 'align:right', 'vert', 'columns:0', - \ 'columns:20', 'columns:999'], + \ 'columns:20', 'columns:999', 'scroll', 'scrollbar', + \ 'align:right,scroll', 'columns:15,vert,scrollbar', + \ 'align:right,columns:12,vert,scrollbar'], \ ['xxx', 'align:', 'align:middle', 'colomns:', 'cols:10', - \ 'cols:-1']], + \ 'cols:-1', 'scrol', 'scrollbarx']], \ 'tagcase': [['followic', 'followscs', 'ignore', 'match', 'smart'], \ ['', 'xxx', 'smart,match']], \ 'termencoding': [has('gui_gtk') ? [] : ['', 'utf-8'], ['xxx']],