Newer
Older
dotfiles / .config / lite-xl / plugins / lsp / listbox.lua
-- A configurable listbox that can be used as tooltip, selection box and
-- selection box with fuzzy search, this may change in the future.
--
-- @note This code is a readaptation of autocomplete plugin from rxi :)
--
-- TODO implement select box with fuzzy search

local core = require "core"
local common = require "core.common"
local command = require "core.command"
local style = require "core.style"
local keymap = require "core.keymap"
local util = require "plugins.lsp.util"
local RootView = require "core.rootview"
local DocView = require "core.docview"

---@class lsp.listbox.item
---@field text string
---@field info string
---@field on_draw fun(item:lsp.listbox.item, x:number, y:number, calc_only?:boolean):number

---@alias lsp.listbox.callback fun(doc: core.doc, item: lsp.listbox.item)

---@class lsp.listbox.signature_param
---@field label string

---@class lsp.listbox.signature
---@field label string
---@field activeParameter? integer
---@field activeSignature? integer
---@field parameters lsp.listbox.signature_param[]

---@class lsp.listbox.signature_list
---@field activeParameter? integer
---@field activeSignature? integer
---@field signatures lsp.listbox.signature[]

---@class lsp.listbox.position
---@field line integer
---@field col integer

---@class lsp.listbox
local listbox = {}

---@class lsp.listbox.settings
---@field items lsp.listbox.item[]
---@field shown_items lsp.listbox.item[]
---@field selected_item_idx integer
---@field show_items_count boolean
---@field max_height integer
---@field active_view core.docview | nil
---@field line integer | nil
---@field col integer | nil
---@field last_line integer | nil
---@field last_col integer | nil
---@field callback lsp.listbox.callback | nil
---@field is_list boolean
---@field has_fuzzy_search boolean
---@field above_text boolean
local settings = {
  items = {},
  shown_items = {},
  selected_item_idx = 1,
  show_items_count = false,
  max_height = 6,
  active_view = nil,
  line = nil,
  col = nil,
  last_line = nil,
  last_col = nil,
  callback = nil,
  is_list = false,
  has_fuzzy_search = false,
  above_text = false,
}

local mt = { __tostring = function(t) return t.text end }

--------------------------------------------------------------------------------
-- Private functions
--------------------------------------------------------------------------------

---@return core.docview | nil
local function get_active_view()
  if getmetatable(core.active_view) == DocView then
    return core.active_view
  end
end

---@param active_view core.docview
---@return number x
---@return number y
---@return number width
---@return number height
local function get_suggestions_rect(active_view)
  if #settings.shown_items == 0 then
    listbox.hide()
    return 0, 0, 0, 0
  end

  local line, col
  if settings.line then
    line, col = settings.line, settings.col
  else
    line, col = active_view.doc:get_selection()
  end

  -- Validate line against current view because there can be cases
  -- when user rapidly switches between tabs causing the deferred draw
  -- to be called late and the current document view already changed.
  if line > #active_view.doc.lines then
    listbox.hide()
    return 0, 0, 0, 0
  end

  local x, y = active_view:get_line_screen_position(line)

  -- This function causes tokenizer to fail if given line is greater than
  -- the amount of lines the document holds, so validation above is needed.
  x = x + active_view:get_col_x_offset(line, col)

  local padding_x = style.padding.x
  local padding_y = style.padding.y

  if settings.above_text and line > 1 then
    y = y - active_view:get_line_height() - style.padding.y
  else
    y = y + active_view:get_line_height() + style.padding.y
  end

  local font = settings.is_list and active_view:get_font() or style.font
  local text_height = font:get_height()

  local max_width = 0
  for _, item in ipairs(settings.shown_items) do
    local w = 0
    if item.on_draw then
      w = item.on_draw(item, 0, 0, true)
    else
      w = font:get_width(item.text)
      if item.info then
        w = w + style.font:get_width(item.info) + style.padding.x
      end
    end
    max_width = math.max(max_width, w)
  end

  local max_items = #settings.shown_items
  if settings.is_list and max_items > settings.max_height then
    max_items = settings.max_height
  end

  -- additional line to display total items
  if settings.show_items_count then
    max_items = max_items + 1
  end

  if max_width < 150 then
    max_width = 150
  end

  local height = max_items * (text_height + (padding_y/4)) + (padding_y*2)
  local width = max_width + padding_x * 2

  x = x - padding_x
  y = y - padding_y

  local win_w = core.root_view.size.x
  if (width/win_w*100) >= 85 and (width+style.padding.x*4) < win_w then
    x = win_w - width - style.padding.x*2
  elseif width > (win_w - x) then
    x = x - (width - (win_w - x))
    if x < 0 then
      x = 0
    end
  end

  return x, y, width, height
end

---@param av core.docview
local function draw_listbox(av)
  if #settings.shown_items <= 0 then
    return
  end

  -- draw background rect
  local rx, ry, rw, rh = get_suggestions_rect(av)

  -- draw border
  if not settings.is_list then
    local border_width = 1
    renderer.draw_rect(
      rx - border_width,
      ry - border_width,
      rw + (border_width * 2),
      rh + (border_width * 2),
      style.divider
    )
  end

  renderer.draw_rect(rx, ry, rw, rh, style.background3)

  local padding_x = style.padding.x
  local padding_y = style.padding.y

  -- draw text
  local font = settings.is_list and av:get_font() or style.font
  local line_height = font:get_height() + (padding_y / 4)
  local y = ry + padding_y

  local max_height = settings.max_height

  local show_count = (
    #settings.shown_items <= max_height or not settings.is_list
    ) and
    #settings.shown_items or max_height

  local start_index = settings.selected_item_idx > max_height and
    (settings.selected_item_idx-(max_height-1)) or 1

  for i=start_index, start_index+show_count-1, 1 do
    if not settings.shown_items[i] then
      break
    end

    local item = settings.shown_items[i]

    if item.on_draw then
      item.on_draw(item, rx + padding_x, y)
    else
      local color = (i == settings.selected_item_idx and settings.is_list) and
        style.accent or style.text

      common.draw_text(
        font, color, item.text, "left",
        rx + padding_x, y, rw, line_height
      )

      if item.info then
        color = (i == settings.selected_item_idx and settings.is_list) and
          style.text or style.dim

        common.draw_text(
          style.font, color, item.info, "right",
          rx, y, rw - padding_x, line_height
        )
      end
    end
    y = y + line_height
  end

  if settings.show_items_count then
    renderer.draw_rect(rx, y, rw, 2, style.caret)
    renderer.draw_rect(rx, y+2, rw, line_height, style.background)
    common.draw_text(
      style.font,
      style.accent,
      "Items",
      "left",
      rx + padding_x, y, rw, line_height
    )
    common.draw_text(
      style.font,
      style.accent,
      tostring(settings.selected_item_idx) .. "/" .. tostring(#settings.shown_items),
      "right",
      rx, y, rw - padding_x, line_height
    )
  end
end

---Set the document position where the listbox will be draw.
---@param position? lsp.listbox.position
local function set_position(position)
  if type(position) == "table" then
    settings.line = position.line
    settings.col = position.col
  else
    settings.line = nil
    settings.col = nil
  end
end

--------------------------------------------------------------------------------
-- Public functions
--------------------------------------------------------------------------------

---@param elements lsp.listbox.item[]
function listbox.add(elements)
  if type(elements) == "table" and #elements > 0 then
    local items = {}
    for _, element in pairs(elements) do
      table.insert(items, setmetatable(element, mt))
    end
    settings.items = items
  end
end

function listbox.clear()
  settings.items = {}
  settings.selected_item_idx = 1
  settings.shown_items = {}
  settings.line = nil
  settings.col = nil
end

---@param element lsp.listbox.item
function listbox.append(element)
  table.insert(settings.items, setmetatable(element, mt))
end

function listbox.hide()
  settings.active_view = nil
  settings.line = nil
  settings.col = nil
  settings.selected_item_idx = 1
  settings.shown_items = {}
  core.redraw = true
end

---@param is_list? boolean
---@param position? lsp.listbox.position
function listbox.show(is_list, position)
  set_position(position)

  local active_view = get_active_view()
  if active_view then
    settings.active_view = active_view
    settings.last_line, settings.last_col = active_view.doc:get_selection()
    if settings.items and #settings.items > 0 then
      settings.is_list = is_list or false
      settings.shown_items = settings.items
    end
  end
  core.redraw = true
end

---@param text string
---@param position? lsp.listbox.position
function listbox.show_text(text, position)
  if text and type("text") == "string" then
    local win_w = core.root_view.size.x - style.padding.x * 6
    text = util.wrap_text(text, style.font, win_w)

    local items = {}
    for result in string.gmatch(text.."\n", "(.-)\n") do
      table.insert(items, {text = result})
    end
    listbox.add(items)
  end

  listbox.show(false, position)
end

---@param items lsp.listbox.item[]
---@param callback lsp.listbox.callback
---@param position? lsp.listbox.position
function listbox.show_list(items, callback, position)
  listbox.add(items)

  if callback then
    settings.callback = callback
  end

  listbox.show(true, position)
end

---@param signatures lsp.listbox.signature_list
---@param position? lsp.listbox.position
function listbox.show_signatures(signatures, position)
  local active_parameter = nil
  local active_signature = nil

  if signatures.activeParameter then
    active_parameter = signatures.activeParameter + 1
  end

  if signatures.activeSignature then
    active_signature = signatures.activeSignature + 1
  end

  local signatures_count = #signatures.signatures

  local items = {}
  for index, signature in ipairs(signatures.signatures) do
    table.insert(items, {
      text = signature.label,
      signature = signature,
      on_draw = function(item, x, y, calc_only)
        local width = 0
        local height = style.font:get_height()

        if item.signature.parameters then
          if signatures_count > 1 then
            if index == active_signature then
              width = style.font:get_width("> ")
            else
              width = style.font:get_width("> ")
              x = x + style.font:get_width("> ")
            end
          end

          width = width
            + style.font:get_width("(")
            + style.font:get_width(")")

          if not calc_only then
            if signatures_count > 1 and index == active_signature then
              x = renderer.draw_text(style.font, "> ", x, y, style.caret)
            end
            x = renderer.draw_text(style.font, "(", x, y, style.text)
          end

          local params_count = #item.signature.parameters
          for pindex, param in ipairs(item.signature.parameters) do
            local label = ""
            if type(param.label) == "table" then
              label = signature.label:sub(param.label[1]+1, param.label[2])
            else
              label = param.label
            end
            if label and pindex ~= params_count then
              label = label .. ", "
            end
            width = width + style.font:get_width(label)
            if not calc_only then
              local color = style.text
              if
                (
                  signature.activeParameter
                  and
                  (signature.activeParameter + 1) == pindex
                )
                or
                (index == active_signature and active_parameter == pindex)
              then
                color = style.accent
              end
              x = renderer.draw_text(
                style.font,
                label,
                x, y,
                color
              )
            end
          end

          if not calc_only then
            renderer.draw_text(style.font, ")", x, y, style.text)
          end
        else
          width = style.font:get_width(item.signature.label)
          if not calc_only then
            renderer.draw_text(
              style.font,
              item.signature.label,
              x, y,
              style.text
            )
          end
        end
        return width, width > 0 and height or 0
      end
    })
  end

  listbox.add(items)

  listbox.show(false, position)
end

function listbox.toggle_above(enable)
  if enable then
    settings.above_text = true
  else
    settings.above_text = false
  end
end

--------------------------------------------------------------------------------
-- Patch event logic into RootView
--------------------------------------------------------------------------------
local root_view_update = RootView.update
local root_view_draw = RootView.draw

RootView.update = function(...)
  root_view_update(...)

  if not settings.active_view then return end

  local active_view = get_active_view()
  if active_view then
    -- reset suggestions if caret was moved or not same active view
    local line, col = active_view.doc:get_selection()
    if
      settings.active_view ~= active_view
      or
      line ~= settings.last_line or col ~= settings.last_col
    then
      listbox.hide()
    end
  else
    listbox.hide()
  end
end

RootView.draw = function(...)
  if settings.active_view then
    local active_view = get_active_view()
    if
      active_view and settings.active_view == active_view
      and
      #settings.shown_items > 0
    then
      -- draw suggestions box after everything else
      core.root_view:defer_draw(draw_listbox, active_view)
    end
  end
  root_view_draw(...)
end

--------------------------------------------------------------------------------
-- Commands
--------------------------------------------------------------------------------
local function predicate()
  local av = get_active_view()
  return av and settings.active_view and #settings.shown_items > 0, av
end

command.add(predicate, {
  ["listbox:select"] = function(av)
    ---@cast av core.docview
    if settings.is_list then
      local doc = av.doc
      local item = settings.shown_items[settings.selected_item_idx]

      if settings.callback then
        settings.callback(doc, item)
      end

      listbox.hide()
    end
  end,

  ["listbox:previous"] = function()
    if settings.is_list then
      settings.selected_item_idx = math.max(settings.selected_item_idx - 1, 1)
    else
      listbox.hide()
    end
  end,

  ["listbox:next"] = function()
    if settings.is_list then
      settings.selected_item_idx = math.min(
        settings.selected_item_idx + 1, #settings.shown_items
      )
    else
      listbox.hide()
    end
  end,

  ["listbox:cancel"] = function()
    listbox.hide()
  end,
})

--------------------------------------------------------------------------------
-- Keymaps
--------------------------------------------------------------------------------
keymap.add {
  ["tab"]    = "listbox:select",
  ["up"]     = "listbox:previous",
  ["down"]   = "listbox:next",
  ["escape"] = "listbox:cancel",
}


return listbox