feat(ui2): configure maximum window heights #38392

Problem:
- Window height is set dynamically to match the text height,
  making it difficult for the user to use a different height.
- Cmdwin is closed to enter the pager but still taken into
  account for the pager position, and not restored when
  the pager is closed.
- Dialog pager handler may unnecessarily consume <Esc>.

Solution:
- Add maximum height config fields for each of the UI2 windows,
  where a number smaller than one is a fraction of 'lines',
  absolute height otherwise (i.e. `cfg.msg.pager.height = 0.5`).
- If the cmdwin will be closed to enter the pager, don't try
  to position the pager above it. Re-enter the cmdwin when the
  pager is closed.
- Only add vim.on_key() handler for the dialog paging is actually
  possible.
This commit is contained in:
luukvbaal
2026-03-21 15:20:06 +01:00
committed by GitHub
parent 7a5e9ef0aa
commit fbac2e5edc
4 changed files with 163 additions and 67 deletions

View File

@@ -13,23 +13,36 @@
--- ---@type string|table<string, 'cmd'|'msg'|'pager'> Default message target
--- ---or table mapping |ui-messages| kinds and triggers to a target.
--- targets = 'cmd',
--- timeout = 4000, -- Time a message is visible in the message window.
--- cmd = { -- Options related to messages in the cmdline window.
--- height = 0.5 -- Maximum height while expanded for messages beyond 'cmdheight'.
--- },
--- dialog = { -- Options related to dialog window.
--- height = 0.5, -- Maximum height.
--- },
--- msg = { -- Options related to msg window.
--- height = 0.5, -- Maximum height.
--- timeout = 4000, -- Time a message is visible in the message window.
--- },
--- pager = { -- Options related to message window.
--- height = 1, -- Maximum height.
--- },
--- },
--- })
--- ```
---
--- There are four special windows/buffers for presenting messages and cmdline:
--- - "cmd": Cmdline. Also used for 'showcmd', 'showmode', 'ruler', and messages if 'cmdheight' > 0.
--- - "msg": Message window, shows messages when 'cmdheight' == 0.
--- - "cmd": Cmdline. Also used for 'showcmd', 'showmode', 'ruler', and messages by default.
--- - "msg": Message window, shows fleeting messages useful for 'cmdheight' == 0.
--- - "pager": Pager window, shows |:messages| and certain messages that are never "collapsed".
--- - "dialog": Dialog window, shows modal prompts that expect user input.
---
--- The buffer 'filetype' is to the above-listed id ("cmd", "msg", …). Handle the |FileType| event
--- to configure any local options for these windows and their respective buffers.
--- The buffer 'filetype' is set to the above-listed id ("cmd", "msg", …).
--- Handle the |FileType| event to configure any local options for these
--- windows and their respective buffers.
---
--- Unlike the legacy |hit-enter| prompt, messages that overflow the cmdline area are instead
--- "collapsed", followed by a `[+x]` "spill" indicator, where `x` indicates the spilled lines. To
--- see the full messages, do either:
--- Unlike the legacy |hit-enter| prompt, messages exceeding 'cmdheight' are
--- instead "collapsed", followed by a `[+x]` "spill" indicator, where `x`
--- indicates the spilled lines. To see the full messages, do either:
--- - ENTER immediately after a message from interactive |:| cmdline.
--- - |g<| at any time.
@@ -46,7 +59,19 @@ local M = {
msg = { -- Options related to the message module.
target = 'cmd', ---@type 'cmd'|'msg' Default message target if not present in targets.
targets = {}, ---@type table<string, 'cmd'|'msg'|'pager'> Kind specific message targets.
timeout = 4000, -- Time a message is visible in the message window.
cmd = { -- Options related to messages in the cmdline window.
height = 0.5, -- Maximum height while expanded for messages beyond 'cmdheight'.
},
dialog = { -- Options related to dialog window.
height = 0.5, -- Maximum height.
},
msg = { -- Options related to msg window.
height = 0.5, -- Maximum height.
timeout = 4000, -- Time a message is visible in the message window.
},
pager = { -- Options related to message window.
height = 1, -- Maximum height.
},
},
},
}

View File

@@ -78,7 +78,7 @@ function M.msg:start_timer(buf, id)
pcall(api.nvim_win_set_config, ui.wins.msg, { hide = true })
self.width, M.virt.msg[M.virt.idx.dupe][1] = 1, nil
end
end, ui.cfg.msg.timeout)
end, ui.cfg.msg.msg.timeout)
end
--- Place or delete a virtual text mark in the cmdline or message window.
@@ -537,6 +537,7 @@ local function set_top_bot_spill()
M.virt.bot[1][1] = botspill > 0 and { 0, (' [+%d]'):format(botspill) } 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.
@@ -571,20 +572,26 @@ local dialog_on_key = function(_, typed)
end
end
local was_cmdwin = ''
---@param min integer Minimum window height.
local function win_row_height(win, min)
if win ~= ui.wins.pager then
return (win == ui.wins.msg and 0 or 1) - ui.cmd.wmnumode,
math.min(min, math.ceil(o.lines * 0.5))
local function win_row_height(tgt, min)
local cfgmin = ui.cfg.msg[tgt].height --[[@as number]]
cfgmin = cfgmin > 1 and cfgmin or math.ceil(o.lines * cfgmin)
if tgt ~= 'pager' then
return (tgt == 'msg' and 0 or 1) - ui.cmd.wmnumode, math.min(min, cfgmin)
end
local cmdwin = fn.getcmdwintype() ~= '' and api.nvim_win_get_height(0) or 0
local cmdwin = fn.getcmdwintype() ~= was_cmdwin and api.nvim_win_get_height(0) or 0
local global_stl = (cmdwin > 0 or o.laststatus == 3) and 1 or 0
local row = 1 - cmdwin - global_stl
return row, math.min(min, o.lines - 1 - ui.cmdheight - global_stl - cmdwin)
return row, math.min(math.min(cfgmin, min), o.lines - 1 - ui.cmdheight - global_stl - cmdwin)
end
local function enter_pager()
in_pager = true
-- Cannot leave the cmdwin to enter the pager, so close and re-open it.
in_pager, was_cmdwin = true, fn.getcmdwintype()
if was_cmdwin ~= '' then
api.nvim_command('quit')
end
-- Cmdwin is closed one event iteration later so schedule in case it was open.
vim.schedule(function()
local height, id = api.nvim_win_get_height(ui.wins.pager), 0
@@ -607,13 +614,16 @@ local function enter_pager()
in_pager = in_pager and api.nvim_win_is_valid(ui.wins.pager)
local cfg = in_pager and { relative = 'laststatus', col = 0 } or { hide = true }
if in_pager then
cfg.row, cfg.height = win_row_height(ui.wins.pager, height)
end
pcall(api.nvim_win_set_config, ui.wins.pager, cfg)
if not in_pager then
cfg.row, cfg.height = win_row_height('pager', height)
else
pcall(api.nvim_set_option_value, 'eiw', 'all', { scope = 'local', win = ui.wins.pager })
api.nvim_del_autocmd(id)
if was_cmdwin ~= '' then
api.nvim_feedkeys('q' .. was_cmdwin, 'n', false)
was_cmdwin = ''
end
end
pcall(api.nvim_win_set_config, ui.wins.pager, cfg)
end,
desc = 'Hide or reposition pager window.',
})
@@ -624,48 +634,37 @@ end
---
---@param tgt? 'cmd'|'dialog'|'msg'|'pager' Target window to be positioned (nil for all).
function M.set_pos(tgt)
local function win_set_pos(win)
local texth = api.nvim_win_text_height(win, {})
local top = { vim.opt.fcs:get().msgsep or ' ', 'MsgSeparator' }
local title = { 'f/d/j: screen/page/line down, b/u/k: up, <Esc>: stop paging', 'MsgSeparator' }
local cfg = { hide = false, relative = 'laststatus', col = 10000 }
cfg.row, cfg.height = win_row_height(win, texth.all)
cfg.border = win ~= ui.wins.msg and { '', top, '', '', '', '', '', '' } or nil
cfg.mouse = tgt == 'cmd' or nil
cfg.title = tgt == 'dialog' and cfg.height < texth.all and { title } or nil
api.nvim_win_set_config(win, cfg)
if tgt == 'cmd' and not M.cmd_on_key then
-- Temporarily expand the cmdline, until next key press.
local save_spill = M.virt.msg[M.virt.idx.spill][1]
local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height)
M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil
set_virttext('msg', 'cmd')
M.virt.msg[M.virt.idx.spill][1] = save_spill
M.cmd_on_key = vim.on_key(cmd_on_key, ui.ns)
elseif tgt == 'dialog' then
M.dialog_on_key = vim.on_key(dialog_on_key, M.dialog_on_key)
set_top_bot_spill()
elseif tgt == 'msg' then
-- Ensure last line is visible and first line is at top of window.
fn.win_execute(ui.wins.msg, 'norm! Gzb')
elseif tgt == 'pager' and not in_pager then
if fn.getcmdwintype() ~= '' then
-- Cannot leave the cmdwin to enter the pager, so close it.
-- NOTE: regression w.r.t. the message grid, which allowed this.
-- Resolving that would require somehow bypassing textlock for the pager.
api.nvim_command('quit')
end
enter_pager()
end
end
for t, win in pairs(ui.wins) do
local cfg = (t == tgt or (tgt == nil and t ~= 'cmd'))
and api.nvim_win_is_valid(win)
and api.nvim_win_get_config(win)
if cfg and (tgt or not cfg.hide) then
win_set_pos(win)
local texth = api.nvim_win_text_height(win, {})
local top = { vim.opt.fcs:get().msgsep or ' ', 'MsgSeparator' }
local hint = { 'f/d/j: screen/page/line down, b/u/k: up, <Esc>: stop paging', 'MsgSeparator' }
cfg = { hide = false, relative = 'laststatus', col = 10000 } ---@type table
cfg.row, cfg.height = win_row_height(t, texth.all)
cfg.border = t ~= 'msg' and { '', top, '', '', '', '', '', '' } or nil
cfg.mouse = tgt == 'cmd' or nil
cfg.title = tgt == 'dialog' and cfg.height < texth.all and { hint } or nil
api.nvim_win_set_config(win, cfg)
if tgt == 'cmd' and not M.cmd_on_key then
-- Temporarily expand the cmdline, until next key press.
local save_spill = M.virt.msg[M.virt.idx.spill][1]
local spill = texth.all > cfg.height and (' [+%d]'):format(texth.all - cfg.height)
M.virt.msg[M.virt.idx.spill][1] = spill and { 0, spill } or nil
set_virttext('msg', 'cmd')
M.virt.msg[M.virt.idx.spill][1] = save_spill
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.
fn.win_execute(ui.wins.msg, 'norm! Gzb')
elseif tgt == 'pager' and not in_pager then
enter_pager()
end
end
end
end