diff --git a/data/plugins/autocomplete.lua b/data/plugins/autocomplete.lua index c76eed02..33c90df7 100644 --- a/data/plugins/autocomplete.lua +++ b/data/plugins/autocomplete.lua @@ -8,25 +8,65 @@ 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.autocomplete_max_suggestions = 6 +-- Amount of characters that need to be written for autocomplete +config.autocomplete_min_len = 1 +-- The max amount of visible items +config.autocomplete_max_height = 6 +-- The max amount of scrollable items +config.autocomplete_max_suggestions = 100 +-- Maximum amount of symbols to cache per document +config.max_symbols = 2000 local autocomplete = {} -autocomplete.map = {} +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) +function autocomplete.add(t, triggered_manually) local items = {} for text, info in pairs(t.items) do - info = (type(info) == "string") and info - table.insert(items, setmetatable({ text = text, info = info }, mt)) + if type(info) == "table" then + table.insert( + items, + setmetatable( + { + text = text, + info = info.info, + desc = info.desc, -- Description shown on item selected + cb = info.cb, -- A callback called once 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 triggered_manually then + autocomplete.map[t.name] = { files = t.files or ".*", items = items } + else + autocomplete.map_manually[t.name] = { files = t.files or ".*", items = items } end - autocomplete.map[t.name] = { files = t.files or ".*", items = items } end -local max_symbols = config.max_symbols or 2000 +-- +-- Thread that scans open document symbols and cache them +-- +local max_symbols = config.max_symbols core.add_thread(function() local cache = setmetatable({}, { __mode = "k" }) @@ -109,16 +149,39 @@ 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 in_table(value, table_array) + for i, element in pairs(table_array) do + if element == value then + return true + end + end + + return false +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(autocomplete.map) do + 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) @@ -138,7 +201,6 @@ local function update_suggestions() end end - local function get_partial_symbol() local doc = core.active_view.doc local line2, col2 = doc:get_selection() @@ -146,14 +208,12 @@ local function get_partial_symbol() 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 @@ -175,15 +235,67 @@ local function get_suggestions_rect(av) max_width = math.max(max_width, w) end + local ah = config.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 + return x - style.padding.x, y - style.padding.y, max_width + style.padding.x * 2, - #suggestions * (th + style.padding.y) + style.padding.y + max_items * (th + style.padding.y) + style.padding.y end +local function draw_description_box(text, av, sx, sy, sw, sh) + local width = 0 + + local lines = {} + for line in string.gmatch(text.."\n", "(.-)\n") do + width = math.max(width, style.font:get_width(line)) + table.insert(lines, line) + end + + local height = #lines * style.font:get_height() + + -- draw background rect + renderer.draw_rect( + sx + sw + style.padding.x / 4, + sy, + width + style.padding.x * 2, + height + style.padding.y * 2, + style.background3 + ) + + -- draw text + local lh = style.font:get_height() + local y = sy + style.padding.y + local x = sx + sw + style.padding.x / 4 + + for _, line in pairs(lines) do + common.draw_text( + style.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.autocomplete_max_height + -- draw background rect local rx, ry, rw, rh = get_suggestions_rect(av) renderer.draw_rect(rx, ry, rw, rh, style.background3) @@ -192,7 +304,14 @@ local function draw_suggestions_box(av) local font = av:get_font() local lh = font:get_height() + style.padding.y local y = ry + style.padding.y / 2 - for i, s in ipairs(suggestions) do + 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 @@ -200,26 +319,55 @@ local function draw_suggestions_box(av) 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.cb then + s.cb(suggestions_idx, s) + s.cb = nil + s.data = 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 - --- patch event logic into RootView -local on_text_input = RootView.on_text_input -local update = RootView.update -local draw = RootView.draw - - -RootView.on_text_input = function(...) - on_text_input(...) - +local function show_autocomplete() local av = get_active_view() if av then -- update partial symbol and suggestions partial = get_partial_symbol() - if #partial >= 3 then + + if #partial >= config.autocomplete_min_len or triggered_manually then update_suggestions() - last_line, last_col = av.doc:get_selection() + + 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 @@ -233,6 +381,30 @@ RootView.on_text_input = function(...) 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(...) @@ -241,13 +413,19 @@ RootView.update = function(...) if av then -- reset suggestions if caret was moved local line, col = av.doc:get_selection() - if line ~= last_line or col ~= last_col then - reset_suggestions() + + 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(...) @@ -258,12 +436,46 @@ RootView.draw = function(...) 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 + + +-- +-- Commands +-- local function predicate() return get_active_view() and #suggestions > 0 end - command.add(predicate, { ["autocomplete:complete"] = function() local doc = core.active_view.doc @@ -288,7 +500,9 @@ command.add(predicate, { end, }) - +-- +-- Keymaps +-- keymap.add { ["tab"] = "autocomplete:complete", ["up"] = "autocomplete:previous",