diff --git a/autoload/ale/floating_preview.vim b/autoload/ale/floating_preview.vim index 3e1fabb859..79986e6b24 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,178 @@ 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 + + let l:lines = getbufline(l:bufnr, 1, '$') + + if !get(popup_getoptions(a:winid), 'wrap', 1) + return map(l:lines, '1') + endif + + return map( + \ l:lines, + \ '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) + let l:size_options.resize = v:false + 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) + let l:size_options.resize = v:true + 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 +339,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..d22ecf184e --- /dev/null +++ b/test/test_floating_preview_popupwin.vader @@ -0,0 +1,335 @@ +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! 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 + 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 AssertPopupStopsScrollingAtTop + delfunction AssertNowrapPopupStopsScrollingAtBottom + delfunction AssertUserSizeOptsAreNotOverridden + 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 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()