diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 9da054ed87..9f78179c61 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -159,6 +159,7 @@ the same range instances now compare equal. OPTIONS +• 'scrolloffpad' allows vertically centering cursor at the end of file. • 'winpinned' prevents window from closing unless specifically targeted. PERFORMANCE diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index f7a796c24d..bedd423e37 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -5278,14 +5278,32 @@ A jump table for the options with a short description can be found at |Q_op|. Minimal number of screen lines to keep above and below the cursor. This will make some context visible around where you are working. If you set it to a very large value (999) the cursor line will always be - in the middle of the window (except at the start or end of the file or - when long lines wrap). + in the middle of the window (except at the start or end of the file, + see 'scrolloffpad', or when long lines wrap). After using the local value, go back the global value with one of these two: >vim setlocal scrolloff< setlocal scrolloff=-1 < For scrolling horizontally see 'sidescrolloff'. + *'scrolloffpad'* *'sop'* +'scrolloffpad' 'sop' number (default 0) + global or local to window |global-local| + When 'scrolloff' and 'scrolloffpad' are greater than zero, allow + the cursor to remain centered when at the end of the file. + Normally, 'scrolloff' will not keep the cursor centered at the + end of the file. + + A value of 0 disables this feature. Any value above 0 enables it. + For a window-local value, -1 means to use the global value. + Values below -1 are invalid. + + After using the local value, go back the global value with one of + these two: >vim + setlocal scrolloffpad< + setlocal scrolloffpad=-1 +< + *'scrollopt'* *'sbo'* 'scrollopt' 'sbo' string (default "ver,jump") global diff --git a/runtime/doc/quickref.txt b/runtime/doc/quickref.txt index 78444dbd33..9fc8770dd3 100644 --- a/runtime/doc/quickref.txt +++ b/runtime/doc/quickref.txt @@ -841,6 +841,7 @@ Short explanation of each option: *option-list* 'scrollbind' 'scb' scroll in window as other windows scroll 'scrolljump' 'sj' minimum number of lines to scroll 'scrolloff' 'so' minimum nr. of lines above and below cursor +'scrolloffpad' 'sop' vertically center cursor at end of file 'scrollopt' 'sbo' how 'scrollbind' should behave 'sections' 'sect' nroff macros that separate sections 'secure' secure mode for reading .vimrc in current dir diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index 3842c9a56d..38fdaa774c 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -5488,8 +5488,8 @@ vim.go.sj = vim.go.scrolljump --- Minimal number of screen lines to keep above and below the cursor. --- This will make some context visible around where you are working. If --- you set it to a very large value (999) the cursor line will always be ---- in the middle of the window (except at the start or end of the file or ---- when long lines wrap). +--- in the middle of the window (except at the start or end of the file, +--- see 'scrolloffpad', or when long lines wrap). --- After using the local value, go back the global value with one of --- these two: --- @@ -5507,6 +5507,32 @@ vim.wo.so = vim.wo.scrolloff vim.go.scrolloff = vim.o.scrolloff vim.go.so = vim.go.scrolloff +--- When 'scrolloff' and 'scrolloffpad' are greater than zero, allow +--- the cursor to remain centered when at the end of the file. +--- Normally, 'scrolloff' will not keep the cursor centered at the +--- end of the file. +--- +--- A value of 0 disables this feature. Any value above 0 enables it. +--- For a window-local value, -1 means to use the global value. +--- Values below -1 are invalid. +--- +--- After using the local value, go back the global value with one of +--- these two: +--- +--- ```vim +--- setlocal scrolloffpad< +--- setlocal scrolloffpad=-1 +--- ``` +--- +--- +--- @type integer +vim.o.scrolloffpad = 0 +vim.o.sop = vim.o.scrolloffpad +vim.wo.scrolloffpad = vim.o.scrolloffpad +vim.wo.sop = vim.wo.scrolloffpad +vim.go.scrolloffpad = vim.o.scrolloffpad +vim.go.sop = vim.go.scrolloffpad + --- This is a comma-separated list of words that specifies how --- 'scrollbind' windows should behave. 'sbo' stands for ScrollBind --- Options. diff --git a/runtime/scripts/optwin.lua b/runtime/scripts/optwin.lua index 6961182cf3..ff5bd8d7f6 100644 --- a/runtime/scripts/optwin.lua +++ b/runtime/scripts/optwin.lua @@ -59,6 +59,7 @@ local options_list = { { 'scroll', N_ 'number of lines to scroll for CTRL-U and CTRL-D' }, { 'smoothscroll', N_ 'scroll by screen line' }, { 'scrolloff', N_ 'number of screen lines to show around the cursor' }, + { 'scrolloffpad', N_ 'vertically center cursor even at end of file' }, { 'wrap', N_ 'long lines wrap' }, { 'linebreak', N_ "wrap long lines at a character in 'breakat'" }, { 'breakindent', N_ 'preserve indentation in wrapped text' }, diff --git a/src/nvim/buffer_defs.h b/src/nvim/buffer_defs.h index e3ed8a67c7..72e67d4fd6 100644 --- a/src/nvim/buffer_defs.h +++ b/src/nvim/buffer_defs.h @@ -204,6 +204,8 @@ typedef struct { #define w_p_siso w_onebuf_opt.wo_siso // 'sidescrolloff' local value OptInt wo_so; #define w_p_so w_onebuf_opt.wo_so // 'scrolloff' local value + OptInt wo_sop; +#define w_p_sop w_onebuf_opt.wo_sop // 'scrolloffpad' local value char *wo_winhl; #define w_p_winhl w_onebuf_opt.wo_winhl // 'winhighlight' char *wo_lcs; diff --git a/src/nvim/move.c b/src/nvim/move.c index a1ef9634d2..99b1e73e32 100644 --- a/src/nvim/move.c +++ b/src/nvim/move.c @@ -246,6 +246,26 @@ static void reset_skipcol(win_T *wp) redraw_later(wp, UPD_SOME_VALID); } +/// Return true when 'scrolloffpad' may augment 'scrolloff'. +/// This only applies to automatic cursor visibility correction. +/// For now 'scrolloffpad' is treated as boolean: 0 disables, > 0 enables. +static bool use_scrolloffpad(win_T *wp) +{ + return get_scrolloff_value(wp) > 0 && get_scrolloffpad_value(wp) > 0; +} + +/// Return true when there are not enough real buffer lines below "lnum" to +/// satisfy the requested "so" context. +static bool scrolloffpad_eof_pressure(win_T *wp, linenr_T lnum, OptInt so) +{ + if (!use_scrolloffpad(wp) || so <= 0) { + return false; + } + + // Use subtraction to avoid signed overflow in "lnum + so". + return lnum > wp->w_buffer->b_ml.ml_line_count - so; +} + // Update wp->w_topline to move the cursor onto the screen. void update_topline(win_T *wp) { @@ -278,6 +298,7 @@ void update_topline(win_T *wp) if (mouse_dragging > 0) { *so_ptr = mouse_dragging - 1; } + bool eof_pressure = scrolloffpad_eof_pressure(wp, wp->w_cursor.lnum, *so_ptr); linenr_T old_topline = wp->w_topline; int old_topfill = wp->w_topfill; @@ -350,10 +371,18 @@ void update_topline(win_T *wp) // cursor in the middle of the window. Otherwise put the cursor // near the top of the window. if (n >= halfheight) { - scroll_cursor_halfway(wp, false, false); + if (eof_pressure) { + scroll_cursor_halfway(wp, true, true); + } else { + scroll_cursor_halfway(wp, false, false); + } } else { - scroll_cursor_top(wp, scrolljump_value(wp), false); - check_botline = true; + if (eof_pressure) { + scroll_cursor_halfway(wp, true, true); + } else { + scroll_cursor_top(wp, scrolljump_value(wp), false); + check_botline = true; + } } } else { // Make sure topline is the first line of a fold. @@ -374,7 +403,7 @@ void update_topline(win_T *wp) } assert(wp->w_buffer != 0); - if (wp->w_botline <= wp->w_buffer->b_ml.ml_line_count) { + if (wp->w_botline <= wp->w_buffer->b_ml.ml_line_count || use_scrolloffpad(wp)) { if (wp->w_cursor.lnum < wp->w_botline) { if ((wp->w_cursor.lnum >= wp->w_botline - *so_ptr || win_lines_concealed(wp))) { lineoff_T loff; @@ -382,7 +411,7 @@ void update_topline(win_T *wp) // Cursor is (a few lines) above botline, check if there are // 'scrolloff' window lines below the cursor. If not, need to // scroll. - int n = wp->w_empty_rows; + int n = eof_pressure ? 0 : wp->w_empty_rows; loff.lnum = wp->w_cursor.lnum; // In a fold go to its last line. hasFolding(wp, loff.lnum, NULL, &loff.lnum); @@ -397,7 +426,7 @@ void update_topline(win_T *wp) } botline_forw(wp, &loff); } - if (n >= *so_ptr) { + if (n >= *so_ptr && !eof_pressure) { // sufficient context, no need to scroll check_botline = false; } @@ -424,9 +453,13 @@ void update_topline(win_T *wp) n = wp->w_cursor.lnum - wp->w_botline + 1 + *so_ptr; } if (n <= wp->w_view_height + 1) { - scroll_cursor_bot(wp, scrolljump_value(wp), false); + if (eof_pressure) { + scroll_cursor_halfway(wp, true, true); + } else { + scroll_cursor_bot(wp, scrolljump_value(wp), false); + } } else { - scroll_cursor_halfway(wp, false, false); + scroll_cursor_halfway(wp, eof_pressure, eof_pressure); } } } @@ -2111,8 +2144,9 @@ void scroll_cursor_bot(win_T *wp, int min_scroll, bool set_topbot) // Scroll up if the cursor is off the bottom of the screen a bit. // Otherwise put it at 1/2 of the screen. + bool eof_pressure = scrolloffpad_eof_pressure(wp, cln, so); if (line_count >= wp->w_view_height && line_count > min_scroll) { - scroll_cursor_halfway(wp, false, true); + scroll_cursor_halfway(wp, eof_pressure, true); } else if (line_count > 0) { if (do_sms) { scrollup(wp, scrolled, true); // TODO(vim): @@ -2289,7 +2323,9 @@ void cursor_correct(win_T *wp) validate_botline_win(wp); if (wp->w_botline == wp->w_buffer->b_ml.ml_line_count + 1 && mouse_dragging == 0) { - below_wanted = 0; + if (!use_scrolloffpad(wp)) { + below_wanted = 0; + } int max_off = (wp->w_view_height - 1) / 2; above_wanted = MIN(above_wanted, max_off); } diff --git a/src/nvim/option.c b/src/nvim/option.c index 2318625720..0e33292f77 100644 --- a/src/nvim/option.c +++ b/src/nvim/option.c @@ -3111,6 +3111,12 @@ static const char *validate_num_option(OptIndex opt_idx, OptInt *newval, char *e return e_positive; } break; + case kOptScrolloffpad: + // if (value < 0 && full_screen) { + if (value < 0) { + return e_invarg; + } + break; case kOptSidescrolloff: if (value < 0 && full_screen) { return e_positive; @@ -3600,6 +3606,7 @@ static OptVal get_option_unset_value(OptIndex opt_idx) case kOptFsync: return BOOLEAN_OPTVAL(kNone); case kOptScrolloff: + case kOptScrolloffpad: case kOptSidescrolloff: return NUMBER_OPTVAL(-1); case kOptUndolevels: @@ -4673,6 +4680,8 @@ void *get_varp_scope_from(vimoption_T *p, int opt_flags, buf_T *buf, win_T *win) return &(win->w_p_siso); case kOptScrolloff: return &(win->w_p_so); + case kOptScrolloffpad: + return &(win->w_p_sop); case kOptDefine: return &(buf->b_p_def); case kOptInclude: @@ -4760,6 +4769,8 @@ void *get_varp_from(vimoption_T *p, buf_T *buf, win_T *win) return win->w_p_siso >= 0 ? &(win->w_p_siso) : p->var; case kOptScrolloff: return win->w_p_so >= 0 ? &(win->w_p_so) : p->var; + case kOptScrolloffpad: + return win->w_p_sop >= 0 ? &(win->w_p_sop) : p->var; case kOptBackupcopy: return *buf->b_p_bkc != NUL ? &(buf->b_p_bkc) : p->var; case kOptDefine: @@ -5121,6 +5132,7 @@ void copy_winopt(winopt_T *from, winopt_T *to) to->wo_crb_save = from->wo_crb_save; to->wo_siso = from->wo_siso; to->wo_so = from->wo_so; + to->wo_sop = from->wo_sop; to->wo_spell = from->wo_spell; to->wo_cuc = from->wo_cuc; to->wo_cul = from->wo_cul; @@ -6560,6 +6572,13 @@ int64_t get_scrolloff_value(win_T *wp) return wp->w_p_so < 0 ? p_so : wp->w_p_so; } +/// Return the effective 'scrolloffpad' value for the current window, using the +/// global value when appropriate. +int64_t get_scrolloffpad_value(win_T *wp) +{ + return wp->w_p_sop == -1 ? p_sop : curwin->w_p_sop; +} + /// Return the effective 'sidescrolloff' value for the current window, using the /// global value when appropriate. int64_t get_sidescrolloff_value(win_T *wp) diff --git a/src/nvim/option_vars.h b/src/nvim/option_vars.h index a8f26f4730..c57020b553 100644 --- a/src/nvim/option_vars.h +++ b/src/nvim/option_vars.h @@ -472,6 +472,7 @@ EXTERN char *p_rtp; ///< 'runtimepath' EXTERN OptInt p_scbk; ///< 'scrollback' EXTERN OptInt p_sj; ///< 'scrolljump' EXTERN OptInt p_so; ///< 'scrolloff' +EXTERN OptInt p_sop; ///< 'scrolloffpad' EXTERN char *p_sbo; ///< 'scrollopt' EXTERN char *p_sections; ///< 'sections' EXTERN int p_secure; ///< 'secure' diff --git a/src/nvim/options.lua b/src/nvim/options.lua index cb3db201bd..666888abcd 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -7237,8 +7237,8 @@ local options = { Minimal number of screen lines to keep above and below the cursor. This will make some context visible around where you are working. If you set it to a very large value (999) the cursor line will always be - in the middle of the window (except at the start or end of the file or - when long lines wrap). + in the middle of the window (except at the start or end of the file, + see 'scrolloffpad', or when long lines wrap). After using the local value, go back the global value with one of these two: >vim setlocal scrolloff< @@ -7251,6 +7251,31 @@ local options = { type = 'number', varname = 'p_so', }, + { + abbreviation = 'sop', + defaults = 0, + desc = [=[ + When 'scrolloff' and 'scrolloffpad' are greater than zero, allow + the cursor to remain centered when at the end of the file. + Normally, 'scrolloff' will not keep the cursor centered at the + end of the file. + + A value of 0 disables this feature. Any value above 0 enables it. + For a window-local value, -1 means to use the global value. + Values below -1 are invalid. + + After using the local value, go back the global value with one of + these two: >vim + setlocal scrolloffpad< + setlocal scrolloffpad=-1 + < + ]=], + full_name = 'scrolloffpad', + scope = { 'global', 'win' }, + short_desc = N_('vertically center cursor even at end of file'), + type = 'number', + varname = 'p_sop', + }, { abbreviation = 'sbo', defaults = 'ver,jump', diff --git a/src/nvim/window.c b/src/nvim/window.c index 5f9082f6e6..40f471a9fb 100644 --- a/src/nvim/window.c +++ b/src/nvim/window.c @@ -5532,6 +5532,7 @@ win_T *win_alloc(win_T *after, bool hidden) // use global option for global-local options new_wp->w_allbuf_opt.wo_so = new_wp->w_p_so = -1; + new_wp->w_allbuf_opt.wo_sop = new_wp->w_p_sop = -1; new_wp->w_allbuf_opt.wo_siso = new_wp->w_p_siso = -1; // We won't calculate w_fraction until resizing the window diff --git a/test/functional/legacy/scroll_opt_spec.lua b/test/functional/legacy/scroll_opt_spec.lua index 16f061a5c7..505d479418 100644 --- a/test/functional/legacy/scroll_opt_spec.lua +++ b/test/functional/legacy/scroll_opt_spec.lua @@ -1397,3 +1397,205 @@ describe('smoothscroll', function() ]]) end) end) + +describe('scrolloffpad', function() + local screen + + before_each(function() + screen = Screen.new(78, 20) + end) + + -- oldtest: Test_scrolloffpad_basic() + it('works', function() + exec([[ + set scrolloff=10 + set scrolloffpad=5 + enew! + call setline(1, map(range(1, 100), 'printf("line %d", v:val)')) + normal! gg + ]]) + + -- Enabled: scrolloffpad > 0, expect EOF centering/padding + exec('normal! G') + screen:expect([[ + line 91 | + line 92 | + line 93 | + line 94 | + line 95 | + line 96 | + line 97 | + line 98 | + line 99 | + ^line 100 | + {1:~ }|*9 + | + ]]) + + -- Beginning-of-file is unchanged (Top) + exec('normal! gg') + screen:expect([[ + ^line 1 | + line 2 | + line 3 | + line 4 | + line 5 | + line 6 | + line 7 | + line 8 | + line 9 | + line 10 | + line 11 | + line 12 | + line 13 | + line 14 | + line 15 | + line 16 | + line 17 | + line 18 | + line 19 | + | + ]]) + + -- Gating: disable scrolloffpad, then go to EOF again + -- Expect normal EOF behavior (no extra centering/padding) + exec('set scrolloffpad=0') + exec('normal! G') + screen:expect([[ + line 82 | + line 83 | + line 84 | + line 85 | + line 86 | + line 87 | + line 88 | + line 89 | + line 90 | + line 91 | + line 92 | + line 93 | + line 94 | + line 95 | + line 96 | + line 97 | + line 98 | + line 99 | + ^line 100 | + | + ]]) + end) + + -- oldtest: Test_scrolloffpad_smoothscroll() + it('works with smoothscroll', function() + exec([[ + set smoothscroll scrolloff=10 scrolloffpad=1 + enew! + call setline(1, map(range(1, 100), 'printf("line %d", v:val)')) + normal! gg + ]]) + + exec('normal! G') + screen:expect([[ + line 91 | + line 92 | + line 93 | + line 94 | + line 95 | + line 96 | + line 97 | + line 98 | + line 99 | + ^line 100 | + {1:~ }|*9 + | + ]]) + + exec([[call setline(line('$'), repeat('LONG ', 30))]]) + exec('normal! 41|') + screen:expect([[ + line 92 | + line 93 | + line 94 | + line 95 | + line 96 | + line 97 | + line 98 | + line 99 | + LONG LONG LONG LONG LONG LONG LONG LONG ^LONG LONG LONG LONG LONG LONG LONG LON| + G LONG LONG LONG LONG LONG LONG LONG LONG LONG LONG LONG LONG LONG LONG | + {1:~ }|*9 + | + ]]) + end) + + -- oldtest: Test_scrolloffpad_with_folds() + it('works with folds', function() + exec([[ + set scrolloff=10 + set scrolloffpad=1 + + enew + call setline(1, map(range(1, 120), {_, v -> 'line ' . v})) + + " Create a large fold near the end of the file. + " Fold lines 60-110, leaving 111-120 visible after the fold. + set foldmethod=manual + set foldenable + normal! gg + normal! 60G + normal! zf50j + normal! gg + ]]) + + -- Case 1: Jump to end-of-file + -- With folds present, scrolloffpad should still + -- keep the cursor positioned with padding below EOF + exec('normal! G') + local s1 = [[ + line 111 | + line 112 | + line 113 | + line 114 | + line 115 | + line 116 | + line 117 | + line 118 | + line 119 | + ^line 120 | + {1:~ }|*9 + | + ]] + screen:expect(s1) + + -- Case 2: Move to the folded line to ensure the fold is actually in view + exec('normal! 60G') + screen:expect([[ + line 51 | + line 52 | + line 53 | + line 54 | + line 55 | + line 56 | + line 57 | + line 58 | + line 59 | + {13:^+-- 51 lines: line 60·························································}| + line 111 | + line 112 | + line 113 | + line 114 | + line 115 | + line 116 | + line 117 | + line 118 | + line 119 | + | + ]]) + + -- Case 3: Close the fold explicitly and go to EOF again + -- Behavior should remain stable with closed folds + exec('normal! zc') + exec('normal! G') + screen:expect(s1) + end) +end) diff --git a/test/old/testdir/gen_opt_test.vim b/test/old/testdir/gen_opt_test.vim index 07997a5ee5..733d8099cc 100644 --- a/test/old/testdir/gen_opt_test.vim +++ b/test/old/testdir/gen_opt_test.vim @@ -24,6 +24,7 @@ while search("^'[^']*'.*\\n.*|global-local", 'W') endwhile call extend(global_locals, #{ \ scrolloff: -1, + \ scrolloffpad: -1, \ sidescrolloff: -1, \ undolevels: -123456, \}) @@ -127,6 +128,7 @@ let test_values = { \ 'scroll': [[0, 1, 2, 15], [-1, 999]], \ 'scrolljump': [[-100, -1, 0, 1, 2, 15], [-101, 999]], \ 'scrolloff': [[0, 1, 8, 999], [-1]], + \ 'scrolloffpad': [[0, 1, 2, 3], [-1]], \ 'shiftwidth': [[0, 1, 8, 999], [-1]], \ 'sidescroll': [[0, 1, 8, 999], [-1]], \ 'sidescrolloff': [[0, 1, 8, 999], [-1]], diff --git a/test/old/testdir/test_cursor_func.vim b/test/old/testdir/test_cursor_func.vim index 2554167c82..912a2d5473 100644 --- a/test/old/testdir/test_cursor_func.vim +++ b/test/old/testdir/test_cursor_func.vim @@ -124,7 +124,8 @@ func Test_screenpos() setlocal nonumber display=lastline so=0 exe "normal G\\" redraw - call assert_equal({'row': winrow + wininfo.height - 1, + let winbar_height = get(wininfo, 'winbar', 0) + call assert_equal({'row': winrow + wininfo.height - 1 + winbar_height, \ 'col': wincol + 7, \ 'curscol': wincol + 7, \ 'endcol': wincol + 7}, winid->screenpos(line('$'), 8)) diff --git a/test/old/testdir/test_options.vim b/test/old/testdir/test_options.vim index b424383791..cbd1f74bd9 100644 --- a/test/old/testdir/test_options.vim +++ b/test/old/testdir/test_options.vim @@ -1470,6 +1470,46 @@ func Test_local_scrolloff() set siso& endfunc +func Test_local_scrolloffpad() + let save_g_sop = &g:sop + let save_l_sop = &l:sop + set sop=0 + call assert_equal(0, &g:sop) + call assert_equal(-1, &l:sop) + call assert_equal(0, &sop) + setglobal sop=1 + call assert_equal(1, &g:sop) + call assert_equal(1, &sop) + split + call assert_equal(1, &g:sop) + call assert_equal(-1, &l:sop) + call assert_equal(1, &sop) + setlocal sop=0 + call assert_equal(0, &l:sop) + call assert_equal(0, &sop) + call assert_equal(1, &g:sop) + wincmd p + call assert_equal(1, &sop) + wincmd p + "setlocal sop< + set sop< + call assert_equal(-1, &l:sop) + call assert_equal(1, &sop) + setlocal sop=2 + call assert_equal(2, &l:sop) + call assert_equal(2, &sop) + setlocal sop=-1 + call assert_equal(-1, &l:sop) + call assert_equal(1, &sop) " Uses global value because local is -1 + call assert_fails("setlocal sop=-2", 'E474:') + call assert_equal(-1, &l:sop) + call assert_equal(1, &sop) + call assert_fails("setlocal sop=foo", 'E521:') + close + let &g:sop = save_g_sop + let &l:sop = save_l_sop +endfunc + func Test_writedelay() CheckFunction reltimefloat diff --git a/test/old/testdir/test_scroll_opt.vim b/test/old/testdir/test_scroll_opt.vim index e5e5067b54..6f2f8010f4 100644 --- a/test/old/testdir/test_scroll_opt.vim +++ b/test/old/testdir/test_scroll_opt.vim @@ -1443,6 +1443,657 @@ func Test_smoothscroll_listchars_eol() bwipe! endfunc +" scrolloffpad contract: +" - augment scrolloff only under EOF pressure (insufficient real lines below); +" - do not change explicit "z" viewport placement command semantics; +" - current scope is EOF-only, so BOF behavior remains unchanged. +func Test_scrolloffpad_zb_keeps_bottom_command_semantics() + new + resize 12 + setlocal scrolloff=10 + call setline(1, map(range(1, 300), 'printf("line %d", v:val)')) + + setlocal scrolloffpad=0 + normal! gg150Gzb + let baseline = [line('.'), line('w$'), winline()] + + setlocal scrolloffpad=1 + normal! gg150Gzb + call assert_equal(baseline, [line('.'), line('w$'), winline()]) + + bwipe! +endfunc + +func Test_scrolloffpad_zminus_keeps_bottom_beginline_semantics() + new + resize 12 + setlocal scrolloff=10 + call setline(1, map(range(1, 300), 'printf(" line %d", v:val)')) + + setlocal scrolloffpad=0 + normal! gg150Gz- + let baseline = [line('.'), line('w$'), winline(), col('.')] + call assert_equal(match(getline('.'), '\S') + 1, col('.')) + + setlocal scrolloffpad=1 + normal! gg150Gz- + call assert_equal(baseline, [line('.'), line('w$'), winline(), col('.')]) + call assert_equal(match(getline('.'), '\S') + 1, col('.')) + + bwipe! +endfunc + +func Test_scrolloffpad_zb_is_one_shot_then_scrolloff_reapplies() + new + resize 12 + setlocal scrolloff=10 + call setline(1, map(range(1, 300), 'printf("line %d", v:val)')) + + let after_zb = {} + let after_j = {} + for sop in [0, 1] + let &l:scrolloffpad = sop + normal! gg150Gzb + let after_zb[sop] = [line('.'), line('w$'), winline(), winsaveview().topline] + + normal! j + let after_j[sop] = [line('.'), line('w$'), winline(), winsaveview().topline] + call assert_notequal(after_zb[sop][3], after_j[sop][3]) + call assert_true(line('.') < line('w$')) + endfor + call assert_equal(after_zb[0], after_zb[1]) + call assert_equal(after_j[0], after_j[1]) + + bwipe! +endfunc + +func Test_scrolloffpad_has_no_mid_buffer_effect() + new + resize 12 + setlocal scrolloff=10 scrolloffpad=0 + call setline(1, map(range(1, 500), 'printf("line %d", v:val)')) + + normal! gg150G + let topline_without_pad = winsaveview().topline + + setlocal scrolloffpad=1 + normal! gg150G + let topline_with_pad = winsaveview().topline + + call assert_equal(topline_without_pad, topline_with_pad) + + bwipe! +endfunc + +func Test_scrolloffpad_changes_eof_pressure_only() + new + resize 12 + setlocal scrolloff=10 scrolloffpad=0 + call setline(1, map(range(1, 200), 'printf("line %d", v:val)')) + + normal! ggG + let view_without_pad = winsaveview() + let cursor_without_pad = line('.') + let row_without_pad = winline() + + setlocal scrolloffpad=1 + normal! ggG + let view_with_pad = winsaveview() + let row_with_pad = winline() + + call assert_equal(line('$'), line('.')) + call assert_equal(cursor_without_pad, line('.')) + call assert_notequal(view_without_pad.topline, view_with_pad.topline) + call assert_true(row_with_pad < row_without_pad) + + bwipe! +endfunc + +func Test_scrolloffpad_large_scrolloff_no_overflow() + new + resize 12 + call setline(1, map(range(1, 200), 'printf("line %d", v:val)')) + setlocal scrolloff=2147483647 scrolloffpad=0 + + normal! ggG + let view_without_pad = winsaveview() + let row_without_pad = winline() + + setlocal scrolloffpad=1 + normal! ggG + let view_with_pad = winsaveview() + let row_with_pad = winline() + + call assert_equal(line('$'), line('.')) + call assert_notequal(view_without_pad.topline, view_with_pad.topline) + call assert_true(row_with_pad < row_without_pad) + + bwipe! +endfunc + +func Test_scrolloffpad_boolean_gate_values() + new + resize 12 + setlocal scrolloff=10 + call setline(1, map(range(1, 200), 'printf("line %d", v:val)')) + + let views = {} + let rows = {} + for sop in [0, 1, 2] + let &l:scrolloffpad = sop + normal! ggG + let views[sop] = winsaveview() + let rows[sop] = winline() + call assert_equal(line('$'), line('.')) + endfor + + call assert_equal(views[1].topline, views[2].topline) + call assert_equal(rows[1], rows[2]) + call assert_notequal(views[0].topline, views[1].topline) + call assert_true(rows[1] < rows[0]) + + bwipe! +endfunc + +func Test_scrolloffpad_requires_scrolloff_nonzero() + new + resize 12 + call setline(1, map(range(1, 200), 'printf("line %d", v:val)')) + + let states = {} + for so in [0, 10] + let states[so] = {} + for sop in [0, 1] + let &l:scrolloff = so + let &l:scrolloffpad = sop + normal! ggG + let states[so][sop] = [line('.'), line('w0'), line('w$'), winline()] + call assert_equal(line('$'), line('.')) + endfor + endfor + + call assert_equal(states[0][0], states[0][1]) + call assert_notequal(states[10][0], states[10][1]) + call assert_true(states[10][1][3] < states[10][0][3]) + + bwipe! +endfunc + +func Test_scrolloffpad_search_to_eof() + new + resize 12 + setlocal scrolloff=10 + call setline(1, map(range(1, 200), 'printf("line %d", v:val)')) + call setline(line('$'), 'EOF TARGET') + + let states = {} + for sop in [0, 1] + let &l:scrolloffpad = sop + normal! gg + call assert_true(search('EOF TARGET') > 0) + let states[sop] = [line('.'), line('w0'), line('w$'), winline()] + call assert_equal(line('$'), line('.')) + endfor + + call assert_notequal(states[0], states[1]) + call assert_true(states[1][3] < states[0][3]) + + bwipe! +endfunc + +func Test_scrolloffpad_paging_to_eof() + new + resize 12 + setlocal scrolloff=10 + call setline(1, map(range(1, 240), 'printf("line %d", v:val)')) + + let states = {} + for sop in [0, 1] + let &l:scrolloffpad = sop + normal! gg + + let prev = -1 + for _ in range(1, 200) + execute "normal! \" + if line('.') == prev + break + endif + let prev = line('.') + endfor + + let states[sop] = [line('.'), line('w0'), line('w$'), winline()] + call assert_equal(line('$'), line('w$')) + endfor + + call assert_notequal(states[0], states[1]) + call assert_true(states[1][3] < states[0][3]) + + bwipe! +endfunc + +func Test_scrolloffpad_autocmd_append_at_eof() + let states = {} + for sop in [0, 1] + new + resize 12 + setlocal scrolloff=10 + let &l:scrolloffpad = sop + call setline(1, map(range(1, 120), 'printf("line %d", v:val)')) + + let b:scrolloffpad_appended = 0 + augroup ScrolloffpadAppendAtEof + autocmd! + autocmd CursorMoved if b:scrolloffpad_appended == 0 && line('.') == line('$') | call append('$', 'appended') | let b:scrolloffpad_appended = 1 | endif + augroup END + + normal! ggG + doautocmd CursorMoved + let states[sop] = [ + \ line('.'), + \ line('$'), + \ line('w0'), + \ line('w$'), + \ winline(), + \ b:scrolloffpad_appended, + \ ] + + call assert_equal(1, b:scrolloffpad_appended) + call assert_equal(states[sop][1] - 1, states[sop][0]) + + augroup ScrolloffpadAppendAtEof + autocmd! + augroup END + bwipe! + endfor + + call assert_notequal(states[0], states[1]) + call assert_true(states[1][4] < states[0][4]) + +endfunc + +func Test_scrolloffpad_eof_no_reverse_scroll_on_j() + new + resize 20 + setlocal scrolloff=20 scrolloffpad=1 + call setline(1, map(range(1, 80), 'printf("line %d", v:val)')) + + normal! gg + let prev_topline = winsaveview().topline + for lnum in range(2, line('$')) + normal! j + let cur_topline = winsaveview().topline + call assert_true( + \ cur_topline >= prev_topline, + \ printf('topline moved backwards at line %d: %d -> %d', + \ lnum, prev_topline, cur_topline)) + let prev_topline = cur_topline + endfor + + bwipe! +endfunc + +func Test_scrolloffpad_bof_unchanged() + new + resize 12 + setlocal scrolloff=10 scrolloffpad=0 + call setline(1, map(range(1, 200), 'printf("line %d", v:val)')) + + normal! Ggg + let view_without_pad = winsaveview() + let w0_without_pad = line('w0') + + setlocal scrolloffpad=1 + normal! Ggg + let view_with_pad = winsaveview() + let w0_with_pad = line('w0') + + call assert_equal(1, w0_without_pad) + call assert_equal(1, w0_with_pad) + call assert_equal(view_without_pad.topline, view_with_pad.topline) + + bwipe! +endfunc + +func Test_scrolloffpad_mouse_drag_uses_drag_scrolloff() + CheckFeature mouse + + let save_mouse = &mouse + set mouse=a + + new + resize 20 + call setline(1, map(range(1, 240), 'printf("line %d", v:val)')) + setlocal scrolloff=50 + + let after_drag = {} + for sop in [0, 1] + let &l:scrolloffpad = sop + normal! gg160Gzt + normal! v + call Ntest_setmouse(2, 1) + call feedkeys("\", 'xt') + call Ntest_setmouse(3, 1) + call feedkeys("\", 'xt') + let after_drag[sop] = [winsaveview().topline, line('.'), winline()] + call feedkeys("\", 'xt') + endfor + + call assert_equal(after_drag[0], after_drag[1]) + + bwipe! + let &mouse = save_mouse +endfunc + +func Test_scrolloffpad_basic() + CheckScreendump + CheckRunVimInTerminal + + let save_termwinsize = &termwinsize + set termwinsize= + + let lines =<< trim END + set scrolloff=10 + set scrolloffpad=5 + enew! + call setline(1, map(range(1, 100), 'printf("line %d", v:val)')) + normal! gg + END + call writefile(lines, 'XScrolloffpadBasic', 'D') + + let buf = RunVimInTerminal('-S XScrolloffpadBasic', {'rows': 20, 'cols': 78}) + + " Enabled: scrolloffpad > 0, expect EOF centering/padding + call term_sendkeys(buf, "\:\normal! G\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_basic_1', {}) + + " Beginning-of-file is unchanged (Top) + call term_sendkeys(buf, "\:\normal! gg\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_basic_2', {}) + + " Gating: disable scrolloffpad, then go to EOF again + " Expect normal EOF behavior (no extra centering/padding) + call term_sendkeys(buf, "\:\set scrolloffpad=0\") + call term_sendkeys(buf, "\:\normal! G\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_basic_3', {}) + + call StopVimInTerminal(buf) + let &termwinsize = save_termwinsize +endfunc + +func Test_scrolloffpad_smoothscroll() + CheckScreendump + CheckRunVimInTerminal + + let save_termwinsize = &termwinsize + set termwinsize= + + let lines =<< trim END + set smoothscroll scrolloff=10 scrolloffpad=1 + enew! + call setline(1, map(range(1, 100), 'printf("line %d", v:val)')) + normal! gg + END + call writefile(lines, 'XScrolloffpadSmoothscroll', 'D') + + let buf = RunVimInTerminal('-S XScrolloffpadSmoothscroll', #{rows: 20, cols: 78}) + + call term_sendkeys(buf, "\:\normal! G\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_smoothscroll_1', {}) + + call term_sendkeys(buf, "\:\call setline(line('$'), repeat('LONG ', 30))\") + call term_sendkeys(buf, "\:\normal! 41|\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_smoothscroll_2', {}) + + call StopVimInTerminal(buf) + let &termwinsize = save_termwinsize +endfunc + +func Test_scrolloffpad_insert_eof() + let save_so = &scrolloff + let save_sop = &scrolloffpad + + set scrolloff=10 scrolloffpad=1 + enew! + call setline(1, map(range(1, 200), 'printf("line %d", v:val)')) + normal! G + + let topline_before = winsaveview().topline + call feedkeys("i\", 'xt') + call assert_equal(topline_before, winsaveview().topline) + + exe "normal! \" + let topline_after = winsaveview().topline + call feedkeys("i\", 'xt') + call assert_equal(topline_after, winsaveview().topline) + + let &scrolloff = save_so + let &scrolloffpad = save_sop + bwipe! +endfunc + +func Test_scrolloffpad_in_diff_mode() + CheckFeature diff + + let save_so = &scrolloff + let save_sop = &scrolloffpad + let save_splitright = &splitright + + set nosplitright + set scrolloff=10 + set scrolloffpad=0 + + enew + call setline(1, map(range(1, 100), {_, v -> 'line ' .. v})) + diffthis + + vnew + call setline(1, map(range(1, 100), {_, v -> 'line ' .. v})) + " Make buffers minimally different to avoid diff folding everything. + call setline(50, 'DIFF LINE 50') + diffthis + + windo normal! zR + windo normal! gg + wincmd = + + let rows_without = [] + let rows_with = [] + let near_states = [] + let eof_states = [] + for sop in [0, 1] + let &scrolloffpad = sop + + " Near EOF with real text visible in both windows. + windo normal! 99G + for w in range(1, winnr('$')) + execute w .. 'wincmd w' + let state = [line('.'), line('w0'), line('w$'), winline()] + call assert_equal(99, state[0]) + call assert_equal(100, state[2]) + if sop == 0 + call add(near_states, state) + endif + endfor + call assert_equal(near_states[0], near_states[1]) + + " EOF in both windows: scrolloffpad should raise the cursor row. + windo normal! G + for w in range(1, winnr('$')) + execute w .. 'wincmd w' + let state = [line('.'), line('w0'), line('w$'), winline()] + call assert_equal(line('$'), state[0]) + if sop == 0 + call add(eof_states, state) + call add(rows_without, state[3]) + else + call add(rows_with, state[3]) + endif + endfor + call assert_equal(eof_states[0], eof_states[1]) + endfor + + call assert_true(rows_with[0] < rows_without[0]) + call assert_true(rows_with[1] < rows_without[1]) + + windo diffoff + %bwipe! + let &scrolloff = save_so + let &scrolloffpad = save_sop + let &splitright = save_splitright +endfunc + +func Test_scrolloffpad_diff_eof_filler_behavior() + CheckFeature diff + + let save_so = &scrolloff + let save_sop = &scrolloffpad + let save_diffopt = &diffopt + let save_splitright = &splitright + + set diffopt+=filler + set scrolloff=10 + set scrolloffpad=0 + set nosplitright + + 20new + call setline(1, map(range(1, 100), {_, v -> 'left ' .. v})) + diffthis + let short_wid = win_getid() + + vnew + call setline(1, map(range(1, 120), {_, v -> 'right ' .. v})) + diffthis + let long_wid = win_getid() + + call assert_true(win_gotoid(short_wid)) + let short_height = winheight(0) + call assert_true(win_gotoid(long_wid)) + let long_height = winheight(0) + call assert_equal(short_height, long_height) + call assert_equal(20, short_height) + + let ordered_diff_wids = [long_wid, short_wid] + let states = {} + for sop in [0, 1] + execute 'set scrolloffpad=' .. sop + for wid in ordered_diff_wids + call assert_true(win_gotoid(wid)) + normal! gg + endfor + for wid in ordered_diff_wids + call assert_true(win_gotoid(wid)) + normal! G + endfor + + call assert_true(win_gotoid(short_wid)) + let short_view = winsaveview() + let short_state = [ + \ line('.'), + \ line('$'), + \ winline(), + \ short_view.topline, + \ short_view.topfill, + \ diff_filler(line('$') + 1), + \ ] + call assert_equal(short_state[1], short_state[0]) + call assert_true(short_state[5] > 0) + + call assert_true(win_gotoid(long_wid)) + let long_view = winsaveview() + let long_state = [ + \ line('.'), + \ line('$'), + \ winline(), + \ long_view.topline, + \ long_view.topfill, + \ ] + call assert_true(long_state[0] > 0 && long_state[0] <= long_state[1]) + call assert_equal(short_state[0], long_state[0]) + + let states[sop] = [short_state, long_state] + endfor + + let short_without = states[0][0] + let short_with = states[1][0] + " Environment/layout can shift direction of movement; require only that + " scrolloffpad changes the short-window viewport state under EOF filler. + call assert_true(short_with[2] != short_without[2] + \ || short_with[3] != short_without[3] + \ || short_with[4] != short_without[4]) + + windo diffoff + call assert_true(win_gotoid(short_wid)) + only! + %bwipe! + let &scrolloff = save_so + let &scrolloffpad = save_sop + let &diffopt = save_diffopt + let &splitright = save_splitright +endfunc + +func Test_scrolloffpad_with_folds() + CheckScreendump + CheckRunVimInTerminal + CheckFeature folding + + let save_termwinsize = &termwinsize + set termwinsize= + + let lines =<< trim END + set scrolloff=10 + set scrolloffpad=1 + + enew + call setline(1, map(range(1, 120), {_, v -> 'line ' . v})) + + " Create a large fold near the end of the file. + " Fold lines 60-110, leaving 111-120 visible after the fold. + set foldmethod=manual + set foldenable + normal! gg + normal! 60G + normal! zf50j + normal! gg + END + call writefile(lines, 'XScrolloffpadFolds', 'D') + + let buf = RunVimInTerminal('-S XScrolloffpadFolds', #{rows: 20, cols: 78}) + + " Case 1: Jump to end-of-file + " With folds present, scrolloffpad should still + " keep the cursor positioned with padding below EOF + call term_sendkeys(buf, "\:\normal! G\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_folds_1', {}) + + " Case 2: Move to the folded line to ensure the fold is actually in view + call term_sendkeys(buf, "\:\normal! 60G\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_folds_2', {}) + + " Case 3: Close the fold explicitly and go to EOF again + " Behavior should remain stable with closed folds + call term_sendkeys(buf, "\:\normal! zc\") + call term_sendkeys(buf, "\:\normal! G\") + call term_sendkeys(buf, "\") + call TermWait(buf) + call VerifyScreenDump(buf, 'Test_scrolloffpad_folds_3', {}) + + call StopVimInTerminal(buf) + let &termwinsize = save_termwinsize +endfunc " Resizing to "textoff" after 'smoothscroll' skips part of a wrapped line must " not crash.