From 0d21f6f395d046ccbfcc3d658c5a264bb82aa45b Mon Sep 17 00:00:00 2001 From: coriocactus <69796618+coriocactus@users.noreply.github.com> Date: Sat, 4 Apr 2026 07:45:48 +0100 Subject: [PATCH 1/5] add stable keyboard scrolling to vim floating preview popups vim popupwin floating previews had no reliable keyboard scrolling, so long hover and detail content was awkward to read without the mouse add a popup filter for /, /, mouse wheel scrolling, and , clamp scrolling at the popup bounds, and keep popup size stable while visible by locking width to the widest popup content line --- autoload/ale/floating_preview.vim | 169 ++++++++++++++ doc/ale.txt | 52 +++++ test/test_floating_preview_popupwin.vader | 259 ++++++++++++++++++++++ 3 files changed, 480 insertions(+) create mode 100644 test/test_floating_preview_popupwin.vader diff --git a/autoload/ale/floating_preview.vim b/autoload/ale/floating_preview.vim index 3e1fabb859..14f0558936 100644 --- a/autoload/ale/floating_preview.vim +++ b/autoload/ale/floating_preview.vim @@ -86,12 +86,15 @@ function! s:VimShow(lines, options) abort call s:VimCreate(a:options) endif + call s:ResetManagedPopupSize() + " Execute commands in window context for l:command in get(a:options, 'commands', []) call win_execute(w:preview['id'], l:command) endfor call popup_settext(w:preview['id'], a:lines) + call s:LockPopupSize(w:preview['id'], a:lines) if g:ale_close_preview_on_insert augroup ale_floating_preview_window @@ -144,6 +147,170 @@ function! s:NvimCreate(options) abort let w:preview = {'id': l:winid, 'buffer': l:buffer} endfunction +function! s:GetPageScrollOffset(winid) abort + let l:position = popup_getpos(a:winid) + + if empty(l:position) + return 1 + endif + + return max([ + \ 1, + \ float2nr(ceil(l:position.core_height / 2.0)), + \]) +endfunction + +function! s:GetPopupContentWidth(lines) abort + if empty(a:lines) + return 1 + endif + + return max(map(copy(a:lines), 'strdisplaywidth(v:val)')) +endfunction + +function! s:GetPopupLineRows(winid) abort + let l:position = popup_getpos(a:winid) + let l:width = get(l:position, 'core_width', 0) + let l:bufnr = winbufnr(a:winid) + + if l:width <= 0 || l:bufnr <= 0 + return [] + endif + + return map( + \ getbufline(l:bufnr, 1, '$'), + \ 'max([1, float2nr(ceil(strdisplaywidth(v:val) / (l:width * 1.0)))])', + \) +endfunction + +function! s:GetPopupMaxFirstLine(winid) abort + let l:position = popup_getpos(a:winid) + let l:line_rows = s:GetPopupLineRows(a:winid) + let l:remaining_rows = 0 + + if empty(l:position) || empty(l:line_rows) + return 1 + endif + + for l:index in reverse(range(0, len(l:line_rows) - 1)) + let l:remaining_rows += l:line_rows[l:index] + + if l:remaining_rows >= l:position.core_height + return l:index + 1 + endif + endfor + + return 1 +endfunction + +function! s:LockPopupSize(winid, lines) abort + let l:options = popup_getoptions(a:winid) + let l:position = popup_getpos(a:winid) + let l:managed_size = {} + let l:size_options = {} + + if empty(l:options) || empty(l:position) + return + endif + + if get(l:options, 'minwidth', 0) is# 0 + \&& get(l:options, 'maxwidth', 0) is# 0 + let l:size_options.minwidth = s:GetPopupContentWidth(a:lines) + let l:size_options.maxwidth = l:size_options.minwidth + let l:managed_size.width = 1 + endif + + if get(l:options, 'minheight', 0) is# 0 + \&& get(l:options, 'maxheight', 0) is# 0 + let l:size_options.minheight = l:position.core_height + let l:size_options.maxheight = l:position.core_height + let l:managed_size.height = 1 + endif + + if !empty(l:size_options) + call popup_setoptions(a:winid, l:size_options) + let w:preview.managed_size = l:managed_size + endif +endfunction + +function! s:ResetManagedPopupSize() abort + let l:managed_size = get(w:preview, 'managed_size', {}) + let l:size_options = {} + + if empty(l:managed_size) + return + endif + + if get(l:managed_size, 'width', 0) + let l:size_options.minwidth = 0 + let l:size_options.maxwidth = 0 + endif + + if get(l:managed_size, 'height', 0) + let l:size_options.minheight = 0 + let l:size_options.maxheight = 0 + endif + + if !empty(l:size_options) + call popup_setoptions(w:preview['id'], l:size_options) + endif + + unlet w:preview.managed_size +endfunction + +function! s:ScrollPopup(winid, count, direction) abort + let l:position = popup_getpos(a:winid) + + if empty(l:position) + return + endif + + if a:direction is# 'j' + let l:count = min([ + \ a:count, + \ s:GetPopupMaxFirstLine(a:winid) - l:position.firstline, + \]) + else + let l:count = min([a:count, l:position.firstline - 1]) + endif + + if l:count <= 0 + return + endif + + call win_execute(a:winid, 'normal! ' . l:count . a:direction . 'zt') +endfunction + +function! ale#floating_preview#PopupFilter(winid, key) abort + if a:key is# "\" || a:key is# "\" + call s:ScrollPopup(a:winid, 1, 'j') + + return 1 + elseif a:key is# "\" || a:key is# "\" + call s:ScrollPopup(a:winid, 1, 'k') + + return 1 + elseif a:key is# "\" + call s:ScrollPopup(a:winid, s:GetPageScrollOffset(a:winid), 'j') + + return 1 + elseif a:key is# "\" + call s:ScrollPopup(a:winid, s:GetPageScrollOffset(a:winid), 'k') + + return 1 + elseif a:key is# "\" + if exists('w:preview') && get(w:preview, 'id', 0) is# a:winid + call s:VimClose() + else + call popup_close(a:winid) + endif + + return 1 + endif + + return 0 +endfunction + function! s:VimCreate(options) abort " default options let l:popup_opts = extend({ @@ -164,6 +331,8 @@ function! s:VimCreate(options) abort \ get(g:ale_floating_window_border, 4, '+'), \ get(g:ale_floating_window_border, 5, '+'), \ ], + \ 'filter': function('ale#floating_preview#PopupFilter'), + \ 'filtermode': 'n', \ 'moved': 'any', \ }, s:GetPopupOpts()) diff --git a/doc/ale.txt b/doc/ale.txt index 361dc75e03..6fcc03380e 100644 --- a/doc/ale.txt +++ b/doc/ale.txt @@ -835,6 +835,40 @@ or |g:ale_floating_preview| is set to `true` or `1`, the hover information will show in a floating window. The borders of the floating preview window can be customized by setting |g:ale_floating_window_border|. +For Vim with |popupwin|, floating preview windows can be scrolled with the +keyboard and dismissed with ||. The following keys are supported: + + || - Scroll down one line + || - Scroll up one line + || - Scroll down half a page + || - Scroll up half a page + || - Close the popup + +Mouse scroll wheel also works when |'mouse'| is enabled. + +If you do not set popup width or height limits yourself, ALE keeps the popup +at a stable size while it is visible. For width, ALE locks Vim popups to the +maximum display width across the popup contents, so later long lines do not +cause the popup to stay stuck at the initial viewport width. + +The filter can be overridden via |g:ale_floating_preview_popup_opts| by +providing a custom `filter` key. For example, to scroll with `j` and `k` +instead: > + + function! MyPopupFilter(winid, key) abort + if a:key is# 'j' + return ale#floating_preview#PopupFilter(a:winid, "\") + elseif a:key is# 'k' + return ale#floating_preview#PopupFilter(a:winid, "\") + elseif a:key is# "\" || a:key is# "\" || a:key is# "\" + return ale#floating_preview#PopupFilter(a:winid, a:key) + endif + return 0 + endfunction + + let g:ale_floating_preview_popup_opts = {'filter': function('MyPopupFilter')} +< + For Vim 8.1+ terminals, mouse hovering is disabled by default. Enabling |balloonexpr| commands in terminals can cause scrolling issues in terminals, so ALE will not attempt to show balloons unless |g:ale_set_balloons| is set to @@ -1464,6 +1498,24 @@ g:ale_floating_preview_popup_opts NOTE: for Vim users see |popup_create-arguments|, for NeoVim users see |nvim_open_win| for argument details + For Vim floating preview popups, if no `minwidth`/`maxwidth` or + `minheight`/`maxheight` values are supplied, ALE will temporarily keep the + popup at a stable size while it is visible. Width is locked to the maximum + display width across the popup contents, while height is locked to the + current rendered popup height. If you set those size options yourself, your + values will be used as-is. + + To set popup size limits explicitly for Vim, put the values in + |g:ale_floating_preview_popup_opts|. For example: > + + let g:ale_floating_preview_popup_opts = { + \ 'minwidth': 60, + \ 'maxwidth': 100, + \ 'minheight': 8, + \ 'maxheight': 20, + \} +< + For example, to enhance popups with a title: > function! CustomOpts() abort diff --git a/test/test_floating_preview_popupwin.vader b/test/test_floating_preview_popupwin.vader new file mode 100644 index 0000000000..16d147328c --- /dev/null +++ b/test/test_floating_preview_popupwin.vader @@ -0,0 +1,259 @@ +Before: + Save g:ale_close_preview_on_insert + Save g:ale_floating_preview_popup_opts + + let g:ale_close_preview_on_insert = 0 + + runtime autoload/ale/floating_preview.vim + + function! OpenTestPopup(...) abort + return ale#floating_preview#Show(get( + \ a:000, + \ 0, + \ map(range(1, 40), 'string(v:val)') + \) + \) + endfunction + + function! GetPopupCursorState(popup_id) abort + let g:popup_cursor_state = [] + + call win_execute( + \ a:popup_id, + \ 'let g:popup_cursor_state = [line("."), winline()]' + \) + + return g:popup_cursor_state + endfunction + + function! AssertPopupFilterConfigured() abort + if !has('popupwin') + return + endif + + let popup_id = OpenTestPopup() + + AssertEqual + \ string(function('ale#floating_preview#PopupFilter')), + \ string(popup_getoptions(popup_id).filter) + endfunction + + function! AssertPopupScrollsDown() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = {'minheight': 4, 'maxheight': 4} + let popup_id = OpenTestPopup() + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [2, 1], GetPopupCursorState(popup_id) + + AssertEqual + \ 1, + \ ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [3, 1], GetPopupCursorState(popup_id) + endfunction + + function! AssertPopupScrollsUp() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = {'minheight': 4, 'maxheight': 4} + let popup_id = OpenTestPopup() + + call ale#floating_preview#PopupFilter(popup_id, "\") + call ale#floating_preview#PopupFilter(popup_id, "\") + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [2, 1], GetPopupCursorState(popup_id) + + AssertEqual + \ 1, + \ ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [1, 1], GetPopupCursorState(popup_id) + endfunction + + function! AssertPopupScrollsByHalfPage() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = {'minheight': 4, 'maxheight': 4} + let popup_id = OpenTestPopup() + let page_size = max([ + \ 1, + \ float2nr(ceil(popup_getpos(popup_id).core_height / 2.0)), + \]) + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [page_size + 1, 1], GetPopupCursorState(popup_id) + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [1, 1], GetPopupCursorState(popup_id) + endfunction + + function! AssertPopupClosesOnEscape() abort + if !has('popupwin') + return + endif + + let popup_id = OpenTestPopup() + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual -1, index(popup_list(), popup_id) + AssertEqual 0, exists('w:preview') + endfunction + + function! AssertPopupPassesThroughUnhandledKeys() abort + if !has('popupwin') + return + endif + + let popup_id = OpenTestPopup() + let popup_position = popup_getpos(popup_id) + let popup_cursor_state = GetPopupCursorState(popup_id) + + AssertEqual 0, ale#floating_preview#PopupFilter(popup_id, 'j') + AssertEqual 0, ale#floating_preview#PopupFilter(popup_id, 'k') + AssertEqual 0, ale#floating_preview#PopupFilter(popup_id, 'q') + AssertEqual popup_position.firstline, popup_getpos(popup_id).firstline + AssertEqual popup_position.width, popup_getpos(popup_id).width + AssertEqual popup_position.height, popup_getpos(popup_id).height + AssertEqual popup_cursor_state, GetPopupCursorState(popup_id) + endfunction + + function! AssertWrappedPopupKeepsItsSize() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = { + \ 'minheight': 4, + \ 'maxheight': 4, + \} + let popup_id = OpenTestPopup([ + \ 'short', + \ 'medium line', + \ 'tiny', + \ 'line 04', + \ 'line 05', + \ 'line 06', + \ repeat('a', 40), + \ 'line 08', + \ 'line 09', + \]) + let position = popup_getpos(popup_id) + + AssertEqual 40, position.core_width + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [2, 1], GetPopupCursorState(popup_id) + AssertEqual position.width, popup_getpos(popup_id).width + AssertEqual position.height, popup_getpos(popup_id).height + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual [3, 1], GetPopupCursorState(popup_id) + AssertEqual position.width, popup_getpos(popup_id).width + AssertEqual position.height, popup_getpos(popup_id).height + endfunction + + function! AssertPopupStopsScrollingAtBottom() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = {'minheight': 4, 'maxheight': 4} + let popup_id = OpenTestPopup(map(range(1, 10), 'printf("line %02d", v:val)')) + + for l:index in range(1, 20) + call ale#floating_preview#PopupFilter(popup_id, "\") + endfor + + AssertEqual 7, popup_getpos(popup_id).firstline + + let popup_cursor_state = GetPopupCursorState(popup_id) + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual 7, popup_getpos(popup_id).firstline + AssertEqual popup_cursor_state, GetPopupCursorState(popup_id) + endfunction + + function! AssertWrappedPopupStopsScrollingAtBottom() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = { + \ 'minwidth': 40, + \ 'maxwidth': 40, + \ 'minheight': 4, + \ 'maxheight': 4, + \} + let popup_id = OpenTestPopup([ + \ 'short', + \ repeat('a', 80), + \ repeat('b', 80), + \ 'last', + \]) + + for l:index in range(1, 20) + call ale#floating_preview#PopupFilter(popup_id, "\") + endfor + + AssertEqual 2, popup_getpos(popup_id).firstline + + let popup_cursor_state = GetPopupCursorState(popup_id) + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual 2, popup_getpos(popup_id).firstline + AssertEqual popup_cursor_state, GetPopupCursorState(popup_id) + endfunction + +After: + Restore + + if has('popupwin') + call popup_clear(1) + endif + + delfunction OpenTestPopup + delfunction GetPopupCursorState + delfunction AssertPopupFilterConfigured + delfunction AssertPopupScrollsDown + delfunction AssertPopupScrollsUp + delfunction AssertPopupScrollsByHalfPage + delfunction AssertPopupClosesOnEscape + delfunction AssertPopupPassesThroughUnhandledKeys + delfunction AssertWrappedPopupKeepsItsSize + delfunction AssertPopupStopsScrollingAtBottom + delfunction AssertWrappedPopupStopsScrollingAtBottom + + +Execute(Floating previews should configure the popup filter): + call AssertPopupFilterConfigured() + +Execute(PopupFilter should scroll down one line): + call AssertPopupScrollsDown() + +Execute(PopupFilter should scroll up one line): + call AssertPopupScrollsUp() + +Execute(PopupFilter should scroll half a page): + call AssertPopupScrollsByHalfPage() + +Execute(PopupFilter should close the popup on Escape): + call AssertPopupClosesOnEscape() + +Execute(PopupFilter should pass through unhandled keys): + call AssertPopupPassesThroughUnhandledKeys() + +Execute(PopupFilter should preserve wrapped popup size while scrolling): + call AssertWrappedPopupKeepsItsSize() + +Execute(PopupFilter should stop scrolling at the bottom): + call AssertPopupStopsScrollingAtBottom() + +Execute(PopupFilter should stop wrapped popups at the bottom): + call AssertWrappedPopupStopsScrollingAtBottom() From 5eed90bf8dec6710f9ee7ddb43dc09fc32404945 Mon Sep 17 00:00:00 2001 From: coriocactus <69796618+coriocactus@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:35:32 +0100 Subject: [PATCH 2/5] handle wrap: 0 in popup scroll boundary calculation row counting assumed wrap was always on, causing long lines to count as multiple rows even when wrap is disabled via popup opts, which stops scrolling too early --- autoload/ale/floating_preview.vim | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/autoload/ale/floating_preview.vim b/autoload/ale/floating_preview.vim index 14f0558936..e58220c77e 100644 --- a/autoload/ale/floating_preview.vim +++ b/autoload/ale/floating_preview.vim @@ -177,8 +177,14 @@ function! s:GetPopupLineRows(winid) abort return [] endif + let l:lines = getbufline(l:bufnr, 1, '$') + + if !get(popup_getoptions(a:winid), 'wrap', 1) + return map(l:lines, '1') + endif + return map( - \ getbufline(l:bufnr, 1, '$'), + \ l:lines, \ 'max([1, float2nr(ceil(strdisplaywidth(v:val) / (l:width * 1.0)))])', \) endfunction From a992a22f599bdfcb58a65a5a10c02eaea0ba25b1 Mon Sep 17 00:00:00 2001 From: coriocactus <69796618+coriocactus@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:35:45 +0100 Subject: [PATCH 3/5] disable resize while popup size is managed by ALE resize: v:true in the default popup opts contradicts the size locking that pins min/max width and height to exact values, disable resize while ALE manages size and restore it on reset --- autoload/ale/floating_preview.vim | 2 ++ 1 file changed, 2 insertions(+) diff --git a/autoload/ale/floating_preview.vim b/autoload/ale/floating_preview.vim index e58220c77e..25734759e5 100644 --- a/autoload/ale/floating_preview.vim +++ b/autoload/ale/floating_preview.vim @@ -234,6 +234,7 @@ function! s:LockPopupSize(winid, lines) abort endif if !empty(l:size_options) + let l:size_options.resize = v:false call popup_setoptions(a:winid, l:size_options) let w:preview.managed_size = l:managed_size endif @@ -258,6 +259,7 @@ function! s:ResetManagedPopupSize() abort endif if !empty(l:size_options) + let l:size_options.resize = v:true call popup_setoptions(w:preview['id'], l:size_options) endif From d185f2870a11e63d66a2f6baef09b0be2dc00fb1 Mon Sep 17 00:00:00 2001 From: coriocactus <69796618+coriocactus@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:35:50 +0100 Subject: [PATCH 4/5] use unlet! for managed_size cleanup --- autoload/ale/floating_preview.vim | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autoload/ale/floating_preview.vim b/autoload/ale/floating_preview.vim index 25734759e5..79986e6b24 100644 --- a/autoload/ale/floating_preview.vim +++ b/autoload/ale/floating_preview.vim @@ -263,7 +263,7 @@ function! s:ResetManagedPopupSize() abort call popup_setoptions(w:preview['id'], l:size_options) endif - unlet w:preview.managed_size + unlet! w:preview.managed_size endfunction function! s:ScrollPopup(winid, count, direction) abort From 3ad02af9e1bebcdd38ff16fc954150b0b9b62bab Mon Sep 17 00:00:00 2001 From: coriocactus <69796618+coriocactus@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:36:16 +0100 Subject: [PATCH 5/5] add tests for top boundary, nowrap scrolling, and user size opts --- test/test_floating_preview_popupwin.vader | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/test_floating_preview_popupwin.vader b/test/test_floating_preview_popupwin.vader index 16d147328c..d22ecf184e 100644 --- a/test/test_floating_preview_popupwin.vader +++ b/test/test_floating_preview_popupwin.vader @@ -180,6 +180,70 @@ Before: AssertEqual popup_cursor_state, GetPopupCursorState(popup_id) endfunction + function! AssertPopupStopsScrollingAtTop() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = {'minheight': 4, 'maxheight': 4} + let popup_id = OpenTestPopup() + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual 1, popup_getpos(popup_id).firstline + AssertEqual [1, 1], GetPopupCursorState(popup_id) + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual 1, popup_getpos(popup_id).firstline + AssertEqual [1, 1], GetPopupCursorState(popup_id) + endfunction + + function! AssertNowrapPopupStopsScrollingAtBottom() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = { + \ 'minwidth': 20, + \ 'maxwidth': 20, + \ 'minheight': 4, + \ 'maxheight': 4, + \ 'wrap': 0, + \} + let popup_id = OpenTestPopup(map(range(1, 10), 'printf("line %02d", v:val)')) + + for l:index in range(1, 20) + call ale#floating_preview#PopupFilter(popup_id, "\") + endfor + + AssertEqual 7, popup_getpos(popup_id).firstline + + let popup_cursor_state = GetPopupCursorState(popup_id) + + AssertEqual 1, ale#floating_preview#PopupFilter(popup_id, "\") + AssertEqual 7, popup_getpos(popup_id).firstline + AssertEqual popup_cursor_state, GetPopupCursorState(popup_id) + endfunction + + function! AssertUserSizeOptsAreNotOverridden() abort + if !has('popupwin') + return + endif + + let g:ale_floating_preview_popup_opts = { + \ 'minwidth': 60, + \ 'maxwidth': 100, + \ 'minheight': 8, + \ 'maxheight': 20, + \} + let popup_id = OpenTestPopup() + let opts = popup_getoptions(popup_id) + + AssertEqual 60, opts.minwidth + AssertEqual 100, opts.maxwidth + AssertEqual 8, opts.minheight + AssertEqual 20, opts.maxheight + endfunction + function! AssertWrappedPopupStopsScrollingAtBottom() abort if !has('popupwin') return @@ -227,6 +291,9 @@ After: delfunction AssertPopupClosesOnEscape delfunction AssertPopupPassesThroughUnhandledKeys delfunction AssertWrappedPopupKeepsItsSize + delfunction AssertPopupStopsScrollingAtTop + delfunction AssertNowrapPopupStopsScrollingAtBottom + delfunction AssertUserSizeOptsAreNotOverridden delfunction AssertPopupStopsScrollingAtBottom delfunction AssertWrappedPopupStopsScrollingAtBottom @@ -252,8 +319,17 @@ Execute(PopupFilter should pass through unhandled keys): Execute(PopupFilter should preserve wrapped popup size while scrolling): call AssertWrappedPopupKeepsItsSize() +Execute(PopupFilter should not scroll past the top): + call AssertPopupStopsScrollingAtTop() + Execute(PopupFilter should stop scrolling at the bottom): call AssertPopupStopsScrollingAtBottom() +Execute(PopupFilter should stop nowrap popups at the bottom): + call AssertNowrapPopupStopsScrollingAtBottom() + Execute(PopupFilter should stop wrapped popups at the bottom): call AssertWrappedPopupStopsScrollingAtBottom() + +Execute(LockPopupSize should not override user-supplied size opts): + call AssertUserSizeOptsAreNotOverridden()