332 lines
8.6 KiB
Lua
332 lines
8.6 KiB
Lua
-- mod-version:3
|
|
local core = require "core"
|
|
local style = require "core.style"
|
|
local config = require "core.config"
|
|
local command = require "core.command"
|
|
local common = require "core.common"
|
|
local DocView = require "core.docview"
|
|
local Highlighter = require "core.doc.highlighter"
|
|
local Doc = require "core.doc"
|
|
|
|
local platform_dictionary_file
|
|
if PLATFORM == "Windows" then
|
|
platform_dictionary_file = EXEDIR .. "/words.txt"
|
|
elseif PLATFORM == "AmigaOS 4" then
|
|
platform_dictionary_file = "PROGDIR:words.txt"
|
|
else
|
|
platform_dictionary_file = "/usr/share/dict/words"
|
|
end
|
|
|
|
config.plugins.spellcheck = common.merge({
|
|
enabled = true,
|
|
files = { "%.txt$", "%.md$", "%.markdown$" },
|
|
dictionary_file = platform_dictionary_file
|
|
}, config.plugins.spellcheck)
|
|
|
|
local last_input_time = 0
|
|
local word_pattern = "%a+"
|
|
local words
|
|
|
|
local spell_cache = setmetatable({}, { __mode = "k" })
|
|
local font_canary
|
|
local font_size_canary
|
|
|
|
|
|
-- Move cache to make space for new lines
|
|
local prev_insert_notify = Highlighter.insert_notify
|
|
function Highlighter:insert_notify(line, n, ...)
|
|
prev_insert_notify(self, line, n, ...)
|
|
local blanks = { }
|
|
if not spell_cache[self] then
|
|
spell_cache[self] = {}
|
|
end
|
|
for i = 1, n do
|
|
blanks[i] = false
|
|
end
|
|
common.splice(spell_cache[self], line, 0, blanks)
|
|
end
|
|
|
|
|
|
-- Close the cache gap created by removed lines
|
|
local prev_remove_notify = Highlighter.remove_notify
|
|
function Highlighter:remove_notify(line, n, ...)
|
|
prev_remove_notify(self, line, n, ...)
|
|
if not spell_cache[self] then
|
|
spell_cache[self] = {}
|
|
end
|
|
common.splice(spell_cache[self], line, n)
|
|
end
|
|
|
|
|
|
-- Remove changed lines from the cache
|
|
local prev_tokenize_line = Highlighter.tokenize_line
|
|
function Highlighter:tokenize_line(idx, state, ...)
|
|
local res = prev_tokenize_line(self, idx, state, ...)
|
|
if not spell_cache[self] then
|
|
spell_cache[self] = {}
|
|
end
|
|
spell_cache[self][idx] = false
|
|
return res
|
|
end
|
|
|
|
local function reset_cache()
|
|
for i=1,#spell_cache do
|
|
local cache = spell_cache[i]
|
|
for j=1,#cache do
|
|
cache[j] = false
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function load_dictionary()
|
|
core.add_thread(function()
|
|
local t = {}
|
|
local i = 0
|
|
for line in io.lines(config.plugins.spellcheck.dictionary_file) do
|
|
for word in line:gmatch(word_pattern) do
|
|
t[word:lower()] = true
|
|
end
|
|
i = i + 1
|
|
if i % 1000 == 0 then coroutine.yield() end
|
|
end
|
|
words = t
|
|
core.redraw = true
|
|
core.log_quiet(
|
|
"Finished loading dictionary file: \"%s\"",
|
|
config.plugins.spellcheck.dictionary_file
|
|
)
|
|
end)
|
|
end
|
|
|
|
|
|
local function matches_any(filename, ptns)
|
|
for _, ptn in ipairs(ptns) do
|
|
if filename:find(ptn) then return true end
|
|
end
|
|
end
|
|
|
|
|
|
local function active_word(doc, line, tail)
|
|
local l, c = doc:get_selection()
|
|
return l == line and c == tail
|
|
and doc == core.active_view.doc
|
|
and system.get_time() - last_input_time < 0.5
|
|
end
|
|
|
|
|
|
local text_input = Doc.text_input
|
|
function Doc:text_input(...)
|
|
text_input(self, ...)
|
|
last_input_time = system.get_time()
|
|
end
|
|
|
|
|
|
local function compare_arrays(a, b)
|
|
if b == a then return true end
|
|
if not a or not b then return false end
|
|
if #b ~= #a then return false end
|
|
for i=1,#a do
|
|
if b[i] ~= a[i] then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
|
|
local draw_line_text = DocView.draw_line_text
|
|
function DocView:draw_line_text(idx, x, y)
|
|
local lh = draw_line_text(self, idx, x, y)
|
|
|
|
if
|
|
not config.plugins.spellcheck.enabled
|
|
or
|
|
not words
|
|
or
|
|
not matches_any(self.doc.filename or "", config.plugins.spellcheck.files)
|
|
then
|
|
return lh
|
|
end
|
|
|
|
if font_canary ~= style.code_font
|
|
or font_size_canary ~= style.code_font:get_size()
|
|
or not compare_arrays(self.wrapped_lines, self.old_wrapped_lines)
|
|
then
|
|
spell_cache[self.doc.highlighter] = {}
|
|
font_canary = style.code_font
|
|
font_size_canary = style.code_font:get_size()
|
|
self.old_wrapped_lines = self.wrapped_lines
|
|
reset_cache()
|
|
end
|
|
if not spell_cache[self.doc.highlighter][idx] then
|
|
local calculated = {}
|
|
local s, e = 0, 0
|
|
local text = self.doc.lines[idx]
|
|
|
|
while true do
|
|
s, e = text:find(word_pattern, e + 1)
|
|
if not s then break end
|
|
local word = text:sub(s, e):lower()
|
|
if not words[word] and not active_word(self.doc, idx, e + 1) then
|
|
local x,y = self:get_line_screen_position(idx, s)
|
|
table.insert(calculated, x + self.scroll.x)
|
|
table.insert(calculated, y + self.scroll.y)
|
|
x,y = self:get_line_screen_position(idx, e + 1)
|
|
table.insert(calculated, x + self.scroll.x)
|
|
table.insert(calculated, y + self.scroll.y)
|
|
end
|
|
end
|
|
|
|
spell_cache[self.doc.highlighter][idx] = calculated
|
|
end
|
|
|
|
local color = style.spellcheck_error or style.syntax.keyword2
|
|
local h = math.ceil(1 * SCALE)
|
|
local slh = self:get_line_height()
|
|
local calculated = spell_cache[self.doc.highlighter][idx]
|
|
for i=1,#calculated,4 do
|
|
local x1, y1, x2, y2 = calculated[i], calculated[i+1], calculated[i+2], calculated[i+3]
|
|
renderer.draw_rect(x1 - self.scroll.x, y1 + slh - self.scroll.y, x2 - x1, h, color)
|
|
end
|
|
return lh
|
|
end
|
|
|
|
|
|
local function get_word_at_caret()
|
|
local doc = core.active_view.doc
|
|
local l, c = doc:get_selection()
|
|
local s, e = 0, 0
|
|
local text = doc.lines[l]
|
|
while true do
|
|
s, e = text:find(word_pattern, e + 1)
|
|
if c >= s and c <= e + 1 then
|
|
return text:sub(s, e):lower(), s, e
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
local function compare_words(word1, word2)
|
|
local res = 0
|
|
for i = 1, math.max(#word1, #word2) do
|
|
if word1:byte(i) ~= word2:byte(i) then
|
|
res = res + 1
|
|
end
|
|
end
|
|
return res
|
|
end
|
|
|
|
|
|
-- The config specification used by the settings gui
|
|
config.plugins.spellcheck.config_spec = {
|
|
name = "Spell Check",
|
|
{
|
|
label = "Enabled",
|
|
description = "Disable or enable spell checking.",
|
|
path = "enabled",
|
|
type = "toggle",
|
|
default = true
|
|
},
|
|
{
|
|
label = "Files",
|
|
description = "List of Lua patterns matching files to spell check.",
|
|
path = "files",
|
|
type = "list_strings",
|
|
default = { "%.txt$", "%.md$", "%.markdown$" }
|
|
},
|
|
{
|
|
label = "Dictionary File",
|
|
description = "Path to a text file that contains a list of dictionary words.",
|
|
path = "dictionary_file",
|
|
type = "file",
|
|
exists = true,
|
|
default = platform_dictionary_file,
|
|
on_apply = function()
|
|
load_dictionary()
|
|
end
|
|
}
|
|
}
|
|
|
|
load_dictionary()
|
|
|
|
command.add("core.docview", {
|
|
|
|
["spell-check:toggle"] = function()
|
|
config.plugins.spellcheck.enabled = not config.plugins.spellcheck.enabled
|
|
end,
|
|
|
|
["spell-check:add-to-dictionary"] = function()
|
|
local word = get_word_at_caret()
|
|
if words[word] then
|
|
core.error("\"%s\" already exists in the dictionary", word)
|
|
return
|
|
end
|
|
if word then
|
|
local fp = assert(io.open(config.plugins.spellcheck.dictionary_file, "a"))
|
|
fp:write("\n" .. word .. "\n")
|
|
fp:close()
|
|
words[word] = true
|
|
core.log("Added \"%s\" to dictionary", word)
|
|
end
|
|
end,
|
|
|
|
|
|
["spell-check:replace"] = function(dv)
|
|
local word, s, e = get_word_at_caret()
|
|
|
|
-- find suggestions
|
|
local suggestions = {}
|
|
local word_len = #word
|
|
for w in pairs(words) do
|
|
if math.abs(#w - word_len) <= 2 then
|
|
local diff = compare_words(word, w)
|
|
if diff < word_len * 0.5 then
|
|
table.insert(suggestions, { diff = diff, text = w })
|
|
end
|
|
end
|
|
end
|
|
if #suggestions == 0 then
|
|
core.error("Could not find any suggestions for \"%s\"", word)
|
|
return
|
|
end
|
|
|
|
-- sort suggestions table and convert to properly-capitalized text
|
|
table.sort(suggestions, function(a, b) return a.diff < b.diff end)
|
|
local doc = dv.doc
|
|
local line = doc:get_selection()
|
|
local has_upper = doc.lines[line]:sub(s, s):match("[A-Z]")
|
|
for k, v in pairs(suggestions) do
|
|
if has_upper then
|
|
v.text = v.text:gsub("^.", string.upper)
|
|
end
|
|
suggestions[k] = v.text
|
|
end
|
|
|
|
-- select word and init replacement selector
|
|
local label = string.format("Replace \"%s\" With", word)
|
|
doc:set_selection(line, e + 1, line, s)
|
|
core.command_view:enter(label, {
|
|
submit = function(text, item)
|
|
text = item and item.text or text
|
|
doc:replace(function() return text end)
|
|
end,
|
|
suggest = function(text)
|
|
local t = {}
|
|
for _, w in ipairs(suggestions) do
|
|
if w:lower():find(text:lower(), 1, true) then
|
|
table.insert(t, w)
|
|
end
|
|
end
|
|
return t
|
|
end
|
|
})
|
|
end,
|
|
|
|
})
|
|
|
|
local contextmenu = require "plugins.contextmenu"
|
|
contextmenu:register("core.docview", {
|
|
contextmenu.DIVIDER,
|
|
{ text = "View Suggestions", command = "spell-check:replace" },
|
|
{ text = "Add to Dictionary", command = "spell-check:add-to-dictionary" }
|
|
})
|