mirror of
https://github.com/neovim/neovim.git
synced 2024-12-19 18:55:14 -07:00
0dd933265f
When a terminal application running inside the terminal emulator sets the cursor shape or blink status of the cursor, update the cursor in the parent terminal to match. This removes the "virtual cursor" that has been in use by the terminal emulator since the beginning. The original rationale for using the virtual cursor was to avoid having to support additional UI methods to change the cursor color for other (non-TUI) UIs, instead relying on the TermCursor and TermCursorNC highlight groups. The TermCursor highlight group is now used in the default 'guicursor' value, which has a new entry for Terminal mode. However, the TermCursorNC highlight group is no longer supported: since terminal windows now use the real cursor, when the window is not focused there is no cursor displayed in the window at all, so there is nothing to highlight. Users can still use the StatusLineTermNC highlight group to differentiate non-focused terminal windows. BREAKING CHANGE: The TermCursorNC highlight group is no longer supported.
1982 lines
55 KiB
Lua
1982 lines
55 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:
|
||
--
|
||
-- -- Attach a screen to the current Nvim instance.
|
||
-- local screen = Screen.new(25, 10)
|
||
-- -- Enter insert-mode and type some text.
|
||
-- feed('ihello screen')
|
||
-- -- Assert the expected screen state.
|
||
-- screen:expect([[
|
||
-- hello screen^ |
|
||
-- {1:~ }|*8
|
||
-- {5:-- 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.
|
||
--
|
||
-- The 30 most common highlight groups are predefined, see init_colors() below.
|
||
-- In this case "5" is a predefined highlight associated with the set composed of one
|
||
-- attribute: bold. Note that since the {5:} markup is not a real part of the
|
||
-- screen, the delimiter "|" moved to the right. Also, the highlighting of the
|
||
-- NonText markers "~" is visible.
|
||
--
|
||
-- Tests will often share a group of extra attribute sets to expect(). Those can be
|
||
-- defined at the beginning of a test:
|
||
--
|
||
-- screen:add_extra_attr_ids({
|
||
-- [100] = { background = Screen.colors.Plum1, underline = true },
|
||
-- [101] = { background = Screen.colors.Red1, bold = true, underline = true },
|
||
-- })
|
||
--
|
||
-- To help write screen tests, see Screen:snapshot_util().
|
||
-- To debug screen tests, see Screen:redraw_debug().
|
||
|
||
local t = require('test.testutil')
|
||
local n = require('test.functional.testnvim')()
|
||
local busted = require('busted')
|
||
|
||
local deepcopy = vim.deepcopy
|
||
local shallowcopy = t.shallowcopy
|
||
local concat_tables = t.concat_tables
|
||
local pesc = vim.pesc
|
||
local run_session = n.run_session
|
||
local eq = t.eq
|
||
local dedent = t.dedent
|
||
local get_session = n.get_session
|
||
local create_callindex = n.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
|
||
|
||
Screen._global_default_attr_ids = {
|
||
[1] = { foreground = Screen.colors.Blue1, bold = true },
|
||
[2] = { reverse = true },
|
||
[3] = { bold = true, reverse = true },
|
||
[4] = { background = Screen.colors.LightMagenta },
|
||
[5] = { bold = true },
|
||
[6] = { foreground = Screen.colors.SeaGreen, bold = true },
|
||
[7] = { background = Screen.colors.Gray, foreground = Screen.colors.DarkBlue },
|
||
[8] = { foreground = Screen.colors.Brown },
|
||
[9] = { background = Screen.colors.Red, foreground = Screen.colors.Grey100 },
|
||
[10] = { background = Screen.colors.Yellow },
|
||
[11] = {
|
||
foreground = Screen.colors.Blue1,
|
||
background = Screen.colors.LightMagenta,
|
||
bold = true,
|
||
},
|
||
[12] = { background = Screen.colors.Gray },
|
||
[13] = { background = Screen.colors.LightGrey, foreground = Screen.colors.DarkBlue },
|
||
[14] = { background = Screen.colors.DarkGray, foreground = Screen.colors.LightGrey },
|
||
[15] = { foreground = Screen.colors.Brown, bold = true },
|
||
[16] = { foreground = Screen.colors.SlateBlue },
|
||
[17] = { background = Screen.colors.LightGrey, foreground = Screen.colors.Black },
|
||
[18] = { foreground = Screen.colors.Blue1 },
|
||
[19] = { foreground = Screen.colors.Red },
|
||
[20] = { background = Screen.colors.Yellow, foreground = Screen.colors.Red },
|
||
[21] = { background = Screen.colors.Grey90 },
|
||
[22] = { background = Screen.colors.LightBlue },
|
||
[23] = { foreground = Screen.colors.Blue1, background = Screen.colors.LightCyan, bold = true },
|
||
[24] = { background = Screen.colors.LightGrey, underline = true },
|
||
[25] = { foreground = Screen.colors.Cyan4 },
|
||
[26] = { foreground = Screen.colors.Fuchsia },
|
||
[27] = { background = Screen.colors.Red, bold = true },
|
||
[28] = { foreground = Screen.colors.SlateBlue, underline = true },
|
||
[29] = { foreground = Screen.colors.SlateBlue, bold = true },
|
||
[30] = { background = Screen.colors.Red },
|
||
}
|
||
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 width? integer
|
||
--- @param height? integer
|
||
--- @param options? test.functional.ui.screen.Opts
|
||
--- @param session? test.Session|false
|
||
--- @return test.functional.ui.screen
|
||
function Screen.new(width, height, options, session)
|
||
if not Screen.colors then
|
||
_init_colors()
|
||
end
|
||
|
||
options = options or {}
|
||
if options.ext_linegrid == nil then
|
||
options.ext_linegrid = true
|
||
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 = {},
|
||
win_viewport_margins = {},
|
||
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,
|
||
_options = options,
|
||
_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)
|
||
|
||
-- session is often nil, which implies the default session
|
||
if session ~= false then
|
||
self:attach(session)
|
||
end
|
||
|
||
return self
|
||
end
|
||
|
||
function Screen:set_default_attr_ids(attr_ids)
|
||
self._default_attr_ids = attr_ids
|
||
self._attrs_overridden = true
|
||
end
|
||
|
||
function Screen:add_extra_attr_ids(extra_attr_ids)
|
||
local attr_ids = vim.deepcopy(Screen._global_default_attr_ids)
|
||
for id, attr in pairs(extra_attr_ids) do
|
||
if type(id) == 'number' and id < 100 then
|
||
error('extra attr ids should be at least 100 or be strings')
|
||
end
|
||
attr_ids[id] = attr
|
||
end
|
||
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
|
||
|
||
--- @param session? test.Session
|
||
function Screen:attach(session)
|
||
session = session or get_session()
|
||
local options = self._options
|
||
|
||
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
|
||
|
||
if self._default_attr_ids == nil then
|
||
self._default_attr_ids = Screen._global_default_attr_ids
|
||
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',
|
||
'win_viewport_margins',
|
||
}
|
||
|
||
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? [integer,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
|
||
if expected.any or grid then
|
||
actual_rows = self:render(not expected.any, attr_state)
|
||
end
|
||
|
||
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.win_viewport_margins == nil then
|
||
extstate.win_viewport_margins = 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)
|
||
-- Collect the current screen state.
|
||
local kwargs = self:get_snapshot()
|
||
|
||
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 then
|
||
minimal_timeout = flags.timeout or default_timeout_factor * 20
|
||
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 and (not flags.intermediate or intermediate_seen) then
|
||
success_seen = true
|
||
if did_minimal_timeout then
|
||
self._session:stop()
|
||
end
|
||
elseif err and 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
|
||
if eof then
|
||
err = 'no flush received'
|
||
end
|
||
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)
|
||
if not did_flush then
|
||
err = 'no flush received'
|
||
end
|
||
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 and self._attr_table[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 and self._attr_table[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
|
||
self.win_viewport_margins[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_viewport_margins(grid, win, top, bottom, left, right)
|
||
self.win_viewport_margins[grid] = {
|
||
win = win,
|
||
top = top,
|
||
bottom = bottom,
|
||
left = left,
|
||
right = right,
|
||
}
|
||
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
|
||
local win_viewport_margins = (next(self.win_viewport_margins) and self.win_viewport_margins)
|
||
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,
|
||
win_viewport_margins = win_viewport_margins,
|
||
}
|
||
end
|
||
|
||
function Screen:_chunks_repr(chunks, attr_state)
|
||
local repr_chunks = {}
|
||
for i, chunk in ipairs(chunks) do
|
||
local hl, text, id = 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, attr_id and id or nil }
|
||
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(request_cb)
|
||
-- TODO: simplify this later when existing tests have been updated
|
||
self:sleep(250, request_cb)
|
||
self:print_snapshot()
|
||
end
|
||
|
||
function Screen:redraw_debug(timeout)
|
||
self:print_snapshot()
|
||
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()
|
||
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()
|
||
local attr_state = {
|
||
ids = {},
|
||
mutable = true, -- allow _row_repr to add missing highlights
|
||
}
|
||
local attrs = self._default_attr_ids
|
||
|
||
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()
|
||
local kwargs, ext_state, attr_state = self:get_snapshot()
|
||
local attrstr = ''
|
||
local modify_attrs = not self._attrs_overridden
|
||
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
|
||
if not (type(i) == 'number' and modify_attrs and i <= 30) then
|
||
table.insert(attrstrs, ' ' .. keyval .. ' = ' .. dict .. ',')
|
||
end
|
||
if modify_attrs then
|
||
self._default_attr_ids = attr_state.ids
|
||
end
|
||
end
|
||
local fn_name = modify_attrs and 'add_extra_attr_ids' or 'set_default_attr_ids'
|
||
attrstr = ('screen:' .. fn_name .. '({\n' .. table.concat(attrstrs, '\n') .. '\n})\n\n')
|
||
end
|
||
|
||
local extstr = ''
|
||
for _, k in ipairs(ext_keys) do
|
||
if ext_state[k] ~= nil and not (k == 'win_viewport' and not self.options.ext_multigrid) then
|
||
extstr = extstr .. '\n ' .. k .. ' = ' .. fmt_ext_state(k, ext_state[k]) .. ','
|
||
end
|
||
end
|
||
|
||
return ('%sscreen:expect(%s%s%s%s%s'):format(
|
||
attrstr,
|
||
#extstr > 0 and '{\n grid = [[\n ' or '[[\n',
|
||
#extstr > 0 and kwargs.grid:gsub('\n', '\n ') or kwargs.grid,
|
||
#extstr > 0 and '\n ]],' or '\n]]',
|
||
extstr,
|
||
#extstr > 0 and '\n})' or ')'
|
||
)
|
||
end
|
||
|
||
function Screen:print_snapshot()
|
||
print('\n' .. self:_print_snapshot() .. '\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
|