diff options
Diffstat (limited to 'test/functional/ui/screen.lua')
-rw-r--r-- | test/functional/ui/screen.lua | 1201 |
1 files changed, 976 insertions, 225 deletions
diff --git a/test/functional/ui/screen.lua b/test/functional/ui/screen.lua index d7af2a4fce..69f4a44dd8 100644 --- a/test/functional/ui/screen.lua +++ b/test/functional/ui/screen.lua @@ -1,31 +1,17 @@ --- This module contains the Screen class, a complete Nvim screen implementation --- designed for functional testing. The goal is to provide a simple and --- intuitive API for verifying screen state after a set of actions. +-- This module contains the Screen class, a complete Nvim UI implementation +-- designed for functional testing (verifying screen state, in particular). -- --- The screen class exposes a single assertion method, "Screen:expect". This --- method takes a string representing the expected screen state and an optional --- set of attribute identifiers for checking highlighted characters(more on --- this later). --- --- The string passed to "expect" will be processed according to these rules: --- --- - Each line of the string represents and is matched individually against --- a screen row. --- - The entire string is stripped of common indentation --- - Expected screen rows are stripped of the last character. The last --- character should be used to write pipes(|) that make clear where the --- screen ends --- - The last line is stripped, so the string must have (row count + 1) --- lines. +-- 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 +-- -- Attach the screen to the current Nvim instance. -- screen:attach() --- --enter insert mode and type some text +-- -- Enter insert-mode and type some text. -- feed('ihello screen') --- -- declare an expectation for the eventual screen state +-- -- Assert the expected screen state. -- screen:expect([[ -- hello screen | -- ~ | @@ -39,31 +25,19 @@ -- -- INSERT -- | -- ]]) -- <- Last line is stripped -- --- Since screen updates are received asynchronously, "expect" is actually --- specifying the eventual screen state. This is how "expect" works: It will --- start the event loop with a timeout of 5 seconds. Each time it receives an --- update the expected state will be checked against the updated 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. +-- Since screen updates are received asynchronously, expect() actually specifies +-- the _eventual_ screen state. -- --- If the second argument is passed to "expect", the screen rows will be --- transformed before being matched against the string lines. The --- transformation rule is simple: Each substring "S" composed with characters --- having the exact same set of attributes will be substituted by "{K:S}", --- where K is a key associated the attribute set via the second argument of --- "expect". --- If a transformation table is present, unexpected attribute sets in the final --- state is considered an error. To make testing simpler, a list of attribute --- sets that should be ignored can be passed as a third argument. Alternatively, --- this third argument can be "true" to indicate that all unexpected attribute --- sets should be ignored. +-- 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. -- --- To illustrate how this works, let's say that in the above example we wanted --- to assert that the "-- INSERT --" string is highlighted with the bold --- attribute(which normally is), here's how the call to "expect" should look --- like: +-- 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([[ @@ -81,52 +55,58 @@ -- -- 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(|) had to be moved right. Also, the highlighting of the --- NonText markers (~) is ignored in this test. +-- 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: -- --- Multiple expect:s will likely share a group of attribute sets to test. --- Therefore these could be specified at the beginning of a test like this: -- NonText = Screen.colors.Blue -- screen:set_default_attr_ids( { -- [1] = {reverse = true, bold = true}, -- [2] = {reverse = true} -- }) -- screen:set_default_attr_ignore( {{}, {bold=true, foreground=NonText}} ) --- These can be overridden for a specific expect expression, by passing --- different sets as parameters. -- --- To help writing screen tests, there is a utility function --- "screen:snapshot_util()", that can be placed in a test file at any point an --- "expect(...)" should be. It will wait a short amount of time and then dump --- the current state of the screen, in the form of an "expect(..)" expression --- that would match it exactly. "snapshot_util" optionally also take the --- transformation and ignore set as parameters, like expect, or uses the default --- set. It will generate a larger attribute transformation set, if needed. --- To generate a text-only test without highlight checks, --- use `screen:snapshot_util({},true)` +-- To help write screen tests, see Screen:snapshot_util(). +-- To debug screen tests, see Screen:redraw_debug(). +local global_helpers = require('test.helpers') +local deepcopy = global_helpers.deepcopy +local shallowcopy = global_helpers.shallowcopy local helpers = require('test.functional.helpers')(nil) -local request, run = helpers.request, helpers.run +local request, run_session = helpers.request, 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 = require('inspect') + +local function isempty(v) + return type(v) == 'table' and next(v) == nil +end local Screen = {} Screen.__index = Screen local debug_screen -local default_screen_timeout = 3500 +local default_timeout_factor = 1 if os.getenv('VALGRIND') then - default_screen_timeout = default_screen_timeout * 3 + default_timeout_factor = default_timeout_factor * 3 end if os.getenv('CI') then - default_screen_timeout = default_screen_timeout * 3 + default_timeout_factor = default_timeout_factor * 3 end +local default_screen_timeout = default_timeout_factor * 3500 + do local spawn, nvim_prog = helpers.spawn, helpers.nvim_prog local session = spawn({nvim_prog, '-u', 'NONE', '-i', 'NONE', '-N', '--embed'}) - local status, rv = session:request('vim_get_color_map') + local status, rv = session:request('nvim_get_color_map') if not status then print('failed to get color map') os.exit(1) @@ -170,17 +150,38 @@ function Screen.new(width, height) update_menu = false, visual_bell = false, suspended = false, + mode = 'normal', + options = {}, + popupmenu = nil, + cmdline = {}, + cmdline_block = {}, + wildmenu_items = nil, + wildmenu_selected = nil, + win_position = {}, + _session = nil, _default_attr_ids = nil, _default_attr_ignore = nil, - _mode = 'normal', _mouse_enabled = true, _attrs = {}, + _hl_info = {}, + _attr_table = {[0]={{},{}}}, + _clear_attrs = {}, + _new_attrs = false, + _width = width, + _height = height, + _grids = {}, _cursor = { - row = 1, col = 1 + grid = 1, row = 1, col = 1 }, - _busy = false + _busy = false, }, Screen) - self:_handle_resize(width, height) + local function ui(method, ...) + local status, rv = self._session:request('nvim_ui_'..method, ...) + if not status then + error(rv[2]) + end + end + self.uimeths = create_callindex(ui) return self end @@ -188,71 +189,307 @@ 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_default_attr_ignore(attr_ignore) self._default_attr_ignore = attr_ignore end -function Screen:attach(rgb) - if rgb == nil then - rgb = true +function Screen:set_hlstate_cterm(val) + self._hlstate_cterm = val +end + +function Screen:attach(options, session) + if session == nil then + session = get_session() + end + if options == nil then + options = {} + end + if options.ext_linegrid == nil then + options.ext_linegrid = true + end + + self._session = session + self._options = options + self._clear_attrs = (options.ext_linegrid and {{},{}}) or {} + 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 - request('ui_attach', self._width, self._height, rgb) + if self._options.ext_multigrid then + self._options.ext_linegrid = true + end + self._session = session end function Screen:detach() - request('ui_detach') + self.uimeths.detach() + self._session = nil end function Screen:try_resize(columns, rows) - request('ui_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 + +function Screen:set_option(option, value) + self.uimeths.set_option(option, value) + 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' +} + +-- Asserts that the screen state eventually matches an expected state +-- +-- This function can either be called with the positional forms +-- +-- screen:expect(grid, [attr_ids, attr_ignore]) +-- screen:expect(condition) +-- +-- or to use additional arguments (or grid and condition at the same time) +-- the keyword form has to be used: +-- +-- screen:expect{grid=[[...]], cmdline={...}, condition=function() ... end} +-- +-- +-- grid: Expected screen state (string). Each line represents a screen +-- row. Last character of each row (typically "|") is stripped. +-- Common indentation is stripped. +-- attr_ids: 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 screen:set_default_attr_ids() to define attributes for many +-- expect() calls. +-- attr_ignore: Ignored text attributes, or `true` to ignore all. By default +-- nothing is ignored. +-- condition: Function asserting some arbitrary condition. Return value is +-- ignored, throw an error (use eq() or similar) to signal failure. +-- any: 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. +-- mode: Expected mode as signaled by "mode_change" event +-- unchanged: 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. +-- intermediate: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. +-- reset: 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. +-- timeout: 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) +-- +-- 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. +-- +-- popupmenu: Expected ext_popupmenu state, +-- cmdline: Expected ext_cmdline state, as an array of cmdlines of +-- different level. +-- cmdline_block: Expected ext_cmdline block (for function definitions) +-- wildmenu_items: Expected items for ext_wildmenu +-- wildmenu_pos: Expected position for ext_wildmenu function Screen:expect(expected, attr_ids, attr_ignore) - -- remove the last line and dedent - expected = dedent(expected:gsub('\n[ ]+$', '')) + local grid, condition = nil, nil local expected_rows = {} - for row in expected:gmatch('[^\n]+') do - -- the last character should be the screen delimiter - row = row:sub(1, #row - 1) - table.insert(expected_rows, row) - end - local ids = attr_ids or self._default_attr_ids - local ignore = attr_ignore or self._default_attr_ignore - self:wait(function() - local actual_rows = {} - for i = 1, self._height do - actual_rows[i] = self:_row_repr(self._rows[i], ids, ignore) - end - for i = 1, self._height do - if expected_rows[i] ~= actual_rows[i] then - local msg_expected_rows = {} - for j = 1, #expected_rows do - msg_expected_rows[j] = expected_rows[j] - end - msg_expected_rows[i] = '*' .. msg_expected_rows[i] - actual_rows[i] = '*' .. actual_rows[i] + if type(expected) == "table" then + assert(not (attr_ids ~= nil or attr_ignore ~= nil)) + local is_key = {grid=true, attr_ids=true, attr_ignore=true, condition=true, + any=true, mode=true, unchanged=true, intermediate=true, + reset=true, timeout=true} + for _, v in ipairs(ext_keys) do + is_key[v] = true + end + for k, _ in pairs(expected) do + if not is_key[k] then + error("Screen:expect: Unknown keyword argument '"..k.."'") + end + end + grid = expected.grid + attr_ids = expected.attr_ids + attr_ignore = expected.attr_ignore + condition = expected.condition + assert(not (expected.any ~= nil and grid ~= nil)) + elseif type(expected) == "string" then + grid = expected + expected = {} + elseif type(expected) == "function" then + assert(not (attr_ids ~= nil or attr_ignore ~= nil)) + condition = expected + expected = {} + else + assert(false) + end + + if grid ~= nil 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, + ignore = attr_ignore or self._default_attr_ignore, + } + if self._options.ext_hlstate then + attr_state.id_to_index = self:hlstate_check_attrs(attr_state.ids or {}) + end + self._new_attrs = false + self:_wait(function() + if condition ~= nil then + local status, res = pcall(condition) + if not status then + return tostring(res) + end + end + + if self._options.ext_hlstate and self._new_attrs then + attr_state.id_to_index = self:hlstate_check_attrs(attr_state.ids or {}) + end + + local actual_rows = self:render(not expected.any, attr_state) + + if expected.any ~= nil then + -- Search for `any` anywhere in the screen lines. + local actual_screen_str = table.concat(actual_rows, '\n') + if nil == string.find(actual_screen_str, expected.any) then return ( - 'Row ' .. tostring(i) .. ' didn\'t match.\n' - .. 'Expected:\n|' .. table.concat(msg_expected_rows, '|\n|') .. '|\n' - .. 'Actual:\n|' .. table.concat(actual_rows, '|\n|') .. '|' - ) + 'Failed to match any screen lines.\n' + .. 'Expected (anywhere): "' .. expected.any .. '"\n' + .. 'Actual:\n |' .. table.concat(actual_rows, '\n |') .. '\n\n') end end - end) + + if grid ~= nil then + -- `expected` must match the screen lines exactly. + if #actual_rows ~= #expected_rows then + return "Expected screen state's row count(" .. #expected_rows + .. ') differs from configured height(' .. #actual_rows .. ') of Screen.' + end + for i = 1, #actual_rows do + if expected_rows[i] ~= actual_rows[i] then + local msg_expected_rows = {} + for j = 1, #expected_rows do + msg_expected_rows[j] = expected_rows[j] + end + msg_expected_rows[i] = '*' .. msg_expected_rows[i] + actual_rows[i] = '*' .. actual_rows[i] + return ( + 'Row ' .. tostring(i) .. ' did not match.\n' + ..'Expected:\n |'..table.concat(msg_expected_rows, '\n |')..'\n' + ..'Actual:\n |'..table.concat(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 + end + + -- Extension features. The default expectations should cover the case of + -- the ext_ feature being disabled, or the feature currently not activated + -- (for instance no external cmdline visible). Some extensions require + -- preprocessing to represent highlights in a reproducible way. + local extstate = self:_extstate_repr(attr_state) + + -- convert assertion errors into invalid screen state descriptions + local status, res = pcall(function() + for _, k in ipairs(ext_keys) do + -- Empty states is considered the default and need not be mentioned + if not (expected[k] == nil and isempty(extstate[k])) then + eq(expected[k], extstate[k], k) + end + end + if expected.mode ~= nil then + eq(expected.mode, self.mode, "mode") + end + end) + if not status then + return tostring(res) + end + end, expected) end -function Screen:wait(check, timeout) - local err, checked = false +function Screen:_wait(check, flags) + local err, checked = false, 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 means the time during the state is + -- expected to be unchanged, so always wait this full time. + if (flags.unchanged or flags.intermediate) and flags.timeout ~= nil then + minimal_timeout = timeout + end + + assert(timeout >= minimal_timeout) + local did_miminal_timeout = false + local function notification_cb(method, args) assert(method == 'redraw') - self:_redraw(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 - helpers.stop() + if did_miminal_timeout then + self._session:stop() + end elseif success_seen and #args > 0 then failure_after_success = true --print(require('inspect')(args)) @@ -260,80 +497,224 @@ function Screen:wait(check, timeout) return true end - run(nil, notification_cb, nil, timeout or self.timeout) - if not checked then + run_session(self._session, nil, 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 timout + success_seen = true + end + end + + if not success_seen then + did_miminal_timeout = true + run_session(self._session, nil, 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 have been received after the expected state was seen. -This is probably due to an indeterminism in the test. Try adding -`wait()` (or even a separate `screen:expect(...)`) at a point of possible -indeterminism, typically in between a `feed()` or `execute()` which is non- -synchronous, and a synchronous api call. - -Note that sometimes a `wait` can trigger redraws and consequently generate more -indeterminism. If adding `wait` calls seems to increase the frequency of these -messages, try removing every `wait` call in the test. - -If everything else fails, use Screen:redraw_debug to help investigate what is - causing the problem. + +warning: Screen changes were received after the expected state. This indicates +indeterminism in the test. Try adding screen:expect(...) (or wait()) 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 wait(), for being closer to real user interaction. + - wait() can trigger redraws and consequently generate more indeterminism. + Try removing wait(). ]]) + did_warn = true + end + + + if err then + assert(false, err) + elseif did_warn then local tb = debug.traceback() local index = string.find(tb, '\n%s*%[C]') print(string.sub(tb,1,index)) end - if err then - assert(false, err) + 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) - pcall(function() self:wait(function() return "error" end, ms) end) + local function notification_cb(method, args) + assert(method == 'redraw') + self:_redraw(args) + end + run_session(self._session, nil, notification_cb, nil, ms) end function Screen:_redraw(updates) - for _, update in ipairs(updates) do + local did_flush = false + for k, update in ipairs(updates) do -- print('--') -- print(require('inspect')(update)) local method = update[1] for i = 2, #update do - local handler = self['_handle_'..method] - handler(self, unpack(update[i])) + local handler_name = '_handle_'..method + local handler = self[handler_name] + if handler ~= nil then + handler(self, unpack(update[i])) + else + assert(self._on_event, + "Add Screen:"..handler_name.." or call Screen:set_on_event_handler") + self._on_event(method, update[i]) + end + end + if k == #updates and method == "flush" then + did_flush = true end - -- print(self:_current_screen()) end + return did_flush +end + +function Screen:set_on_event_handler(callback) + self._on_event = callback 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 = {}}) + table.insert(cols, {text = ' ', attrs = self._clear_attrs, hl_id = 0}) end table.insert(rows, cols) end - self._cursor.row = 1 - self._cursor.col = 1 - self._rows = rows - self._width = width - self._height = height - self._scroll_region = { - top = 1, bot = height, left = 1, right = width + 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 + self._cursor.col = 1 + end + self._grids[grid] = { + rows=rows, + width=width, + height=height, } end +function Screen:_handle_win_scroll_over_start() + self.scroll_over = true + self.scroll_over_pos = self._grids[1].height +end + +function Screen:_handle_win_scroll_over_reset() + self.scroll_over = false +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 +end + + +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() - self:_clear_block(self._scroll_region.top, self._scroll_region.bot, - self._scroll_region.left, self._scroll_region.right) + -- 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 + assert(self.win_position[grid]) + self.win_position[grid] = nil + end end function Screen:_handle_eol_clear() local row, col = self._cursor.row, self._cursor.col - self:_clear_block(row, row, col, self._scroll_region.right) + self:_clear_block(self._grid, row, row, col, self._grid.width) end function Screen:_handle_cursor_goto(row, col) @@ -341,6 +722,12 @@ function Screen:_handle_cursor_goto(row, col) self._cursor.col = col + 1 end +function Screen:_handle_grid_cursor_goto(grid, row, col) + self._cursor.grid = grid + self._cursor.row = row + 1 + self._cursor.col = col + 1 +end + function Screen:_handle_busy_start() self._busy = true end @@ -357,9 +744,9 @@ function Screen:_handle_mouse_off() self._mouse_enabled = false end -function Screen:_handle_mode_change(mode) - assert(mode == 'insert' or mode == 'replace' or mode == 'normal') - self._mode = mode +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) @@ -374,31 +761,73 @@ function Screen:_handle_scroll(count) 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 + +function Screen:_handle_grid_scroll(g, top, bot, left, right, rows, cols) + if self.scroll_over and g == 1 and top < self.scroll_over_pos then + self.scroll_over_pos = top + end + + top = top+1 + left = left+1 + assert(cols == 0) + local grid = self._grids[g] local start, stop, step - if count > 0 then + + if rows > 0 then start = top - stop = bot - count + stop = bot - rows step = 1 else start = bot - stop = top - count + stop = top - rows step = -1 end -- shift scroll region for i = start, stop, step do - local target = self._rows[i] - local source = self._rows[i + count] + 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 + count, step do - self:_clear_row_section(i, left, right) + for i = stop + step, stop + rows, step do + self:_clear_row_section(grid, i, left, right) + 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 + +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 + } +end + +function Screen:_handle_win_hide(grid) + self.win_position[grid] = nil +end + +function Screen:get_hl(val) + if self._options.ext_newgrid then + return self._attr_table[val][1] + else + return val end end @@ -407,12 +836,36 @@ function Screen:_handle_highlight_set(attrs) end function Screen:_handle_put(str) - local cell = self._rows[self._cursor.row][self._cursor.col] + 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 +function Screen:_handle_grid_line(grid, row, col, items) + assert(self._options.ext_linegrid) + local line = self._grids[grid].rows[row+1] + local colpos = col+1 + local hl = self._clear_attrs + local hl_id = 0 + for _,item in ipairs(items) do + local text, hl_id_cell, count = unpack(item) + if hl_id_cell ~= nil then + hl_id = hl_id_cell + hl = self._attr_table[hl_id] + end + for _ = 1, (count or 1) do + local cell = line[colpos] + cell.text = text + cell.hl_id = hl_id + cell.attrs = hl + colpos = colpos+1 + end + end +end + function Screen:_handle_bell() self.bell = true end @@ -421,6 +874,16 @@ 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 @@ -449,41 +912,129 @@ function Screen:_handle_set_icon(icon) self.icon = icon end -function Screen:_clear_block(top, bot, left, right) +function Screen:_handle_option_set(name, value) + self.options[name] = value +end + +function Screen:_handle_popupmenu_show(items, selected, row, col) + self.popupmenu = {items=items,pos=selected, anchor={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 + 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:_clear_block(grid, top, bot, left, right) for i = top, bot do - self:_clear_row_section(i, left, right) + self:_clear_row_section(grid, i, left, right) end end -function Screen:_clear_row_section(rownum, startcol, stopcol) - local row = self._rows[rownum] +function Screen:_clear_row_section(grid, rownum, startcol, stopcol) + local row = grid.rows[rownum] for i = startcol, stopcol do row[i].text = ' ' - row[i].attrs = {} + row[i].attrs = self._clear_attrs end end -function Screen:_row_repr(row, attr_ids, attr_ignore) +function Screen:_row_repr(gridnr, rownr, attr_state, cursor) local rv = {} local current_attr_id - for i = 1, self._width do - local attr_id = self:_get_attr_id(attr_ids, attr_ignore, row[i].attrs) - if current_attr_id and attr_id ~= current_attr_id then - -- close current attribute bracket, add it before any whitespace - -- up to the current cell - -- table.insert(rv, backward_find_meaningful(rv, i), '}') - 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 + local i = 1 + local has_windows = self._options.ext_multigrid and gridnr == 1 + if self.scroll_over and self.scroll_over_pos < rownr then + has_windows = false + end + local row = self._grids[gridnr].rows[rownr] + 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 self._busy and self._rows[self._cursor.row] == row and self._cursor.col == i then - table.insert(rv, '^') + + if not did_window then + local attrs = row[i].attrs + if self._options.ext_linegrid then + attrs = attrs[(self._options.rgb and 1) or 2] + end + local attr_id = self:_get_attr_id(attr_state, 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 - table.insert(rv, row[i].text) end if current_attr_id then table.insert(rv, '}') @@ -493,18 +1044,50 @@ function Screen:_row_repr(row, attr_ids, attr_ignore) 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 -function Screen:_current_screen() - -- get a string that represents the current screen state(debugging helper) - local rv = {} - for i = 1, self._height do - table.insert(rv, "'"..self:_row_repr(self._rows[i]).."'") + local cmdline_block = {} + for i, entry in ipairs(self.cmdline_block) do + cmdline_block[i] = self:_chunks_repr(entry, attr_state) end - return table.concat(rv, '\n') + + return { + popupmenu=self.popupmenu, + cmdline=cmdline, + cmdline_block=cmdline_block, + wildmenu_items=self.wildmenu_items, + wildmenu_pos=self.wildmenu_pos, + } 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({},true) to generate a text-only (no attributes) test. +-- +-- @see Screen:redraw_debug() function Screen:snapshot_util(attrs, ignore) - -- util to generate screen test self:sleep(250) self:print_snapshot(attrs, ignore) end @@ -523,60 +1106,196 @@ function Screen:redraw_debug(attrs, ignore, timeout) if timeout == nil then timeout = 250 end - run(nil, notification_cb, nil, timeout) + run_session(self._session, nil, notification_cb, nil, timeout) +end + +function Screen:render(headers, attr_state, preview) + headers = headers and self._options.ext_multigrid + local rv = {} + for igrid,grid in pairs(self._grids) do + if headers then + table.insert(rv, "## grid "..igrid) + end + for i = 1, grid.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 function Screen:print_snapshot(attrs, ignore) + attrs = attrs or self._default_attr_ids if ignore == nil then ignore = self._default_attr_ignore end - if attrs == nil then - attrs = {} - if self._default_attr_ids ~= nil then - for i, a in ipairs(self._default_attr_ids) do - attrs[i] = a + local attr_state = { + ids = {}, + ignore = ignore, + mutable = true, -- allow _row_repr to add missing highlights + } + + if attrs ~= nil then + for i, a in pairs(attrs) do + attr_state.ids[i] = a + end + end + if self._options.ext_hlstate then + attr_state.id_to_index = self:hlstate_check_attrs(attr_state.ids) + end + + local lines = self:render(true, attr_state, true) + + local ext_state = self:_extstate_repr(attr_state) + local keys = false + for k, v in pairs(ext_state) do + if isempty(v) then + ext_state[k] = nil -- deleting keys while iterating is ok + else + keys = true + end + end + + local attrstr = "" + if attr_state.modified then + local attrstrs = {} + for i, a in pairs(attr_state.ids) do + local dict + if self._options.ext_hlstate then + dict = self:_pprint_hlstate(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 = (", "..(keys and "attr_ids=" or "") + .."{\n"..table.concat(attrstrs, "\n").."\n}") + end + print( "\nscreen:expect"..(keys and "{grid=" or "(").."[[") + print( table.concat(lines, '\n')) + io.stdout:write( "]]"..attrstr) + for _, k in ipairs(ext_keys) do + if ext_state[k] ~= nil then + io.stdout:write(", "..k.."="..inspect(ext_state[k])) + end + end + print((keys and "}" or ")").."\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 = {} + 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 + + local entry = self._attr_table[hl_id] + local attrval + if self._hlstate_cterm then + attrval = {entry[1], entry[2], info} -- unpack() doesn't work + else + attrval = {entry[1], info} + end - if ignore ~= true then - for i = 1, self._height do - local row = self._rows[i] - for j = 1, self._width do - local attr = row[j].attrs - if self:_attr_index(attrs, attr) == nil and self:_attr_index(ignore, attr) == nil then - if not self:_equal_attrs(attr, {}) then - table.insert(attrs, attr) + + table.insert(attr_state.ids, attrval) + attr_state.id_to_index[hl_id] = #attr_state.ids + return #attr_state.ids +end + +function Screen:hlstate_check_attrs(attrs) + local id_to_index = {} + for i = 1,#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._hlstate_cterm then + attr_rgb, attr_cterm, info = unpack(v) + attr = {attr_rgb, attr_cterm} + else + attr, info = unpack(v) + end + if self:_equal_attr_def(attr, self._attr_table[i]) 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 end + return id_to_index +end - local rv = {} - for i = 1, self._height do - table.insert(rv, " "..self:_row_repr(self._rows[i],attrs, ignore).."|") - end - local attrstrs = {} - local alldefault = true - for i, a in ipairs(attrs) do - if self._default_attr_ids == nil or self._default_attr_ids[i] ~= a then - alldefault = false - end - local dict = "{"..self:_pprint_attrs(a).."}" - table.insert(attrstrs, "["..tostring(i).."] = "..dict) - end - local attrstr = "{"..table.concat(attrstrs, ", ").."}" - print( "\nscreen:expect([[") - print( table.concat(rv, '\n')) - if alldefault then - print( "]])\n") + +function Screen:_pprint_hlstate(item) + --print(require('inspect')(item)) + local attrdict = "{"..self:_pprint_attrs(item[1]).."}, " + local attrdict2, hlinfo + if self._hlstate_cterm then + attrdict2 = "{"..self:_pprint_attrs(item[2]).."}, " + hlinfo = item[3] + else + attrdict2 = "" + hlinfo = item[2] + end + local descdict = "{"..self:_pprint_hlinfo(hlinfo).."}" + return "{"..attrdict..attrdict2..descdict.."}" +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 - print( "]], "..attrstr..")\n") + return table.concat(states, ", ") end - io.stdout:flush() end + function Screen:_pprint_attrs(attrs) local items = {} for f, v in pairs(attrs) do @@ -591,7 +1310,7 @@ function Screen:_pprint_attrs(attrs) return table.concat(items, ", ") end -function backward_find_meaningful(tbl, from) -- luacheck: ignore +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 @@ -600,32 +1319,64 @@ function backward_find_meaningful(tbl, from) -- luacheck: ignore return from end -function Screen:_get_attr_id(attr_ids, ignore, attrs) - if not attr_ids then +function Screen:_get_attr_id(attr_state, attrs, hl_id) + if not attr_state.ids then return end - for id, a in pairs(attr_ids) do - if self:_equal_attrs(a, attrs) then - return id - end + + if self._options.ext_hlstate then + local id = attr_state.id_to_index[hl_id] + if id ~= nil or hl_id == 0 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 + return "UNEXPECTED "..self:_pprint_attrs(self._attr_table[hl_id][1]) + else + for id, a in pairs(attr_state.ids) do + if self:_equal_attrs(a, attrs) then + return id + end + end + if self:_equal_attrs(attrs, {}) or + attr_state.ignore == true or + self:_attr_index(attr_state.ignore, attrs) ~= nil then + -- ignore this attrs + return nil + 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 - if self:_equal_attrs(attrs, {}) or - ignore == true or self:_attr_index(ignore, attrs) ~= nil then - -- ignore this attrs - return nil +end + +function Screen:_equal_attr_def(a, b) + if self._hlstate_cterm then + return self:_equal_attrs(a[1],b[1]) and self:_equal_attrs(a[2],b[2]) + else + return self:_equal_attrs(a,b[1]) end - return "UNEXPECTED "..self:_pprint_attrs(attrs) 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.italic == b.italic and a.reverse == b.reverse and - a.foreground == b.foreground and - a.background == b.background and + a.foreground == b.foreground and a.background == b.background and a.special == b.special 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 |