neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

screen.lua (59699B)


      1 -- This module contains the Screen class, a complete Nvim UI implementation
      2 -- designed for functional testing (verifying screen state, in particular).
      3 --
      4 -- Screen:expect() takes a string representing the expected screen state and an
      5 -- optional set of attribute identifiers for checking highlighted characters.
      6 --
      7 -- Example usage:
      8 --
      9 --     -- Attach a screen to the current Nvim instance.
     10 --     local screen = Screen.new(25, 10)
     11 --     -- Enter insert-mode and type some text.
     12 --     feed('ihello screen')
     13 --     -- Assert the expected screen state.
     14 --     screen:expect([[
     15 --       hello screen^             |
     16 --       {1:~                        }|*8
     17 --       {5:-- INSERT --}             |
     18 --     ]]) -- <- Last line is stripped
     19 --
     20 -- Since screen updates are received asynchronously, expect() actually specifies
     21 -- the _eventual_ screen state.
     22 --
     23 -- This is how expect() works:
     24 --  * It starts the event loop with a timeout.
     25 --  * Each time it receives an update it checks that against the expected state.
     26 --    * If the expected state matches the current state, the event loop will be
     27 --      stopped and expect() will return.
     28 --    * If the timeout expires, the last match error will be reported and the
     29 --      test will fail.
     30 --
     31 -- The 30 most common highlight groups are predefined, see init_colors() below.
     32 -- In this case "5" is a predefined highlight associated with the set composed of one
     33 -- attribute: bold. Note that since the {5:} markup is not a real part of the
     34 -- screen, the delimiter "|" moved to the right. Also, the highlighting of the
     35 -- NonText markers "~" is visible.
     36 --
     37 -- Tests will often share a group of extra attribute sets to expect(). Those can be
     38 -- defined at the beginning of a test:
     39 --
     40 --    screen:add_extra_attr_ids({
     41 --      [100] = { background = Screen.colors.Plum1, underline = true },
     42 --      [101] = { background = Screen.colors.Red1, bold = true, underline = true },
     43 --    })
     44 --
     45 -- To help write screen tests, see Screen:snapshot_util().
     46 -- To debug screen tests, see Screen:redraw_debug().
     47 
     48 local t = require('test.testutil')
     49 local n = require('test.functional.testnvim')()
     50 local busted = require('busted')
     51 local uv = vim.uv
     52 
     53 local deepcopy = vim.deepcopy
     54 local shallowcopy = t.shallowcopy
     55 local concat_tables = t.concat_tables
     56 local pesc = vim.pesc
     57 local run_session = n.run_session
     58 local eq = t.eq
     59 local dedent = t.dedent
     60 local get_session = n.get_session
     61 local create_callindex = n.create_callindex
     62 
     63 local inspect = vim.inspect
     64 
     65 local function isempty(v)
     66  return type(v) == 'table' and next(v) == nil
     67 end
     68 
     69 --- @class test.functional.ui.screen.Grid
     70 --- @field rows table[][]
     71 --- @field width integer
     72 --- @field height integer
     73 
     74 --- @class test.functional.ui.screen
     75 --- @field colors table<string,integer>
     76 --- @field colornames table<integer,string>
     77 --- @field uimeths table<string,function>
     78 --- @field options? table<string,any>
     79 --- @field timeout integer
     80 --- @field win_position table<integer,table<string,integer>>
     81 --- @field float_pos table<integer,table>
     82 --- @field cmdline table<integer,table>
     83 --- @field cmdline_hide_level integer?
     84 --- @field cmdline_block table[]
     85 --- @field hl_groups table<string,integer> Highlight group to attr ID map
     86 --- @field hl_names table<integer,string> Highlight ID to group map
     87 --- @field messages table<integer,table>
     88 --- @field private _cursor {grid:integer,row:integer,col:integer}
     89 --- @field private _grids table<integer,test.functional.ui.screen.Grid>
     90 --- @field private _grid_win_extmarks table<integer,table>
     91 --- @field private _attr_table table<integer,table>
     92 --- @field private _hl_info table<integer,table>
     93 --- @field private _stdout uv.uv_pipe_t?
     94 local Screen = {}
     95 Screen.__index = Screen
     96 
     97 local default_timeout_factor = 1
     98 if os.getenv('VALGRIND') then
     99  default_timeout_factor = default_timeout_factor * 3
    100 end
    101 
    102 if os.getenv('CI') then
    103  default_timeout_factor = default_timeout_factor * 3
    104 end
    105 
    106 local default_screen_timeout = default_timeout_factor * 3500
    107 
    108 local function _init_colors()
    109  local session = get_session()
    110  local status, rv = session:request('nvim_get_color_map')
    111  if not status then
    112    error('failed to get color map')
    113  end
    114  local colors = rv --- @type table<string,integer>
    115  local colornames = {} --- @type table<integer,string>
    116  for name, rgb in pairs(colors) do
    117    -- we disregard the case that colornames might not be unique, as
    118    -- this is just a helper to get any canonical name of a color
    119    colornames[rgb] = name
    120  end
    121  Screen.colors = colors
    122  Screen.colornames = colornames
    123 
    124  Screen._global_default_attr_ids = {
    125    [1] = { foreground = Screen.colors.Blue1, bold = true },
    126    [2] = { reverse = true },
    127    [3] = { bold = true, reverse = true },
    128    [4] = { background = Screen.colors.LightMagenta },
    129    [5] = { bold = true },
    130    [6] = { foreground = Screen.colors.SeaGreen, bold = true },
    131    [7] = { background = Screen.colors.Gray, foreground = Screen.colors.DarkBlue },
    132    [8] = { foreground = Screen.colors.Brown },
    133    [9] = { background = Screen.colors.Red, foreground = Screen.colors.Grey100 },
    134    [10] = { background = Screen.colors.Yellow },
    135    [11] = {
    136      foreground = Screen.colors.Blue1,
    137      background = Screen.colors.LightMagenta,
    138      bold = true,
    139    },
    140    [12] = { background = Screen.colors.Gray },
    141    [13] = { background = Screen.colors.LightGrey, foreground = Screen.colors.DarkBlue },
    142    [14] = { background = Screen.colors.DarkGray, foreground = Screen.colors.LightGrey },
    143    [15] = { foreground = Screen.colors.Brown, bold = true },
    144    [16] = { foreground = Screen.colors.SlateBlue },
    145    [17] = { background = Screen.colors.LightGrey, foreground = Screen.colors.Black },
    146    [18] = { foreground = Screen.colors.Blue1 },
    147    [19] = { foreground = Screen.colors.Red },
    148    [20] = { background = Screen.colors.Yellow, foreground = Screen.colors.Red },
    149    [21] = { background = Screen.colors.Grey90 },
    150    [22] = { background = Screen.colors.LightBlue },
    151    [23] = { foreground = Screen.colors.Blue1, background = Screen.colors.LightCyan, bold = true },
    152    [24] = { background = Screen.colors.LightGrey, underline = true },
    153    [25] = { foreground = Screen.colors.Cyan4 },
    154    [26] = { foreground = Screen.colors.Fuchsia },
    155    [27] = { background = Screen.colors.Red, bold = true },
    156    [28] = { foreground = Screen.colors.SlateBlue, underline = true },
    157    [29] = { foreground = Screen.colors.SlateBlue, bold = true },
    158    [30] = { background = Screen.colors.Red },
    159  }
    160 
    161  Screen._global_hl_names = {}
    162  for group in pairs(n.api.nvim_get_hl(0, {})) do
    163    Screen._global_hl_names[n.api.nvim_get_hl_id_by_name(group)] = group
    164  end
    165 end
    166 
    167 --- @class test.functional.ui.screen.Opts
    168 --- @field ext_linegrid? boolean
    169 --- @field ext_multigrid? boolean
    170 --- @field ext_newgrid? boolean
    171 --- @field ext_popupmenu? boolean
    172 --- @field ext_wildmenu? boolean
    173 --- @field rgb? boolean
    174 --- @field _debug_float? boolean
    175 
    176 --- @param width? integer
    177 --- @param height? integer
    178 --- @param options? test.functional.ui.screen.Opts
    179 --- @param session? test.Session|false
    180 --- @return test.functional.ui.screen
    181 function Screen.new(width, height, options, session)
    182  if not Screen.colors then
    183    _init_colors()
    184  end
    185 
    186  options = options or {}
    187  if options.ext_linegrid == nil then
    188    options.ext_linegrid = true
    189  end
    190 
    191  local self = setmetatable({
    192    timeout = default_screen_timeout,
    193    title = '',
    194    icon = '',
    195    bell = false,
    196    update_menu = false,
    197    visual_bell = false,
    198    suspended = false,
    199    mode = 'normal',
    200    options = {},
    201    pwd = '',
    202    popupmenu = nil,
    203    cmdline = {},
    204    cmdline_block = {},
    205    wildmenu_items = nil,
    206    wildmenu_selected = nil,
    207    win_position = {},
    208    win_viewport = {},
    209    win_viewport_margins = {},
    210    float_pos = {},
    211    msg_grid = nil,
    212    msg_grid_pos = nil,
    213    _session = nil,
    214    rpc_async = false,
    215    messages = {},
    216    msg_history = {},
    217    showmode = {},
    218    showcmd = {},
    219    ruler = {},
    220    hl_groups = {},
    221    hl_names = vim.deepcopy(Screen._global_hl_names),
    222    _default_attr_ids = nil,
    223    mouse_enabled = true,
    224    _attrs = {},
    225    _hl_info = { [0] = {} },
    226    _attr_table = { [0] = { {}, {} } },
    227    _clear_attrs = nil,
    228    _new_attrs = false,
    229    _width = width or 53,
    230    _height = height or 14,
    231    _options = options,
    232    _grids = {},
    233    _grid_win_extmarks = {},
    234    _cursor = {
    235      grid = 1,
    236      row = 1,
    237      col = 1,
    238    },
    239    _busy = false,
    240    _stdout = nil,
    241  }, Screen)
    242 
    243  local function ui(method, ...)
    244    if self.rpc_async then
    245      self._session:notify('nvim_ui_' .. method, ...)
    246    else
    247      local status, rv = self._session:request('nvim_ui_' .. method, ...)
    248      if not status then
    249        error(rv[2])
    250      end
    251    end
    252  end
    253 
    254  self.uimeths = create_callindex(ui)
    255 
    256  -- session is often nil, which implies the default session
    257  if session ~= false then
    258    self:attach(session)
    259  end
    260 
    261  return self
    262 end
    263 
    264 function Screen:set_default_attr_ids(attr_ids)
    265  self._default_attr_ids = attr_ids
    266  self._attrs_overridden = true
    267 end
    268 
    269 function Screen:add_extra_attr_ids(extra_attr_ids)
    270  local attr_ids = vim.deepcopy(Screen._global_default_attr_ids)
    271  for id, attr in pairs(extra_attr_ids) do
    272    if type(id) == 'number' and id < 100 then
    273      error('extra attr ids should be at least 100 or be strings')
    274    end
    275    attr_ids[id] = attr
    276  end
    277  self._default_attr_ids = attr_ids
    278 end
    279 
    280 function Screen:set_rgb_cterm(val)
    281  self._rgb_cterm = val
    282 end
    283 
    284 --- @param fd number
    285 function Screen:set_stdout(fd)
    286  self._stdout = assert(uv.new_pipe())
    287  self._stdout:open(fd)
    288 end
    289 
    290 --- @param session? test.Session
    291 function Screen:attach(session)
    292  session = session or get_session()
    293  local options = self._options
    294 
    295  if options.ext_linegrid == nil then
    296    options.ext_linegrid = true
    297  end
    298 
    299  self._session = session
    300  self._options = options
    301  self._clear_attrs = (not options.ext_linegrid) and {} or nil
    302  self:_handle_resize(self._width, self._height)
    303  self.uimeths.attach(self._width, self._height, options)
    304  if self._options.rgb == nil then
    305    -- nvim defaults to rgb=true internally,
    306    -- simplify test code by doing the same.
    307    self._options.rgb = true
    308  end
    309  if self._options.ext_multigrid then
    310    self._options.ext_linegrid = true
    311  end
    312 
    313  if self._default_attr_ids == nil then
    314    self._default_attr_ids = Screen._global_default_attr_ids
    315  end
    316 end
    317 
    318 function Screen:detach()
    319  self.uimeths.detach()
    320  self._session = nil
    321 end
    322 
    323 function Screen:try_resize(columns, rows)
    324  self._width = columns
    325  self._height = rows
    326  self.uimeths.try_resize(columns, rows)
    327 end
    328 
    329 function Screen:try_resize_grid(grid, columns, rows)
    330  self.uimeths.try_resize_grid(grid, columns, rows)
    331 end
    332 
    333 --- @param option 'ext_linegrid'|'ext_multigrid'|'ext_popupmenu'|'ext_wildmenu'|'rgb'
    334 --- @param value boolean
    335 function Screen:set_option(option, value)
    336  self.uimeths.set_option(option, value)
    337  --- @diagnostic disable-next-line:no-unknown
    338  self._options[option] = value
    339 end
    340 
    341 -- canonical order of ext keys, used  to generate asserts
    342 local ext_keys = {
    343  'popupmenu',
    344  'cmdline',
    345  'cmdline_block',
    346  'wildmenu_items',
    347  'wildmenu_pos',
    348  'messages',
    349  'msg_history',
    350  'showmode',
    351  'showcmd',
    352  'ruler',
    353  'win_pos',
    354  'float_pos',
    355  'win_viewport',
    356  'win_viewport_margins',
    357 }
    358 
    359 local expect_keys = {
    360  grid = true,
    361  attr_ids = true,
    362  condition = true,
    363  mouse_enabled = true,
    364  any = true,
    365  none = true,
    366  mode = true,
    367  unchanged = true,
    368  intermediate = true,
    369  reset = true,
    370  timeout = true,
    371  request_cb = true,
    372  hl_groups = true,
    373  extmarks = true,
    374  win_pos = true,
    375 }
    376 
    377 for _, v in ipairs(ext_keys) do
    378  expect_keys[v] = true
    379 end
    380 
    381 --- @class test.functional.ui.screen.Expect
    382 ---
    383 --- Expected screen state (string). Each line represents a screen
    384 --- row. Last character of each row (typically "|") is stripped.
    385 --- Common indentation is stripped.
    386 --- "{MATCH:x}" in a line is matched against Lua pattern `x`.
    387 --- "*n" at the end of a line means it repeats `n` times.
    388 --- @field grid? string
    389 ---
    390 --- Expected text attributes. Screen rows are transformed according
    391 --- to this table, as follows: each substring S composed of
    392 --- characters having the same attributes will be substituted by
    393 --- "{K:S}", where K is a key in `attr_ids`. Any unexpected
    394 --- attributes in the final state are an error.
    395 --- Use an empty table for a text-only (no attributes) expectation.
    396 --- Use screen:set_default_attr_ids() to define attributes for many
    397 --- expect() calls.
    398 --- @field attr_ids? table<integer,table<string,any>>
    399 ---
    400 --- Expected win_extmarks accumulated for the grids. For each grid,
    401 --- the win_extmark messages are accumulated into an array.
    402 --- @field extmarks? table<integer,table>
    403 ---
    404 --- Function asserting some arbitrary condition. Return value is
    405 --- ignored, throw an error (use eq() or similar) to signal failure.
    406 --- @field condition? fun()
    407 ---
    408 --- Lua pattern string expected to match a screen line. NB: the
    409 --- following chars are magic characters
    410 ---    ( ) . % + - * ? [ ^ $
    411 --- and must be escaped with a preceding % for a literal match.
    412 --- @field any? string|table<string>
    413 ---
    414 --- Lua pattern string expected to not match a screen line. NB: the
    415 --- following chars are magic characters
    416 ---    ( ) . % + - * ? [ ^ $
    417 --- and must be escaped with a preceding % for a literal match.
    418 --- @field none? string|table<string>
    419 ---
    420 --- Expected mode as signaled by "mode_change" event
    421 --- @field mode? string
    422 ---
    423 --- Test that the screen state is unchanged since the previous
    424 --- expect(...). Any flush event resulting in a different state is
    425 --- considered an error. Not observing any events until timeout
    426 --- is acceptable.
    427 --- @field unchanged? boolean
    428 ---
    429 --- Test that the final state is the same as the previous expect,
    430 --- but expect an intermediate state that is different. If possible
    431 --- it is better to use an explicit screen:expect(...) for this
    432 --- intermediate state.
    433 --- @field intermediate? boolean
    434 ---
    435 --- Reset the state internal to the test Screen before starting to
    436 --- receive updates. This should be used after command("redraw!")
    437 --- or some other mechanism that will invoke "redraw!", to check
    438 --- that all screen state is transmitted again. This includes
    439 --- state related to ext_ features as mentioned below.
    440 --- @field reset? boolean
    441 ---
    442 --- maximum time that will be waited until the expected state is
    443 --- seen (or maximum time to observe an incorrect change when
    444 --- `unchanged` flag is used)
    445 --- @field timeout? integer
    446 ---
    447 --- @field mouse_enabled? boolean
    448 ---
    449 --- @field win_viewport? table<integer,table<string,integer>>
    450 --- @field win_viewport_margins? table<integer,table<string,integer>>
    451 --- @field win_pos? table<integer,table<string,integer>>
    452 --- @field float_pos? [integer,integer]
    453 --- @field hl_groups? table<string,integer>
    454 ---
    455 --- The following keys should be used to expect the state of various ext_
    456 --- features. Note that an absent key will assert that the item is currently
    457 --- NOT present on the screen, also when positional form is used.
    458 ---
    459 --- Expected ext_popupmenu state,
    460 --- @field popupmenu? table
    461 ---
    462 --- Expected ext_cmdline state, as an array of cmdlines of
    463 --- different level.
    464 --- @field cmdline? table
    465 ---
    466 --- Expected ext_cmdline block (for function definitions)
    467 --- @field cmdline_block? table
    468 ---
    469 --- items for ext_wildmenu
    470 --- @field wildmenu_items? table
    471 ---
    472 --- position for ext_wildmenu
    473 --- @field wildmenu_pos? table
    474 
    475 --- Asserts that the screen state eventually matches an expected state.
    476 ---
    477 --- Can be called with positional args:
    478 ---    screen:expect(grid, [attr_ids])
    479 ---    screen:expect(condition)
    480 --- or keyword args (supports more options):
    481 ---    screen:expect({ grid=[[...]], cmdline={...}, condition=function() ... end })
    482 ---
    483 --- @param expected string|function|test.functional.ui.screen.Expect
    484 --- @param attr_ids? table<integer,table<string,any>>
    485 function Screen:expect(expected, attr_ids, ...)
    486  --- @type string, fun()
    487  local grid, condition
    488 
    489  assert(next({ ... }) == nil, 'invalid args to expect()')
    490 
    491  if type(expected) == 'table' then
    492    assert(attr_ids == nil)
    493    for k, _ in
    494      pairs(expected --[[@as table<string,any>]])
    495    do
    496      if not expect_keys[k] then
    497        error("Screen:expect: Unknown keyword argument '" .. k .. "'")
    498      end
    499    end
    500    grid = expected.grid
    501    attr_ids = expected.attr_ids
    502    condition = expected.condition
    503    assert((expected.any == nil and expected.none == nil) or grid == nil)
    504  elseif type(expected) == 'string' then
    505    grid = expected
    506    expected = {}
    507  elseif type(expected) == 'function' then
    508    assert(attr_ids == nil)
    509    condition = expected
    510    expected = {}
    511  else
    512    assert(false)
    513  end
    514 
    515  local expected_rows = {} --- @type string[]
    516  if grid then
    517    -- Dedent (ignores last line if it is blank).
    518    grid = dedent(grid, 0)
    519    for row in grid:gmatch('[^\n]+') do
    520      table.insert(expected_rows, row)
    521    end
    522  end
    523 
    524  local attr_state = {
    525    ids = attr_ids or self._default_attr_ids,
    526  }
    527 
    528  if isempty(attr_ids) then
    529    attr_state.ids = nil
    530  end
    531 
    532  if self._options.ext_linegrid then
    533    attr_state.id_to_index = self:linegrid_check_attrs(attr_state.ids or {})
    534  end
    535 
    536  self._new_attrs = false
    537  self:_wait(function()
    538    if condition then
    539      --- @type boolean, string
    540      local status, res = pcall(condition)
    541      if not status then
    542        return tostring(res)
    543      end
    544    end
    545 
    546    if self._options.ext_linegrid and self._new_attrs then
    547      attr_state.id_to_index = self:linegrid_check_attrs(attr_state.ids or {})
    548    end
    549 
    550    local actual_rows
    551    if expected.any or grid then
    552      actual_rows = self:render(not (expected.any or expected.none), attr_state)
    553    end
    554 
    555    local any_or_none = function(screen_str, value, is_any)
    556      if value then
    557        local v = value
    558        if type(v) == 'string' then
    559          v = { v }
    560        end
    561        local msg
    562        if is_any then
    563          msg = 'Expected (anywhere): "'
    564        else
    565          msg = 'Expected (nowhere): "'
    566        end
    567        for _, v2 in ipairs(v) do
    568          local test = screen_str:find(v2)
    569          if is_any then
    570            test = not test
    571          end
    572          -- Search for `any` anywhere in the screen lines.
    573          if test then
    574            return (
    575              'Failed to match any screen lines.\n'
    576              .. msg
    577              .. v2
    578              .. '"\n'
    579              .. 'Actual:\n  |'
    580              .. table.concat(actual_rows, '\n  |')
    581              .. '\n\n'
    582            )
    583          end
    584        end
    585      end
    586      return nil
    587    end
    588    if expected.any or expected.none then
    589      local actual_screen_str = table.concat(actual_rows, '\n')
    590      if expected.any then
    591        local res = any_or_none(actual_screen_str, expected.any, true)
    592        if res then
    593          return res
    594        end
    595      end
    596      if expected.none then
    597        local res = any_or_none(actual_screen_str, expected.none, false)
    598        if res then
    599          return res
    600        end
    601      end
    602    end
    603 
    604    if grid then
    605      for i, row in ipairs(expected_rows) do
    606        local count --- @type integer?
    607        row, count = row:match('^(.*%|)%*(%d+)$')
    608        if row then
    609          count = tonumber(count)
    610          table.remove(expected_rows, i)
    611          for _ = 1, count do
    612            table.insert(expected_rows, i, row)
    613          end
    614        end
    615      end
    616      local err_msg = nil
    617      -- `expected` must match the screen lines exactly.
    618      if #actual_rows ~= #expected_rows then
    619        err_msg = 'Expected screen height '
    620          .. #expected_rows
    621          .. ' differs from actual height '
    622          .. #actual_rows
    623          .. '.'
    624      end
    625      local msg_expected_rows = shallowcopy(expected_rows)
    626      local msg_actual_rows = shallowcopy(actual_rows)
    627      for i, row in ipairs(expected_rows) do
    628        local pat = nil --- @type string?
    629        if actual_rows[i] and row ~= actual_rows[i] then
    630          local after = row
    631          while true do
    632            local s, e, m = after:find('{MATCH:(.-)}')
    633            if not s then
    634              pat = pat and (pat .. pesc(after))
    635              break
    636            end
    637            --- @type string
    638            pat = (pat or '') .. pesc(after:sub(1, s - 1)) .. m
    639            after = after:sub(e + 1)
    640          end
    641        end
    642        pat = pat and '^' .. pat .. '$'
    643        if row ~= actual_rows[i] and (not pat or not actual_rows[i]:match(pat)) then
    644          msg_expected_rows[i] = '*' .. msg_expected_rows[i]
    645          if i <= #actual_rows then
    646            msg_actual_rows[i] = '*' .. msg_actual_rows[i]
    647          end
    648          if err_msg == nil then
    649            err_msg = 'Row ' .. tostring(i) .. ' did not match.'
    650          end
    651        end
    652      end
    653      if err_msg ~= nil then
    654        return (
    655          err_msg
    656          .. '\nExpected:\n  |'
    657          .. table.concat(msg_expected_rows, '\n  |')
    658          .. '\n'
    659          .. 'Actual:\n  |'
    660          .. table.concat(msg_actual_rows, '\n  |')
    661          .. '\n\n'
    662          .. [[
    663 To print the expect() call that would assert the current screen state, use
    664 screen:snapshot_util(). In case of non-deterministic failures, use
    665 screen:redraw_debug() to show all intermediate screen states.]]
    666        )
    667      end
    668    end
    669 
    670    -- UI extensions. The default expectations should cover the case of
    671    -- the ext_ feature being disabled, or the feature currently not activated
    672    -- (e.g. no external cmdline visible). Some extensions require
    673    -- preprocessing to represent highlights in a reproducible way.
    674    local extstate = self:_extstate_repr(attr_state)
    675    if expected.mode ~= nil then
    676      extstate.mode = self.mode
    677    end
    678    if expected.mouse_enabled ~= nil then
    679      extstate.mouse_enabled = self.mouse_enabled
    680    end
    681    if expected.win_viewport == nil then
    682      extstate.win_viewport = nil
    683    end
    684    if expected.win_viewport_margins == nil then
    685      extstate.win_viewport_margins = nil
    686    end
    687    if expected.win_pos == nil then
    688      extstate.win_pos = nil
    689    end
    690    if expected.cmdline == nil then
    691      extstate.cmdline = nil
    692    end
    693 
    694    if expected.float_pos then
    695      expected.float_pos = deepcopy(expected.float_pos)
    696      for _, v in pairs(expected.float_pos) do
    697        if not v.external and v[7] == nil then
    698          v[7] = 50
    699        end
    700      end
    701    end
    702 
    703    -- Convert assertion errors into invalid screen state descriptions.
    704    for _, k in ipairs(concat_tables(ext_keys, { 'mode', 'mouse_enabled' })) do
    705      -- Empty states are considered the default and need not be mentioned.
    706      if not (expected[k] == nil and isempty(extstate[k])) then
    707        local status, res = pcall(eq, expected[k], extstate[k], k)
    708        if not status then
    709          return (
    710            tostring(res)
    711            .. '\nHint: full state of "'
    712            .. k
    713            .. '":\n  '
    714            .. inspect(extstate[k])
    715          )
    716        end
    717      end
    718    end
    719 
    720    if expected.hl_groups ~= nil then
    721      for name, id in pairs(expected.hl_groups) do
    722        local expected_hl = attr_state.ids[id]
    723        local actual_hl = self._attr_table[self.hl_groups[name]][(self._options.rgb and 1) or 2]
    724        local status, res = pcall(eq, expected_hl, actual_hl, 'highlight ' .. name)
    725        if not status then
    726          return tostring(res)
    727        end
    728      end
    729    end
    730 
    731    if expected.extmarks ~= nil then
    732      for gridid, expected_marks in pairs(expected.extmarks) do
    733        local stored_marks = self._grid_win_extmarks[gridid]
    734        if stored_marks == nil then
    735          return 'no win_extmark for grid ' .. tostring(gridid)
    736        end
    737        local status, res =
    738          pcall(eq, expected_marks, stored_marks, 'extmarks for grid ' .. tostring(gridid))
    739        if not status then
    740          return tostring(res)
    741        end
    742      end
    743      for gridid, _ in pairs(self._grid_win_extmarks) do
    744        local expected_marks = expected.extmarks[gridid]
    745        if expected_marks == nil then
    746          return 'unexpected win_extmark for grid ' .. tostring(gridid)
    747        end
    748      end
    749    end
    750  end, expected)
    751  -- Only test the abort state of a cmdline level once.
    752  if self.cmdline_hide_level ~= nil then
    753    self.cmdline[self.cmdline_hide_level] = nil
    754    self.cmdline_hide_level = nil
    755  end
    756  self.messages, self.msg_history = {}, {}
    757 end
    758 
    759 function Screen:expect_unchanged(intermediate, waittime_ms)
    760  -- Collect the current screen state.
    761  local kwargs = self:get_snapshot()
    762 
    763  if intermediate then
    764    kwargs.intermediate = true
    765  else
    766    kwargs.unchanged = true
    767  end
    768 
    769  kwargs.timeout = waittime_ms
    770  -- Check that screen state does not change.
    771  self:expect(kwargs)
    772 end
    773 
    774 --- @private
    775 --- @param check fun(): string
    776 --- @param flags table<string,any>
    777 function Screen:_wait(check, flags)
    778  local err --- @type string?
    779  local checked = false
    780  local success_seen = false
    781  local failure_after_success = false
    782  local did_flush = true
    783  local warn_immediate = not (flags.unchanged or flags.intermediate)
    784 
    785  if flags.intermediate and flags.unchanged then
    786    error("Choose only one of 'intermediate' and 'unchanged', not both")
    787  end
    788 
    789  if flags.reset then
    790    -- throw away all state, we expect it to be retransmitted
    791    self:_reset()
    792  end
    793 
    794  -- Maximum timeout, after which a incorrect state will be regarded as a
    795  -- failure
    796  local timeout = flags.timeout or self.timeout
    797 
    798  -- Minimal timeout before the loop is allowed to be stopped so we
    799  -- always do some check for failure after success.
    800  local minimal_timeout = default_timeout_factor * 2
    801 
    802  local immediate_seen, intermediate_seen = false, false
    803  if not check() then
    804    minimal_timeout = default_timeout_factor * 20
    805    immediate_seen = true
    806  end
    807 
    808  -- For an "unchanged" test, flags.timeout is the time during which the state
    809  -- must not change, so always wait this full time.
    810  if flags.unchanged then
    811    minimal_timeout = flags.timeout or default_timeout_factor * 20
    812  elseif flags.intermediate then
    813    minimal_timeout = default_timeout_factor * 20
    814  end
    815 
    816  assert(timeout >= minimal_timeout)
    817  local did_minimal_timeout = false
    818 
    819  local function notification_cb(method, args)
    820    assert(
    821      method == 'redraw',
    822      string.format('notification_cb: unexpected method (%s, args=%s)', method, inspect(args))
    823    )
    824    did_flush = self:_redraw(args)
    825    if not did_flush then
    826      return
    827    end
    828    err = check()
    829    checked = true
    830    if err and immediate_seen then
    831      intermediate_seen = true
    832    end
    833 
    834    if not err and (not flags.intermediate or intermediate_seen) then
    835      success_seen = true
    836      if did_minimal_timeout then
    837        self._session:stop()
    838      end
    839    elseif err and success_seen and #args > 0 then
    840      success_seen = false
    841      failure_after_success = true
    842      -- print(inspect(args))
    843    end
    844 
    845    return true
    846  end
    847  local eof = run_session(self._session, flags.request_cb, notification_cb, nil, minimal_timeout)
    848  if not did_flush then
    849    if eof then
    850      err = 'no flush received'
    851    end
    852  elseif not checked then
    853    err = check()
    854    if not err and flags.unchanged then
    855      -- expecting NO screen change: use a shorter timeout
    856      success_seen = true
    857    end
    858  end
    859 
    860  if not success_seen and not eof then
    861    did_minimal_timeout = true
    862    eof =
    863      run_session(self._session, flags.request_cb, notification_cb, nil, timeout - minimal_timeout)
    864    if not did_flush then
    865      err = 'no flush received'
    866    end
    867  end
    868 
    869  local did_warn = false
    870  if warn_immediate and immediate_seen then
    871    print([[
    872 
    873 warning: Screen test succeeded immediately. Try to avoid this unless the
    874 purpose of the test really requires it.]])
    875    if intermediate_seen then
    876      print([[
    877 There are intermediate states between the two identical expects.
    878 Use screen:snapshot_util() or screen:redraw_debug() to find them, and add them
    879 to the test if they make sense.
    880 ]])
    881    else
    882      print([[If necessary, silence this warning with 'unchanged' argument of screen:expect.]])
    883    end
    884    did_warn = true
    885  end
    886 
    887  if failure_after_success then
    888    print([[
    889 
    890 warning: Screen changes were received after the expected state. This indicates
    891 indeterminism in the test. Try adding screen:expect(...) (or poke_eventloop())
    892 between asynchronous (feed(), nvim_input()) and synchronous API calls.
    893  - Use screen:redraw_debug() to investigate; it may find relevant intermediate
    894    states that should be added to the test to make it more robust.
    895  - If the purpose of the test is to assert state after some user input sent
    896    with feed(), adding screen:expect() before the feed() will help to ensure
    897    the input is sent when Nvim is in a predictable state. This is preferable
    898    to poke_eventloop(), for being closer to real user interaction.
    899  - poke_eventloop() can trigger redraws and thus generate more indeterminism.
    900    Try removing poke_eventloop().
    901      ]])
    902    did_warn = true
    903  end
    904 
    905  if err then
    906    if eof then
    907      err = err .. '\n\n' .. eof[2]
    908    end
    909    busted.fail(err .. '\n\nSnapshot:\n' .. self:_print_snapshot(), 3)
    910  elseif did_warn then
    911    if eof then
    912      print(eof[2])
    913    end
    914    local tb = debug.traceback()
    915    local index = string.find(tb, '\n%s*%[C]')
    916    print(string.sub(tb, 1, index))
    917  end
    918 
    919  if flags.intermediate then
    920    assert(intermediate_seen, 'expected intermediate screen state before final screen state')
    921  elseif flags.unchanged then
    922    assert(not intermediate_seen, 'expected screen state to be unchanged')
    923  end
    924 end
    925 
    926 function Screen:sleep(ms, request_cb)
    927  local function notification_cb(method, args)
    928    assert(method == 'redraw')
    929    self:_redraw(args)
    930  end
    931  run_session(self._session, request_cb, notification_cb, nil, ms)
    932 end
    933 
    934 --- @private
    935 --- @param updates {[1]:string, [integer]:any[]}[]
    936 function Screen:_redraw(updates)
    937  local did_flush = false
    938  for k, update in ipairs(updates) do
    939    -- print('--', inspect(update))
    940    local method = update[1]
    941    for i = 2, #update do
    942      local handler_name = '_handle_' .. method
    943      --- @type function
    944      local handler = self[handler_name]
    945      assert(handler ~= nil, 'missing handler: Screen:' .. handler_name)
    946      local status, res = pcall(handler, self, unpack(update[i]))
    947      if not status then
    948        error(
    949          handler_name
    950            .. ' failed'
    951            .. '\n  payload: '
    952            .. inspect(update)
    953            .. '\n  error:   '
    954            .. tostring(res)
    955        )
    956      end
    957    end
    958    if k == #updates and method == 'flush' then
    959      did_flush = true
    960    end
    961  end
    962  return did_flush
    963 end
    964 
    965 function Screen:_handle_resize(width, height)
    966  self:_handle_grid_resize(1, width, height)
    967  self._scroll_region = {
    968    top = 1,
    969    bot = height,
    970    left = 1,
    971    right = width,
    972  }
    973  self._grid = self._grids[1]
    974 end
    975 
    976 local function min(x, y)
    977  if x < y then
    978    return x
    979  else
    980    return y
    981  end
    982 end
    983 
    984 function Screen:_handle_grid_resize(grid, width, height)
    985  local rows = {}
    986  for _ = 1, height do
    987    local cols = {}
    988    for _ = 1, width do
    989      table.insert(cols, { text = ' ', attrs = self._clear_attrs, hl_id = 0 })
    990    end
    991    table.insert(rows, cols)
    992  end
    993  if grid > 1 and self._grids[grid] ~= nil then
    994    local old = self._grids[grid]
    995    for i = 1, min(height, old.height) do
    996      for j = 1, min(width, old.width) do
    997        rows[i][j] = old.rows[i][j]
    998      end
    999    end
   1000  end
   1001 
   1002  if self._cursor.grid == grid then
   1003    self._cursor.row = 1 -- -1 ?
   1004    self._cursor.col = 1
   1005  end
   1006  self._grids[grid] = {
   1007    rows = rows,
   1008    width = width,
   1009    height = height,
   1010  }
   1011 end
   1012 
   1013 function Screen:_handle_msg_set_pos(grid, row, scrolled, char, zindex, compindex)
   1014  self.msg_grid = grid
   1015  self.msg_grid_pos = row
   1016  self.msg_scrolled = scrolled
   1017  self.msg_sep_char = char
   1018  self.msg_zindex = zindex
   1019  self.msg_compindex = compindex
   1020 end
   1021 
   1022 function Screen:_handle_flush() end
   1023 
   1024 function Screen:_reset()
   1025  -- TODO: generalize to multigrid later
   1026  self:_handle_grid_clear(1)
   1027 
   1028  -- TODO: share with initialization, so it generalizes?
   1029  self.popupmenu = nil
   1030  self.cmdline = {}
   1031  self.cmdline_block = {}
   1032  self.wildmenu_items = nil
   1033  self.wildmenu_pos = nil
   1034  self._grid_win_extmarks = {}
   1035  self.msg_grid = nil
   1036  self.msg_grid_pos = nil
   1037  self.msg_scrolled = false
   1038  self.msg_sep_char = nil
   1039 end
   1040 
   1041 --- @param cursor_style_enabled boolean
   1042 --- @param mode_info table[]
   1043 function Screen:_handle_mode_info_set(cursor_style_enabled, mode_info)
   1044  self._cursor_style_enabled = cursor_style_enabled
   1045  for _, item in pairs(mode_info) do
   1046    -- attr IDs are not stable, but their value should be
   1047    if item.attr_id ~= nil and self._attr_table[item.attr_id] ~= nil then
   1048      item.attr = self._attr_table[item.attr_id][1]
   1049      item.attr_id = nil
   1050    end
   1051    if item.attr_id_lm ~= nil and self._attr_table[item.attr_id_lm] ~= nil then
   1052      item.attr_lm = self._attr_table[item.attr_id_lm][1]
   1053      item.attr_id_lm = nil
   1054    end
   1055  end
   1056  self._mode_info = mode_info
   1057 end
   1058 
   1059 function Screen:_handle_clear()
   1060  -- the first implemented UI protocol clients (python-gui and builitin TUI)
   1061  -- allowed the cleared region to be restricted by setting the scroll region.
   1062  -- this was never used by nvim tough, and not documented and implemented by
   1063  -- newer clients, to check we remain compatible with both kind of clients,
   1064  -- ensure the scroll region is in a reset state.
   1065  local expected_region = {
   1066    top = 1,
   1067    bot = self._grid.height,
   1068    left = 1,
   1069    right = self._grid.width,
   1070  }
   1071  eq(expected_region, self._scroll_region)
   1072  self:_handle_grid_clear(1)
   1073 end
   1074 
   1075 function Screen:_handle_grid_clear(grid)
   1076  self:_clear_block(self._grids[grid], 1, self._grids[grid].height, 1, self._grids[grid].width)
   1077 end
   1078 
   1079 function Screen:_handle_grid_destroy(grid)
   1080  self._grids[grid] = nil
   1081  if self._options.ext_multigrid then
   1082    self.win_position[grid] = nil
   1083    self.win_viewport[grid] = nil
   1084    self.win_viewport_margins[grid] = nil
   1085  end
   1086 end
   1087 
   1088 function Screen:_handle_eol_clear()
   1089  local row, col = self._cursor.row, self._cursor.col
   1090  self:_clear_block(self._grid, row, row, col, self._grid.width)
   1091 end
   1092 
   1093 function Screen:_handle_cursor_goto(row, col)
   1094  self._cursor.row = row + 1
   1095  self._cursor.col = col + 1
   1096 end
   1097 
   1098 function Screen:_handle_grid_cursor_goto(grid, row, col)
   1099  self._cursor.grid = grid
   1100  assert(row >= 0 and col >= 0)
   1101  self._cursor.row = row + 1
   1102  self._cursor.col = col + 1
   1103 end
   1104 
   1105 function Screen:_handle_win_pos(grid, win, startrow, startcol, width, height)
   1106  self.win_position[grid] = {
   1107    win = win,
   1108    startrow = startrow,
   1109    startcol = startcol,
   1110    width = width,
   1111    height = height,
   1112  }
   1113  self.float_pos[grid] = nil
   1114 end
   1115 
   1116 function Screen:_handle_win_viewport(
   1117  grid,
   1118  win,
   1119  topline,
   1120  botline,
   1121  curline,
   1122  curcol,
   1123  linecount,
   1124  scroll_delta
   1125 )
   1126  -- accumulate scroll delta
   1127  local last_scroll_delta = self.win_viewport[grid] and self.win_viewport[grid].sum_scroll_delta
   1128    or 0
   1129  self.win_viewport[grid] = {
   1130    win = win,
   1131    topline = topline,
   1132    botline = botline,
   1133    curline = curline,
   1134    curcol = curcol,
   1135    linecount = linecount,
   1136    sum_scroll_delta = scroll_delta + last_scroll_delta,
   1137  }
   1138 end
   1139 
   1140 function Screen:_handle_win_viewport_margins(grid, win, top, bottom, left, right)
   1141  self.win_viewport_margins[grid] = {
   1142    win = win,
   1143    top = top,
   1144    bottom = bottom,
   1145    left = left,
   1146    right = right,
   1147  }
   1148 end
   1149 
   1150 function Screen:_handle_win_float_pos(grid, ...)
   1151  self.win_position[grid] = nil
   1152  self.float_pos[grid] = { ... }
   1153 end
   1154 
   1155 function Screen:_handle_win_external_pos(grid)
   1156  self.win_position[grid] = nil
   1157  self.float_pos[grid] = { external = true }
   1158 end
   1159 
   1160 function Screen:_handle_win_hide(grid)
   1161  self.win_position[grid] = nil
   1162  self.float_pos[grid] = nil
   1163 end
   1164 
   1165 function Screen:_handle_win_close(grid)
   1166  self.float_pos[grid] = nil
   1167 end
   1168 
   1169 function Screen:_handle_win_extmark(grid, ...)
   1170  if self._grid_win_extmarks[grid] == nil then
   1171    self._grid_win_extmarks[grid] = {}
   1172  end
   1173  table.insert(self._grid_win_extmarks[grid], { ... })
   1174 end
   1175 
   1176 function Screen:_handle_busy_start()
   1177  self._busy = true
   1178 end
   1179 
   1180 function Screen:_handle_busy_stop()
   1181  self._busy = false
   1182 end
   1183 
   1184 function Screen:_handle_mouse_on()
   1185  self.mouse_enabled = true
   1186 end
   1187 
   1188 function Screen:_handle_mouse_off()
   1189  self.mouse_enabled = false
   1190 end
   1191 
   1192 function Screen:_handle_mode_change(mode, idx)
   1193  assert(mode == self._mode_info[idx + 1].name)
   1194  self.mode = mode
   1195 end
   1196 
   1197 function Screen:_handle_set_scroll_region(top, bot, left, right)
   1198  self._scroll_region.top = top + 1
   1199  self._scroll_region.bot = bot + 1
   1200  self._scroll_region.left = left + 1
   1201  self._scroll_region.right = right + 1
   1202 end
   1203 
   1204 function Screen:_handle_scroll(count)
   1205  local top = self._scroll_region.top
   1206  local bot = self._scroll_region.bot
   1207  local left = self._scroll_region.left
   1208  local right = self._scroll_region.right
   1209  self:_handle_grid_scroll(1, top - 1, bot, left - 1, right, count, 0)
   1210 end
   1211 
   1212 --- @param g any
   1213 --- @param top integer
   1214 --- @param bot integer
   1215 --- @param left integer
   1216 --- @param right integer
   1217 --- @param rows integer
   1218 --- @param cols integer
   1219 function Screen:_handle_grid_scroll(g, top, bot, left, right, rows, cols)
   1220  top = top + 1
   1221  left = left + 1
   1222  assert(cols == 0)
   1223  local grid = self._grids[g]
   1224  --- @type integer, integer, integer
   1225  local start, stop, step
   1226 
   1227  if rows > 0 then
   1228    start = top
   1229    stop = bot - rows
   1230    step = 1
   1231  else
   1232    start = bot
   1233    stop = top - rows
   1234    step = -1
   1235  end
   1236 
   1237  -- shift scroll region
   1238  for i = start, stop, step do
   1239    local target = grid.rows[i]
   1240    local source = grid.rows[i + rows]
   1241    for j = left, right do
   1242      target[j].text = source[j].text
   1243      target[j].attrs = source[j].attrs
   1244      target[j].hl_id = source[j].hl_id
   1245    end
   1246  end
   1247 
   1248  -- clear invalid rows
   1249  for i = stop + step, stop + rows, step do
   1250    self:_clear_row_section(grid, i, left, right, true)
   1251  end
   1252 end
   1253 
   1254 function Screen:_handle_hl_attr_define(id, rgb_attrs, cterm_attrs, info)
   1255  self._attr_table[id] = { rgb_attrs, cterm_attrs }
   1256  self._hl_info[id] = info
   1257  self._new_attrs = true
   1258 end
   1259 
   1260 --- @param name string
   1261 --- @param id integer
   1262 function Screen:_handle_hl_group_set(name, id)
   1263  self.hl_groups[name] = id
   1264 end
   1265 
   1266 function Screen:get_hl(val)
   1267  if self._options.ext_newgrid then
   1268    return self._attr_table[val][1]
   1269  end
   1270  return val
   1271 end
   1272 
   1273 function Screen:_handle_highlight_set(attrs)
   1274  self._attrs = attrs
   1275 end
   1276 
   1277 function Screen:_handle_put(str)
   1278  assert(not self._options.ext_linegrid)
   1279  local cell = self._grid.rows[self._cursor.row][self._cursor.col]
   1280  cell.text = str
   1281  cell.attrs = self._attrs
   1282  cell.hl_id = -1
   1283  self._cursor.col = self._cursor.col + 1
   1284 end
   1285 
   1286 --- @param grid integer
   1287 --- @param row integer
   1288 --- @param col integer
   1289 --- @param items integer[][]
   1290 function Screen:_handle_grid_line(grid, row, col, items, wrap)
   1291  assert(self._options.ext_linegrid)
   1292  assert(#items > 0)
   1293  local line = self._grids[grid].rows[row + 1]
   1294  local colpos = col + 1
   1295  local hl_id = 0
   1296  line.wrap = wrap
   1297  for _, item in ipairs(items) do
   1298    local text, hl_id_cell, count = item[1], item[2], item[3]
   1299    if hl_id_cell ~= nil then
   1300      hl_id = hl_id_cell
   1301    end
   1302    for _ = 1, (count or 1) do
   1303      local cell = line[colpos]
   1304      cell.text = text
   1305      cell.hl_id = hl_id
   1306      colpos = colpos + 1
   1307    end
   1308  end
   1309 end
   1310 
   1311 function Screen:_handle_bell()
   1312  self.bell = true
   1313 end
   1314 
   1315 function Screen:_handle_visual_bell()
   1316  self.visual_bell = true
   1317 end
   1318 
   1319 function Screen:_handle_default_colors_set(rgb_fg, rgb_bg, rgb_sp, cterm_fg, cterm_bg)
   1320  self.default_colors = {
   1321    rgb_fg = rgb_fg,
   1322    rgb_bg = rgb_bg,
   1323    rgb_sp = rgb_sp,
   1324    cterm_fg = cterm_fg,
   1325    cterm_bg = cterm_bg,
   1326  }
   1327 end
   1328 
   1329 function Screen:_handle_update_fg(fg)
   1330  self._fg = fg
   1331 end
   1332 
   1333 function Screen:_handle_update_bg(bg)
   1334  self._bg = bg
   1335 end
   1336 
   1337 function Screen:_handle_update_sp(sp)
   1338  self._sp = sp
   1339 end
   1340 
   1341 function Screen:_handle_suspend()
   1342  self.suspended = true
   1343 end
   1344 
   1345 function Screen:_handle_update_menu()
   1346  self.update_menu = true
   1347 end
   1348 
   1349 function Screen:_handle_set_title(title)
   1350  self.title = title
   1351 end
   1352 
   1353 function Screen:_handle_set_icon(icon)
   1354  self.icon = icon
   1355 end
   1356 
   1357 function Screen:_handle_option_set(name, value)
   1358  self.options[name] = value
   1359 end
   1360 
   1361 function Screen:_handle_chdir(path)
   1362  self.pwd = vim.fs.normalize(path, { expand_env = false })
   1363 end
   1364 
   1365 function Screen:_handle_popupmenu_show(items, selected, row, col, grid)
   1366  self.popupmenu = { items = items, pos = selected, anchor = { grid, row, col } }
   1367 end
   1368 
   1369 function Screen:_handle_popupmenu_select(selected)
   1370  self.popupmenu.pos = selected
   1371 end
   1372 
   1373 function Screen:_handle_popupmenu_hide()
   1374  self.popupmenu = nil
   1375 end
   1376 
   1377 function Screen:_handle_cmdline_show(content, pos, firstc, prompt, indent, level, hl_id)
   1378  if firstc == '' then
   1379    firstc = nil
   1380  end
   1381  if prompt == '' then
   1382    prompt = nil
   1383  end
   1384  if indent == 0 then
   1385    indent = nil
   1386  end
   1387 
   1388  -- check position is valid #10000
   1389  local len = 0
   1390  for _, chunk in ipairs(content) do
   1391    len = len + string.len(chunk[2])
   1392  end
   1393  assert(pos <= len)
   1394 
   1395  self.cmdline[level] = {
   1396    content = content,
   1397    pos = pos,
   1398    firstc = firstc,
   1399    prompt = prompt,
   1400    indent = indent,
   1401    hl = hl_id,
   1402  }
   1403 end
   1404 
   1405 function Screen:_handle_cmdline_hide(level, abort)
   1406  self.cmdline[level] = abort and { abort = abort } or nil
   1407  self.cmdline_hide_level = level
   1408 end
   1409 
   1410 function Screen:_handle_cmdline_special_char(char, shift, level)
   1411  -- cleared by next cmdline_show on the same level
   1412  self.cmdline[level].special = { char, shift }
   1413 end
   1414 
   1415 function Screen:_handle_cmdline_pos(pos, level)
   1416  self.cmdline[level].pos = pos
   1417 end
   1418 
   1419 function Screen:_handle_cmdline_block_show(block)
   1420  self.cmdline_block = block
   1421 end
   1422 
   1423 function Screen:_handle_cmdline_block_append(item)
   1424  self.cmdline_block[#self.cmdline_block + 1] = item
   1425 end
   1426 
   1427 function Screen:_handle_cmdline_block_hide()
   1428  self.cmdline_block = {}
   1429 end
   1430 
   1431 function Screen:_handle_wildmenu_show(items)
   1432  self.wildmenu_items = items
   1433 end
   1434 
   1435 function Screen:_handle_wildmenu_select(pos)
   1436  self.wildmenu_pos = pos
   1437 end
   1438 
   1439 function Screen:_handle_wildmenu_hide()
   1440  self.wildmenu_items, self.wildmenu_pos = nil, nil
   1441 end
   1442 
   1443 function Screen:_handle_msg_show(kind, chunks, replace_last, history, append, id, progress)
   1444  local pos = #self.messages
   1445  if not replace_last or pos == 0 then
   1446    pos = pos + 1
   1447  end
   1448  self.messages[pos] = {
   1449    kind = kind,
   1450    content = chunks,
   1451    history = history,
   1452    append = append,
   1453    id = id,
   1454    progress = progress,
   1455  }
   1456 end
   1457 
   1458 function Screen:_handle_msg_clear()
   1459  self.messages = {}
   1460 end
   1461 
   1462 function Screen:_handle_msg_showcmd(msg)
   1463  self.showcmd = msg
   1464 end
   1465 
   1466 function Screen:_handle_msg_showmode(msg)
   1467  self.showmode = msg
   1468 end
   1469 
   1470 function Screen:_handle_msg_ruler(msg)
   1471  self.ruler = msg
   1472 end
   1473 
   1474 function Screen:_handle_msg_history_show(entries, prev_cmd)
   1475  self.msg_history = { entries, prev_cmd }
   1476 end
   1477 
   1478 function Screen:_handle_ui_send(content)
   1479  if self._stdout then
   1480    self._stdout:write(content)
   1481  end
   1482 end
   1483 
   1484 function Screen:_clear_block(grid, top, bot, left, right)
   1485  for i = top, bot do
   1486    self:_clear_row_section(grid, i, left, right)
   1487  end
   1488 end
   1489 
   1490 function Screen:_clear_row_section(grid, rownum, startcol, stopcol, invalid)
   1491  local row = grid.rows[rownum]
   1492  for i = startcol, stopcol do
   1493    row[i].text = (invalid and '�' or ' ')
   1494    row[i].attrs = self._clear_attrs
   1495    row[i].hl_id = 0
   1496  end
   1497 end
   1498 
   1499 function Screen:_row_repr(gridnr, rownr, attr_state, cursor)
   1500  local rv = {}
   1501  local current_attr_id
   1502  local i = 1
   1503  local has_windows = self._options.ext_multigrid and gridnr == 1
   1504  local row = self._grids[gridnr].rows[rownr]
   1505  if has_windows and self.msg_grid and self.msg_grid_pos < rownr then
   1506    return '[' .. self.msg_grid .. ':' .. string.rep('-', #row) .. ']'
   1507  end
   1508  while i <= #row do
   1509    local did_window = false
   1510    if has_windows then
   1511      for id, pos in pairs(self.win_position) do
   1512        if
   1513          i - 1 == pos.startcol
   1514          and pos.startrow <= rownr - 1
   1515          and rownr - 1 < pos.startrow + pos.height
   1516        then
   1517          if current_attr_id then
   1518            -- close current attribute bracket
   1519            table.insert(rv, '}')
   1520            current_attr_id = nil
   1521          end
   1522          table.insert(rv, '[' .. id .. ':' .. string.rep('-', pos.width) .. ']')
   1523          i = i + pos.width
   1524          did_window = true
   1525        end
   1526      end
   1527    end
   1528 
   1529    if not did_window then
   1530      local attr_id = self:_get_attr_id(attr_state, row[i].attrs, row[i].hl_id)
   1531      if current_attr_id and attr_id ~= current_attr_id then
   1532        -- close current attribute bracket
   1533        table.insert(rv, '}')
   1534        current_attr_id = nil
   1535      end
   1536      if not current_attr_id and attr_id then
   1537        -- open a new attribute bracket
   1538        table.insert(rv, '{' .. attr_id .. ':')
   1539        current_attr_id = attr_id
   1540      end
   1541      if not self._busy and cursor and self._cursor.col == i then
   1542        table.insert(rv, '^')
   1543      end
   1544      table.insert(rv, row[i].text)
   1545      i = i + 1
   1546    end
   1547  end
   1548  if current_attr_id then
   1549    table.insert(rv, '}')
   1550  end
   1551  -- return the line representation, but remove empty attribute brackets and
   1552  -- trailing whitespace
   1553  return table.concat(rv, '') --:gsub('%s+$', '')
   1554 end
   1555 
   1556 local function hl_id_to_name(self, id)
   1557  if id and id > 0 and not self.hl_names[id] then
   1558    self.hl_names[id] = n.fn.synIDattr(id, 'name')
   1559  end
   1560  return id and self.hl_names[id] or nil
   1561 end
   1562 
   1563 function Screen:_extstate_repr(attr_state)
   1564  local cmdline = {}
   1565  for i, entry in pairs(self.cmdline) do
   1566    entry = shallowcopy(entry)
   1567    if entry.content ~= nil then
   1568      entry.content = self:_chunks_repr(entry.content, attr_state)
   1569    end
   1570    entry.hl = hl_id_to_name(self, entry.hl)
   1571    cmdline[i] = entry
   1572  end
   1573 
   1574  local cmdline_block = {}
   1575  for i, entry in ipairs(self.cmdline_block) do
   1576    cmdline_block[i] = self:_chunks_repr(entry, attr_state)
   1577  end
   1578 
   1579  local messages = {}
   1580  for i, entry in ipairs(self.messages) do
   1581    messages[i] = {
   1582      kind = entry.kind,
   1583      content = self:_chunks_repr(entry.content, attr_state),
   1584      history = entry.history or nil,
   1585      append = entry.append or nil,
   1586      id = entry.kind == 'progress' and entry.id or nil,
   1587      progress = entry.kind == 'progress' and entry.progress or nil,
   1588    }
   1589  end
   1590 
   1591  local msg_history = { prev_cmd = self.msg_history[2] or nil }
   1592  for i, entry in ipairs(self.msg_history[1] or {}) do
   1593    msg_history[i] = {
   1594      kind = entry[1],
   1595      content = self:_chunks_repr(entry[2], attr_state),
   1596      append = entry[3] or nil,
   1597    }
   1598  end
   1599 
   1600  local win_viewport = (next(self.win_viewport) and self.win_viewport) or nil
   1601  local win_viewport_margins = (next(self.win_viewport_margins) and self.win_viewport_margins)
   1602    or nil
   1603 
   1604  return {
   1605    popupmenu = self.popupmenu,
   1606    cmdline = cmdline,
   1607    cmdline_block = cmdline_block,
   1608    wildmenu_items = self.wildmenu_items,
   1609    wildmenu_pos = self.wildmenu_pos,
   1610    messages = messages,
   1611    showmode = self:_chunks_repr(self.showmode, attr_state),
   1612    showcmd = self:_chunks_repr(self.showcmd, attr_state),
   1613    ruler = self:_chunks_repr(self.ruler, attr_state),
   1614    msg_history = msg_history,
   1615    float_pos = self.float_pos,
   1616    win_viewport = win_viewport,
   1617    win_viewport_margins = win_viewport_margins,
   1618    win_pos = self.win_position,
   1619  }
   1620 end
   1621 
   1622 function Screen:_chunks_repr(chunks, attr_state)
   1623  local repr_chunks = {}
   1624  for i, chunk in ipairs(chunks) do
   1625    local hl, text, id = unpack(chunk)
   1626    local attrs
   1627    if self._options.ext_linegrid then
   1628      attrs = self._attr_table[hl][1]
   1629    else
   1630      attrs = hl
   1631    end
   1632    local attr_id = self:_get_attr_id(attr_state, attrs, hl)
   1633    repr_chunks[i] = { text, attr_id }
   1634    repr_chunks[i][#repr_chunks[i] + 1] = hl_id_to_name(self, id)
   1635  end
   1636  return repr_chunks
   1637 end
   1638 
   1639 -- Generates tests. Call it where Screen:expect() would be. Waits briefly, then
   1640 -- dumps the current screen state in the form of Screen:expect().
   1641 -- Use snapshot_util({}) to generate a text-only (no attributes) test.
   1642 --
   1643 -- @see Screen:redraw_debug()
   1644 function Screen:snapshot_util(request_cb)
   1645  -- TODO: simplify this later when existing tests have been updated
   1646  self:sleep(250, request_cb)
   1647  self:print_snapshot()
   1648 end
   1649 
   1650 function Screen:redraw_debug(timeout)
   1651  self:print_snapshot()
   1652  local function notification_cb(method, args)
   1653    assert(method == 'redraw')
   1654    for _, update in ipairs(args) do
   1655      -- mode_info_set is quite verbose, comment out the condition to debug it.
   1656      if update[1] ~= 'mode_info_set' then
   1657        print(inspect(update))
   1658      end
   1659    end
   1660    self:_redraw(args)
   1661    self:print_snapshot()
   1662    return true
   1663  end
   1664  if timeout == nil then
   1665    timeout = 250
   1666  end
   1667  run_session(self._session, nil, notification_cb, nil, timeout)
   1668 end
   1669 
   1670 --- @param headers boolean
   1671 --- @param attr_state any
   1672 --- @param preview? boolean
   1673 --- @return string[]
   1674 function Screen:render(headers, attr_state, preview)
   1675  headers = headers and (self._options.ext_multigrid or self._options._debug_float)
   1676  local rv = {}
   1677  for igrid, grid in vim.spairs(self._grids) do
   1678    if headers then
   1679      local suffix = ''
   1680      if
   1681        igrid > 1
   1682        and self.win_position[igrid] == nil
   1683        and self.float_pos[igrid] == nil
   1684        and self.msg_grid ~= igrid
   1685      then
   1686        suffix = ' (hidden)'
   1687      end
   1688      table.insert(rv, '## grid ' .. igrid .. suffix)
   1689    end
   1690    local height = grid.height
   1691    if igrid == self.msg_grid then
   1692      height = self._grids[1].height - self.msg_grid_pos
   1693    end
   1694    for i = 1, height do
   1695      local cursor = self._cursor.grid == igrid and self._cursor.row == i
   1696      local prefix = (headers or preview) and '  ' or ''
   1697      table.insert(rv, prefix .. self:_row_repr(igrid, i, attr_state, cursor) .. '|')
   1698    end
   1699  end
   1700  return rv
   1701 end
   1702 
   1703 -- Returns the current screen state in the form of a screen:expect()
   1704 -- keyword-args map.
   1705 function Screen:get_snapshot()
   1706  local attr_state = {
   1707    ids = {},
   1708    mutable = true, -- allow _row_repr to add missing highlights
   1709  }
   1710  local attrs = self._default_attr_ids
   1711 
   1712  if attrs ~= nil then
   1713    for i, a in pairs(attrs) do
   1714      attr_state.ids[i] = a
   1715    end
   1716  end
   1717  if self._options.ext_linegrid then
   1718    attr_state.id_to_index = self:linegrid_check_attrs(attr_state.ids or {})
   1719  end
   1720 
   1721  local lines = self:render(true, attr_state, true)
   1722 
   1723  for i, row in ipairs(lines) do
   1724    local count = 1
   1725    while i < #lines and lines[i + 1] == row do
   1726      count = count + 1
   1727      table.remove(lines, i + 1)
   1728    end
   1729    if count > 1 then
   1730      lines[i] = lines[i] .. '*' .. count
   1731    end
   1732  end
   1733 
   1734  local ext_state = self:_extstate_repr(attr_state)
   1735  for k, v in pairs(ext_state) do
   1736    if isempty(v) then
   1737      ext_state[k] = nil -- deleting keys while iterating is ok
   1738    end
   1739  end
   1740 
   1741  -- Build keyword-args for screen:expect().
   1742  local kwargs = {}
   1743  if attr_state.modified then
   1744    kwargs['attr_ids'] = {}
   1745    for i, a in pairs(attr_state.ids) do
   1746      kwargs['attr_ids'][i] = a
   1747    end
   1748  end
   1749  kwargs['grid'] = table.concat(lines, '\n')
   1750  for _, k in ipairs(ext_keys) do
   1751    if ext_state[k] ~= nil then
   1752      kwargs[k] = ext_state[k]
   1753    end
   1754  end
   1755 
   1756  return kwargs, ext_state, attr_state
   1757 end
   1758 
   1759 local function fmt_ext_state(name, state)
   1760  local function remove_all_metatables(item, path)
   1761    if path[#path] ~= inspect.METATABLE then
   1762      return item
   1763    end
   1764  end
   1765  if name == 'win_viewport' then
   1766    local str = '{\n'
   1767    for k, v in pairs(state) do
   1768      str = (
   1769        str
   1770        .. '  ['
   1771        .. k
   1772        .. '] = {win = '
   1773        .. v.win
   1774        .. ', topline = '
   1775        .. v.topline
   1776        .. ', botline = '
   1777        .. v.botline
   1778        .. ', curline = '
   1779        .. v.curline
   1780        .. ', curcol = '
   1781        .. v.curcol
   1782        .. ', linecount = '
   1783        .. v.linecount
   1784        .. ', sum_scroll_delta = '
   1785        .. v.sum_scroll_delta
   1786        .. '};\n'
   1787      )
   1788    end
   1789    return str .. '}'
   1790  elseif name == 'float_pos' then
   1791    local str = '{\n'
   1792    for k, v in pairs(state) do
   1793      str = str .. '  [' .. k .. '] = {' .. v[1]
   1794      for i = 2, #v do
   1795        str = str .. ', ' .. inspect(v[i])
   1796      end
   1797      str = str .. '};\n'
   1798    end
   1799    return str .. '}'
   1800  else
   1801    -- TODO(bfredl): improve formatting of more states
   1802    return inspect(state, { process = remove_all_metatables })
   1803  end
   1804 end
   1805 
   1806 function Screen:_print_snapshot()
   1807  local kwargs, ext_state, attr_state = self:get_snapshot()
   1808  local attrstr = ''
   1809  local modify_attrs = not self._attrs_overridden
   1810  if attr_state.modified then
   1811    local attrstrs = {}
   1812    for i, a in pairs(attr_state.ids) do
   1813      local dict
   1814      if self._options.ext_linegrid then
   1815        dict = self:_pprint_hlitem(a)
   1816      else
   1817        dict = '{ ' .. self:_pprint_attrs(a) .. ' }'
   1818      end
   1819      local keyval = (type(i) == 'number') and '[' .. tostring(i) .. ']' or i
   1820      if not (type(i) == 'number' and modify_attrs and i <= 30) then
   1821        table.insert(attrstrs, '  ' .. keyval .. ' = ' .. dict .. ',')
   1822      end
   1823      if modify_attrs then
   1824        self._default_attr_ids = attr_state.ids
   1825      end
   1826    end
   1827    local fn_name = modify_attrs and 'add_extra_attr_ids' or 'set_default_attr_ids'
   1828    attrstr = ('screen:' .. fn_name .. '({\n' .. table.concat(attrstrs, '\n') .. '\n})\n\n')
   1829  end
   1830 
   1831  local extstr = ''
   1832  for _, k in ipairs(ext_keys) do
   1833    if ext_state[k] ~= nil and not (k == 'win_viewport' and not self.options.ext_multigrid) then
   1834      extstr = extstr .. '\n  ' .. k .. ' = ' .. fmt_ext_state(k, ext_state[k]) .. ','
   1835    end
   1836  end
   1837 
   1838  return ('%sscreen:expect(%s%s%s%s%s'):format(
   1839    attrstr,
   1840    #extstr > 0 and '{\n  grid = [[\n  ' or '[[\n',
   1841    #extstr > 0 and kwargs.grid:gsub('\n', '\n  ') or kwargs.grid,
   1842    #extstr > 0 and '\n  ]],' or '\n]]',
   1843    extstr,
   1844    #extstr > 0 and '\n})' or ')'
   1845  )
   1846 end
   1847 
   1848 function Screen:print_snapshot()
   1849  print('\n' .. self:_print_snapshot() .. '\n')
   1850  io.stdout:flush()
   1851 end
   1852 
   1853 function Screen:_insert_hl_id(attr_state, hl_id)
   1854  if attr_state.id_to_index[hl_id] ~= nil then
   1855    return attr_state.id_to_index[hl_id]
   1856  end
   1857  local raw_info = self._hl_info[hl_id]
   1858  local info = nil
   1859  if self._options.ext_hlstate then
   1860    info = {}
   1861    if #raw_info > 1 then
   1862      for i, item in ipairs(raw_info) do
   1863        info[i] = self:_insert_hl_id(attr_state, item.id)
   1864      end
   1865    else
   1866      info[1] = {}
   1867      for k, v in pairs(raw_info[1]) do
   1868        if k ~= 'id' then
   1869          info[1][k] = v
   1870        end
   1871      end
   1872    end
   1873  end
   1874 
   1875  local entry = self._attr_table[hl_id]
   1876  local attrval
   1877  if self._rgb_cterm then
   1878    attrval = { entry[1], entry[2], info } -- unpack() doesn't work
   1879  elseif self._options.ext_hlstate then
   1880    attrval = { entry[1], info }
   1881  else
   1882    attrval = self._options.rgb and entry[1] or entry[2]
   1883  end
   1884 
   1885  table.insert(attr_state.ids, attrval)
   1886  attr_state.id_to_index[hl_id] = #attr_state.ids
   1887  return #attr_state.ids
   1888 end
   1889 
   1890 function Screen:linegrid_check_attrs(attrs)
   1891  local id_to_index = {}
   1892  for i, def_attr in pairs(self._attr_table) do
   1893    local iinfo = self._hl_info[i]
   1894    local matchinfo = {}
   1895    if #iinfo > 1 then
   1896      for k, item in ipairs(iinfo) do
   1897        matchinfo[k] = id_to_index[item.id]
   1898      end
   1899    else
   1900      matchinfo = iinfo
   1901    end
   1902    for k, v in pairs(attrs) do
   1903      local attr, info, attr_rgb, attr_cterm
   1904      if self._rgb_cterm then
   1905        attr_rgb, attr_cterm, info = unpack(v)
   1906        attr = { attr_rgb, attr_cterm }
   1907        info = info or {}
   1908      elseif self._options.ext_hlstate then
   1909        attr, info = unpack(v)
   1910      else
   1911        attr = v
   1912        info = {}
   1913      end
   1914      if self:_equal_attr_def(attr, def_attr) then
   1915        if #info == #matchinfo then
   1916          local match = false
   1917          if #info == 1 then
   1918            if self:_equal_info(info[1], matchinfo[1]) then
   1919              match = true
   1920            end
   1921          else
   1922            match = true
   1923            for j = 1, #info do
   1924              if info[j] ~= matchinfo[j] then
   1925                match = false
   1926              end
   1927            end
   1928          end
   1929          if match then
   1930            id_to_index[i] = k
   1931          end
   1932        end
   1933      end
   1934    end
   1935    if
   1936      self:_equal_attr_def(self._rgb_cterm and { {}, {} } or {}, def_attr)
   1937      and #self._hl_info[i] == 0
   1938    then
   1939      id_to_index[i] = ''
   1940    end
   1941  end
   1942  return id_to_index
   1943 end
   1944 
   1945 function Screen:_pprint_hlitem(item)
   1946  -- print(inspect(item))
   1947  local multi = self._rgb_cterm or self._options.ext_hlstate
   1948  local cterm = (not self._rgb_cterm and not self._options.rgb)
   1949  local attrdict = '{ ' .. self:_pprint_attrs(multi and item[1] or item, cterm) .. ' }'
   1950  local attrdict2, hlinfo
   1951  local descdict = ''
   1952  if self._rgb_cterm then
   1953    attrdict2 = ', { ' .. self:_pprint_attrs(item[2], true) .. ' }'
   1954    hlinfo = item[3]
   1955  else
   1956    attrdict2 = ''
   1957    hlinfo = item[2]
   1958  end
   1959  if self._options.ext_hlstate then
   1960    descdict = ', { ' .. self:_pprint_hlinfo(hlinfo) .. ' }'
   1961  end
   1962  return (multi and '{ ' or '') .. attrdict .. attrdict2 .. descdict .. (multi and ' }' or '')
   1963 end
   1964 
   1965 function Screen:_pprint_hlinfo(states)
   1966  if #states == 1 then
   1967    local items = {}
   1968    for f, v in pairs(states[1]) do
   1969      local desc = tostring(v)
   1970      if type(v) == type('') then
   1971        desc = '"' .. desc .. '"'
   1972      end
   1973      table.insert(items, f .. ' = ' .. desc)
   1974    end
   1975    return '{' .. table.concat(items, ', ') .. '}'
   1976  else
   1977    return table.concat(states, ', ')
   1978  end
   1979 end
   1980 
   1981 function Screen:_pprint_attrs(attrs, cterm)
   1982  local items = {}
   1983  for f, v in pairs(attrs) do
   1984    local desc = tostring(v)
   1985    if f == 'foreground' or f == 'background' or f == 'special' then
   1986      if Screen.colornames[v] ~= nil then
   1987        desc = 'Screen.colors.' .. Screen.colornames[v]
   1988      elseif cterm then
   1989        desc = tostring(v)
   1990      else
   1991        desc = string.format("tonumber('0x%06x')", v)
   1992      end
   1993    end
   1994    table.insert(items, f .. ' = ' .. desc)
   1995  end
   1996  return table.concat(items, ', ')
   1997 end
   1998 
   1999 ---@diagnostic disable-next-line: unused-local, unused-function
   2000 local function backward_find_meaningful(tbl, from) -- luacheck: no unused
   2001  for i = from or #tbl, 1, -1 do
   2002    if tbl[i] ~= ' ' then
   2003      return i + 1
   2004    end
   2005  end
   2006  return from
   2007 end
   2008 
   2009 function Screen:_get_attr_id(attr_state, attrs, hl_id)
   2010  if not attr_state.ids then
   2011    return
   2012  end
   2013 
   2014  if self._options.ext_linegrid then
   2015    local id = attr_state.id_to_index[hl_id]
   2016    if id == '' then -- sentinel for empty it
   2017      return nil
   2018    elseif id ~= nil then
   2019      return id
   2020    end
   2021    if attr_state.mutable then
   2022      id = self:_insert_hl_id(attr_state, hl_id)
   2023      attr_state.modified = true
   2024      return id
   2025    end
   2026    local kind = self._options.rgb and 1 or 2
   2027    return 'UNEXPECTED ' .. self:_pprint_attrs(self._attr_table[hl_id][kind])
   2028  else
   2029    if self:_equal_attrs(attrs, {}) then
   2030      -- ignore this attrs
   2031      return nil
   2032    end
   2033    for id, a in pairs(attr_state.ids) do
   2034      if self:_equal_attrs(a, attrs) then
   2035        return id
   2036      end
   2037    end
   2038    if attr_state.mutable then
   2039      table.insert(attr_state.ids, attrs)
   2040      attr_state.modified = true
   2041      return #attr_state.ids
   2042    end
   2043    return 'UNEXPECTED ' .. self:_pprint_attrs(attrs)
   2044  end
   2045 end
   2046 
   2047 function Screen:_equal_attr_def(a, b)
   2048  if self._rgb_cterm then
   2049    return self:_equal_attrs(a[1], b[1]) and self:_equal_attrs(a[2], b[2])
   2050  elseif self._options.rgb then
   2051    return self:_equal_attrs(a, b[1])
   2052  else
   2053    return self:_equal_attrs(a, b[2])
   2054  end
   2055 end
   2056 
   2057 function Screen:_equal_attrs(a, b)
   2058  return a.bold == b.bold
   2059    and a.standout == b.standout
   2060    and a.underline == b.underline
   2061    and a.undercurl == b.undercurl
   2062    and a.underdouble == b.underdouble
   2063    and a.underdotted == b.underdotted
   2064    and a.underdashed == b.underdashed
   2065    and a.italic == b.italic
   2066    and a.reverse == b.reverse
   2067    and a.foreground == b.foreground
   2068    and a.background == b.background
   2069    and a.special == b.special
   2070    and a.blend == b.blend
   2071    and a.strikethrough == b.strikethrough
   2072    and a.fg_indexed == b.fg_indexed
   2073    and a.bg_indexed == b.bg_indexed
   2074    and a.url == b.url
   2075 end
   2076 
   2077 function Screen:_equal_info(a, b)
   2078  return a.kind == b.kind and a.hi_name == b.hi_name and a.ui_name == b.ui_name
   2079 end
   2080 
   2081 function Screen:_attr_index(attrs, attr)
   2082  if not attrs then
   2083    return nil
   2084  end
   2085  for i, a in pairs(attrs) do
   2086    if self:_equal_attrs(a, attr) then
   2087      return i
   2088    end
   2089  end
   2090  return nil
   2091 end
   2092 
   2093 return Screen