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