Files
deb-lite-xl/data/plugins/autocomplete.lua
T
Guldoman cf29a6a45f Allow command predicates to manage parameters passed to the commands
When a command is performed with parameters, those are now passed to the
predicate.
The predicate can then return, after the validity boolean, any other
value that will then be passed to the actual command.
If the predicate only returns the validity boolean, the original
parameters are passed through to the actual command.

This allows predicates to manipulate the received parameters, and allows
them to pass the result of an expensive computation to the actual
command, which won't have to recalculate it.

String and table predicates will now also return `core.active_view`.
2022-08-16 22:13:16 +02:00

676 lines
17 KiB
Lua

-- mod-version:3
local core = require "core"
local common = require "core.common"
local config = require "core.config"
local command = require "core.command"
local style = require "core.style"
local keymap = require "core.keymap"
local translate = require "core.doc.translate"
local RootView = require "core.rootview"
local DocView = require "core.docview"
local Doc = require "core.doc"
config.plugins.autocomplete = common.merge({
-- Amount of characters that need to be written for autocomplete
min_len = 3,
-- The max amount of visible items
max_height = 6,
-- The max amount of scrollable items
max_suggestions = 100,
-- Maximum amount of symbols to cache per document
max_symbols = 4000,
-- Font size of the description box
desc_font_size = 12,
-- The config specification used by gui generators
config_spec = {
name = "Autocomplete",
{
label = "Minimum Length",
description = "Amount of characters that need to be written for autocomplete to popup.",
path = "min_len",
type = "number",
default = 3,
min = 1,
max = 5
},
{
label = "Maximum Height",
description = "The maximum amount of visible items.",
path = "max_height",
type = "number",
default = 6,
min = 1,
max = 20
},
{
label = "Maximum Suggestions",
description = "The maximum amount of scrollable items.",
path = "max_suggestions",
type = "number",
default = 100,
min = 10,
max = 10000
},
{
label = "Maximum Symbols",
description = "Maximum amount of symbols to cache per document.",
path = "max_symbols",
type = "number",
default = 4000,
min = 1000,
max = 10000
},
{
label = "Description Font Size",
description = "Font size of the description box.",
path = "desc_font_size",
type = "number",
default = 12,
min = 8
}
}
}, config.plugins.autocomplete)
local autocomplete = {}
autocomplete.map = {}
autocomplete.map_manually = {}
autocomplete.on_close = nil
-- Flag that indicates if the autocomplete box was manually triggered
-- with the autocomplete.complete() function to prevent the suggestions
-- from getting cluttered with arbitrary document symbols by using the
-- autocomplete.map_manually table.
local triggered_manually = false
local mt = { __tostring = function(t) return t.text end }
function autocomplete.add(t, manually_triggered)
local items = {}
for text, info in pairs(t.items) do
if type(info) == "table" then
table.insert(
items,
setmetatable(
{
text = text,
info = info.info,
desc = info.desc, -- Description shown on item selected
onhover = info.onhover, -- A callback called once when item is hovered
onselect = info.onselect, -- A callback called when item is selected
data = info.data -- Optional data that can be used on cb
},
mt
)
)
else
info = (type(info) == "string") and info
table.insert(items, setmetatable({ text = text, info = info }, mt))
end
end
if not manually_triggered then
autocomplete.map[t.name] = { files = t.files or ".*", items = items }
else
autocomplete.map_manually[t.name] = { files = t.files or ".*", items = items }
end
end
--
-- Thread that scans open document symbols and cache them
--
local max_symbols = config.plugins.autocomplete.max_symbols
core.add_thread(function()
local cache = setmetatable({}, { __mode = "k" })
local function get_syntax_symbols(symbols, doc)
if doc.syntax then
for sym in pairs(doc.syntax.symbols) do
symbols[sym] = true
end
end
end
local function get_symbols(doc)
local s = {}
get_syntax_symbols(s, doc)
if doc.disable_symbols then return s end
local i = 1
local symbols_count = 0
while i <= #doc.lines do
for sym in doc.lines[i]:gmatch(config.symbol_pattern) do
if not s[sym] then
symbols_count = symbols_count + 1
if symbols_count > max_symbols then
s = nil
doc.disable_symbols = true
local filename_message
if doc.filename then
filename_message = "document " .. doc.filename
else
filename_message = "unnamed document"
end
core.status_view:show_message("!", style.accent,
"Too many symbols in "..filename_message..
": stopping auto-complete for this document according to "..
"config.plugins.autocomplete.max_symbols."
)
collectgarbage('collect')
return {}
end
s[sym] = true
end
end
i = i + 1
if i % 100 == 0 then coroutine.yield() end
end
return s
end
local function cache_is_valid(doc)
local c = cache[doc]
return c and c.last_change_id == doc:get_change_id()
end
while true do
local symbols = {}
-- lift all symbols from all docs
for _, doc in ipairs(core.docs) do
-- update the cache if the doc has changed since the last iteration
if not cache_is_valid(doc) then
cache[doc] = {
last_change_id = doc:get_change_id(),
symbols = get_symbols(doc)
}
end
-- update symbol set with doc's symbol set
for sym in pairs(cache[doc].symbols) do
symbols[sym] = true
end
coroutine.yield()
end
-- update symbols list
autocomplete.add { name = "open-docs", items = symbols }
-- wait for next scan
local valid = true
while valid do
coroutine.yield(1)
for _, doc in ipairs(core.docs) do
if not cache_is_valid(doc) then
valid = false
break
end
end
end
end
end)
local partial = ""
local suggestions_idx = 1
local suggestions = {}
local last_line, last_col
local function reset_suggestions()
suggestions_idx = 1
suggestions = {}
triggered_manually = false
local doc = core.active_view.doc
if autocomplete.on_close then
autocomplete.on_close(doc, suggestions[suggestions_idx])
autocomplete.on_close = nil
end
end
local function update_suggestions()
local doc = core.active_view.doc
local filename = doc and doc.filename or ""
local map = autocomplete.map
if triggered_manually then
map = autocomplete.map_manually
end
-- get all relevant suggestions for given filename
local items = {}
for _, v in pairs(map) do
if common.match_pattern(filename, v.files) then
for _, item in pairs(v.items) do
table.insert(items, item)
end
end
end
-- fuzzy match, remove duplicates and store
items = common.fuzzy_match(items, partial)
local j = 1
for i = 1, config.plugins.autocomplete.max_suggestions do
suggestions[i] = items[j]
while items[j] and items[i].text == items[j].text do
items[i].info = items[i].info or items[j].info
j = j + 1
end
end
suggestions_idx = 1
end
local function get_partial_symbol()
local doc = core.active_view.doc
local line2, col2 = doc:get_selection()
local line1, col1 = doc:position_offset(line2, col2, translate.start_of_word)
return doc:get_text(line1, col1, line2, col2)
end
local function get_active_view()
if getmetatable(core.active_view) == DocView then
return core.active_view
end
end
local function get_suggestions_rect(av)
if #suggestions == 0 then
return 0, 0, 0, 0
end
local line, col = av.doc:get_selection()
local x, y = av:get_line_screen_position(line, col - #partial)
y = y + av:get_line_height() + style.padding.y
local font = av:get_font()
local th = font:get_height()
local max_width = 0
for _, s in ipairs(suggestions) do
local w = font:get_width(s.text)
if s.info then
w = w + style.font:get_width(s.info) + style.padding.x
end
max_width = math.max(max_width, w)
end
local ah = config.plugins.autocomplete.max_height
local max_items = #suggestions
if max_items > ah then
max_items = ah
end
-- additional line to display total items
max_items = max_items + 1
if max_width < 150 then
max_width = 150
end
-- if portion not visiable to right, reposition to DocView right margin
if (x - av.position.x) + max_width > av.size.x then
x = (av.size.x + av.position.x) - max_width - (style.padding.x * 2)
end
return
x - style.padding.x,
y - style.padding.y,
max_width + style.padding.x * 2,
max_items * (th + style.padding.y) + style.padding.y
end
local function wrap_line(line, max_chars)
if #line > max_chars then
local lines = {}
local line_len = #line
local new_line = ""
local prev_char = ""
local position = 0
local indent = line:match("^%s+")
for char in line:gmatch(".") do
position = position + 1
if #new_line < max_chars then
new_line = new_line .. char
prev_char = char
if position >= line_len then
table.insert(lines, new_line)
end
else
if
not prev_char:match("%s")
and
not string.sub(line, position+1, 1):match("%s")
and
position < line_len
then
new_line = new_line .. "-"
end
table.insert(lines, new_line)
if indent then
new_line = indent .. char
else
new_line = char
end
end
end
return lines
end
return line
end
local previous_scale = SCALE
local desc_font = style.code_font:copy(
config.plugins.autocomplete.desc_font_size * SCALE
)
local function draw_description_box(text, av, sx, sy, sw, sh)
if previous_scale ~= SCALE then
desc_font = style.code_font:copy(
config.plugins.autocomplete.desc_font_size * SCALE
)
previous_scale = SCALE
end
local font = desc_font
local lh = font:get_height()
local y = sy + style.padding.y
local x = sx + sw + style.padding.x / 4
local width = 0
local char_width = font:get_width(" ")
local draw_left = false;
local max_chars = 0
if sx - av.position.x < av.size.x - (sx - av.position.x) - sw then
max_chars = (((av.size.x+av.position.x) - x) / char_width) - 5
else
draw_left = true;
max_chars = (
(sx - av.position.x - (style.padding.x / 4) - style.scrollbar_size)
/ char_width
) - 5
end
local lines = {}
for line in string.gmatch(text.."\n", "(.-)\n") do
local wrapper_lines = wrap_line(line, max_chars)
if type(wrapper_lines) == "table" then
for _, wrapped_line in pairs(wrapper_lines) do
width = math.max(width, font:get_width(wrapped_line))
table.insert(lines, wrapped_line)
end
else
width = math.max(width, font:get_width(line))
table.insert(lines, line)
end
end
if draw_left then
x = sx - (style.padding.x / 4) - width - (style.padding.x * 2)
end
local height = #lines * font:get_height()
-- draw background rect
renderer.draw_rect(
x,
sy,
width + style.padding.x * 2,
height + style.padding.y * 2,
style.background3
)
-- draw text
for _, line in pairs(lines) do
common.draw_text(
font, style.text, line, "left",
x + style.padding.x, y, width, lh
)
y = y + lh
end
end
local function draw_suggestions_box(av)
if #suggestions <= 0 then
return
end
local ah = config.plugins.autocomplete.max_height
-- draw background rect
local rx, ry, rw, rh = get_suggestions_rect(av)
renderer.draw_rect(rx, ry, rw, rh, style.background3)
-- draw text
local font = av:get_font()
local lh = font:get_height() + style.padding.y
local y = ry + style.padding.y / 2
local show_count = #suggestions <= ah and #suggestions or ah
local start_index = suggestions_idx > ah and (suggestions_idx-(ah-1)) or 1
for i=start_index, start_index+show_count-1, 1 do
if not suggestions[i] then
break
end
local s = suggestions[i]
local color = (i == suggestions_idx) and style.accent or style.text
common.draw_text(font, color, s.text, "left", rx + style.padding.x, y, rw, lh)
if s.info then
color = (i == suggestions_idx) and style.text or style.dim
common.draw_text(style.font, color, s.info, "right", rx, y, rw - style.padding.x, lh)
end
y = y + lh
if suggestions_idx == i then
if s.onhover then
s.onhover(suggestions_idx, s)
s.onhover = nil
end
if s.desc and #s.desc > 0 then
draw_description_box(s.desc, av, rx, ry, rw, rh)
end
end
end
renderer.draw_rect(rx, y, rw, 2, style.caret)
renderer.draw_rect(rx, y+2, rw, lh, style.background)
common.draw_text(
style.font,
style.accent,
"Items",
"left",
rx + style.padding.x, y, rw, lh
)
common.draw_text(
style.font,
style.accent,
tostring(suggestions_idx) .. "/" .. tostring(#suggestions),
"right",
rx, y, rw - style.padding.x, lh
)
end
local function show_autocomplete()
local av = get_active_view()
if av then
-- update partial symbol and suggestions
partial = get_partial_symbol()
if #partial >= config.plugins.autocomplete.min_len or triggered_manually then
update_suggestions()
if not triggered_manually then
last_line, last_col = av.doc:get_selection()
else
local line, col = av.doc:get_selection()
local char = av.doc:get_char(line, col-1, line, col-1)
if char:match("%s") or (char:match("%p") and col ~= last_col) then
reset_suggestions()
end
end
else
reset_suggestions()
end
-- scroll if rect is out of bounds of view
local _, y, _, h = get_suggestions_rect(av)
local limit = av.position.y + av.size.y
if y + h > limit then
av.scroll.to.y = av.scroll.y + y + h - limit
end
end
end
--
-- Patch event logic into RootView and Doc
--
local on_text_input = RootView.on_text_input
local on_text_remove = Doc.remove
local update = RootView.update
local draw = RootView.draw
RootView.on_text_input = function(...)
on_text_input(...)
show_autocomplete()
end
Doc.remove = function(self, line1, col1, line2, col2)
on_text_remove(self, line1, col1, line2, col2)
if triggered_manually and line1 == line2 then
if last_col >= col1 then
reset_suggestions()
else
show_autocomplete()
end
end
end
RootView.update = function(...)
update(...)
local av = get_active_view()
if av then
-- reset suggestions if caret was moved
local line, col = av.doc:get_selection()
if not triggered_manually then
if line ~= last_line or col ~= last_col then
reset_suggestions()
end
else
if line ~= last_line or col < last_col then
reset_suggestions()
end
end
end
end
RootView.draw = function(...)
draw(...)
local av = get_active_view()
if av then
-- draw suggestions box after everything else
core.root_view:defer_draw(draw_suggestions_box, av)
end
end
--
-- Public functions
--
function autocomplete.open(on_close)
triggered_manually = true
if on_close then
autocomplete.on_close = on_close
end
local av = get_active_view()
last_line, last_col = av.doc:get_selection()
update_suggestions()
end
function autocomplete.close()
reset_suggestions()
end
function autocomplete.is_open()
return #suggestions > 0
end
function autocomplete.complete(completions, on_close)
reset_suggestions()
autocomplete.map_manually = {}
autocomplete.add(completions, true)
autocomplete.open(on_close)
end
function autocomplete.can_complete()
if #partial >= config.plugins.autocomplete.min_len then
return true
end
return false
end
--
-- Commands
--
local function predicate()
local active_docview = get_active_view()
return active_docview and #suggestions > 0, active_docview
end
command.add(predicate, {
["autocomplete:complete"] = function(dv)
local doc = dv.doc
local line, col = doc:get_selection()
local item = suggestions[suggestions_idx]
local text = item.text
local inserted = false
if item.onselect then
inserted = item.onselect(suggestions_idx, item)
end
if not inserted then
doc:insert(line, col, text)
doc:remove(line, col, line, col - #partial)
doc:set_selection(line, col + #text - #partial)
end
reset_suggestions()
end,
["autocomplete:previous"] = function()
suggestions_idx = math.max(suggestions_idx - 1, 1)
end,
["autocomplete:next"] = function()
suggestions_idx = math.min(suggestions_idx + 1, #suggestions)
end,
["autocomplete:cycle"] = function()
local newidx = suggestions_idx + 1
suggestions_idx = newidx > #suggestions and 1 or newidx
end,
["autocomplete:cancel"] = function()
reset_suggestions()
end,
})
--
-- Keymaps
--
keymap.add {
["tab"] = "autocomplete:complete",
["up"] = "autocomplete:previous",
["down"] = "autocomplete:next",
["escape"] = "autocomplete:cancel",
}
return autocomplete