mirror of
https://github.com/neovim/neovim.git
synced 2024-12-26 14:11:15 -07:00
6ea6b3fee2
Extmarks can contain URLs which can then be drawn in any supporting UI. In the TUI, for example, URLs are "drawn" by emitting the OSC 8 control sequence to the TTY. On terminals which support the OSC 8 sequence this will create clickable hyperlinks. URLs are treated as inline highlights in the decoration subsystem, so are included in the `DecorSignHighlight` structure. However, unlike other inline highlights they use allocated memory which must be freed, so they set the `ext` flag in `DecorInline` so that their lifetimes are managed along with other allocated memory like virtual text. The decoration subsystem then adds the URLs as a new highlight attribute. The highlight subsystem maintains a set of unique URLs to avoid duplicating allocations for the same string. To attach a URL to an existing highlight attribute we call `hl_add_url` which finds the URL in the set (allocating and adding it if it does not exist) and sets the `url` highlight attribute to the index of the URL in the set (using an index helps keep the size of the `HlAttrs` struct small). This has the potential to lead to an increase in highlight attributes if a URL is used over a range that contains many different highlight attributes, because now each existing attribute must be combined with the URL. In practice, however, URLs typically span a range containing a single highlight (e.g. link text in Markdown), so this is likely just a pathological edge case. When a new highlight attribute is defined with a URL it is copied to all attached UIs with the `hl_attr_define` UI event. The TUI manages its own set of URLs (just like the highlight subsystem) to minimize allocations. The TUI keeps track of which URL is "active" for the cell it is printing. If no URL is active and a cell containing a URL is printed, the opening OSC 8 sequence is emitted and that URL becomes the actively tracked URL. If the cursor is moved while in the middle of a URL span, we emit the terminating OSC sequence to prevent the hyperlink from spanning multiple lines. This does not support nested hyperlinks, but that is a rare (and, frankly, bizarre) use case. If a valid use case for nested hyperlinks ever presents itself we can address that issue then.
1913 lines
52 KiB
Lua
1913 lines
52 KiB
Lua
-- This module contains the Screen class, a complete Nvim UI implementation
|
||
-- designed for functional testing (verifying screen state, in particular).
|
||
--
|
||
-- Screen:expect() takes a string representing the expected screen state and an
|
||
-- optional set of attribute identifiers for checking highlighted characters.
|
||
--
|
||
-- Example usage:
|
||
--
|
||
-- local screen = Screen.new(25, 10)
|
||
-- -- Attach the screen to the current Nvim instance.
|
||
-- screen:attach()
|
||
-- -- Enter insert-mode and type some text.
|
||
-- feed('ihello screen')
|
||
-- -- Assert the expected screen state.
|
||
-- screen:expect([[
|
||
-- hello screen |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- -- INSERT -- |
|
||
-- ]]) -- <- Last line is stripped
|
||
--
|
||
-- Since screen updates are received asynchronously, expect() actually specifies
|
||
-- the _eventual_ screen state.
|
||
--
|
||
-- This is how expect() works:
|
||
-- * It starts the event loop with a timeout.
|
||
-- * Each time it receives an update it checks that against the expected state.
|
||
-- * If the expected state matches the current state, the event loop will be
|
||
-- stopped and expect() will return.
|
||
-- * If the timeout expires, the last match error will be reported and the
|
||
-- test will fail.
|
||
--
|
||
-- Continuing the above example, say we want to assert that "-- INSERT --" is
|
||
-- highlighted with the bold attribute. The expect() call should look like this:
|
||
--
|
||
-- NonText = Screen.colors.Blue
|
||
-- screen:expect([[
|
||
-- hello screen |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- ~ |
|
||
-- {b:-- INSERT --} |
|
||
-- ]], {b = {bold = true}}, {{bold = true, foreground = NonText}})
|
||
--
|
||
-- In this case "b" is a string associated with the set composed of one
|
||
-- attribute: bold. Note that since the {b:} markup is not a real part of the
|
||
-- screen, the delimiter "|" moved to the right. Also, the highlighting of the
|
||
-- NonText markers "~" is ignored in this test.
|
||
--
|
||
-- Tests will often share a group of attribute sets to expect(). Those can be
|
||
-- defined at the beginning of a test:
|
||
--
|
||
-- NonText = Screen.colors.Blue
|
||
-- screen:set_default_attr_ids( {
|
||
-- [1] = {reverse = true, bold = true},
|
||
-- [2] = {reverse = true}
|
||
-- })
|
||
--
|
||
-- To help write screen tests, see Screen:snapshot_util().
|
||
-- To debug screen tests, see Screen:redraw_debug().
|
||
|
||
local helpers = require('test.functional.helpers')(nil)
|
||
local busted = require('busted')
|
||
local deepcopy = vim.deepcopy
|
||
local shallowcopy = helpers.shallowcopy
|
||
local concat_tables = helpers.concat_tables
|
||
local pesc = vim.pesc
|
||
local run_session = helpers.run_session
|
||
local eq = helpers.eq
|
||
local dedent = helpers.dedent
|
||
local get_session = helpers.get_session
|
||
local create_callindex = helpers.create_callindex
|
||
|
||
local inspect = vim.inspect
|
||
|
||
local function isempty(v)
|
||
return type(v) == 'table' and next(v) == nil
|
||
end
|
||
|
||
--- @class test.functional.ui.screen.Grid
|
||
--- @field rows table[][]
|
||
--- @field width integer
|
||
--- @field height integer
|
||
|
||
--- @class test.functional.ui.screen
|
||
--- @field colors table<string,integer>
|
||
--- @field colornames table<integer,string>
|
||
--- @field uimeths table<string,function>
|
||
--- @field options? table<string,any>
|
||
--- @field timeout integer
|
||
--- @field win_position table<integer,table<string,integer>>
|
||
--- @field float_pos table<integer,table>
|
||
--- @field cmdline table<integer,table>
|
||
--- @field cmdline_block table[]
|
||
--- @field hl_groups table<string,integer>
|
||
--- @field messages table<integer,table>
|
||
--- @field private _cursor {grid:integer,row:integer,col:integer}
|
||
--- @field private _grids table<integer,test.functional.ui.screen.Grid>
|
||
--- @field private _grid_win_extmarks table<integer,table>
|
||
--- @field private _attr_table table<integer,table>
|
||
--- @field private _hl_info table<integer,table>
|
||
local Screen = {}
|
||
Screen.__index = Screen
|
||
|
||
local default_timeout_factor = 1
|
||
if os.getenv('VALGRIND') then
|
||
default_timeout_factor = default_timeout_factor * 3
|
||
end
|
||
|
||
if os.getenv('CI') then
|
||
default_timeout_factor = default_timeout_factor * 3
|
||
end
|
||
|
||
local default_screen_timeout = default_timeout_factor * 3500
|
||
|
||
local function _init_colors()
|
||
local session = get_session()
|
||
local status, rv = session:request('nvim_get_color_map')
|
||
if not status then
|
||
error('failed to get color map')
|
||
end
|
||
local colors = rv --- @type table<string,integer>
|
||
local colornames = {} --- @type table<integer,string>
|
||
for name, rgb in pairs(colors) do
|
||
-- we disregard the case that colornames might not be unique, as
|
||
-- this is just a helper to get any canonical name of a color
|
||
colornames[rgb] = name
|
||
end
|
||
Screen.colors = colors
|
||
Screen.colornames = colornames
|
||
end
|
||
|
||
--- @param width? integer
|
||
--- @param height? integer
|
||
--- @return test.functional.ui.screen
|
||
function Screen.new(width, height)
|
||
if not Screen.colors then
|
||
_init_colors()
|
||
end
|
||
|
||
local self = setmetatable({
|
||
timeout = default_screen_timeout,
|
||
title = '',
|
||
icon = '',
|
||
bell = false,
|
||
update_menu = false,
|
||
visual_bell = false,
|
||
suspended = false,
|
||
mode = 'normal',
|
||
options = {},
|
||
pwd = '',
|
||
popupmenu = nil,
|
||
cmdline = {},
|
||
cmdline_block = {},
|
||
wildmenu_items = nil,
|
||
wildmenu_selected = nil,
|
||
win_position = {},
|
||
win_viewport = {},
|
||
float_pos = {},
|
||
msg_grid = nil,
|
||
msg_grid_pos = nil,
|
||
_session = nil,
|
||
rpc_async = false,
|
||
messages = {},
|
||
msg_history = {},
|
||
showmode = {},
|
||
showcmd = {},
|
||
ruler = {},
|
||
hl_groups = {},
|
||
_default_attr_ids = nil,
|
||
mouse_enabled = true,
|
||
_attrs = {},
|
||
_hl_info = { [0] = {} },
|
||
_attr_table = { [0] = { {}, {} } },
|
||
_clear_attrs = nil,
|
||
_new_attrs = false,
|
||
_width = width or 53,
|
||
_height = height or 14,
|
||
_grids = {},
|
||
_grid_win_extmarks = {},
|
||
_cursor = {
|
||
grid = 1,
|
||
row = 1,
|
||
col = 1,
|
||
},
|
||
_busy = false,
|
||
}, Screen)
|
||
|
||
local function ui(method, ...)
|
||
if self.rpc_async then
|
||
self._session:notify('nvim_ui_' .. method, ...)
|
||
else
|
||
local status, rv = self._session:request('nvim_ui_' .. method, ...)
|
||
if not status then
|
||
error(rv[2])
|
||
end
|
||
end
|
||
end
|
||
|
||
self.uimeths = create_callindex(ui)
|
||
|
||
return self
|
||
end
|
||
|
||
function Screen:set_default_attr_ids(attr_ids)
|
||
self._default_attr_ids = attr_ids
|
||
end
|
||
|
||
function Screen:get_default_attr_ids()
|
||
return deepcopy(self._default_attr_ids)
|
||
end
|
||
|
||
function Screen:set_rgb_cterm(val)
|
||
self._rgb_cterm = val
|
||
end
|
||
|
||
--- @class test.functional.ui.screen.Opts
|
||
--- @field ext_linegrid? boolean
|
||
--- @field ext_multigrid? boolean
|
||
--- @field ext_newgrid? boolean
|
||
--- @field ext_popupmenu? boolean
|
||
--- @field ext_wildmenu? boolean
|
||
--- @field rgb? boolean
|
||
--- @field _debug_float? boolean
|
||
|
||
--- @param options test.functional.ui.screen.Opts
|
||
--- @param session? test.Session
|
||
function Screen:attach(options, session)
|
||
session = session or get_session()
|
||
options = options or {}
|
||
|
||
if options.ext_linegrid == nil then
|
||
options.ext_linegrid = true
|
||
end
|
||
|
||
self._session = session
|
||
self._options = options
|
||
self._clear_attrs = (not options.ext_linegrid) and {} or nil
|
||
self:_handle_resize(self._width, self._height)
|
||
self.uimeths.attach(self._width, self._height, options)
|
||
if self._options.rgb == nil then
|
||
-- nvim defaults to rgb=true internally,
|
||
-- simplify test code by doing the same.
|
||
self._options.rgb = true
|
||
end
|
||
if self._options.ext_multigrid then
|
||
self._options.ext_linegrid = true
|
||
end
|
||
end
|
||
|
||
function Screen:detach()
|
||
self.uimeths.detach()
|
||
self._session = nil
|
||
end
|
||
|
||
function Screen:try_resize(columns, rows)
|
||
self._width = columns
|
||
self._height = rows
|
||
self.uimeths.try_resize(columns, rows)
|
||
end
|
||
|
||
function Screen:try_resize_grid(grid, columns, rows)
|
||
self.uimeths.try_resize_grid(grid, columns, rows)
|
||
end
|
||
|
||
--- @param option 'ext_linegrid'|'ext_multigrid'|'ext_popupmenu'|'ext_wildmenu'|'rgb'
|
||
--- @param value boolean
|
||
function Screen:set_option(option, value)
|
||
self.uimeths.set_option(option, value)
|
||
--- @diagnostic disable-next-line:no-unknown
|
||
self._options[option] = value
|
||
end
|
||
|
||
-- canonical order of ext keys, used to generate asserts
|
||
local ext_keys = {
|
||
'popupmenu',
|
||
'cmdline',
|
||
'cmdline_block',
|
||
'wildmenu_items',
|
||
'wildmenu_pos',
|
||
'messages',
|
||
'msg_history',
|
||
'showmode',
|
||
'showcmd',
|
||
'ruler',
|
||
'float_pos',
|
||
'win_viewport',
|
||
}
|
||
|
||
local expect_keys = {
|
||
grid = true,
|
||
attr_ids = true,
|
||
condition = true,
|
||
mouse_enabled = true,
|
||
any = true,
|
||
mode = true,
|
||
unchanged = true,
|
||
intermediate = true,
|
||
reset = true,
|
||
timeout = true,
|
||
request_cb = true,
|
||
hl_groups = true,
|
||
extmarks = true,
|
||
}
|
||
|
||
for _, v in ipairs(ext_keys) do
|
||
expect_keys[v] = true
|
||
end
|
||
|
||
--- @class test.function.ui.screen.Expect
|
||
---
|
||
--- Expected screen state (string). Each line represents a screen
|
||
--- row. Last character of each row (typically "|") is stripped.
|
||
--- Common indentation is stripped.
|
||
--- "{MATCH:x}" in a line is matched against Lua pattern `x`.
|
||
--- "*n" at the end of a line means it repeats `n` times.
|
||
--- @field grid? string
|
||
---
|
||
--- Expected text attributes. Screen rows are transformed according
|
||
--- to this table, as follows: each substring S composed of
|
||
--- characters having the same attributes will be substituted by
|
||
--- "{K:S}", where K is a key in `attr_ids`. Any unexpected
|
||
--- attributes in the final state are an error.
|
||
--- Use an empty table for a text-only (no attributes) expectation.
|
||
--- Use screen:set_default_attr_ids() to define attributes for many
|
||
--- expect() calls.
|
||
--- @field attr_ids? table<integer,table<string,any>>
|
||
---
|
||
--- Expected win_extmarks accumulated for the grids. For each grid,
|
||
--- the win_extmark messages are accumulated into an array.
|
||
--- @field extmarks? table<integer,table>
|
||
---
|
||
--- Function asserting some arbitrary condition. Return value is
|
||
--- ignored, throw an error (use eq() or similar) to signal failure.
|
||
--- @field condition? fun()
|
||
---
|
||
--- Lua pattern string expected to match a screen line. NB: the
|
||
--- following chars are magic characters
|
||
--- ( ) . % + - * ? [ ^ $
|
||
--- and must be escaped with a preceding % for a literal match.
|
||
--- @field any? string
|
||
---
|
||
--- Expected mode as signaled by "mode_change" event
|
||
--- @field mode? string
|
||
---
|
||
--- Test that the screen state is unchanged since the previous
|
||
--- expect(...). Any flush event resulting in a different state is
|
||
--- considered an error. Not observing any events until timeout
|
||
--- is acceptable.
|
||
--- @field unchanged? boolean
|
||
---
|
||
--- Test that the final state is the same as the previous expect,
|
||
--- but expect an intermediate state that is different. If possible
|
||
--- it is better to use an explicit screen:expect(...) for this
|
||
--- intermediate state.
|
||
--- @field intermediate? boolean
|
||
---
|
||
--- Reset the state internal to the test Screen before starting to
|
||
--- receive updates. This should be used after command("redraw!")
|
||
--- or some other mechanism that will invoke "redraw!", to check
|
||
--- that all screen state is transmitted again. This includes
|
||
--- state related to ext_ features as mentioned below.
|
||
--- @field reset? boolean
|
||
---
|
||
--- maximum time that will be waited until the expected state is
|
||
--- seen (or maximum time to observe an incorrect change when
|
||
--- `unchanged` flag is used)
|
||
--- @field timeout? integer
|
||
---
|
||
--- @field mouse_enabled? boolean
|
||
---
|
||
--- @field win_viewport? table<integer,table<string,integer>>
|
||
--- @field float_pos? {[1]:integer,[2]:integer}
|
||
--- @field hl_groups? table<string,integer>
|
||
---
|
||
--- The following keys should be used to expect the state of various ext_
|
||
--- features. Note that an absent key will assert that the item is currently
|
||
--- NOT present on the screen, also when positional form is used.
|
||
---
|
||
--- Expected ext_popupmenu state,
|
||
--- @field popupmenu? table
|
||
---
|
||
--- Expected ext_cmdline state, as an array of cmdlines of
|
||
--- different level.
|
||
--- @field cmdline? table
|
||
---
|
||
--- Expected ext_cmdline block (for function definitions)
|
||
--- @field cmdline_block? table
|
||
---
|
||
--- items for ext_wildmenu
|
||
--- @field wildmenu_items? table
|
||
---
|
||
--- position for ext_wildmenu
|
||
--- @field wildmenu_pos? table
|
||
|
||
--- Asserts that the screen state eventually matches an expected state.
|
||
---
|
||
--- Can be called with positional args:
|
||
--- screen:expect(grid, [attr_ids])
|
||
--- screen:expect(condition)
|
||
--- or keyword args (supports more options):
|
||
--- screen:expect{grid=[[...]], cmdline={...}, condition=function() ... end}
|
||
---
|
||
--- @param expected string|function|test.function.ui.screen.Expect
|
||
--- @param attr_ids? table<integer,table<string,any>>
|
||
function Screen:expect(expected, attr_ids, ...)
|
||
--- @type string, fun()
|
||
local grid, condition
|
||
|
||
assert(next({ ... }) == nil, 'invalid args to expect()')
|
||
|
||
if type(expected) == 'table' then
|
||
assert(attr_ids == nil)
|
||
for k, _ in
|
||
pairs(expected --[[@as table<string,any>]])
|
||
do
|
||
if not expect_keys[k] then
|
||
error("Screen:expect: Unknown keyword argument '" .. k .. "'")
|
||
end
|
||
end
|
||
grid = expected.grid
|
||
attr_ids = expected.attr_ids
|
||
condition = expected.condition
|
||
assert(expected.any == nil or grid == nil)
|
||
elseif type(expected) == 'string' then
|
||
grid = expected
|
||
expected = {}
|
||
elseif type(expected) == 'function' then
|
||
assert(attr_ids == nil)
|
||
condition = expected
|
||
expected = {}
|
||
else
|
||
assert(false)
|
||
end
|
||
|
||
local expected_rows = {} --- @type string[]
|
||
if grid then
|
||
-- Remove the last line and dedent. Note that gsub returns more then one
|
||
-- value.
|
||
grid = dedent(grid:gsub('\n[ ]+$', ''), 0)
|
||
for row in grid:gmatch('[^\n]+') do
|
||
table.insert(expected_rows, row)
|
||
end
|
||
end
|
||
|
||
local attr_state = {
|
||
ids = attr_ids or self._default_attr_ids,
|
||
}
|
||
|
||
if isempty(attr_ids) then
|
||
attr_state.ids = nil
|
||
end
|
||
|
||
if self._options.ext_linegrid then
|
||
attr_state.id_to_index = self:linegrid_check_attrs(attr_state.ids or {})
|
||
end
|
||
|
||
self._new_attrs = false
|
||
self:_wait(function()
|
||
if condition then
|
||
--- @type boolean, string
|
||
local status, res = pcall(condition)
|
||
if not status then
|
||
return tostring(res)
|
||
end
|
||
end
|
||
|
||
if self._options.ext_linegrid and self._new_attrs then
|
||
attr_state.id_to_index = self:linegrid_check_attrs(attr_state.ids or {})
|
||
end
|
||
|
||
local actual_rows = self:render(not expected.any, attr_state)
|
||
|
||
if expected.any then
|
||
-- Search for `any` anywhere in the screen lines.
|
||
local actual_screen_str = table.concat(actual_rows, '\n')
|
||
if not actual_screen_str:find(expected.any) then
|
||
return (
|
||
'Failed to match any screen lines.\n'
|
||
.. 'Expected (anywhere): "'
|
||
.. expected.any
|
||
.. '"\n'
|
||
.. 'Actual:\n |'
|
||
.. table.concat(actual_rows, '\n |')
|
||
.. '\n\n'
|
||
)
|
||
end
|
||
end
|
||
|
||
if grid then
|
||
for i, row in ipairs(expected_rows) do
|
||
local count --- @type integer?
|
||
row, count = row:match('^(.*%|)%*(%d+)$')
|
||
if row then
|
||
count = tonumber(count)
|
||
table.remove(expected_rows, i)
|
||
for _ = 1, count do
|
||
table.insert(expected_rows, i, row)
|
||
end
|
||
end
|
||
end
|
||
local err_msg = nil
|
||
-- `expected` must match the screen lines exactly.
|
||
if #actual_rows ~= #expected_rows then
|
||
err_msg = 'Expected screen height '
|
||
.. #expected_rows
|
||
.. ' differs from actual height '
|
||
.. #actual_rows
|
||
.. '.'
|
||
end
|
||
local msg_expected_rows = shallowcopy(expected_rows)
|
||
local msg_actual_rows = shallowcopy(actual_rows)
|
||
for i, row in ipairs(expected_rows) do
|
||
local pat = nil --- @type string?
|
||
if actual_rows[i] and row ~= actual_rows[i] then
|
||
local after = row
|
||
while true do
|
||
local s, e, m = after:find('{MATCH:(.-)}')
|
||
if not s then
|
||
pat = pat and (pat .. pesc(after))
|
||
break
|
||
end
|
||
--- @type string
|
||
pat = (pat or '') .. pesc(after:sub(1, s - 1)) .. m
|
||
after = after:sub(e + 1)
|
||
end
|
||
end
|
||
if row ~= actual_rows[i] and (not pat or not actual_rows[i]:match(pat)) then
|
||
msg_expected_rows[i] = '*' .. msg_expected_rows[i]
|
||
if i <= #actual_rows then
|
||
msg_actual_rows[i] = '*' .. msg_actual_rows[i]
|
||
end
|
||
if err_msg == nil then
|
||
err_msg = 'Row ' .. tostring(i) .. ' did not match.'
|
||
end
|
||
end
|
||
end
|
||
if err_msg ~= nil then
|
||
return (
|
||
err_msg
|
||
.. '\nExpected:\n |'
|
||
.. table.concat(msg_expected_rows, '\n |')
|
||
.. '\n'
|
||
.. 'Actual:\n |'
|
||
.. table.concat(msg_actual_rows, '\n |')
|
||
.. '\n\n'
|
||
.. [[
|
||
To print the expect() call that would assert the current screen state, use
|
||
screen:snapshot_util(). In case of non-deterministic failures, use
|
||
screen:redraw_debug() to show all intermediate screen states.]]
|
||
)
|
||
end
|
||
end
|
||
|
||
-- UI extensions. The default expectations should cover the case of
|
||
-- the ext_ feature being disabled, or the feature currently not activated
|
||
-- (e.g. no external cmdline visible). Some extensions require
|
||
-- preprocessing to represent highlights in a reproducible way.
|
||
local extstate = self:_extstate_repr(attr_state)
|
||
if expected.mode ~= nil then
|
||
extstate.mode = self.mode
|
||
end
|
||
if expected.mouse_enabled ~= nil then
|
||
extstate.mouse_enabled = self.mouse_enabled
|
||
end
|
||
if expected.win_viewport == nil then
|
||
extstate.win_viewport = nil
|
||
end
|
||
|
||
if expected.float_pos then
|
||
expected.float_pos = deepcopy(expected.float_pos)
|
||
for _, v in pairs(expected.float_pos) do
|
||
if not v.external and v[7] == nil then
|
||
v[7] = 50
|
||
end
|
||
end
|
||
end
|
||
|
||
-- Convert assertion errors into invalid screen state descriptions.
|
||
for _, k in ipairs(concat_tables(ext_keys, { 'mode', 'mouse_enabled' })) do
|
||
-- Empty states are considered the default and need not be mentioned.
|
||
if not (expected[k] == nil and isempty(extstate[k])) then
|
||
local status, res = pcall(eq, expected[k], extstate[k], k)
|
||
if not status then
|
||
return (
|
||
tostring(res)
|
||
.. '\nHint: full state of "'
|
||
.. k
|
||
.. '":\n '
|
||
.. inspect(extstate[k])
|
||
)
|
||
end
|
||
end
|
||
end
|
||
|
||
if expected.hl_groups ~= nil then
|
||
for name, id in pairs(expected.hl_groups) do
|
||
local expected_hl = attr_state.ids[id]
|
||
local actual_hl = self._attr_table[self.hl_groups[name]][(self._options.rgb and 1) or 2]
|
||
local status, res = pcall(eq, expected_hl, actual_hl, 'highlight ' .. name)
|
||
if not status then
|
||
return tostring(res)
|
||
end
|
||
end
|
||
end
|
||
|
||
if expected.extmarks ~= nil then
|
||
for gridid, expected_marks in pairs(expected.extmarks) do
|
||
local stored_marks = self._grid_win_extmarks[gridid]
|
||
if stored_marks == nil then
|
||
return 'no win_extmark for grid ' .. tostring(gridid)
|
||
end
|
||
local status, res =
|
||
pcall(eq, expected_marks, stored_marks, 'extmarks for grid ' .. tostring(gridid))
|
||
if not status then
|
||
return tostring(res)
|
||
end
|
||
end
|
||
for gridid, _ in pairs(self._grid_win_extmarks) do
|
||
local expected_marks = expected.extmarks[gridid]
|
||
if expected_marks == nil then
|
||
return 'unexpected win_extmark for grid ' .. tostring(gridid)
|
||
end
|
||
end
|
||
end
|
||
end, expected)
|
||
end
|
||
|
||
function Screen:expect_unchanged(intermediate, waittime_ms, ignore_attrs)
|
||
waittime_ms = waittime_ms and waittime_ms or 100
|
||
-- Collect the current screen state.
|
||
local kwargs = self:get_snapshot(nil, ignore_attrs)
|
||
|
||
if intermediate then
|
||
kwargs.intermediate = true
|
||
else
|
||
kwargs.unchanged = true
|
||
end
|
||
|
||
kwargs.timeout = waittime_ms
|
||
-- Check that screen state does not change.
|
||
self:expect(kwargs)
|
||
end
|
||
|
||
--- @private
|
||
--- @param check fun(): string
|
||
--- @param flags table<string,any>
|
||
function Screen:_wait(check, flags)
|
||
local err --- @type string?
|
||
local checked = false
|
||
local success_seen = false
|
||
local failure_after_success = false
|
||
local did_flush = true
|
||
local warn_immediate = not (flags.unchanged or flags.intermediate)
|
||
|
||
if flags.intermediate and flags.unchanged then
|
||
error("Choose only one of 'intermediate' and 'unchanged', not both")
|
||
end
|
||
|
||
if flags.reset then
|
||
-- throw away all state, we expect it to be retransmitted
|
||
self:_reset()
|
||
end
|
||
|
||
-- Maximum timeout, after which a incorrect state will be regarded as a
|
||
-- failure
|
||
local timeout = flags.timeout or self.timeout
|
||
|
||
-- Minimal timeout before the loop is allowed to be stopped so we
|
||
-- always do some check for failure after success.
|
||
local minimal_timeout = default_timeout_factor * 2
|
||
|
||
local immediate_seen, intermediate_seen = false, false
|
||
if not check() then
|
||
minimal_timeout = default_timeout_factor * 20
|
||
immediate_seen = true
|
||
end
|
||
|
||
-- For an "unchanged" test, flags.timeout is the time during which the state
|
||
-- must not change, so always wait this full time.
|
||
if (flags.unchanged or flags.intermediate) and flags.timeout then
|
||
minimal_timeout = timeout
|
||
end
|
||
|
||
assert(timeout >= minimal_timeout)
|
||
local did_minimal_timeout = false
|
||
|
||
local function notification_cb(method, args)
|
||
assert(
|
||
method == 'redraw',
|
||
string.format('notification_cb: unexpected method (%s, args=%s)', method, inspect(args))
|
||
)
|
||
did_flush = self:_redraw(args)
|
||
if not did_flush then
|
||
return
|
||
end
|
||
err = check()
|
||
checked = true
|
||
if err and immediate_seen then
|
||
intermediate_seen = true
|
||
end
|
||
|
||
if not err then
|
||
success_seen = true
|
||
if did_minimal_timeout then
|
||
self._session:stop()
|
||
end
|
||
elseif success_seen and #args > 0 then
|
||
success_seen = false
|
||
failure_after_success = true
|
||
-- print(inspect(args))
|
||
end
|
||
|
||
return true
|
||
end
|
||
local eof = run_session(self._session, flags.request_cb, notification_cb, nil, minimal_timeout)
|
||
if not did_flush then
|
||
err = 'no flush received'
|
||
elseif not checked then
|
||
err = check()
|
||
if not err and flags.unchanged then
|
||
-- expecting NO screen change: use a shorter timeout
|
||
success_seen = true
|
||
end
|
||
end
|
||
|
||
if not success_seen and not eof then
|
||
did_minimal_timeout = true
|
||
eof =
|
||
run_session(self._session, flags.request_cb, notification_cb, nil, timeout - minimal_timeout)
|
||
end
|
||
|
||
local did_warn = false
|
||
if warn_immediate and immediate_seen then
|
||
print([[
|
||
|
||
warning: Screen test succeeded immediately. Try to avoid this unless the
|
||
purpose of the test really requires it.]])
|
||
if intermediate_seen then
|
||
print([[
|
||
There are intermediate states between the two identical expects.
|
||
Use screen:snapshot_util() or screen:redraw_debug() to find them, and add them
|
||
to the test if they make sense.
|
||
]])
|
||
else
|
||
print([[If necessary, silence this warning with 'unchanged' argument of screen:expect.]])
|
||
end
|
||
did_warn = true
|
||
end
|
||
|
||
if failure_after_success then
|
||
print([[
|
||
|
||
warning: Screen changes were received after the expected state. This indicates
|
||
indeterminism in the test. Try adding screen:expect(...) (or poke_eventloop())
|
||
between asynchronous (feed(), nvim_input()) and synchronous API calls.
|
||
- Use screen:redraw_debug() to investigate; it may find relevant intermediate
|
||
states that should be added to the test to make it more robust.
|
||
- If the purpose of the test is to assert state after some user input sent
|
||
with feed(), adding screen:expect() before the feed() will help to ensure
|
||
the input is sent when Nvim is in a predictable state. This is preferable
|
||
to poke_eventloop(), for being closer to real user interaction.
|
||
- poke_eventloop() can trigger redraws and thus generate more indeterminism.
|
||
Try removing poke_eventloop().
|
||
]])
|
||
did_warn = true
|
||
end
|
||
|
||
if err then
|
||
if eof then
|
||
err = err .. '\n\n' .. eof[2]
|
||
end
|
||
busted.fail(err .. '\n\nSnapshot:\n' .. self:_print_snapshot(), 3)
|
||
elseif did_warn then
|
||
if eof then
|
||
print(eof[2])
|
||
end
|
||
local tb = debug.traceback()
|
||
local index = string.find(tb, '\n%s*%[C]')
|
||
print(string.sub(tb, 1, index))
|
||
end
|
||
|
||
if flags.intermediate then
|
||
assert(intermediate_seen, 'expected intermediate screen state before final screen state')
|
||
elseif flags.unchanged then
|
||
assert(not intermediate_seen, 'expected screen state to be unchanged')
|
||
end
|
||
end
|
||
|
||
function Screen:sleep(ms, request_cb)
|
||
local function notification_cb(method, args)
|
||
assert(method == 'redraw')
|
||
self:_redraw(args)
|
||
end
|
||
run_session(self._session, request_cb, notification_cb, nil, ms)
|
||
end
|
||
|
||
--- @private
|
||
--- @param updates {[1]:string, [integer]:any[]}[]
|
||
function Screen:_redraw(updates)
|
||
local did_flush = false
|
||
for k, update in ipairs(updates) do
|
||
-- print('--', inspect(update))
|
||
local method = update[1]
|
||
for i = 2, #update do
|
||
local handler_name = '_handle_' .. method
|
||
--- @type function
|
||
local handler = self[handler_name]
|
||
assert(handler ~= nil, 'missing handler: Screen:' .. handler_name)
|
||
local status, res = pcall(handler, self, unpack(update[i]))
|
||
if not status then
|
||
error(
|
||
handler_name
|
||
.. ' failed'
|
||
.. '\n payload: '
|
||
.. inspect(update)
|
||
.. '\n error: '
|
||
.. tostring(res)
|
||
)
|
||
end
|
||
end
|
||
if k == #updates and method == 'flush' then
|
||
did_flush = true
|
||
end
|
||
end
|
||
return did_flush
|
||
end
|
||
|
||
function Screen:_handle_resize(width, height)
|
||
self:_handle_grid_resize(1, width, height)
|
||
self._scroll_region = {
|
||
top = 1,
|
||
bot = height,
|
||
left = 1,
|
||
right = width,
|
||
}
|
||
self._grid = self._grids[1]
|
||
end
|
||
|
||
local function min(x, y)
|
||
if x < y then
|
||
return x
|
||
else
|
||
return y
|
||
end
|
||
end
|
||
|
||
function Screen:_handle_grid_resize(grid, width, height)
|
||
local rows = {}
|
||
for _ = 1, height do
|
||
local cols = {}
|
||
for _ = 1, width do
|
||
table.insert(cols, { text = ' ', attrs = self._clear_attrs, hl_id = 0 })
|
||
end
|
||
table.insert(rows, cols)
|
||
end
|
||
if grid > 1 and self._grids[grid] ~= nil then
|
||
local old = self._grids[grid]
|
||
for i = 1, min(height, old.height) do
|
||
for j = 1, min(width, old.width) do
|
||
rows[i][j] = old.rows[i][j]
|
||
end
|
||
end
|
||
end
|
||
|
||
if self._cursor.grid == grid then
|
||
self._cursor.row = 1 -- -1 ?
|
||
self._cursor.col = 1
|
||
end
|
||
self._grids[grid] = {
|
||
rows = rows,
|
||
width = width,
|
||
height = height,
|
||
}
|
||
end
|
||
|
||
function Screen:_handle_msg_set_pos(grid, row, scrolled, char)
|
||
self.msg_grid = grid
|
||
self.msg_grid_pos = row
|
||
self.msg_scrolled = scrolled
|
||
self.msg_sep_char = char
|
||
end
|
||
|
||
function Screen:_handle_flush() end
|
||
|
||
function Screen:_reset()
|
||
-- TODO: generalize to multigrid later
|
||
self:_handle_grid_clear(1)
|
||
|
||
-- TODO: share with initialization, so it generalizes?
|
||
self.popupmenu = nil
|
||
self.cmdline = {}
|
||
self.cmdline_block = {}
|
||
self.wildmenu_items = nil
|
||
self.wildmenu_pos = nil
|
||
self._grid_win_extmarks = {}
|
||
end
|
||
|
||
--- @param cursor_style_enabled boolean
|
||
--- @param mode_info table[]
|
||
function Screen:_handle_mode_info_set(cursor_style_enabled, mode_info)
|
||
self._cursor_style_enabled = cursor_style_enabled
|
||
for _, item in pairs(mode_info) do
|
||
-- attr IDs are not stable, but their value should be
|
||
if item.attr_id ~= nil then
|
||
item.attr = self._attr_table[item.attr_id][1]
|
||
item.attr_id = nil
|
||
end
|
||
if item.attr_id_lm ~= nil then
|
||
item.attr_lm = self._attr_table[item.attr_id_lm][1]
|
||
item.attr_id_lm = nil
|
||
end
|
||
end
|
||
self._mode_info = mode_info
|
||
end
|
||
|
||
function Screen:_handle_clear()
|
||
-- the first implemented UI protocol clients (python-gui and builitin TUI)
|
||
-- allowed the cleared region to be restricted by setting the scroll region.
|
||
-- this was never used by nvim tough, and not documented and implemented by
|
||
-- newer clients, to check we remain compatible with both kind of clients,
|
||
-- ensure the scroll region is in a reset state.
|
||
local expected_region = {
|
||
top = 1,
|
||
bot = self._grid.height,
|
||
left = 1,
|
||
right = self._grid.width,
|
||
}
|
||
eq(expected_region, self._scroll_region)
|
||
self:_handle_grid_clear(1)
|
||
end
|
||
|
||
function Screen:_handle_grid_clear(grid)
|
||
self:_clear_block(self._grids[grid], 1, self._grids[grid].height, 1, self._grids[grid].width)
|
||
end
|
||
|
||
function Screen:_handle_grid_destroy(grid)
|
||
self._grids[grid] = nil
|
||
if self._options.ext_multigrid then
|
||
self.win_position[grid] = nil
|
||
self.win_viewport[grid] = nil
|
||
end
|
||
end
|
||
|
||
function Screen:_handle_eol_clear()
|
||
local row, col = self._cursor.row, self._cursor.col
|
||
self:_clear_block(self._grid, row, row, col, self._grid.width)
|
||
end
|
||
|
||
function Screen:_handle_cursor_goto(row, col)
|
||
self._cursor.row = row + 1
|
||
self._cursor.col = col + 1
|
||
end
|
||
|
||
function Screen:_handle_grid_cursor_goto(grid, row, col)
|
||
self._cursor.grid = grid
|
||
assert(row >= 0 and col >= 0)
|
||
self._cursor.row = row + 1
|
||
self._cursor.col = col + 1
|
||
end
|
||
|
||
function Screen:_handle_win_pos(grid, win, startrow, startcol, width, height)
|
||
self.win_position[grid] = {
|
||
win = win,
|
||
startrow = startrow,
|
||
startcol = startcol,
|
||
width = width,
|
||
height = height,
|
||
}
|
||
self.float_pos[grid] = nil
|
||
end
|
||
|
||
function Screen:_handle_win_viewport(
|
||
grid,
|
||
win,
|
||
topline,
|
||
botline,
|
||
curline,
|
||
curcol,
|
||
linecount,
|
||
scroll_delta
|
||
)
|
||
-- accumulate scroll delta
|
||
local last_scroll_delta = self.win_viewport[grid] and self.win_viewport[grid].sum_scroll_delta
|
||
or 0
|
||
self.win_viewport[grid] = {
|
||
win = win,
|
||
topline = topline,
|
||
botline = botline,
|
||
curline = curline,
|
||
curcol = curcol,
|
||
linecount = linecount,
|
||
sum_scroll_delta = scroll_delta + last_scroll_delta,
|
||
}
|
||
end
|
||
|
||
function Screen:_handle_win_float_pos(grid, ...)
|
||
self.win_position[grid] = nil
|
||
self.float_pos[grid] = { ... }
|
||
end
|
||
|
||
function Screen:_handle_win_external_pos(grid)
|
||
self.win_position[grid] = nil
|
||
self.float_pos[grid] = { external = true }
|
||
end
|
||
|
||
function Screen:_handle_win_hide(grid)
|
||
self.win_position[grid] = nil
|
||
self.float_pos[grid] = nil
|
||
end
|
||
|
||
function Screen:_handle_win_close(grid)
|
||
self.float_pos[grid] = nil
|
||
end
|
||
|
||
function Screen:_handle_win_extmark(grid, ...)
|
||
if self._grid_win_extmarks[grid] == nil then
|
||
self._grid_win_extmarks[grid] = {}
|
||
end
|
||
table.insert(self._grid_win_extmarks[grid], { ... })
|
||
end
|
||
|
||
function Screen:_handle_busy_start()
|
||
self._busy = true
|
||
end
|
||
|
||
function Screen:_handle_busy_stop()
|
||
self._busy = false
|
||
end
|
||
|
||
function Screen:_handle_mouse_on()
|
||
self.mouse_enabled = true
|
||
end
|
||
|
||
function Screen:_handle_mouse_off()
|
||
self.mouse_enabled = false
|
||
end
|
||
|
||
function Screen:_handle_mode_change(mode, idx)
|
||
assert(mode == self._mode_info[idx + 1].name)
|
||
self.mode = mode
|
||
end
|
||
|
||
function Screen:_handle_set_scroll_region(top, bot, left, right)
|
||
self._scroll_region.top = top + 1
|
||
self._scroll_region.bot = bot + 1
|
||
self._scroll_region.left = left + 1
|
||
self._scroll_region.right = right + 1
|
||
end
|
||
|
||
function Screen:_handle_scroll(count)
|
||
local top = self._scroll_region.top
|
||
local bot = self._scroll_region.bot
|
||
local left = self._scroll_region.left
|
||
local right = self._scroll_region.right
|
||
self:_handle_grid_scroll(1, top - 1, bot, left - 1, right, count, 0)
|
||
end
|
||
|
||
--- @param g any
|
||
--- @param top integer
|
||
--- @param bot integer
|
||
--- @param left integer
|
||
--- @param right integer
|
||
--- @param rows integer
|
||
--- @param cols integer
|
||
function Screen:_handle_grid_scroll(g, top, bot, left, right, rows, cols)
|
||
top = top + 1
|
||
left = left + 1
|
||
assert(cols == 0)
|
||
local grid = self._grids[g]
|
||
--- @type integer, integer, integer
|
||
local start, stop, step
|
||
|
||
if rows > 0 then
|
||
start = top
|
||
stop = bot - rows
|
||
step = 1
|
||
else
|
||
start = bot
|
||
stop = top - rows
|
||
step = -1
|
||
end
|
||
|
||
-- shift scroll region
|
||
for i = start, stop, step do
|
||
local target = grid.rows[i]
|
||
local source = grid.rows[i + rows]
|
||
for j = left, right do
|
||
target[j].text = source[j].text
|
||
target[j].attrs = source[j].attrs
|
||
target[j].hl_id = source[j].hl_id
|
||
end
|
||
end
|
||
|
||
-- clear invalid rows
|
||
for i = stop + step, stop + rows, step do
|
||
self:_clear_row_section(grid, i, left, right, true)
|
||
end
|
||
end
|
||
|
||
function Screen:_handle_hl_attr_define(id, rgb_attrs, cterm_attrs, info)
|
||
self._attr_table[id] = { rgb_attrs, cterm_attrs }
|
||
self._hl_info[id] = info
|
||
self._new_attrs = true
|
||
end
|
||
|
||
--- @param name string
|
||
--- @param id integer
|
||
function Screen:_handle_hl_group_set(name, id)
|
||
self.hl_groups[name] = id
|
||
end
|
||
|
||
function Screen:get_hl(val)
|
||
if self._options.ext_newgrid then
|
||
return self._attr_table[val][1]
|
||
end
|
||
return val
|
||
end
|
||
|
||
function Screen:_handle_highlight_set(attrs)
|
||
self._attrs = attrs
|
||
end
|
||
|
||
function Screen:_handle_put(str)
|
||
assert(not self._options.ext_linegrid)
|
||
local cell = self._grid.rows[self._cursor.row][self._cursor.col]
|
||
cell.text = str
|
||
cell.attrs = self._attrs
|
||
cell.hl_id = -1
|
||
self._cursor.col = self._cursor.col + 1
|
||
end
|
||
|
||
--- @param grid integer
|
||
--- @param row integer
|
||
--- @param col integer
|
||
--- @param items integer[][]
|
||
function Screen:_handle_grid_line(grid, row, col, items)
|
||
assert(self._options.ext_linegrid)
|
||
assert(#items > 0)
|
||
local line = self._grids[grid].rows[row + 1]
|
||
local colpos = col + 1
|
||
local hl_id = 0
|
||
for _, item in ipairs(items) do
|
||
local text, hl_id_cell, count = item[1], item[2], item[3]
|
||
if hl_id_cell ~= nil then
|
||
hl_id = hl_id_cell
|
||
end
|
||
for _ = 1, (count or 1) do
|
||
local cell = line[colpos]
|
||
cell.text = text
|
||
cell.hl_id = hl_id
|
||
colpos = colpos + 1
|
||
end
|
||
end
|
||
end
|
||
|
||
function Screen:_handle_bell()
|
||
self.bell = true
|
||
end
|
||
|
||
function Screen:_handle_visual_bell()
|
||
self.visual_bell = true
|
||
end
|
||
|
||
function Screen:_handle_default_colors_set(rgb_fg, rgb_bg, rgb_sp, cterm_fg, cterm_bg)
|
||
self.default_colors = {
|
||
rgb_fg = rgb_fg,
|
||
rgb_bg = rgb_bg,
|
||
rgb_sp = rgb_sp,
|
||
cterm_fg = cterm_fg,
|
||
cterm_bg = cterm_bg,
|
||
}
|
||
end
|
||
|
||
function Screen:_handle_update_fg(fg)
|
||
self._fg = fg
|
||
end
|
||
|
||
function Screen:_handle_update_bg(bg)
|
||
self._bg = bg
|
||
end
|
||
|
||
function Screen:_handle_update_sp(sp)
|
||
self._sp = sp
|
||
end
|
||
|
||
function Screen:_handle_suspend()
|
||
self.suspended = true
|
||
end
|
||
|
||
function Screen:_handle_update_menu()
|
||
self.update_menu = true
|
||
end
|
||
|
||
function Screen:_handle_set_title(title)
|
||
self.title = title
|
||
end
|
||
|
||
function Screen:_handle_set_icon(icon)
|
||
self.icon = icon
|
||
end
|
||
|
||
function Screen:_handle_option_set(name, value)
|
||
self.options[name] = value
|
||
end
|
||
|
||
function Screen:_handle_chdir(path)
|
||
self.pwd = vim.fs.normalize(path, { expand_env = false })
|
||
end
|
||
|
||
function Screen:_handle_popupmenu_show(items, selected, row, col, grid)
|
||
self.popupmenu = { items = items, pos = selected, anchor = { grid, row, col } }
|
||
end
|
||
|
||
function Screen:_handle_popupmenu_select(selected)
|
||
self.popupmenu.pos = selected
|
||
end
|
||
|
||
function Screen:_handle_popupmenu_hide()
|
||
self.popupmenu = nil
|
||
end
|
||
|
||
function Screen:_handle_cmdline_show(content, pos, firstc, prompt, indent, level)
|
||
if firstc == '' then
|
||
firstc = nil
|
||
end
|
||
if prompt == '' then
|
||
prompt = nil
|
||
end
|
||
if indent == 0 then
|
||
indent = nil
|
||
end
|
||
|
||
-- check position is valid #10000
|
||
local len = 0
|
||
for _, chunk in ipairs(content) do
|
||
len = len + string.len(chunk[2])
|
||
end
|
||
assert(pos <= len)
|
||
|
||
self.cmdline[level] = {
|
||
content = content,
|
||
pos = pos,
|
||
firstc = firstc,
|
||
prompt = prompt,
|
||
indent = indent,
|
||
}
|
||
end
|
||
|
||
function Screen:_handle_cmdline_hide(level)
|
||
self.cmdline[level] = nil
|
||
end
|
||
|
||
function Screen:_handle_cmdline_special_char(char, shift, level)
|
||
-- cleared by next cmdline_show on the same level
|
||
self.cmdline[level].special = { char, shift }
|
||
end
|
||
|
||
function Screen:_handle_cmdline_pos(pos, level)
|
||
self.cmdline[level].pos = pos
|
||
end
|
||
|
||
function Screen:_handle_cmdline_block_show(block)
|
||
self.cmdline_block = block
|
||
end
|
||
|
||
function Screen:_handle_cmdline_block_append(item)
|
||
self.cmdline_block[#self.cmdline_block + 1] = item
|
||
end
|
||
|
||
function Screen:_handle_cmdline_block_hide()
|
||
self.cmdline_block = {}
|
||
end
|
||
|
||
function Screen:_handle_wildmenu_show(items)
|
||
self.wildmenu_items = items
|
||
end
|
||
|
||
function Screen:_handle_wildmenu_select(pos)
|
||
self.wildmenu_pos = pos
|
||
end
|
||
|
||
function Screen:_handle_wildmenu_hide()
|
||
self.wildmenu_items, self.wildmenu_pos = nil, nil
|
||
end
|
||
|
||
function Screen:_handle_msg_show(kind, chunks, replace_last)
|
||
local pos = #self.messages
|
||
if not replace_last or pos == 0 then
|
||
pos = pos + 1
|
||
end
|
||
self.messages[pos] = { kind = kind, content = chunks }
|
||
end
|
||
|
||
function Screen:_handle_msg_clear()
|
||
self.messages = {}
|
||
end
|
||
|
||
function Screen:_handle_msg_showcmd(msg)
|
||
self.showcmd = msg
|
||
end
|
||
|
||
function Screen:_handle_msg_showmode(msg)
|
||
self.showmode = msg
|
||
end
|
||
|
||
function Screen:_handle_msg_ruler(msg)
|
||
self.ruler = msg
|
||
end
|
||
|
||
function Screen:_handle_msg_history_show(entries)
|
||
self.msg_history = entries
|
||
end
|
||
|
||
function Screen:_handle_msg_history_clear()
|
||
self.msg_history = {}
|
||
end
|
||
|
||
function Screen:_clear_block(grid, top, bot, left, right)
|
||
for i = top, bot do
|
||
self:_clear_row_section(grid, i, left, right)
|
||
end
|
||
end
|
||
|
||
function Screen:_clear_row_section(grid, rownum, startcol, stopcol, invalid)
|
||
local row = grid.rows[rownum]
|
||
for i = startcol, stopcol do
|
||
row[i].text = (invalid and '<EFBFBD>' or ' ')
|
||
row[i].attrs = self._clear_attrs
|
||
row[i].hl_id = 0
|
||
end
|
||
end
|
||
|
||
function Screen:_row_repr(gridnr, rownr, attr_state, cursor)
|
||
local rv = {}
|
||
local current_attr_id
|
||
local i = 1
|
||
local has_windows = self._options.ext_multigrid and gridnr == 1
|
||
local row = self._grids[gridnr].rows[rownr]
|
||
if has_windows and self.msg_grid and self.msg_grid_pos < rownr then
|
||
return '[' .. self.msg_grid .. ':' .. string.rep('-', #row) .. ']'
|
||
end
|
||
while i <= #row do
|
||
local did_window = false
|
||
if has_windows then
|
||
for id, pos in pairs(self.win_position) do
|
||
if
|
||
i - 1 == pos.startcol
|
||
and pos.startrow <= rownr - 1
|
||
and rownr - 1 < pos.startrow + pos.height
|
||
then
|
||
if current_attr_id then
|
||
-- close current attribute bracket
|
||
table.insert(rv, '}')
|
||
current_attr_id = nil
|
||
end
|
||
table.insert(rv, '[' .. id .. ':' .. string.rep('-', pos.width) .. ']')
|
||
i = i + pos.width
|
||
did_window = true
|
||
end
|
||
end
|
||
end
|
||
|
||
if not did_window then
|
||
local attr_id = self:_get_attr_id(attr_state, row[i].attrs, row[i].hl_id)
|
||
if current_attr_id and attr_id ~= current_attr_id then
|
||
-- close current attribute bracket
|
||
table.insert(rv, '}')
|
||
current_attr_id = nil
|
||
end
|
||
if not current_attr_id and attr_id then
|
||
-- open a new attribute bracket
|
||
table.insert(rv, '{' .. attr_id .. ':')
|
||
current_attr_id = attr_id
|
||
end
|
||
if not self._busy and cursor and self._cursor.col == i then
|
||
table.insert(rv, '^')
|
||
end
|
||
table.insert(rv, row[i].text)
|
||
i = i + 1
|
||
end
|
||
end
|
||
if current_attr_id then
|
||
table.insert(rv, '}')
|
||
end
|
||
-- return the line representation, but remove empty attribute brackets and
|
||
-- trailing whitespace
|
||
return table.concat(rv, '') --:gsub('%s+$', '')
|
||
end
|
||
|
||
function Screen:_extstate_repr(attr_state)
|
||
local cmdline = {}
|
||
for i, entry in pairs(self.cmdline) do
|
||
entry = shallowcopy(entry)
|
||
entry.content = self:_chunks_repr(entry.content, attr_state)
|
||
cmdline[i] = entry
|
||
end
|
||
|
||
local cmdline_block = {}
|
||
for i, entry in ipairs(self.cmdline_block) do
|
||
cmdline_block[i] = self:_chunks_repr(entry, attr_state)
|
||
end
|
||
|
||
local messages = {}
|
||
for i, entry in ipairs(self.messages) do
|
||
messages[i] = { kind = entry.kind, content = self:_chunks_repr(entry.content, attr_state) }
|
||
end
|
||
|
||
local msg_history = {}
|
||
for i, entry in ipairs(self.msg_history) do
|
||
msg_history[i] = { kind = entry[1], content = self:_chunks_repr(entry[2], attr_state) }
|
||
end
|
||
|
||
local win_viewport = (next(self.win_viewport) and self.win_viewport) or nil
|
||
|
||
return {
|
||
popupmenu = self.popupmenu,
|
||
cmdline = cmdline,
|
||
cmdline_block = cmdline_block,
|
||
wildmenu_items = self.wildmenu_items,
|
||
wildmenu_pos = self.wildmenu_pos,
|
||
messages = messages,
|
||
showmode = self:_chunks_repr(self.showmode, attr_state),
|
||
showcmd = self:_chunks_repr(self.showcmd, attr_state),
|
||
ruler = self:_chunks_repr(self.ruler, attr_state),
|
||
msg_history = msg_history,
|
||
float_pos = self.float_pos,
|
||
win_viewport = win_viewport,
|
||
}
|
||
end
|
||
|
||
function Screen:_chunks_repr(chunks, attr_state)
|
||
local repr_chunks = {}
|
||
for i, chunk in ipairs(chunks) do
|
||
local hl, text = unpack(chunk)
|
||
local attrs
|
||
if self._options.ext_linegrid then
|
||
attrs = self._attr_table[hl][1]
|
||
else
|
||
attrs = hl
|
||
end
|
||
local attr_id = self:_get_attr_id(attr_state, attrs, hl)
|
||
repr_chunks[i] = { text, attr_id }
|
||
end
|
||
return repr_chunks
|
||
end
|
||
|
||
-- Generates tests. Call it where Screen:expect() would be. Waits briefly, then
|
||
-- dumps the current screen state in the form of Screen:expect().
|
||
-- Use snapshot_util({}) to generate a text-only (no attributes) test.
|
||
--
|
||
-- @see Screen:redraw_debug()
|
||
function Screen:snapshot_util(attrs, ignore, request_cb)
|
||
self:sleep(250, request_cb)
|
||
self:print_snapshot(attrs, ignore)
|
||
end
|
||
|
||
function Screen:redraw_debug(attrs, ignore, timeout)
|
||
self:print_snapshot(attrs, ignore)
|
||
local function notification_cb(method, args)
|
||
assert(method == 'redraw')
|
||
for _, update in ipairs(args) do
|
||
-- mode_info_set is quite verbose, comment out the condition to debug it.
|
||
if update[1] ~= 'mode_info_set' then
|
||
print(inspect(update))
|
||
end
|
||
end
|
||
self:_redraw(args)
|
||
self:print_snapshot(attrs, ignore)
|
||
return true
|
||
end
|
||
if timeout == nil then
|
||
timeout = 250
|
||
end
|
||
run_session(self._session, nil, notification_cb, nil, timeout)
|
||
end
|
||
|
||
--- @param headers boolean
|
||
--- @param attr_state any
|
||
--- @param preview? boolean
|
||
--- @return string[]
|
||
function Screen:render(headers, attr_state, preview)
|
||
headers = headers and (self._options.ext_multigrid or self._options._debug_float)
|
||
local rv = {}
|
||
for igrid, grid in vim.spairs(self._grids) do
|
||
if headers then
|
||
local suffix = ''
|
||
if
|
||
igrid > 1
|
||
and self.win_position[igrid] == nil
|
||
and self.float_pos[igrid] == nil
|
||
and self.msg_grid ~= igrid
|
||
then
|
||
suffix = ' (hidden)'
|
||
end
|
||
table.insert(rv, '## grid ' .. igrid .. suffix)
|
||
end
|
||
local height = grid.height
|
||
if igrid == self.msg_grid then
|
||
height = self._grids[1].height - self.msg_grid_pos
|
||
end
|
||
for i = 1, height do
|
||
local cursor = self._cursor.grid == igrid and self._cursor.row == i
|
||
local prefix = (headers or preview) and ' ' or ''
|
||
table.insert(rv, prefix .. self:_row_repr(igrid, i, attr_state, cursor) .. '|')
|
||
end
|
||
end
|
||
return rv
|
||
end
|
||
|
||
-- Returns the current screen state in the form of a screen:expect()
|
||
-- keyword-args map.
|
||
function Screen:get_snapshot(attrs, ignore)
|
||
if ignore == nil then
|
||
ignore = self._default_attr_ignore
|
||
end
|
||
local attr_state = {
|
||
ids = {},
|
||
ignore = ignore,
|
||
mutable = true, -- allow _row_repr to add missing highlights
|
||
}
|
||
if attrs == nil then
|
||
attrs = self._default_attr_ids
|
||
elseif isempty(attrs) then
|
||
attrs = nil
|
||
attr_state.ids = nil
|
||
else
|
||
attr_state.modified = true
|
||
end
|
||
|
||
if attrs ~= nil then
|
||
for i, a in pairs(attrs) do
|
||
attr_state.ids[i] = a
|
||
end
|
||
end
|
||
if self._options.ext_linegrid then
|
||
attr_state.id_to_index = self:linegrid_check_attrs(attr_state.ids or {})
|
||
end
|
||
|
||
local lines = self:render(true, attr_state, true)
|
||
|
||
for i, row in ipairs(lines) do
|
||
local count = 1
|
||
while i < #lines and lines[i + 1] == row do
|
||
count = count + 1
|
||
table.remove(lines, i + 1)
|
||
end
|
||
if count > 1 then
|
||
lines[i] = lines[i] .. '*' .. count
|
||
end
|
||
end
|
||
|
||
local ext_state = self:_extstate_repr(attr_state)
|
||
for k, v in pairs(ext_state) do
|
||
if isempty(v) then
|
||
ext_state[k] = nil -- deleting keys while iterating is ok
|
||
end
|
||
end
|
||
|
||
-- Build keyword-args for screen:expect().
|
||
local kwargs = {}
|
||
if attr_state.modified then
|
||
kwargs['attr_ids'] = {}
|
||
for i, a in pairs(attr_state.ids) do
|
||
kwargs['attr_ids'][i] = a
|
||
end
|
||
end
|
||
kwargs['grid'] = table.concat(lines, '\n')
|
||
for _, k in ipairs(ext_keys) do
|
||
if ext_state[k] ~= nil then
|
||
kwargs[k] = ext_state[k]
|
||
end
|
||
end
|
||
|
||
return kwargs, ext_state, attr_state
|
||
end
|
||
|
||
local function fmt_ext_state(name, state)
|
||
local function remove_all_metatables(item, path)
|
||
if path[#path] ~= inspect.METATABLE then
|
||
return item
|
||
end
|
||
end
|
||
if name == 'win_viewport' then
|
||
local str = '{\n'
|
||
for k, v in pairs(state) do
|
||
str = (
|
||
str
|
||
.. ' ['
|
||
.. k
|
||
.. '] = {win = '
|
||
.. v.win
|
||
.. ', topline = '
|
||
.. v.topline
|
||
.. ', botline = '
|
||
.. v.botline
|
||
.. ', curline = '
|
||
.. v.curline
|
||
.. ', curcol = '
|
||
.. v.curcol
|
||
.. ', linecount = '
|
||
.. v.linecount
|
||
.. ', sum_scroll_delta = '
|
||
.. v.sum_scroll_delta
|
||
.. '};\n'
|
||
)
|
||
end
|
||
return str .. '}'
|
||
elseif name == 'float_pos' then
|
||
local str = '{\n'
|
||
for k, v in pairs(state) do
|
||
str = str .. ' [' .. k .. '] = {' .. v[1]
|
||
for i = 2, #v do
|
||
str = str .. ', ' .. inspect(v[i])
|
||
end
|
||
str = str .. '};\n'
|
||
end
|
||
return str .. '}'
|
||
else
|
||
-- TODO(bfredl): improve formatting of more states
|
||
return inspect(state, { process = remove_all_metatables })
|
||
end
|
||
end
|
||
|
||
function Screen:_print_snapshot(attrs, ignore)
|
||
local kwargs, ext_state, attr_state = self:get_snapshot(attrs, ignore)
|
||
local attrstr = ''
|
||
if attr_state.modified then
|
||
local attrstrs = {}
|
||
for i, a in pairs(attr_state.ids) do
|
||
local dict
|
||
if self._options.ext_linegrid then
|
||
dict = self:_pprint_hlitem(a)
|
||
else
|
||
dict = '{' .. self:_pprint_attrs(a) .. '}'
|
||
end
|
||
local keyval = (type(i) == 'number') and '[' .. tostring(i) .. ']' or i
|
||
table.insert(attrstrs, ' ' .. keyval .. ' = ' .. dict .. ';')
|
||
end
|
||
attrstr = (', attr_ids={\n' .. table.concat(attrstrs, '\n') .. '\n}')
|
||
elseif isempty(attrs) then
|
||
attrstr = ', attr_ids={}'
|
||
end
|
||
|
||
local result = 'screen:expect{grid=[[\n' .. kwargs.grid .. '\n]]' .. attrstr
|
||
for _, k in ipairs(ext_keys) do
|
||
if ext_state[k] ~= nil and not (k == 'win_viewport' and not self.options.ext_multigrid) then
|
||
result = result .. ', ' .. k .. '=' .. fmt_ext_state(k, ext_state[k])
|
||
end
|
||
end
|
||
result = result .. '}'
|
||
|
||
return result
|
||
end
|
||
|
||
function Screen:print_snapshot(attrs, ignore)
|
||
print('\n' .. self:_print_snapshot(attrs, ignore) .. '\n')
|
||
io.stdout:flush()
|
||
end
|
||
|
||
function Screen:_insert_hl_id(attr_state, hl_id)
|
||
if attr_state.id_to_index[hl_id] ~= nil then
|
||
return attr_state.id_to_index[hl_id]
|
||
end
|
||
local raw_info = self._hl_info[hl_id]
|
||
local info = nil
|
||
if self._options.ext_hlstate then
|
||
info = {}
|
||
if #raw_info > 1 then
|
||
for i, item in ipairs(raw_info) do
|
||
info[i] = self:_insert_hl_id(attr_state, item.id)
|
||
end
|
||
else
|
||
info[1] = {}
|
||
for k, v in pairs(raw_info[1]) do
|
||
if k ~= 'id' then
|
||
info[1][k] = v
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
local entry = self._attr_table[hl_id]
|
||
local attrval
|
||
if self._rgb_cterm then
|
||
attrval = { entry[1], entry[2], info } -- unpack() doesn't work
|
||
elseif self._options.ext_hlstate then
|
||
attrval = { entry[1], info }
|
||
else
|
||
attrval = self._options.rgb and entry[1] or entry[2]
|
||
end
|
||
|
||
table.insert(attr_state.ids, attrval)
|
||
attr_state.id_to_index[hl_id] = #attr_state.ids
|
||
return #attr_state.ids
|
||
end
|
||
|
||
function Screen:linegrid_check_attrs(attrs)
|
||
local id_to_index = {}
|
||
for i, def_attr in pairs(self._attr_table) do
|
||
local iinfo = self._hl_info[i]
|
||
local matchinfo = {}
|
||
if #iinfo > 1 then
|
||
for k, item in ipairs(iinfo) do
|
||
matchinfo[k] = id_to_index[item.id]
|
||
end
|
||
else
|
||
matchinfo = iinfo
|
||
end
|
||
for k, v in pairs(attrs) do
|
||
local attr, info, attr_rgb, attr_cterm
|
||
if self._rgb_cterm then
|
||
attr_rgb, attr_cterm, info = unpack(v)
|
||
attr = { attr_rgb, attr_cterm }
|
||
info = info or {}
|
||
elseif self._options.ext_hlstate then
|
||
attr, info = unpack(v)
|
||
else
|
||
attr = v
|
||
info = {}
|
||
end
|
||
if self:_equal_attr_def(attr, def_attr) then
|
||
if #info == #matchinfo then
|
||
local match = false
|
||
if #info == 1 then
|
||
if self:_equal_info(info[1], matchinfo[1]) then
|
||
match = true
|
||
end
|
||
else
|
||
match = true
|
||
for j = 1, #info do
|
||
if info[j] ~= matchinfo[j] then
|
||
match = false
|
||
end
|
||
end
|
||
end
|
||
if match then
|
||
id_to_index[i] = k
|
||
end
|
||
end
|
||
end
|
||
end
|
||
if
|
||
self:_equal_attr_def(self._rgb_cterm and { {}, {} } or {}, def_attr)
|
||
and #self._hl_info[i] == 0
|
||
then
|
||
id_to_index[i] = ''
|
||
end
|
||
end
|
||
return id_to_index
|
||
end
|
||
|
||
function Screen:_pprint_hlitem(item)
|
||
-- print(inspect(item))
|
||
local multi = self._rgb_cterm or self._options.ext_hlstate
|
||
local cterm = (not self._rgb_cterm and not self._options.rgb)
|
||
local attrdict = '{' .. self:_pprint_attrs(multi and item[1] or item, cterm) .. '}'
|
||
local attrdict2, hlinfo
|
||
local descdict = ''
|
||
if self._rgb_cterm then
|
||
attrdict2 = ', {' .. self:_pprint_attrs(item[2], true) .. '}'
|
||
hlinfo = item[3]
|
||
else
|
||
attrdict2 = ''
|
||
hlinfo = item[2]
|
||
end
|
||
if self._options.ext_hlstate then
|
||
descdict = ', {' .. self:_pprint_hlinfo(hlinfo) .. '}'
|
||
end
|
||
return (multi and '{' or '') .. attrdict .. attrdict2 .. descdict .. (multi and '}' or '')
|
||
end
|
||
|
||
function Screen:_pprint_hlinfo(states)
|
||
if #states == 1 then
|
||
local items = {}
|
||
for f, v in pairs(states[1]) do
|
||
local desc = tostring(v)
|
||
if type(v) == type('') then
|
||
desc = '"' .. desc .. '"'
|
||
end
|
||
table.insert(items, f .. ' = ' .. desc)
|
||
end
|
||
return '{' .. table.concat(items, ', ') .. '}'
|
||
else
|
||
return table.concat(states, ', ')
|
||
end
|
||
end
|
||
|
||
function Screen:_pprint_attrs(attrs, cterm)
|
||
local items = {}
|
||
for f, v in pairs(attrs) do
|
||
local desc = tostring(v)
|
||
if f == 'foreground' or f == 'background' or f == 'special' then
|
||
if Screen.colornames[v] ~= nil then
|
||
desc = 'Screen.colors.' .. Screen.colornames[v]
|
||
elseif cterm then
|
||
desc = tostring(v)
|
||
else
|
||
desc = string.format("tonumber('0x%06x')", v)
|
||
end
|
||
end
|
||
table.insert(items, f .. ' = ' .. desc)
|
||
end
|
||
return table.concat(items, ', ')
|
||
end
|
||
|
||
---@diagnostic disable-next-line: unused-local, unused-function
|
||
local function backward_find_meaningful(tbl, from) -- luacheck: no unused
|
||
for i = from or #tbl, 1, -1 do
|
||
if tbl[i] ~= ' ' then
|
||
return i + 1
|
||
end
|
||
end
|
||
return from
|
||
end
|
||
|
||
function Screen:_get_attr_id(attr_state, attrs, hl_id)
|
||
if not attr_state.ids then
|
||
return
|
||
end
|
||
|
||
if self._options.ext_linegrid then
|
||
local id = attr_state.id_to_index[hl_id]
|
||
if id == '' then -- sentinel for empty it
|
||
return nil
|
||
elseif id ~= nil then
|
||
return id
|
||
end
|
||
if attr_state.mutable then
|
||
id = self:_insert_hl_id(attr_state, hl_id)
|
||
attr_state.modified = true
|
||
return id
|
||
end
|
||
local kind = self._options.rgb and 1 or 2
|
||
return 'UNEXPECTED ' .. self:_pprint_attrs(self._attr_table[hl_id][kind])
|
||
else
|
||
if self:_equal_attrs(attrs, {}) then
|
||
-- ignore this attrs
|
||
return nil
|
||
end
|
||
for id, a in pairs(attr_state.ids) do
|
||
if self:_equal_attrs(a, attrs) then
|
||
return id
|
||
end
|
||
end
|
||
if attr_state.mutable then
|
||
table.insert(attr_state.ids, attrs)
|
||
attr_state.modified = true
|
||
return #attr_state.ids
|
||
end
|
||
return 'UNEXPECTED ' .. self:_pprint_attrs(attrs)
|
||
end
|
||
end
|
||
|
||
function Screen:_equal_attr_def(a, b)
|
||
if self._rgb_cterm then
|
||
return self:_equal_attrs(a[1], b[1]) and self:_equal_attrs(a[2], b[2])
|
||
elseif self._options.rgb then
|
||
return self:_equal_attrs(a, b[1])
|
||
else
|
||
return self:_equal_attrs(a, b[2])
|
||
end
|
||
end
|
||
|
||
function Screen:_equal_attrs(a, b)
|
||
return a.bold == b.bold
|
||
and a.standout == b.standout
|
||
and a.underline == b.underline
|
||
and a.undercurl == b.undercurl
|
||
and a.underdouble == b.underdouble
|
||
and a.underdotted == b.underdotted
|
||
and a.underdashed == b.underdashed
|
||
and a.italic == b.italic
|
||
and a.reverse == b.reverse
|
||
and a.foreground == b.foreground
|
||
and a.background == b.background
|
||
and a.special == b.special
|
||
and a.blend == b.blend
|
||
and a.strikethrough == b.strikethrough
|
||
and a.fg_indexed == b.fg_indexed
|
||
and a.bg_indexed == b.bg_indexed
|
||
and a.url == b.url
|
||
end
|
||
|
||
function Screen:_equal_info(a, b)
|
||
return a.kind == b.kind and a.hi_name == b.hi_name and a.ui_name == b.ui_name
|
||
end
|
||
|
||
function Screen:_attr_index(attrs, attr)
|
||
if not attrs then
|
||
return nil
|
||
end
|
||
for i, a in pairs(attrs) do
|
||
if self:_equal_attrs(a, attr) then
|
||
return i
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
return Screen
|