local api, fn, o = vim.api, vim.fn, vim.o local nvim_on = require('vim._core.util').nvim_on local ui = require('vim._core.ui2') local more_msg = api.nvim_get_hl_id_by_name('MoreMsg') -- Highlight for the [+x] indicators. ---@alias Msg { extid: integer, timer: uv.uv_timer_t? } ---@class vim._core.ui2.messages local M = { -- Message window. Used for regular messages with cfg.msg.target == 'msg'. -- Automatically resizes to the text dimensions up to a point, at which point -- only the most recent messages will fit and be shown. A timer is started for -- each message whose callback will remove the message from the window again. msg = { ids = {}, ---@type table List of visible messages. width = 1, -- Current width of the message window. prev_msg = '', -- Concatenated content of the previous message. prev_id = 0, ---@type string|integer Message id of the previous message. dupe = 0, -- Number of times message is repeated. }, -- Cmdline message window. Used for regular messages with cfg.msg.target == 'cmd'. -- Also contains 'ruler', 'showcmd' and search_cmd/count messages as virt_text. -- Messages that don't fit the 'cmdheight' are first shown in an expanded cmdline. -- Otherwise, or after an expanded cmdline is closed upon the first keypress, the -- cmdline contains the messages with spilled and duplicate lines indicators. cmd = { ids = {}, ---@type table List of visible messages. msg_row = -1, -- Last row of message to distinguish for placing virt_text. last_col = o.columns, -- Crop text to start column of 'last' virt_text. last_msg = 0, -- Time an important message was printed that should not be overwritten. prev_msg = '', -- Concatenated content of the previous message. prev_id = 0, ---@type string|integer Message id of the previous message. dupe = 0, -- Number of times message is repeated. }, virt = { -- Stored virt_text state. ---@type [MsgContent, MsgContent, MsgContent, MsgContent] last = { {}, {}, {}, {} }, -- Status in last cmdline row. cmd = { {}, {} }, ---@type [MsgContent, MsgContent] # [(x)] indicators in cmd window. msg = { {}, {} }, ---@type [MsgContent, MsgContent] # [(x)] indicators in msg window. top = { {} }, ---@type [MsgContent] # [+x] top indicator in dialog window. bot = { {} }, ---@type [MsgContent] # [+x] bottom indicator in dialog window. idx = { mode = 1, search = 2, cmd = 3, ruler = 4, spill = 1, dupe = 2 }, ids = {}, ---@type { ['last'|'cmd'|'msg'|'top'|'bot']: integer? } Table of mark IDs. delayed = false, -- Whether placement of 'last' virt_text is delayed. }, dialog_on_key = nil, ---@type integer? vim.on_key namespace for paging in the dialog window. cmd_on_key = nil, ---@type integer? vim.on_key namespace while cmdline is expanded. } --- Resolved options values from 'messagesopt', 'fillchars'. local mopt = { maxrows = 0, msgsep = ' ', pager = nil, timeout = 4000 } ---@type table --- Resolves the option values used by this module. function M.on_option_changed() local fcs = assert(vim.opt_global.fcs:get()) mopt.msgsep = fcs.msgsep or ' ' local v = vim.opt.messagesopt:get() --[[@as table]] mopt.maxrows = math.ceil(o.lines * (tonumber(v.maxheight) or 50) / 100) mopt.timeout = tonumber(v.timeout) or 4000 -- Normalize to the keytrans() form that cmd_on_key() compares against: "" => "". local key = v.pager mopt.pager = type(key) == 'string' and key ~= '' and fn.keytrans(vim.keycode(key)) or nil end -- An external redraw indicates the start of a new batch of messages in the cmdline. api.nvim_set_decoration_provider(ui.ns, { on_start = function() M.cmd.ids = (ui.redrawing or M.cmd_on_key) and M.cmd.ids or {} end, }) --- Start a timer whose callback will remove the message from the message window. --- ---@param buf integer Buffer the message was written to. ---@param id integer|string Message ID. function M.msg:start_timer(buf, id) if self.ids[id].timer then self.ids[id].timer:stop() end self.ids[id].timer = vim.defer_fn(function() -- Postpone message removal when user has entered the msg window. if api.nvim_get_current_win() == ui.wins.msg then M.msg:start_timer(buf, id) return end local extid = api.nvim_buf_is_valid(buf) and self.ids[id] and self.ids[id].extid local mark = extid and api.nvim_buf_get_extmark_by_id(buf, ui.ns, extid, { details = true }) self.ids[id] = nil if not mark or not mark[1] then return end -- Message span marks are created with both endpoints in show_msg(). local details = assert(mark[3]) local end_row, end_col = assert(details.end_row), assert(details.end_col) -- Clear prev_msg when line that may have dupe marker is removed. local erow = api.nvim_buf_line_count(buf) - 1 self.prev_msg = end_row == erow and '' or self.prev_msg -- Remove message (including potentially leftover empty line). api.nvim_buf_set_text(buf, mark[1], mark[2], end_row, end_col, {}) if api.nvim_buf_get_lines(buf, mark[1], mark[1] + 1, false)[1] == '' then api.nvim_buf_set_lines(buf, mark[1], mark[1] + 1, false, {}) end -- Resize or hide message window for removed message. if next(self.ids) then M.set_pos('msg') else self:clear() end end, mopt.timeout) end --- Place or delete a virtual text mark in the cmdline or message window. --- ---@param type 'last'|'cmd'|'msg'|'top'|'bot' ---@param tgt 'cmd'|'msg'|'dialog'|'pager' local function set_virttext(type, tgt) if type == 'last' and (ui.cmdheight == 0 or M.virt.delayed) then return -- Don't show virtual text while cmdline is expanded or delaying for error. end -- Concatenate the components of M.virt[type] and calculate the concatenated width. local width = 0 ---@type integer local chunks = {} ---@type [string, (integer|string)?][] local contents = M.virt[type] ---@type MsgContent[] for _, content in ipairs(contents) do for _, chunk in ipairs(content) do chunks[#chunks + 1] = { chunk[2], chunk[3] } width = width + api.nvim_strwidth(chunk[2]) end end if M.virt.ids[type] and #chunks == 0 then api.nvim_buf_del_extmark(ui.bufs[tgt], ui.ns, M.virt.ids[type]) M.cmd.last_col = type == 'last' and o.columns or M.cmd.last_col M.virt.ids[type] = nil elseif #chunks > 0 then local win = ui.wins[tgt] local line = (tgt == 'msg' or type == 'top') and 'w0' or type == 'bot' and 'w$' local srow = line and fn.line(line, ui.wins[tgt]) - 1 local erow = tgt == 'cmd' and math.min(M.cmd.msg_row, api.nvim_buf_line_count(ui.bufs.cmd) - 1) local texth = api.nvim_win_text_height(win, { max_height = (type == 'top' or type == 'bot') and 1 or api.nvim_win_get_height(win), start_row = srow or nil, end_row = erow or nil, }) local row = texth.end_row local col = fn.virtcol2col(win, row + 1, texth.end_vcol) local scol = fn.screenpos(win, row + 1, col).endcol ---@type integer if type ~= 'last' then -- Calculate at which column to place the virt_text such that it is at the end -- of the last visible message line, overlapping the message text if necessary, -- but not overlapping the 'last' virt_text. local offset = tgt ~= 'msg' and 0 or api.nvim_win_get_position(win)[2] + (api.nvim_win_get_config(win).border ~= 'none' and 1 or 0) -- Check if adding the virt_text on this line will exceed the current window width. local maxwidth = math.max(M.msg.width, math.min(o.columns, scol - offset + width)) if tgt == 'msg' and api.nvim_win_get_width(win) < maxwidth then api.nvim_win_resize(win, maxwidth, -1) M.msg.width = maxwidth end local mwidth = tgt == 'msg' and M.msg.width or tgt == 'dialog' and o.columns or M.cmd.last_col if scol - offset + width > mwidth then col = fn.virtcol2col(win, row + 1, texth.end_vcol - (scol - offset + width - mwidth)) end -- Give virt_text without its own highlight the same highlight as the message tail. local pos, opts = { row, col }, { details = true, overlap = true, type = 'highlight' } local hl = api.nvim_buf_get_extmarks(ui.bufs[tgt], ui.ns, pos, pos, opts)[1] if hl then local details = assert(hl[4]) for _, chunk in ipairs(chunks) do chunk[2] = chunk[2] or details.hl_group end end else local mode = #M.virt.last[M.virt.idx.mode] local pad = o.columns - width ---@type integer local newlines = math.max(0, ui.cmdheight - texth.all) row = row + newlines M.cmd.last_col = mode > 0 and 0 or o.columns - (newlines > 0 and 0 or width) if newlines > 0 then -- Add empty lines to place virt_text on the last screen row. api.nvim_buf_set_lines(ui.bufs.cmd, -1, -1, false, fn['repeat']({ '' }, newlines)) col = 0 else if scol > M.cmd.last_col then -- Give the user some time to read an important message. if os.time() - M.cmd.last_msg < 2 then -- The existing overlay may already cover the message, so hide it during the delay. if M.virt.ids.last then api.nvim_buf_del_extmark(ui.bufs.cmd, ui.ns, M.virt.ids.last) M.virt.ids.last = nil end M.virt.delayed = true vim.defer_fn(function() M.virt.delayed = false set_virttext('last', 'cmd') end, 2000) return end -- Crop text on last screen row and find byte offset to place mark at. local vcol = texth.end_vcol - (scol - M.cmd.last_col) col = vcol <= 0 and 0 or fn.virtcol2col(win, row + 1, vcol + 1) - 1 scol = fn.screenpos(win, row + 1, col).endcol M.cmd.prev_msg = mode > 0 and '' or M.cmd.prev_msg M.virt.cmd = mode > 0 and { {}, {} } or M.virt.cmd api.nvim_buf_set_text(ui.bufs.cmd, row, col, row, -1, { mode > 0 and ' ' or '' }) end pad = pad - ((mode > 0 or col == 0) and 0 or math.min(M.cmd.last_col, scol)) end table.insert(chunks, mode + 1, { (' '):rep(pad) }) -- Readjust to new M.cmd.last_col or clear for mode, but don't overwrite -- locked spill indicator while cmdline is expanded for messages. if not M.cmd_on_key then set_virttext('cmd', 'cmd') end end local opts = { undo_restore = false, invalidate = true, id = M.virt.ids[type] } opts.priority = (type == 'cmd' or type == 'msg') and 2 or 1 opts.virt_text_pos = 'overlay' opts.virt_text = chunks M.virt.ids[type] = api.nvim_buf_set_extmark(ui.bufs[tgt], ui.ns, row, col, opts) end end local function pager_shown() return api.nvim_win_is_valid(ui.wins.pager) and not api.nvim_win_get_config(ui.wins.pager).hide end ---@type vim.api.keyset.set_extmark local hlopts = { undo_restore = false, invalidate = true, priority = 1, strict = false } --- Move messages to expanded cmdline, dialog or pager to show in full. --- Return updated target+buffer in case it differs from 'src'. --- ---@param src 'cmd'|'msg'|'dialog'|'pager' ---@param tgt? 'cmd'|'msg'|'dialog'|'pager' ---@param focus? boolean Enter the pager: it was explicitly requested. function M.expand_msg(src, tgt, focus) -- Copy and clear message from src to enlarged cmdline that is dismissed by any -- key press. Append to pager instead if it isn't hidden or we want to enter it -- after cmdline was entered during expanded cmdline. local hidden = not pager_shown() tgt = tgt or not hidden and 'pager' or 'cmd' ---@type 'cmd'|'dialog'|'msg'|'pager' if tgt ~= src then local opts = { details = true, type = 'highlight' } local marks = api.nvim_buf_get_extmarks(ui.bufs[src], -1, 0, -1, opts) local lines = api.nvim_buf_get_lines(ui.bufs[src], 0, -1, false) -- Clear unless we want to keep the entered command. if ui.cmd.expand == 0 then M.msg_clear() end if M[src] then M.virt[src] = { {}, {} } end -- Append to visible pager, replace any other target. msg_clear() events may have edited pager. local srow = (not hidden and tgt == 'pager') and api.nvim_buf_line_count(ui.bufs.pager) or 0 api.nvim_buf_set_lines(ui.bufs[tgt], srow, -1, false, lines) for _, m in ipairs(marks) do local details = assert(m[4]) hlopts.hl_group, hlopts.end_col, hlopts.end_row = details.hl_group, details.end_col, details.end_row and srow + details.end_row api.nvim_buf_set_extmark(ui.bufs[tgt], ui.ns, srow + m[2], m[3], hlopts) end else if M[src] then M.virt[src][M.virt.idx.dupe][1] = nil end for _, id in pairs(M.virt.ids) do api.nvim_buf_del_extmark(ui.bufs.cmd, ui.ns, id) end end M.set_pos(tgt, focus) return tgt, ui.bufs[tgt] end local col = 0 -- Current message column appended to with :echon or reset for carriage return. local cmd_timer ---@type uv.uv_timer_t? Timer resetting cmdline state next event loop. ---@param tgt 'cmd'|'dialog'|'msg'|'pager' ---@param kind string ---@param content MsgContent ---@param replace_last boolean ---@param append boolean ---@param id integer|string function M.show_msg(tgt, kind, content, replace_last, append, id) local mark, msg, cr, dupe, buf = {}, '', false, 0, ui.bufs[tgt] if M[tgt] then -- tgt == 'cmd'|'msg' local extid = M[tgt].ids[id] and M[tgt].ids[id].extid -- Save the concatenated message to identify repeated messages. for _, chunk in ipairs(content) do msg = msg .. chunk[2] end local reset = extid or append or msg ~= M[tgt].prev_msg or ui.cmd.srow > 0 dupe = (reset and 0 or M[tgt].dupe + 1) local prev_id = M[tgt].prev_id cr = next(M[tgt].ids) ~= nil and msg:sub(1, 1) == '\r' replace_last = next(M[tgt].ids) ~= nil and not extid and (replace_last or dupe > 0) extid = extid or replace_last and M[tgt].ids[prev_id] and M[tgt].ids[prev_id].extid mark = extid and api.nvim_buf_get_extmark_by_id(buf, ui.ns, extid, { details = true }) or {} -- Ensure cmdline is clear when writing the first message. if tgt == 'cmd' and dupe == 0 and not next(M.cmd.ids) and ui.cmd.srow == 0 then api.nvim_buf_set_lines(buf, 0, -1, false, {}) end end -- Filter out empty newline messages. TODO: don't emit them. if msg == '\n' then return end ---@type integer Start row after last line in the target buffer, unless ---this is the first message, or in case of a repeated or replaced message. local row = mark[1] or (M[tgt] and not next(M[tgt].ids) and ui.cmd.srow == 0 and 0) or (api.nvim_buf_line_count(buf) - ((replace_last or cr or append) and 1 or 0)) local curline = (cr or append) and api.nvim_buf_get_lines(buf, row, row + 1, false)[1] col = mark[2] or (append and not cr and math.min(col, #curline) or 0) --- EmmyLuaLs/emmylua-analyzer-rust#1269 --- @type integer, integer, integer local start_row, start_col, lines = row, col, o.lines local function set_target_pos() if tgt == 'msg' then local width_cmd = [[echo max(map(range(1, line('$')), 'virtcol([v:val, "$"])'))]] local width = vim._assert_integer(fn.win_execute(ui.wins.msg, width_cmd)) - 1 api.nvim_win_resize(ui.wins.msg, width, -1) local texth = api.nvim_win_text_height(ui.wins.msg, { start_row = start_row, end_row = row }) if texth.all > math.ceil(lines * 0.5) then tgt, buf = M.expand_msg(tgt) else M.msg.width = width M.msg:start_timer(ui.bufs[tgt], id) M.set_pos(tgt) end elseif tgt == 'cmd' and dupe == 0 then fn.clearmatches(ui.wins.cmd) -- Clear matchparen highlights. if ui.cmd.srow > 0 then -- In block mode the cmdheight is already dynamic, so just print the full message -- regardless of height. Put cmdline below message. ui.cmd.srow = row + 1 else -- Place [+x] indicator for lines that spill over 'cmdheight'. local texth = api.nvim_win_text_height(ui.wins.cmd, { max_height = lines }) texth.all = math.max(texth.all, api.nvim_buf_line_count(ui.bufs.cmd)) local spill = texth.all > ui.cmdheight and (' [+%d]'):format(texth.all - ui.cmdheight) M.virt.cmd[M.virt.idx.spill][1] = spill and { 0, spill, more_msg } or nil M.cmd.msg_row = texth.end_row -- Expand the cmdline for a non-error message that doesn't fit. local expand = mopt.maxrows > ui.cmdheight local error_kinds = { rpc_error = 1, emsg = 1, echoerr = 1, lua_error = 1 } if expand and texth.all > ui.cmdheight and (ui.cmdheight == 0 or not error_kinds[kind]) then tgt, buf = M.expand_msg(tgt) end end elseif tgt ~= 'cmd' then M.set_pos(tgt) end end local repl, did_cr, code, stime = { '' }, false, nil, vim.uv.hrtime() for _, chunk in ipairs(content) do for str in (chunk[2] .. '\0'):gmatch('.-[\n\r%z]') do local pat, time = str:sub(-1), vim.uv.hrtime() repl[#repl] = str:sub(1, -2) repl[#repl + 1] = (pat == '\n' and not did_cr) and '' or nil -- Cannot accumulate newlines after a carriage return. if pat ~= '\n' or did_cr or time - stime > 100000000 then local end_col = #repl[#repl] + (#repl == 1 and col or 0) if row >= api.nvim_buf_line_count(buf) then api.nvim_buf_set_lines(buf, row, -1, false, repl) else local erow = mark[3] and mark[3].end_row or row local ecol = mark[3] and mark[3].end_col or curline and math.min(col + #repl[1], #curline) api.nvim_buf_set_text(buf, row, col, erow, ecol or -1, repl) end if chunk[3] > 0 then hlopts.hl_group, hlopts.end_col, hlopts.end_row = chunk[3], end_col, row + #repl - 1 api.nvim_buf_set_extmark(buf, ui.ns, row, col, hlopts) end row = row + #repl - (did_cr and pat == '\n' and 0 or 1) col = pat == '\0' and end_col or 0 curline = #repl > 1 and repl[#repl] or api.nvim_buf_get_lines(buf, row, row + 1, false)[1] repl, mark[3] = { '' }, nil did_cr = pat == '\r' end -- Show progress and check for CTRL-C when taking a while. if time - stime > 100000000 then stime, _, code = time, vim.wait(0, nil, 0) if code == -2 then break -- CTRL-C end set_target_pos() api.nvim__redraw({ flush = true }) end end if code == -2 then break -- CTRL-C end end if M[tgt] then -- Keep track of message span to replace by ID. local opts = { end_row = row, end_col = col, invalidate = true, undo_restore = false } M[tgt].ids[id] = M[tgt].ids[id] or {} M[tgt].ids[id].extid = api.nvim_buf_set_extmark(buf, ui.ns, start_row, start_col, opts) M[tgt].prev_id, M[tgt].prev_msg, M[tgt].dupe = id, msg, dupe end set_target_pos() if M[tgt] and not M.cmd_on_key and row == api.nvim_buf_line_count(buf) - 1 then -- Place (x) indicator for repeated messages. Mainly to mitigate unnecessary -- resizing of the message window, but also placed in the cmdline. M.virt[tgt][M.virt.idx.dupe][1] = dupe > 0 and { 0, ('(%d)'):format(dupe) } or nil M.virt[tgt][M.virt.idx.spill][1] = tgt == 'cmd' and M.virt.cmd[M.virt.idx.spill][1] or nil set_virttext(tgt --[[@as 'cmd'|'msg']], tgt) if tgt == 'cmd' then -- make sure repeated long messages are cropped to keep the ruler intact -- (if this is not scheduled, it breaks messages during textlock) vim.schedule(function() set_virttext('last', 'cmd') end) end end -- Reset message state the next event loop iteration. if not cmd_timer and (col > 0 or next(M.cmd.ids) ~= nil) then cmd_timer = vim.defer_fn(function() M.cmd.ids, cmd_timer, col = M.cmd_on_key and M.cmd.ids or {}, nil, 0 end, 0) end end ---@alias MsgChunk [integer, string, integer?] ---@alias MsgContent MsgChunk[] local in_pager = false -- Whether the pager is or will be the current window. --- Route the message to the appropriate sink. --- ---@param kind string ---@param content MsgContent ---@param replace_last boolean --@param history boolean ---@param append boolean ---@param id integer|string ---@param trigger string function M.msg_show(kind, content, replace_last, _, append, id, trigger) -- Match configured target mappings as Lua pattern to ID: local id_target ---@type 'cmd'|'msg'|'pager'|nil if type(id) == 'string' then local k, v = next(ui.cfg.msg.targets) while k and not id_target do id_target = id:match(k) and v k, v = next(ui.cfg.msg.targets, k) end end -- Set the entered search command in the cmdline (if available). local tgt = kind == 'search_cmd' and 'cmd' -- When the pager is open always route typed commands there. This better simulates -- the UI1 behavior after opening the cmdline below a previous multiline message, -- and seems useful enough even when the pager was entered manually. or (trigger == 'typed_cmd' and pager_shown() and fn.getcmdwintype() == '') and 'pager' -- Otherwise route to configured target: or (trigger ~= '' and ui.cfg.msg.targets[trigger]) or id_target or (kind ~= '' and ui.cfg.msg.targets[kind]) or ui.cfg.msg.targets.default if kind == 'search_cmd' and ui.cmdheight == 0 then -- Blocked by messaging() without ext_messages. TODO: look at other messaging() guards. return elseif kind == 'empty' then -- A sole empty message clears the cmdline. if not next(M.cmd.ids) and ui.cmd.srow == 0 then M.cmd:clear() end elseif kind == 'search_count' then -- Extract only the search_count, not the entered search command. -- Match any of search.c:cmdline_search_stat():' [(x | >x | ?)/(y | >y | ??)]' content = { content[#content] } local stat = content[1][2]:match('W? %[>?%d*%??/>?%d*%?*%]') if stat then content[1][2] = stat .. ' ' M.virt.last[M.virt.idx.search] = content M.virt.last[M.virt.idx.cmd] = { { 0, (' '):rep(11) } } set_virttext('last', 'cmd') end elseif (ui.cmd.prompt or (ui.cmd.level > 0 and tgt == 'cmd')) and ui.cmd.srow == 0 then -- Route to dialog when a prompt is active, or message would overwrite active cmdline. replace_last = api.nvim_win_get_config(ui.wins.dialog).hide or kind == 'wildlist' if kind == 'wildlist' then api.nvim_buf_set_lines(ui.bufs.dialog, 0, -1, false, {}) if ui.cmd.wmnumode ~= (fn.pumvisible() == 0 and fn.wildmenumode() or 0) then ui.cmd.wmnumode = (ui.cmd.wmnumode == 1 and 0 or 1) end end ui.cmd.dialog = true -- Ensure dialog is closed when cmdline is hidden. M.show_msg('dialog', kind, content, replace_last, append, id) else if tgt == 'cmd' then -- Store the time when an important message was emitted in order to not overwrite -- it with 'last' virt_text in the cmdline so that the user has a chance to read it. local mode = api.nvim_get_mode().mode local visual = mode:match('^[vV\22]') ~= nil M.cmd.last_msg = (kind == 'emsg' or kind == 'wmsg' or visual) and os.time() or M.cmd.last_msg -- Should clear the search count now, mark itself is cleared by invalidate. M.virt.last[M.virt.idx.search][1] = nil M.virt.last[M.virt.idx.cmd] = {} end -- When message was emitted below an already expanded cmdline, move and route to pager. tgt = ui.cmd.expand > 0 and 'pager' or tgt if ui.cmd.expand == 1 then M.expand_msg('dialog', 'pager') end ui.cmd.expand = ui.cmd.expand + (ui.cmd.expand > 0 and 1 or 0) local open_pager = tgt == 'pager' and not pager_shown() M.show_msg(tgt, kind, content, replace_last or open_pager, append, id) if kind == 'search_cmd' then -- Don't remember search_cmd message as actual message. M.cmd.ids, M.cmd.prev_msg = {}, '' elseif tgt == 'pager' then -- Position cursor at start of first or last message at bottom of window. -- Keep Normal commands: nvim_win_set_cursor() only ensures visibility, while zb -- reliably places the final message at the bottom. :noautocmd avoids #40780. fn.win_execute(ui.wins.pager, 'noautocmd norm! ' .. (open_pager and 'gg0' or 'G0zb')) end end end --Clear message state in the cmdline. function M.cmd:clear() api.nvim_buf_set_lines(ui.bufs.cmd, 0, -1, false, {}) self.ids, self.prev_msg, self.dupe, self.msg_row = {}, '', 0, -1 M.virt.cmd, M.virt.last = { {}, {} }, { {}, {}, {}, {} } end --Clear message state in msg window. function M.msg:clear() api.nvim_buf_set_lines(ui.bufs.msg, 0, -1, false, {}) pcall(api.nvim_win_set_config, ui.wins.msg, { hide = true }) M.virt.msg, self.ids, self.prev_msg, self.dupe, self.width = { {}, {} }, {}, '', 0, 1 end ---Clear currently visible messages. function M.msg_clear() M.cmd:clear() M.msg:clear() end --- Place the mode text in the cmdline. --- ---@param content MsgContent function M.msg_showmode(content) M.virt.last[M.virt.idx.mode] = ui.cmd.level > 0 and {} or content M.virt.last[M.virt.idx.search] = {} set_virttext('last', 'cmd') end --- Place text from the 'showcmd' buffer in the cmdline. --- ---@param content MsgContent function M.msg_showcmd(content) local str = content[1] and content[1][2]:sub(-10) or '' M.virt.last[M.virt.idx.cmd][1] = (content[1] or M.virt.last[M.virt.idx.search][1]) and { 0, str .. (' '):rep(11 - #str) } set_virttext('last', 'cmd') end --- Place the 'ruler' text in the cmdline window. --- ---@param content MsgContent function M.msg_ruler(content) M.virt.last[M.virt.idx.ruler] = (ui.cmd.level > 0 or M.cmd_on_key) and {} or content set_virttext('last', 'cmd') end ---@alias MsgHistory [string, MsgContent, boolean] --- Open the message history in the pager. --- ---@param entries MsgHistory[] ---@param prev_cmd boolean function M.msg_history_show(entries, prev_cmd) if #entries == 0 then return end -- Showing output of previous command, clear in case still visible. if M.cmd_on_key or prev_cmd then M.cmd:clear() end api.nvim_buf_set_lines(ui.bufs.pager, 0, -1, false, {}) for i, entry in ipairs(entries) do M.show_msg('pager', entry[1], entry[2], i == 1, entry[3], 0) end -- Message history was explicitly requested (|g<|, |:messages|): enter the pager. M.set_pos('pager', true) end local typed_g = false --- @param key string --- @param typed string local function cmd_on_key(key, typed) typed = fn.keytrans(typed) -- Don't dismiss for non-typed keys and mouse movement. When 'g' is passed (typed -- or mapped), wait until the next key to avoid flickering when the pager is opened. if not typed_g and (typed == '' or (typed == '' or typed == 'g' or key == 'g')) then typed_g = typed ~= '' and (typed == 'g' or key == 'g') return end if typed == ':' or ui.cmd.level > 0 or fn.getcmdtype() ~= '' then -- Keep expanded messages open until the cmdline closes, including for the -- key that executes it. Stay armed: a command that emits no message would -- otherwise leave an unfocused pager without a handler. return end vim.on_key(nil, ui.ns) -- Check if window was entered and reopen with original config. A shown (but not entered) -- pager is dismissed instead; "g<" passes through to reopen and enter it. local can_enter = not api.nvim_get_mode().mode:match('[it]') and not pager_shown() local enter = can_enter and (typed == mopt.pager or typed_g and (typed == '' or key == '<')) or (typed:find('LeftMouse') and fn.getmousepos().winid == ui.wins.cmd) if enter then M.expand_msg('cmd', 'pager', true) elseif not in_pager then pcall(api.nvim_win_set_config, ui.wins.pager, { hide = true }) end pcall(api.nvim_win_close, ui.wins.cmd, true) ui.check_targets() api.nvim_win_set_cursor(ui.wins[enter and 'pager' or 'cmd'], { 1, 0 }) set_virttext('cmd', 'cmd') api.nvim__redraw({ flush = true }) typed_g, M.cmd_on_key, M.cmd.ids = false, nil, {} return enter and '' or nil end --- Add virtual [+x] text to indicate scrolling is possible. local function set_top_bot_spill() local topspill = fn.line('w0', ui.wins.dialog) - 1 local botspill = api.nvim_buf_line_count(ui.bufs.dialog) - fn.line('w$', ui.wins.dialog) M.virt.top[1][1] = topspill > 0 and { 0, (' [+%d]'):format(topspill), more_msg } or nil set_virttext('top', 'dialog') M.virt.bot[1][1] = botspill > 0 and { 0, (' [+%d]'):format(botspill), more_msg } or nil set_virttext('bot', 'dialog') api.nvim__redraw({ flush = true }) return topspill > 0 or botspill > 0 end --- Allow paging in the dialog window, consume the key if the topline changes. --- @param typed string? local function dialog_on_key(_, typed) typed = typed and fn.keytrans(typed) if not typed then return end -- TODO: no hint anymore, so should at least be documented at some point. local map, eob = {}, typed:match('PageDown') map[''], map[''] = 'gg', 'G' map[''], map[''] = 'Hk', 'Hk' map[''], map[''] = 'Lj', 'Lj' map[''], map[''] = [[\]], [[\]] map[''], map[''] = [[\]], [[\]] local ver = tonumber(o.mousescroll:match('ver:(%d+)')) or 3 if ver > 0 then map[''], map[''] = ver .. [[\]], ver .. [[\]] end local info = map[typed] and fn.getwininfo(ui.wins.dialog)[1] if info and (not eob or info.botline < api.nvim_buf_line_count(ui.bufs.dialog)) then -- Keep Normal commands for screen-relative H/L and page scrolling behavior. fn.win_execute(ui.wins.dialog, ('exe "norm! %s"'):format(map[typed])) set_top_bot_spill() local updated = fn.getwininfo(ui.wins.dialog)[1] return updated and updated.topline ~= info.topline and '' or nil end end ---@param tgt 'cmd'|'msg'|'dialog'|'pager' ---@param min integer Minimum window height. local function win_row_height_border(tgt, min) local h = (tgt ~= 'cmd' and ui.cfg.msg[tgt].height or 0) --[[@as number]] min = math.min(min, tgt == 'cmd' and mopt.maxrows or h < 1 and math.ceil(o.lines * h) or h) if tgt ~= 'pager' then return (tgt == 'msg' and 0 or 1) - ui.cmd.wmnumode, math.max(1, min), min < o.lines - ui.cmdheight and ui.cmdheight > 0 end local cmdwin_win = require('vim._core.cmdwin').win() local cmdwin = cmdwin_win and api.nvim_win_get_height(cmdwin_win) or 0 local global_stl = (cmdwin > 0 or o.laststatus == 3) and 1 or 0 local row = 1 - cmdwin - global_stl local top = min < o.lines - ui.cmdheight - global_stl - cmdwin local height = math.min(min, o.lines - (top and 1 or 0) - ui.cmdheight - global_stl - cmdwin) return row, math.max(1, height), top end local function enter_pager() in_pager = true if M.cmd_on_key then api.nvim_feedkeys(vim.keycode(''), 'tn', false) end -- Schedule: the fed above is processed one event iteration later. vim.schedule(function() if not api.nvim_win_is_valid(ui.wins.pager) then return -- Pager was already closed somehow. end local height, id = api.nvim_win_get_height(ui.wins.pager), 0 api.nvim_set_option_value('eiw', '', { scope = 'local', win = ui.wins.pager }) api.nvim_set_current_win(ui.wins.pager) id = nvim_on({ 'WinEnter', 'CmdwinEnter', 'WinResized' }, ui.augroup, { desc = 'Hide or reposition pager window.', }, function(ev) if fn.getcmdtype() ~= '' then -- WinEnter fires before we can detect cmdwin will be entered: keep open. return elseif ev.event == 'WinResized' and fn.getcmdwintype() == '' then -- Remember height to be restored when cmdwin is closed. height = api.nvim_win_get_height(ui.wins.pager) elseif ev.event == 'WinEnter' then -- Close when no longer current window. in_pager = api.nvim_get_current_win() == ui.wins.pager end in_pager = in_pager and api.nvim_win_is_valid(ui.wins.pager) --- @type vim.api.keyset.win_config local cfg = in_pager and { relative = 'laststatus', col = 0 } or { hide = true } if in_pager then local has_border cfg.row, cfg.height, has_border = win_row_height_border('pager', height) cfg.border = has_border and { '', { mopt.msgsep, 'MsgSeparator' }, '', '', '', '', '', '' } or 'none' else pcall(api.nvim_set_option_value, 'eiw', 'all', { scope = 'local', win = ui.wins.pager }) api.nvim_del_autocmd(id) end pcall(api.nvim_win_set_config, ui.wins.pager, cfg) end) end) end --- Adjust visibility and dimensions of the message windows after certain events. --- ---@param tgt? 'cmd'|'dialog'|'msg'|'pager' Target window to be positioned (nil for all). ---@param focus? boolean Enter the pager: it was explicitly requested. function M.set_pos(tgt, focus) for t, win in pairs(ui.wins) do local current_cfg = (t == tgt or (tgt == nil and t ~= 'cmd')) and api.nvim_win_is_valid(win) and api.nvim_win_get_config(win) if current_cfg and (tgt or not current_cfg.hide) then local texth = api.nvim_win_text_height(win, { max_height = o.lines }) local top = { mopt.msgsep, 'MsgSeparator' } ---@type vim.api.keyset.win_config local cfg = { hide = false, relative = 'laststatus', col = 10000 } local has_border cfg.row, cfg.height, has_border = win_row_height_border(t, texth.all) cfg.border = has_border and t ~= 'msg' and { '', top, '', '', '', '', '', '' } or nil cfg.mouse = tgt == 'cmd' or t == 'msg' or nil api.nvim_win_set_config(win, cfg) api.nvim_win_set_cursor(win, { 1, 0 }) if tgt == 'cmd' then -- Dismiss temporarily expanded cmdline on next keypress and update spill indicator. texth.all = math.max(texth.all, api.nvim_buf_line_count(ui.bufs.cmd)) local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height) M.virt.cmd[M.virt.idx.spill][1] = spill and { 0, spill, more_msg } or nil set_virttext('cmd', 'cmd') M.virt.cmd[M.virt.idx.spill][1] = { 0, (' [+%d]'):format(texth.all - ui.cmdheight), more_msg } M.cmd_on_key = vim.on_key(cmd_on_key, ui.ns) elseif tgt == 'dialog' and set_top_bot_spill() then M.dialog_on_key = vim.on_key(dialog_on_key, M.dialog_on_key) elseif tgt == 'msg' then -- Ensure last line is visible and first line is at top of window. -- Keep Normal commands: nvim_win_set_cursor() only ensures visibility, while zb -- reliably places the final message at the bottom. :noautocmd avoids #40780. fn.win_execute(ui.wins.msg, 'noautocmd norm! Gzb') elseif tgt == 'pager' and not in_pager then if focus then enter_pager() else -- Reuse only the handler: M.cmd_on_key would mark the cmdline as expanded. vim.on_key(cmd_on_key, ui.ns) end end end end end return M