neovim

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

ui2.lua (9771B)


      1 --- @brief
      2 ---
      3 ---WARNING: This is an experimental interface intended to replace the message
      4 ---grid in the TUI.
      5 ---
      6 ---To enable the experimental UI (default opts shown):
      7 ---```lua
      8 ---require('vim._core.ui2').enable({
      9 ---  enable = true, -- Whether to enable or disable the UI.
     10 ---  msg = { -- Options related to the message module.
     11 ---    ---@type 'cmd'|'msg' Default message target, either in the
     12 ---    ---cmdline or in a separate ephemeral message window.
     13 ---    ---@type string|table<string, 'cmd'|'msg'|'pager'> Default message target
     14 ---    or table mapping |ui-messages| kinds to a target.
     15 ---    targets = 'cmd',
     16 ---    timeout = 4000, -- Time a message is visible in the message window.
     17 ---  },
     18 ---})
     19 ---```
     20 ---
     21 ---There are four separate window types used by this interface:
     22 ---- "cmd": The cmdline window; also used for 'showcmd', 'showmode', 'ruler', and
     23 ---  messages if 'cmdheight' > 0.
     24 ---- "msg": The message window; used for messages when 'cmdheight' == 0.
     25 ---- "pager": The pager window; used for |:messages| and certain messages
     26 ---   that should be shown in full.
     27 ---- "dialog": The dialog window; used for prompt messages that expect user input.
     28 ---
     29 ---These four windows are assigned the "cmd", "msg", "pager" and "dialog"
     30 ---'filetype' respectively. Use a |FileType| autocommand to configure any local
     31 ---options for these windows and their respective buffers.
     32 ---
     33 ---Rather than a |hit-enter-prompt|, messages shown in the cmdline area that do
     34 ---not fit are appended with a `[+x]` "spill" indicator, where `x` indicates the
     35 ---spilled lines. To see the full message, the |g<| command can be used.
     36 
     37 local api = vim.api
     38 local M = {
     39  ns = api.nvim_create_namespace('nvim.ui2'),
     40  augroup = api.nvim_create_augroup('nvim.ui2', {}),
     41  cmdheight = vim.o.cmdheight, -- 'cmdheight' option value set by user.
     42  redrawing = false, -- True when redrawing to display UI event.
     43  wins = { cmd = -1, dialog = -1, msg = -1, pager = -1 },
     44  bufs = { cmd = -1, dialog = -1, msg = -1, pager = -1 },
     45  cfg = {
     46    enable = true,
     47    msg = { -- Options related to the message module.
     48      target = 'cmd', ---@type 'cmd'|'msg' Default message target if not present in targets.
     49      targets = {}, ---@type table<string, 'cmd'|'msg'|'pager'> Kind specific message targets.
     50      timeout = 4000, -- Time a message is visible in the message window.
     51    },
     52  },
     53 }
     54 --- @type vim.api.keyset.win_config
     55 local wincfg = { -- Default cfg for nvim_open_win().
     56  relative = 'laststatus',
     57  style = 'minimal',
     58  col = 0,
     59  row = 1,
     60  width = 10000,
     61  height = 1,
     62  noautocmd = true,
     63  focusable = false,
     64 }
     65 
     66 local tab = 0
     67 ---Ensure target buffers and windows are still valid.
     68 function M.check_targets()
     69  local curtab = api.nvim_get_current_tabpage()
     70  for i, type in ipairs({ 'cmd', 'dialog', 'msg', 'pager' }) do
     71    local setopt = not api.nvim_buf_is_valid(M.bufs[type])
     72    if setopt then
     73      M.bufs[type] = api.nvim_create_buf(false, false)
     74    end
     75 
     76    if
     77      tab ~= curtab
     78      or not api.nvim_win_is_valid(M.wins[type])
     79      or not api.nvim_win_get_config(M.wins[type]).zindex -- no longer floating
     80    then
     81      local cfg = vim.tbl_deep_extend('force', wincfg, {
     82        mouse = type ~= 'cmd' and true or nil,
     83        anchor = type ~= 'cmd' and 'SE' or nil,
     84        hide = type ~= 'cmd' or M.cmdheight == 0 or nil,
     85        border = type ~= 'msg' and 'none' or nil,
     86        -- kZIndexMessages < cmd zindex < kZIndexCmdlinePopupMenu (grid_defs.h), pager below others.
     87        zindex = 201 - i,
     88        _cmdline_offset = type == 'cmd' and 0 or nil,
     89      })
     90      if tab ~= curtab and api.nvim_win_is_valid(M.wins[type]) then
     91        cfg = api.nvim_win_get_config(M.wins[type])
     92        api.nvim_win_close(M.wins[type], true)
     93      end
     94      M.wins[type] = api.nvim_open_win(M.bufs[type], false, cfg)
     95      setopt = true
     96    elseif api.nvim_win_get_buf(M.wins[type]) ~= M.bufs[type] then
     97      api.nvim_win_set_buf(M.wins[type], M.bufs[type])
     98      setopt = true
     99    end
    100 
    101    if setopt then
    102      -- Set options without firing OptionSet and BufFilePost.
    103      vim._with({ win = M.wins[type], noautocmd = true }, function()
    104        api.nvim_set_option_value('eventignorewin', 'all,-FileType', { scope = 'local' })
    105        api.nvim_set_option_value('wrap', true, { scope = 'local' })
    106        api.nvim_set_option_value('linebreak', false, { scope = 'local' })
    107        api.nvim_set_option_value('smoothscroll', true, { scope = 'local' })
    108        api.nvim_set_option_value('breakindent', false, { scope = 'local' })
    109        api.nvim_set_option_value('foldenable', false, { scope = 'local' })
    110        api.nvim_set_option_value('showbreak', '', { scope = 'local' })
    111        api.nvim_set_option_value('spell', false, { scope = 'local' })
    112        api.nvim_set_option_value('swapfile', false, { scope = 'local' })
    113        api.nvim_set_option_value('modifiable', true, { scope = 'local' })
    114        api.nvim_set_option_value('bufhidden', 'hide', { scope = 'local' })
    115        api.nvim_set_option_value('buftype', 'nofile', { scope = 'local' })
    116        -- Use MsgArea except in the msg window. Hide Search highlighting except in the pager.
    117        local search_hide = 'Search:,CurSearch:,IncSearch:'
    118        local hl = 'Normal:MsgArea,' .. search_hide
    119        if type == 'pager' then
    120          hl = 'Normal:MsgArea'
    121        elseif type == 'msg' then
    122          hl = search_hide
    123        end
    124        api.nvim_set_option_value('winhighlight', hl, { scope = 'local' })
    125      end)
    126      api.nvim_buf_set_name(M.bufs[type], ('[%s]'):format(type:sub(1, 1):upper() .. type:sub(2)))
    127      -- Fire FileType with window context to let the user reconfigure local options.
    128      vim._with({ win = M.wins[type] }, function()
    129        api.nvim_set_option_value('filetype', type, { scope = 'local' })
    130      end)
    131 
    132      if type == 'pager' then
    133        -- Close pager with `q`, same as `checkhealth`
    134        api.nvim_buf_set_keymap(M.bufs.pager, 'n', 'q', '<Cmd>wincmd c<CR>', {})
    135      elseif type == M.cfg.msg.target then
    136        M.msg.prev_msg = '' -- Will no longer be visible.
    137      end
    138    end
    139  end
    140  tab = curtab
    141 end
    142 
    143 local function ui_callback(redraw_msg, event, ...)
    144  local handler = M.msg[event] or M.cmd[event] --[[@as function]]
    145  M.check_targets()
    146  handler(...)
    147  -- Cmdline mode, non-fast message and non-empty showcmd require an immediate redraw.
    148  if M.cmd[event] or redraw_msg or (event == 'msg_showcmd' and select(1, ...)[1]) then
    149    M.redrawing = true
    150    api.nvim__redraw({
    151      flush = handler ~= M.cmd.cmdline_hide or nil,
    152      cursor = handler == M.cmd[event] and true or nil,
    153      win = handler == M.cmd[event] and M.wins.cmd or nil,
    154    })
    155    M.redrawing = false
    156  end
    157 end
    158 local scheduled_ui_callback = vim.schedule_wrap(ui_callback)
    159 
    160 ---@nodoc
    161 function M.enable(opts)
    162  vim.validate('opts', opts, 'table', true)
    163  M.cfg = vim.tbl_deep_extend('keep', opts, M.cfg)
    164  M.cfg.msg.target = type(M.cfg.msg.targets) == 'string' and M.cfg.msg.targets or M.cfg.msg.target
    165  M.cfg.msg.targets = type(M.cfg.msg.targets) == 'table' and M.cfg.msg.targets or {}
    166  if #vim.api.nvim_list_uis() == 0 then
    167    return -- Don't prevent stdout messaging when no UIs are attached.
    168  end
    169 
    170  if M.cfg.enable == false then
    171    -- Detach and cleanup windows, buffers and autocommands.
    172    for _, win in pairs(M.wins) do
    173      if api.nvim_win_is_valid(win) then
    174        api.nvim_win_close(win, true)
    175      end
    176    end
    177    for _, buf in pairs(M.bufs) do
    178      if api.nvim_buf_is_valid(buf) then
    179        api.nvim_buf_delete(buf, {})
    180      end
    181    end
    182    api.nvim_clear_autocmds({ group = M.augroup })
    183    vim.ui_detach(M.ns)
    184    return
    185  end
    186 
    187  M.cmd = require('vim._core.ui2.cmdline')
    188  M.msg = require('vim._core.ui2.messages')
    189  vim.ui_attach(M.ns, { ext_messages = true, set_cmdheight = false }, function(event, ...)
    190    if not (M.msg[event] or M.cmd[event]) then
    191      return
    192    end
    193    -- Ensure cmdline is placed after a scheduled message in block mode.
    194    if vim.in_fast_event() or (event == 'cmdline_show' and M.cmd.srow > 0) then
    195      scheduled_ui_callback(false, event, ...)
    196    else
    197      ui_callback(event == 'msg_show', event, ...)
    198    end
    199    return true
    200  end)
    201 
    202  -- The visibility and appearance of the cmdline and message window is
    203  -- dependent on some option values. Reconfigure windows when option value
    204  -- has changed and after VimEnter when the user configured value is known.
    205  -- TODO: Reconsider what is needed when this module is enabled by default early in startup.
    206  local function check_cmdheight(value)
    207    M.check_targets()
    208    -- 'cmdheight' set; (un)hide cmdline window and set its height.
    209    local cfg = { height = math.max(value, 1), hide = value == 0 }
    210    api.nvim_win_set_config(M.wins.cmd, cfg)
    211    M.cmdheight = value
    212  end
    213 
    214  if vim.v.vim_did_enter == 0 then
    215    vim.schedule(function()
    216      check_cmdheight(vim.o.cmdheight)
    217    end)
    218  end
    219 
    220  api.nvim_create_autocmd('OptionSet', {
    221    group = M.augroup,
    222    pattern = { 'cmdheight', 'laststatus' },
    223    callback = function(ev)
    224      if ev.match == 'cmdheight' then
    225        check_cmdheight(vim.v.option_new)
    226      end
    227      M.msg.set_pos()
    228    end,
    229    desc = 'Set cmdline and message window dimensions for changed option values.',
    230  })
    231 
    232  api.nvim_create_autocmd({ 'VimResized', 'TabEnter' }, {
    233    group = M.augroup,
    234    callback = function()
    235      M.msg.set_pos()
    236    end,
    237    desc = 'Set cmdline and message window dimensions after shell resize or tabpage change.',
    238  })
    239 
    240  api.nvim_create_autocmd('WinEnter', {
    241    callback = function()
    242      local win = api.nvim_get_current_win()
    243      if vim.tbl_contains(M.wins, win) and api.nvim_win_get_config(win).hide then
    244        vim.cmd.wincmd('p')
    245      end
    246    end,
    247    desc = 'Make sure hidden UI window is never current.',
    248  })
    249 end
    250 
    251 return M