neovim

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

messages.lua (26784B)


      1 local api, fn, o = vim.api, vim.fn, vim.o
      2 local ui = require('vim._core.ui2')
      3 
      4 ---@alias Msg { extid: integer, timer: uv.uv_timer_t? }
      5 ---@class vim._core.ui2.messages
      6 local M = {
      7  -- Message window. Used for regular messages with cfg.msg.target == 'msg'.
      8  -- Automatically resizes to the text dimensions up to a point, at which point
      9  -- only the most recent messages will fit and be shown. A timer is started for
     10  -- each message whose callback will remove the message from the window again.
     11  msg = {
     12    ids = {}, ---@type table<string|integer, Msg> List of visible messages.
     13    width = 1, -- Current width of the message window.
     14  },
     15  -- Cmdline message window. Used for regular messages with cfg.msg.target == 'cmd'.
     16  -- Also contains 'ruler', 'showcmd' and search_cmd/count messages as virt_text.
     17  -- Messages that don't fit the 'cmdheight' are first shown in an expanded cmdline.
     18  -- Otherwise, or after an expanded cmdline is closed upon the first keypress, the
     19  -- cmdline contains the messages with spilled and duplicate lines indicators.
     20  cmd = {
     21    ids = {}, ---@type table<string|integer, Msg> List of visible messages.
     22    msg_row = -1, -- Last row of message to distinguish for placing virt_text.
     23    last_col = o.columns, -- Crop text to start column of 'last' virt_text.
     24    last_emsg = 0, -- Time an error was printed that should not be overwritten.
     25  },
     26  dupe = 0, -- Number of times message is repeated.
     27  prev_id = 0, ---@type string|integer Message id of the previous message.
     28  prev_msg = '', -- Concatenated content of the previous message.
     29  virt = { -- Stored virt_text state.
     30    last = { {}, {}, {}, {} }, ---@type MsgContent[] status in last cmdline row.
     31    msg = { {}, {} }, ---@type MsgContent[] [(x)] indicators in msg window.
     32    top = { {} }, ---@type MsgContent[] [+x] top indicator in dialog window.
     33    bot = { {} }, ---@type MsgContent[] [+x] bottom indicator in dialog window.
     34    idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 },
     35    ids = {}, ---@type { ['last'|'msg'|'top'|'bot']: integer? } Table of mark IDs.
     36    delayed = false, -- Whether placement of 'last' virt_text is delayed.
     37  },
     38  dialog_on_key = nil, ---@type integer? vim.on_key namespace for paging in the dialog window.
     39 }
     40 
     41 local cmd_on_key ---@type integer? Set to vim.on_key namespace while cmdline is expanded.
     42 -- An external redraw indicates the start of a new batch of messages in the cmdline.
     43 api.nvim_set_decoration_provider(ui.ns, {
     44  on_start = function()
     45    M.cmd.ids = (ui.redrawing or cmd_on_key) and M.cmd.ids or {}
     46  end,
     47 })
     48 
     49 --- Start a timer whose callback will remove the message from the message window.
     50 ---
     51 ---@param buf integer Buffer the message was written to.
     52 ---@param id integer|string Message ID.
     53 function M.msg:start_timer(buf, id)
     54  if self.ids[id].timer then
     55    self.ids[id].timer:stop()
     56  end
     57  self.ids[id].timer = vim.defer_fn(function()
     58    local extid = api.nvim_buf_is_valid(buf) and self.ids[id] and self.ids[id].extid
     59    local mark = extid and api.nvim_buf_get_extmark_by_id(buf, ui.ns, extid, { details = true })
     60    self.ids[id] = nil
     61    if not mark or not mark[1] then
     62      return
     63    end
     64    -- Clear prev_msg when line that may have dupe marker is removed.
     65    local erow = api.nvim_buf_line_count(buf) - 1
     66    M.prev_msg = ui.cfg.msg.target == 'msg' and mark[3].end_row == erow and '' or M.prev_msg
     67 
     68    -- Remove message (including potentially leftover empty line).
     69    api.nvim_buf_set_text(buf, mark[1], mark[2], mark[3].end_row, mark[3].end_col, {})
     70    if api.nvim_buf_get_lines(ui.bufs.msg, mark[1], mark[1] + 1, false)[1] == '' then
     71      api.nvim_buf_set_lines(buf, mark[1], mark[1] + 1, false, {})
     72    end
     73 
     74    -- Resize or hide message window for removed message.
     75    if next(self.ids) then
     76      M.set_pos('msg')
     77    else
     78      pcall(api.nvim_win_set_config, ui.wins.msg, { hide = true })
     79      self.width, M.virt.msg[M.virt.idx.dupe][1] = 1, nil
     80    end
     81  end, ui.cfg.msg.timeout)
     82 end
     83 
     84 --- Place or delete a virtual text mark in the cmdline or message window.
     85 ---
     86 ---@param type 'last'|'msg'|'top'|'bot'
     87 ---@param tgt? 'cmd'|'msg'|'dialog'
     88 local function set_virttext(type, tgt)
     89  if (type == 'last' and (ui.cmdheight == 0 or M.virt.delayed)) or cmd_on_key then
     90    return -- Don't show virtual text while cmdline is expanded or delaying for error.
     91  end
     92 
     93  -- Concatenate the components of M.virt[type] and calculate the concatenated width.
     94  local width, chunks = 0, {} ---@type integer, [string, integer|string][]
     95  local contents = M.virt[type] ---@type MsgContent[]
     96  for _, content in ipairs(contents) do
     97    for _, chunk in ipairs(content) do
     98      chunks[#chunks + 1] = { chunk[2], chunk[3] }
     99      width = width + api.nvim_strwidth(chunk[2])
    100    end
    101  end
    102  tgt = tgt or type == 'msg' and ui.cfg.msg.target or 'cmd'
    103 
    104  if M.virt.ids[type] and #chunks == 0 then
    105    api.nvim_buf_del_extmark(ui.bufs[tgt], ui.ns, M.virt.ids[type])
    106    M.cmd.last_col = type == 'last' and o.columns or M.cmd.last_col
    107    M.virt.ids[type] = nil
    108  elseif #chunks > 0 then
    109    local win = ui.wins[tgt]
    110    local line = (tgt == 'msg' or type == 'top') and 'w0' or type == 'bot' and 'w$'
    111    local srow = line and fn.line(line, ui.wins.dialog) - 1
    112    local erow = tgt == 'cmd' and math.min(M.cmd.msg_row, api.nvim_buf_line_count(ui.bufs.cmd) - 1)
    113    local texth = api.nvim_win_text_height(win, {
    114      max_height = (type == 'top' or type == 'bot') and 1 or api.nvim_win_get_height(win),
    115      start_row = srow or nil,
    116      end_row = erow or nil,
    117    })
    118    local row = texth.end_row
    119    local col = fn.virtcol2col(win, row + 1, texth.end_vcol)
    120    local scol = fn.screenpos(win, row + 1, col).col ---@type integer
    121 
    122    if type ~= 'last' then
    123      -- Calculate at which column to place the virt_text such that it is at the end
    124      -- of the last visible message line, overlapping the message text if necessary,
    125      -- but not overlapping the 'last' virt_text.
    126      local offset = tgt ~= 'msg' and 0
    127        or api.nvim_win_get_position(win)[2]
    128          + (api.nvim_win_get_config(win).border ~= 'none' and 1 or 0)
    129 
    130      -- Check if adding the virt_text on this line will exceed the current window width.
    131      local maxwidth = math.max(M.msg.width, math.min(o.columns, scol - offset + width))
    132      if tgt == 'msg' and api.nvim_win_get_width(win) < maxwidth then
    133        api.nvim_win_set_width(win, maxwidth)
    134        M.msg.width = maxwidth
    135      end
    136 
    137      local mwidth = tgt == 'msg' and M.msg.width or tgt == 'dialog' and o.columns or M.cmd.last_col
    138      if scol - offset + width > mwidth then
    139        col = fn.virtcol2col(win, row + 1, texth.end_vcol - (scol - offset + width - mwidth))
    140      end
    141 
    142      -- Give virt_text the same highlight as the message tail.
    143      local pos, opts = { row, col }, { details = true, overlap = true, type = 'highlight' }
    144      local hl = api.nvim_buf_get_extmarks(ui.bufs[tgt], ui.ns, pos, pos, opts)
    145      for _, chunk in ipairs(hl[1] and chunks or {}) do
    146        chunk[2] = hl[1][4].hl_group
    147      end
    148    else
    149      local mode = #M.virt.last[M.virt.idx.mode]
    150      local pad = o.columns - width ---@type integer
    151      local newlines = math.max(0, ui.cmdheight - texth.all)
    152      row = row + newlines
    153      M.cmd.last_col = mode > 0 and 0 or o.columns - (newlines > 0 and 0 or width)
    154 
    155      if newlines > 0 then
    156        -- Add empty lines to place virt_text on the last screen row.
    157        api.nvim_buf_set_lines(ui.bufs.cmd, -1, -1, false, fn['repeat']({ '' }, newlines))
    158        col = 0
    159      else
    160        if scol > M.cmd.last_col then
    161          -- Give the user some time to read an important message.
    162          if os.time() - M.cmd.last_emsg < 2 then
    163            M.virt.delayed = true
    164            vim.defer_fn(function()
    165              M.virt.delayed = false
    166              set_virttext('last')
    167            end, 2000)
    168            return
    169          end
    170 
    171          -- Crop text on last screen row and find byte offset to place mark at.
    172          local vcol = texth.end_vcol - (scol - M.cmd.last_col)
    173          col = vcol <= 0 and 0 or fn.virtcol2col(win, row + 1, vcol)
    174          M.prev_msg = mode > 0 and '' or M.prev_msg
    175          M.virt.msg = mode > 0 and { {}, {} } or M.virt.msg
    176          api.nvim_buf_set_text(ui.bufs.cmd, row, col, row, -1, { mode > 0 and ' ' or '' })
    177        end
    178 
    179        pad = pad - ((mode > 0 or col == 0) and 0 or math.min(M.cmd.last_col, scol))
    180      end
    181      table.insert(chunks, mode + 1, { (' '):rep(pad) })
    182      set_virttext('msg') -- Readjust to new M.cmd.last_col or clear for mode.
    183    end
    184 
    185    M.virt.ids[type] = api.nvim_buf_set_extmark(ui.bufs[tgt], ui.ns, row, col, {
    186      virt_text = chunks,
    187      virt_text_pos = 'overlay',
    188      right_gravity = false,
    189      undo_restore = false,
    190      invalidate = true,
    191      id = M.virt.ids[type],
    192      priority = type == 'msg' and 2 or 1,
    193    })
    194  end
    195 end
    196 
    197 local hlopts = { undo_restore = false, invalidate = true, priority = 1 }
    198 --- Move messages to expanded cmdline or pager to show in full.
    199 local function expand_msg(src)
    200  -- Copy and clear message from src to enlarged cmdline that is dismissed by any
    201  -- key press, or append to pager in case that is already open (not hidden).
    202  local hidden = api.nvim_win_get_config(ui.wins.pager).hide
    203  local tgt = hidden and 'cmd' or 'pager'
    204  if tgt ~= src then
    205    local srow = hidden and 0 or api.nvim_buf_line_count(ui.bufs.pager)
    206    local opts = { details = true, type = 'highlight' }
    207    local marks = api.nvim_buf_get_extmarks(ui.bufs[src], -1, 0, -1, opts)
    208    local lines = api.nvim_buf_get_lines(ui.bufs[src], 0, -1, false)
    209    M.msg_clear()
    210 
    211    api.nvim_buf_set_lines(ui.bufs[tgt], srow, -1, false, lines)
    212    for _, mark in ipairs(marks) do
    213      hlopts.end_col, hlopts.hl_group = mark[4].end_col, mark[4].hl_group
    214      api.nvim_buf_set_extmark(ui.bufs[tgt], ui.ns, srow + mark[2], mark[3], hlopts)
    215    end
    216 
    217    if tgt == 'cmd' and ui.cmd.highlighter then
    218      ui.cmd.highlighter.active[ui.bufs.cmd] = nil
    219    end
    220  else
    221    M.virt.msg[M.virt.idx.dupe][1] = nil
    222    for _, id in pairs(M.virt.ids) do
    223      api.nvim_buf_del_extmark(ui.bufs.cmd, ui.ns, id)
    224    end
    225  end
    226  M.set_pos(tgt)
    227 end
    228 
    229 -- Keep track of the current message column to be able to
    230 -- append or overwrite messages for :echon or carriage returns.
    231 local col = 0
    232 local cmd_timer ---@type uv.uv_timer_t? Timer resetting cmdline state next event loop.
    233 ---@param tgt 'cmd'|'dialog'|'msg'|'pager'
    234 ---@param kind string
    235 ---@param content MsgContent
    236 ---@param replace_last boolean
    237 ---@param append boolean
    238 ---@param id integer|string
    239 function M.show_msg(tgt, kind, content, replace_last, append, id)
    240  local mark, msg, cr, dupe, buf = {}, '', false, 0, ui.bufs[tgt]
    241 
    242  if M[tgt] then -- tgt == 'cmd'|'msg'
    243    local extid = M[tgt].ids[id] and M[tgt].ids[id].extid
    244    if tgt == ui.cfg.msg.target then
    245      -- Save the concatenated message to identify repeated messages.
    246      for _, chunk in ipairs(content) do
    247        msg = msg .. chunk[2]
    248      end
    249      local reset = extid or append or msg ~= M.prev_msg or ui.cmd.srow > 0
    250      dupe = (reset and 0 or M.dupe + 1)
    251    end
    252 
    253    cr = next(M[tgt].ids) ~= nil and msg:sub(1, 1) == '\r'
    254    replace_last = next(M[tgt].ids) ~= nil and not extid and (replace_last or dupe > 0)
    255    extid = extid or replace_last and M[tgt].ids[M.prev_id] and M[tgt].ids[M.prev_id].extid
    256    mark = extid and api.nvim_buf_get_extmark_by_id(buf, ui.ns, extid, { details = true }) or {}
    257 
    258    -- Ensure cmdline is clear when writing the first message.
    259    if tgt == 'cmd' and dupe == 0 and not next(M.cmd.ids) and ui.cmd.srow == 0 then
    260      api.nvim_buf_set_lines(buf, 0, -1, false, {})
    261    end
    262  end
    263 
    264  -- Filter out empty newline messages. TODO: don't emit them.
    265  if msg == '\n' then
    266    return
    267  end
    268 
    269  local line_count = api.nvim_buf_line_count(buf)
    270  ---@type integer Start row after last line in the target buffer, unless
    271  ---this is the first message, or in case of a repeated or replaced message.
    272  local row = mark[1]
    273    or (M[tgt] and not next(M[tgt].ids) and ui.cmd.srow == 0 and 0)
    274    or (line_count - ((replace_last or cr or append) and 1 or 0))
    275  local curline = (cr or append) and api.nvim_buf_get_lines(buf, row, row + 1, false)[1]
    276  local start_row, width = row, M.msg.width
    277  col = mark[2] or (append and not cr and math.min(col, #curline) or 0)
    278  local start_col, insert = col, false
    279 
    280  -- Accumulate to be inserted and highlighted message chunks.
    281  for i, chunk in ipairs(content) do
    282    -- Split at newline and write to start of line after carriage return.
    283    for str in (chunk[2] .. '\0'):gmatch('.-[\n\r%z]') do
    284      local repl, pat = str:sub(1, -2), str:sub(-1)
    285      local end_col = col + #repl ---@type integer
    286 
    287      -- Insert new line at end of buffer or when inserting lines for a replaced message.
    288      if line_count < row + 1 or insert then
    289        api.nvim_buf_set_lines(buf, row, row > start_row and row or -1, false, { repl })
    290        insert, line_count = false, line_count + 1
    291      else
    292        local erow = mark[3] and mark[3].end_row or row
    293        local ecol = mark[3] and mark[3].end_col or curline and math.min(end_col, #curline) or -1
    294        api.nvim_buf_set_text(buf, row, col, erow, ecol, { repl })
    295      end
    296      curline = api.nvim_buf_get_lines(buf, row, row + 1, false)[1]
    297      mark[3] = nil
    298 
    299      if chunk[3] > 0 then
    300        hlopts.end_col, hlopts.hl_group = end_col, chunk[3]
    301        api.nvim_buf_set_extmark(buf, ui.ns, row, col, hlopts)
    302      end
    303 
    304      if pat == '\n' then
    305        row, col, insert = row + 1, 0, mark[1] ~= nil
    306      else
    307        col = pat == '\r' and 0 or end_col
    308      end
    309      if tgt == 'msg' and (pat == '\n' or (i == #content and pat == '\0')) then
    310        width = api.nvim_win_call(ui.wins.msg, function()
    311          return math.max(width, fn.strdisplaywidth(curline))
    312        end)
    313      end
    314    end
    315  end
    316 
    317  if M[tgt] then
    318    -- Keep track of message span to replace by ID.
    319    local opts = { end_row = row, end_col = col, invalidate = true, undo_restore = false }
    320    M[tgt].ids[id] = M[tgt].ids[id] or {}
    321    M[tgt].ids[id].extid = api.nvim_buf_set_extmark(buf, ui.ns, start_row, start_col, opts)
    322  end
    323 
    324  if tgt == 'msg' then
    325    api.nvim_win_set_width(ui.wins.msg, width)
    326    local texth = api.nvim_win_text_height(ui.wins.msg, { start_row = start_row, end_row = row })
    327    if texth.all > math.ceil(o.lines * 0.5) then
    328      expand_msg(tgt)
    329    else
    330      M.msg.width = width
    331      M.msg:start_timer(buf, id)
    332    end
    333  elseif tgt == 'cmd' and dupe == 0 then
    334    fn.clearmatches(ui.wins.cmd) -- Clear matchparen highlights.
    335    if ui.cmd.srow > 0 then
    336      -- In block mode the cmdheight is already dynamic, so just print the full message
    337      -- regardless of height. Put cmdline below message.
    338      ui.cmd.srow = row + 1
    339    else
    340      api.nvim_win_set_cursor(ui.wins.cmd, { 1, 0 }) -- ensure first line is visible
    341      if ui.cmd.highlighter then
    342        ui.cmd.highlighter.active[buf] = nil
    343      end
    344      -- Place [+x] indicator for lines that spill over 'cmdheight'.
    345      local texth = api.nvim_win_text_height(ui.wins.cmd, {})
    346      local spill = texth.all > ui.cmdheight and (' [+%d]'):format(texth.all - ui.cmdheight)
    347      M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil
    348      M.cmd.msg_row = texth.end_row
    349 
    350      -- Expand the cmdline for a non-error message that doesn't fit.
    351      local error_kinds = { rpc_error = 1, emsg = 1, echoerr = 1, lua_error = 1 }
    352      if texth.all > ui.cmdheight and (ui.cmdheight == 0 or not error_kinds[kind]) then
    353        expand_msg(tgt)
    354      end
    355    end
    356  end
    357 
    358  -- Set pager/dialog/msg dimensions unless sent to expanded cmdline.
    359  if tgt ~= 'cmd' and (tgt ~= 'msg' or M.msg.ids[id]) then
    360    M.set_pos(tgt)
    361  end
    362 
    363  if M[tgt] and (tgt == 'cmd' or row == api.nvim_buf_line_count(buf) - 1) then
    364    -- Place (x) indicator for repeated messages. Mainly to mitigate unnecessary
    365    -- resizing of the message window, but also placed in the cmdline.
    366    M.virt.msg[M.virt.idx.dupe][1] = dupe > 0 and { 0, ('(%d)'):format(dupe) } or nil
    367    M.prev_id, M.prev_msg, M.dupe = id, msg, dupe
    368    set_virttext('msg')
    369  end
    370 
    371  -- Reset message state the next event loop iteration.
    372  if not cmd_timer and (col > 0 or next(M.cmd.ids) ~= nil) then
    373    cmd_timer = vim.defer_fn(function()
    374      M.cmd.ids, cmd_timer, col = cmd_on_key and M.cmd.ids or {}, nil, 0
    375    end, 0)
    376  end
    377 end
    378 
    379 --- Route the message to the appropriate sink.
    380 ---
    381 ---@param kind string
    382 ---@alias MsgChunk [integer, string, integer]
    383 ---@alias MsgContent MsgChunk[]
    384 ---@param content MsgContent
    385 ---@param replace_last boolean
    386 --@param history boolean
    387 ---@param append boolean
    388 ---@param id integer|string
    389 function M.msg_show(kind, content, replace_last, _, append, id)
    390  -- Set the entered search command in the cmdline (if available).
    391  local tgt = kind == 'search_cmd' and 'cmd' or ui.cfg.msg.targets[kind] or ui.cfg.msg.target
    392  if kind == 'search_cmd' and ui.cmdheight == 0 then
    393    -- Blocked by messaging() without ext_messages. TODO: look at other messaging() guards.
    394    return
    395  elseif kind == 'empty' then
    396    -- A sole empty message clears the cmdline.
    397    if ui.cfg.msg.target == 'cmd' and not next(M.cmd.ids) and ui.cmd.srow == 0 then
    398      M.msg_clear()
    399    end
    400  elseif kind == 'search_count' then
    401    -- Extract only the search_count, not the entered search command.
    402    -- Match any of search.c:cmdline_search_stat():' [(x | >x | ?)/(y | >y | ??)]'
    403    content = { content[#content] }
    404    content[1][2] = content[1][2]:match('W? %[>?%d*%??/>?%d*%?*%]') .. '  '
    405    M.virt.last[M.virt.idx.search] = content
    406    M.virt.last[M.virt.idx.cmd] = { { 0, (' '):rep(11) } }
    407    set_virttext('last')
    408  elseif (ui.cmd.prompt or (ui.cmd.level > 0 and tgt == 'cmd')) and ui.cmd.srow == 0 then
    409    -- Route to dialog when a prompt is active, or message would overwrite active cmdline.
    410    replace_last = api.nvim_win_get_config(ui.wins.dialog).hide or kind == 'wildlist'
    411    if kind == 'wildlist' then
    412      api.nvim_buf_set_lines(ui.bufs.dialog, 0, -1, false, {})
    413    end
    414    ui.cmd.dialog = true -- Ensure dialog is closed when cmdline is hidden.
    415    M.show_msg('dialog', kind, content, replace_last, append, id)
    416  else
    417    if tgt == 'cmd' then
    418      -- Store the time when an important message was emitted in order to not overwrite
    419      -- it with 'last' virt_text in the cmdline so that the user has a chance to read it.
    420      M.cmd.last_emsg = (kind == 'emsg' or kind == 'wmsg') and os.time() or M.cmd.last_emsg
    421      -- Should clear the search count now, mark itself is cleared by invalidate.
    422      M.virt.last[M.virt.idx.search][1] = nil
    423    end
    424 
    425    local enter_pager = tgt == 'pager' and api.nvim_get_current_win() ~= ui.wins.pager
    426    M.show_msg(tgt, kind, content, replace_last or enter_pager, append, id)
    427    -- Don't remember search_cmd message as actual message.
    428    if kind == 'search_cmd' then
    429      M.cmd.ids, M.prev_msg = {}, ''
    430    elseif api.nvim_get_current_win() == ui.wins.pager and not enter_pager then
    431      api.nvim_command('norm! G')
    432    end
    433  end
    434 end
    435 
    436 ---Clear currently visible messages.
    437 function M.msg_clear()
    438  api.nvim_buf_set_lines(ui.bufs.cmd, 0, -1, false, {})
    439  api.nvim_buf_set_lines(ui.bufs.msg, 0, -1, false, {})
    440  api.nvim_win_set_config(ui.wins.msg, { hide = true })
    441  M[ui.cfg.msg.target].ids, M.dupe, M.cmd.msg_row, M.msg.width = {}, 0, -1, 1
    442  M.prev_msg, M.virt.msg = '', { {}, {} }
    443 end
    444 
    445 --- Place the mode text in the cmdline.
    446 ---
    447 ---@param content MsgContent
    448 function M.msg_showmode(content)
    449  M.virt.last[M.virt.idx.mode] = ui.cmd.level > 0 and {} or content
    450  M.virt.last[M.virt.idx.search] = {}
    451  set_virttext('last')
    452 end
    453 
    454 --- Place text from the 'showcmd' buffer in the cmdline.
    455 ---
    456 ---@param content MsgContent
    457 function M.msg_showcmd(content)
    458  local str = content[1] and content[1][2]:sub(-10) or ''
    459  M.virt.last[M.virt.idx.cmd][1] = (content[1] or M.virt.last[M.virt.idx.search][1])
    460    and { 0, str .. (' '):rep(11 - #str) }
    461  set_virttext('last')
    462 end
    463 
    464 --- Place the 'ruler' text in the cmdline window.
    465 ---
    466 ---@param content MsgContent
    467 function M.msg_ruler(content)
    468  M.virt.last[M.virt.idx.ruler] = ui.cmd.level > 0 and {} or content
    469  set_virttext('last')
    470 end
    471 
    472 ---@alias MsgHistory [string, MsgContent, boolean]
    473 --- Open the message history in the pager.
    474 ---
    475 ---@param entries MsgHistory[]
    476 ---@param prev_cmd boolean
    477 function M.msg_history_show(entries, prev_cmd)
    478  if #entries == 0 then
    479    return
    480  end
    481 
    482  -- Showing output of previous command, clear in case still visible.
    483  if cmd_on_key or prev_cmd then
    484    M.msg_clear()
    485    api.nvim_feedkeys(vim.keycode('<Esc>'), 'n', false)
    486  end
    487 
    488  api.nvim_buf_set_lines(ui.bufs.pager, 0, -1, false, {})
    489  for i, entry in ipairs(entries) do
    490    M.show_msg('pager', entry[1], entry[2], i == 1, entry[3], 0)
    491  end
    492 
    493  M.set_pos('pager')
    494 end
    495 
    496 --- Adjust visibility and dimensions of the message windows after certain events.
    497 ---
    498 ---@param tgt? 'cmd'|'dialog'|'msg'|'pager' Target window to be positioned (nil for all).
    499 function M.set_pos(tgt)
    500  local function win_set_pos(win)
    501    local cfg = { hide = false, relative = 'laststatus', col = 10000 }
    502    local texth = api.nvim_win_text_height(win, {})
    503    local top = { vim.opt.fcs:get().msgsep or ' ', 'MsgSeparator' }
    504    local lines = o.lines - (win == ui.wins.pager and ui.cmdheight + (o.ls == 3 and 2 or 0) or 0)
    505    cfg.height = math.min(texth.all, math.ceil(lines * (win == ui.wins.pager and 1 or 0.5)))
    506    cfg.border = win ~= ui.wins.msg and { '', top, '', '', '', '', '', '' } or nil
    507    cfg.focusable = tgt == 'cmd' or nil
    508    cfg.row = (win == ui.wins.msg and 0 or 1) - ui.cmd.wmnumode
    509    cfg.row = cfg.row - ((win == ui.wins.pager and o.laststatus == 3) and 1 or 0)
    510    local title = { 'f/d/j: screen/page/line down, b/u/k: up, <Esc>: stop paging', 'MsgSeparator' }
    511    cfg.title = tgt == 'dialog' and cfg.height < texth.all and { title } or nil
    512    api.nvim_win_set_config(win, cfg)
    513 
    514    if tgt == 'cmd' and not cmd_on_key then
    515      -- Temporarily expand the cmdline, until next key press.
    516      local save_spill = M.virt.msg[M.virt.idx.spill][1]
    517      local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height)
    518      M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil
    519      set_virttext('msg', 'cmd')
    520      M.virt.msg[M.virt.idx.spill][1] = save_spill
    521      cmd_on_key = vim.on_key(function(_, typed)
    522        typed = typed and fn.keytrans(typed)
    523        if not typed or typed == '<MouseMove>' then
    524          return
    525        end
    526        vim.on_key(nil, ui.ns)
    527        cmd_on_key, M.cmd.ids = nil, {}
    528 
    529        -- Check if window was entered and reopen with original config.
    530        local entered = typed == '<CR>'
    531          or typed:find('LeftMouse') and fn.getmousepos().winid == ui.wins.cmd
    532        pcall(api.nvim_win_close, ui.wins.cmd, true)
    533        ui.check_targets()
    534 
    535        -- Show or clear the message depending on if the pager was opened.
    536        if entered then
    537          api.nvim_command('norm! g<')
    538        end
    539        set_virttext('msg')
    540      end, ui.ns)
    541    elseif tgt == 'dialog' then
    542      -- Add virtual [+x] text to indicate scrolling is possible.
    543      local function set_top_bot_spill()
    544        local topspill = fn.line('w0', ui.wins.dialog) - 1
    545        local botspill = api.nvim_buf_line_count(ui.bufs.dialog) - fn.line('w$', ui.wins.dialog)
    546        M.virt.top[1][1] = topspill > 0 and { 0, (' [+%d]'):format(topspill) } or nil
    547        set_virttext('top', 'dialog')
    548        M.virt.bot[1][1] = botspill > 0 and { 0, (' [+%d]'):format(botspill) } or nil
    549        set_virttext('bot', 'dialog')
    550        api.nvim__redraw({ flush = true })
    551      end
    552      set_top_bot_spill()
    553 
    554      -- Allow paging in the dialog window, consume the key if the topline changes.
    555      M.dialog_on_key = vim.on_key(function(_, typed)
    556        typed = typed and fn.keytrans(typed)
    557        if not typed then
    558          return
    559        elseif typed == '<Esc>' then
    560          -- Stop paging, redraw empty title to reflect paging is no longer active.
    561          api.nvim_win_set_config(ui.wins.dialog, { title = '' })
    562          api.nvim__redraw({ flush = true })
    563          vim.on_key(nil, M.dialog_on_key)
    564          return ''
    565        end
    566 
    567        local page_keys = {
    568          g = 'gg',
    569          G = 'G',
    570          j = 'Lj',
    571          k = 'Hk',
    572          d = [[\<C-D>]],
    573          u = [[\<C-U>]],
    574          f = [[\<C-F>]],
    575          b = [[\<C-B>]],
    576        }
    577        local info = page_keys[typed] and fn.getwininfo(ui.wins.dialog)[1]
    578        if info and (typed ~= 'f' or info.botline < api.nvim_buf_line_count(ui.bufs.dialog)) then
    579          fn.win_execute(ui.wins.dialog, ('exe "norm! %s"'):format(page_keys[typed]))
    580          set_top_bot_spill()
    581          return fn.getwininfo(ui.wins.dialog)[1].topline ~= info.topline and '' or nil
    582        end
    583      end, M.dialog_on_key)
    584    elseif tgt == 'msg' then
    585      -- Ensure last line is visible and first line is at top of window.
    586      fn.win_execute(ui.wins.msg, 'norm! Gzb')
    587    elseif tgt == 'pager' and api.nvim_get_current_win() ~= ui.wins.pager then
    588      if fn.getcmdwintype() ~= '' then
    589        -- Cannot leave the cmdwin to enter the pager, so close it.
    590        -- NOTE: regression w.r.t. the message grid, which allowed this.
    591        -- Resolving that would require somehow bypassing textlock for the pager.
    592        api.nvim_command('quit')
    593      end
    594 
    595      -- Cmdwin is actually closed one event iteration later so schedule in case it was open.
    596      vim.schedule(function()
    597        -- Allow events while the user is in the pager.
    598        api.nvim_set_option_value('eiw', '', { scope = 'local', win = ui.wins.pager })
    599        api.nvim_set_current_win(ui.wins.pager)
    600        api.nvim_win_set_cursor(ui.wins.pager, { 1, 0 })
    601 
    602        -- Make pager relative to cmdwin when it is opened, restore when it is closed.
    603        api.nvim_create_autocmd({ 'WinEnter', 'CmdwinEnter', 'CmdwinLeave' }, {
    604          callback = function(ev)
    605            if api.nvim_win_is_valid(ui.wins.pager) then
    606              local config = ev.event == 'CmdwinLeave' and cfg
    607                or ev.event == 'WinEnter' and { hide = true }
    608                or { relative = 'win', win = 0, row = 0, col = 0 }
    609              api.nvim_win_set_config(ui.wins.pager, config)
    610              api.nvim_set_option_value('eiw', 'all', { scope = 'local', win = ui.wins.pager })
    611            end
    612            return ev.event == 'WinEnter'
    613          end,
    614          desc = 'Hide or reposition pager window.',
    615        })
    616      end)
    617    end
    618  end
    619 
    620  for t, win in pairs(ui.wins) do
    621    local cfg = (t == tgt or (tgt == nil and t ~= 'cmd'))
    622      and api.nvim_win_is_valid(win)
    623      and api.nvim_win_get_config(win)
    624    if cfg and (tgt or not cfg.hide) then
    625      win_set_pos(win)
    626    end
    627  end
    628 end
    629 
    630 return M