aboutsummaryrefslogtreecommitdiffstats
path: root/lua/mason-core/ui/display.lua
diff options
context:
space:
mode:
authorWilliam Boman <william@redwill.se>2022-07-08 18:34:38 +0200
committerGitHub <noreply@github.com>2022-07-08 18:34:38 +0200
commit976aa4fbee8a070f362cab6f6ec84e9251a90cf9 (patch)
tree5e8d9c9c59444a25c7801b8f39763c4ba6e1f76d /lua/mason-core/ui/display.lua
parentfeat: add gotests, gomodifytags, impl (#28) (diff)
downloadmason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.gz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.bz2
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.lz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.xz
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.tar.zst
mason-976aa4fbee8a070f362cab6f6ec84e9251a90cf9.zip
refactor: add mason-schemas and mason-core modules (#29)
* refactor: add mason-schemas and move generated filetype map to mason-lspconfig * refactor: add mason-core module
Diffstat (limited to 'lua/mason-core/ui/display.lua')
-rw-r--r--lua/mason-core/ui/display.lua507
1 files changed, 507 insertions, 0 deletions
diff --git a/lua/mason-core/ui/display.lua b/lua/mason-core/ui/display.lua
new file mode 100644
index 00000000..47368079
--- /dev/null
+++ b/lua/mason-core/ui/display.lua
@@ -0,0 +1,507 @@
+local log = require "mason-core.log"
+local state = require "mason-core.ui.state"
+
+local M = {}
+
+---@generic T
+---@param debounced_fn fun(arg1: T)
+---@return fun(arg1: T)
+local function debounced(debounced_fn)
+ local queued = false
+ local last_arg = nil
+ return function(a)
+ last_arg = a
+ if queued then
+ return
+ end
+ queued = true
+ vim.schedule(function()
+ debounced_fn(last_arg)
+ queued = false
+ last_arg = nil
+ end)
+ end
+end
+
+---@param line string
+---@param render_context RenderContext
+local function get_styles(line, render_context)
+ local indentation = 0
+
+ for i = 1, #render_context.applied_block_styles do
+ local styles = render_context.applied_block_styles[i]
+ for j = 1, #styles do
+ local style = styles[j]
+ if style == "INDENT" then
+ indentation = indentation + 2
+ elseif style == "CENTERED" then
+ local padding = math.floor((render_context.viewport_context.win_width - #line) / 2)
+ indentation = math.max(0, padding) -- CENTERED overrides any already applied indentation
+ end
+ end
+ end
+
+ return {
+ indentation = indentation,
+ }
+end
+
+---@param viewport_context ViewportContext
+---@param node INode
+---@param _render_context RenderContext|nil
+---@param _output RenderOutput|nil
+local function render_node(viewport_context, node, _render_context, _output)
+ ---@class RenderContext
+ ---@field viewport_context ViewportContext
+ ---@field applied_block_styles CascadingStyle[]
+ local render_context = _render_context
+ or {
+ viewport_context = viewport_context,
+ applied_block_styles = {},
+ }
+ ---@class RenderHighlight
+ ---@field hl_group string
+ ---@field line number
+ ---@field col_start number
+ ---@field col_end number
+
+ ---@class RenderKeybind
+ ---@field line number
+ ---@field key string
+ ---@field effect string
+ ---@field payload any
+
+ ---@class RenderDiagnostic
+ ---@field line number
+ ---@field diagnostic {message: string, severity: integer, source: string|nil}
+
+ ---@class RenderOutput
+ ---@field lines string[] @The buffer lines.
+ ---@field virt_texts string[][] @List of (text, highlight) tuples.
+ ---@field highlights RenderHighlight[]
+ ---@field keybinds RenderKeybind[]
+ ---@field diagnostics RenderDiagnostic[]
+ ---@field sticky_cursors { line_map: table<number, string>, id_map: table<string, number> }
+ local output = _output
+ or {
+ lines = {},
+ virt_texts = {},
+ highlights = {},
+ keybinds = {},
+ diagnostics = {},
+ sticky_cursors = { line_map = {}, id_map = {} },
+ }
+
+ if node.type == "VIRTUAL_TEXT" then
+ output.virt_texts[#output.virt_texts + 1] = {
+ line = #output.lines - 1,
+ content = node.virt_text,
+ }
+ elseif node.type == "HL_TEXT" then
+ for i = 1, #node.lines do
+ local line = node.lines[i]
+ local line_highlights = {}
+ local full_line = ""
+ for j = 1, #line do
+ local span = line[j]
+ local content, hl_group = span[1], span[2]
+ local col_start = #full_line
+ full_line = full_line .. content
+ if hl_group ~= "" then
+ line_highlights[#line_highlights + 1] = {
+ hl_group = hl_group,
+ line = #output.lines,
+ col_start = col_start,
+ col_end = col_start + #content,
+ }
+ end
+ end
+
+ local active_styles = get_styles(full_line, render_context)
+
+ -- apply indentation
+ full_line = (" "):rep(active_styles.indentation) .. full_line
+ for j = 1, #line_highlights do
+ local highlight = line_highlights[j]
+ highlight.col_start = highlight.col_start + active_styles.indentation
+ highlight.col_end = highlight.col_end + active_styles.indentation
+ output.highlights[#output.highlights + 1] = highlight
+ end
+
+ output.lines[#output.lines + 1] = full_line
+ end
+ elseif node.type == "NODE" or node.type == "CASCADING_STYLE" then
+ if node.type == "CASCADING_STYLE" then
+ render_context.applied_block_styles[#render_context.applied_block_styles + 1] = node.styles
+ end
+ for i = 1, #node.children do
+ render_node(viewport_context, node.children[i], render_context, output)
+ end
+ if node.type == "CASCADING_STYLE" then
+ render_context.applied_block_styles[#render_context.applied_block_styles] = nil
+ end
+ elseif node.type == "KEYBIND_HANDLER" then
+ output.keybinds[#output.keybinds + 1] = {
+ line = node.is_global and -1 or #output.lines,
+ key = node.key,
+ effect = node.effect,
+ payload = node.payload,
+ }
+ elseif node.type == "DIAGNOSTICS" then
+ output.diagnostics[#output.diagnostics + 1] = {
+ line = #output.lines,
+ message = node.diagnostic.message,
+ severity = node.diagnostic.severity,
+ source = node.diagnostic.source,
+ }
+ elseif node.type == "STICKY_CURSOR" then
+ output.sticky_cursors.id_map[node.id] = #output.lines
+ output.sticky_cursors.line_map[#output.lines] = node.id
+ end
+
+ return output
+end
+
+-- exported for tests
+M._render_node = render_node
+
+---@alias WindowOpts {effects: table<string, fun()>, highlight_groups: table<string, table>, border: string|table}
+
+---@param opts WindowOpenOpts
+---@param sizes_only boolean @Whether to only return properties that control the window size.
+local function create_popup_window_opts(opts, sizes_only)
+ local win_height = vim.o.lines - vim.o.cmdheight - 2 -- Add margin for status and buffer line
+ local win_width = vim.o.columns
+ local height = math.floor(win_height * 0.9)
+ local width = math.floor(win_width * 0.8)
+ local popup_layout = {
+ height = height,
+ width = width,
+ row = math.floor((win_height - height) / 2),
+ col = math.floor((win_width - width) / 2),
+ relative = "editor",
+ style = "minimal",
+ zindex = 50,
+ }
+
+ if not sizes_only then
+ popup_layout.border = opts.border
+ end
+
+ return popup_layout
+end
+
+---@param name string @Human readable identifier.
+---@param filetype string
+function M.new_view_only_win(name, filetype)
+ local namespace = vim.api.nvim_create_namespace(("installer_%s"):format(name))
+ local bufnr, renderer, mutate_state, get_state, unsubscribe, win_id, window_mgmt_augroup, autoclose_augroup, registered_keymaps, registered_keybinds, registered_effect_handlers
+ local has_initiated = false
+ ---@type WindowOpts
+ local window_opts = {}
+
+ local function delete_win_buf()
+ -- We queue the win_buf to be deleted in a schedule call, otherwise when used with folke/which-key (and
+ -- set timeoutlen=0) we run into a weird segfault.
+ -- It should probably be unnecessary once https://github.com/neovim/neovim/issues/15548 is resolved
+ vim.schedule(function()
+ if win_id and vim.api.nvim_win_is_valid(win_id) then
+ log.trace "Deleting window"
+ vim.api.nvim_win_close(win_id, true)
+ end
+ if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
+ log.trace "Deleting buffer"
+ vim.api.nvim_buf_delete(bufnr, { force = true })
+ end
+ end)
+ end
+
+ ---@param line number
+ ---@param key string
+ local function call_effect_handler(line, key)
+ local line_keybinds = registered_keybinds[line]
+ if line_keybinds then
+ local keybind = line_keybinds[key]
+ if keybind then
+ local effect_handler = registered_effect_handlers[keybind.effect]
+ if effect_handler then
+ log.fmt_trace("Calling handler for effect %s on line %d for key %s", keybind.effect, line, key)
+ effect_handler { payload = keybind.payload }
+ return true
+ end
+ end
+ end
+ return false
+ end
+
+ local function dispatch_effect(key)
+ local line = vim.api.nvim_win_get_cursor(0)[1]
+ log.fmt_trace("Dispatching effect on line %d, key %s, bufnr %s", line, key, bufnr)
+ call_effect_handler(line, key) -- line keybinds
+ call_effect_handler(-1, key) -- global keybinds
+ end
+
+ local output
+ local draw = function(view)
+ local win_valid = win_id ~= nil and vim.api.nvim_win_is_valid(win_id)
+ local buf_valid = bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
+ log.fmt_trace("got bufnr=%s", bufnr)
+ log.fmt_trace("got win_id=%s", win_id)
+
+ if not win_valid or not buf_valid then
+ -- the window has been closed or the buffer is somehow no longer valid
+ unsubscribe(true)
+ log.trace("Buffer or window is no longer valid", win_id, bufnr)
+ return
+ end
+
+ local win_width = vim.api.nvim_win_get_width(win_id)
+ ---@class ViewportContext
+ local viewport_context = {
+ win_width = win_width,
+ }
+ local cursor_pos_pre_render = vim.api.nvim_win_get_cursor(win_id)
+ local sticky_cursor
+ if output then
+ sticky_cursor = output.sticky_cursors.line_map[cursor_pos_pre_render[1]]
+ end
+
+ output = render_node(viewport_context, view)
+ local lines, virt_texts, highlights, keybinds, diagnostics =
+ output.lines, output.virt_texts, output.highlights, output.keybinds, output.diagnostics
+
+ -- set line contents
+ vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
+ vim.api.nvim_buf_set_option(bufnr, "modifiable", true)
+ vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
+ vim.api.nvim_buf_set_option(bufnr, "modifiable", false)
+
+ -- restore sticky cursor position
+ if sticky_cursor then
+ local new_sticky_cursor_line = output.sticky_cursors.id_map[sticky_cursor]
+ if new_sticky_cursor_line and new_sticky_cursor_line ~= cursor_pos_pre_render then
+ vim.api.nvim_win_set_cursor(win_id, { new_sticky_cursor_line, cursor_pos_pre_render[2] })
+ end
+ end
+
+ -- set virtual texts
+ for i = 1, #virt_texts do
+ local virt_text = virt_texts[i]
+ vim.api.nvim_buf_set_extmark(bufnr, namespace, virt_text.line, 0, {
+ virt_text = virt_text.content,
+ })
+ end
+
+ -- set diagnostics
+ vim.diagnostic.set(
+ namespace,
+ bufnr,
+ vim.tbl_map(function(diagnostic)
+ return {
+ lnum = diagnostic.line - 1,
+ col = 0,
+ message = diagnostic.message,
+ severity = diagnostic.severity,
+ source = diagnostic.source,
+ }
+ end, diagnostics),
+ {
+ signs = false,
+ }
+ )
+
+ -- set highlights
+ for i = 1, #highlights do
+ local highlight = highlights[i]
+ vim.api.nvim_buf_add_highlight(
+ bufnr,
+ namespace,
+ highlight.hl_group,
+ highlight.line,
+ highlight.col_start,
+ highlight.col_end
+ )
+ end
+
+ -- set keybinds
+ registered_keybinds = {}
+ for i = 1, #keybinds do
+ local keybind = keybinds[i]
+ if not registered_keybinds[keybind.line] then
+ registered_keybinds[keybind.line] = {}
+ end
+ registered_keybinds[keybind.line][keybind.key] = keybind
+ if not registered_keymaps[keybind.key] then
+ registered_keymaps[keybind.key] = true
+ vim.keymap.set("n", keybind.key, function()
+ dispatch_effect(keybind.key)
+ end, {
+ buffer = bufnr,
+ nowait = true,
+ silent = true,
+ })
+ end
+ end
+ end
+
+ ---@param opts WindowOpenOpts
+ local function open(opts)
+ bufnr = vim.api.nvim_create_buf(false, true)
+ win_id = vim.api.nvim_open_win(bufnr, true, create_popup_window_opts(opts, false))
+
+ registered_effect_handlers = window_opts.effects
+ registered_keybinds = {}
+ registered_keymaps = {}
+
+ local buf_opts = {
+ modifiable = false,
+ swapfile = false,
+ textwidth = 0,
+ buftype = "nofile",
+ bufhidden = "wipe",
+ buflisted = false,
+ filetype = filetype,
+ undolevels = -1,
+ }
+
+ local win_opts = {
+ number = false,
+ relativenumber = false,
+ wrap = false,
+ spell = false,
+ foldenable = false,
+ signcolumn = "no",
+ colorcolumn = "",
+ cursorline = true,
+ }
+
+ -- window options
+ for key, value in pairs(win_opts) do
+ vim.api.nvim_win_set_option(win_id, key, value)
+ end
+
+ -- buffer options
+ for key, value in pairs(buf_opts) do
+ vim.api.nvim_buf_set_option(bufnr, key, value)
+ end
+
+ vim.cmd [[ syntax clear ]]
+
+ window_mgmt_augroup = vim.api.nvim_create_augroup("MasonWindowMgmt", {})
+ autoclose_augroup = vim.api.nvim_create_augroup("MasonWindow", {})
+
+ vim.api.nvim_create_autocmd({ "VimResized" }, {
+ group = window_mgmt_augroup,
+ buffer = bufnr,
+ callback = function()
+ if vim.api.nvim_win_is_valid(win_id) then
+ draw(renderer(get_state()))
+ vim.api.nvim_win_set_config(win_id, create_popup_window_opts(window_opts, true))
+ end
+ end,
+ })
+
+ vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, {
+ group = autoclose_augroup,
+ buffer = bufnr,
+ callback = function()
+ -- Schedule is done because otherwise the window wont actually close in some cases (for example if
+ -- you're loading another buffer into it)
+ vim.schedule(function()
+ if vim.api.nvim_win_is_valid(win_id) then
+ vim.api.nvim_win_close(win_id, true)
+ end
+ end)
+ end,
+ })
+
+ local win_enter_aucmd
+ win_enter_aucmd = vim.api.nvim_create_autocmd({ "WinEnter" }, {
+ group = autoclose_augroup,
+ pattern = "*",
+ callback = function()
+ local buftype = vim.api.nvim_buf_get_option(0, "buftype")
+ -- This allows us to keep the floating window open for things like diagnostic popups, UI inputs รก la dressing.nvim, etc.
+ if buftype ~= "prompt" and buftype ~= "nofile" then
+ delete_win_buf()
+ vim.api.nvim_del_autocmd(win_enter_aucmd)
+ end
+ end,
+ })
+
+ return win_id
+ end
+
+ return {
+ ---@param _renderer fun(state: table): table
+ view = function(_renderer)
+ renderer = _renderer
+ end,
+ ---@param _effects table<string, fun()>
+ effects = function(_effects)
+ window_opts.effects = _effects
+ end,
+ ---@generic T : table
+ ---@param initial_state T
+ ---@return fun(mutate_fn: fun(current_state: T)), fun(): T
+ state = function(initial_state)
+ mutate_state, get_state, unsubscribe = state.create_state_container(
+ initial_state,
+ debounced(function(new_state)
+ draw(renderer(new_state))
+ end)
+ )
+
+ -- we don't need to subscribe to state changes until the window is actually opened
+ unsubscribe(true)
+
+ return mutate_state, get_state
+ end,
+ ---@param opts WindowOpts
+ init = function(opts)
+ assert(renderer ~= nil, "No view function has been registered. Call .view() before .init().")
+ assert(unsubscribe ~= nil, "No state has been registered. Call .state() before .init().")
+ window_opts = opts
+ if opts.highlight_groups then
+ for hl_name, args in pairs(opts.highlight_groups) do
+ vim.api.nvim_set_hl(0, hl_name, args)
+ end
+ end
+ has_initiated = true
+ end,
+ ---@alias WindowOpenOpts { border: string | table }
+ ---@type fun(opts: WindowOpenOpts)
+ open = vim.schedule_wrap(function(opts)
+ log.trace "Opening window"
+ assert(has_initiated, "Display has not been initiated, cannot open.")
+ if win_id and vim.api.nvim_win_is_valid(win_id) then
+ -- window is already open
+ return
+ end
+ unsubscribe(false)
+ open(opts)
+ draw(renderer(get_state()))
+ end),
+ ---@type fun()
+ close = vim.schedule_wrap(function()
+ assert(has_initiated, "Display has not been initiated, cannot close.")
+ unsubscribe(true)
+ log.fmt_trace("Closing window win_id=%s, bufnr=%s", win_id, bufnr)
+ delete_win_buf()
+ vim.api.nvim_del_augroup_by_id(window_mgmt_augroup)
+ vim.api.nvim_del_augroup_by_id(autoclose_augroup)
+ end),
+ ---@param pos number[] @(row, col) tuple
+ set_cursor = function(pos)
+ assert(win_id ~= nil, "Window has not been opened, cannot set cursor.")
+ return vim.api.nvim_win_set_cursor(win_id, pos)
+ end,
+ ---@return number[] @(row, col) tuple
+ get_cursor = function()
+ assert(win_id ~= nil, "Window has not been opened, cannot get cursor.")
+ return vim.api.nvim_win_get_cursor(win_id)
+ end,
+ }
+end
+
+return M