lite-xl/data/core/init.lua

913 lines
25 KiB
Lua

require "core.strict"
local common = require "core.common"
local config = require "core.config"
local style = require "core.style"
local command
local keymap
local RootView
local StatusView
local CommandView
local NagView
local DocView
local Doc
local core = {}
local function load_session()
local ok, t = pcall(dofile, USERDIR .. "/session.lua")
if ok then
return t.recents, t.window
end
return {}
end
local function save_session()
local fp = io.open(USERDIR .. "/session.lua", "w")
if fp then
fp:write("return {recents=", common.serialize(core.recent_projects),
", window=", common.serialize(table.pack(system.get_window_size())),
"}\n")
fp:close()
end
end
local function normalize_path(s)
local drive, path = s:match("^([a-z]):([/\\].*)")
return drive and drive:upper() .. ":" .. path or s
end
local function update_recents_project(action, dir_path_abs)
local dirname = normalize_path(dir_path_abs)
if not dirname then return end
local recents = core.recent_projects
local n = #recents
for i = 1, n do
if dirname == recents[i] then
table.remove(recents, i)
break
end
end
if action == "add" then
table.insert(recents, 1, dirname)
end
end
function core.reschedule_project_scan()
if core.project_scan_thread_id then
core.threads[core.project_scan_thread_id].wake = 0
end
end
function core.set_project_dir(new_dir, change_project_fn)
local chdir_ok = pcall(system.chdir, new_dir)
if chdir_ok then
if change_project_fn then change_project_fn() end
core.project_dir = normalize_path(new_dir)
core.project_directories = {}
core.add_project_directory(new_dir)
core.project_files = {}
core.reschedule_project_scan()
return true
end
return false
end
function core.open_folder_project(dir_path_abs)
if core.set_project_dir(dir_path_abs, core.on_quit_project) then
core.root_view:close_all_docviews()
update_recents_project("add", dir_path_abs)
core.on_enter_project(dir_path_abs)
end
end
local function strip_leading_path(filename)
return filename:sub(2)
end
local function strip_trailing_slash(filename)
if filename:match("[^:][/\\]$") then
return filename:sub(1, -2)
end
return filename
end
local function project_scan_thread()
local function diff_files(a, b)
if #a ~= #b then return true end
for i, v in ipairs(a) do
if b[i].filename ~= v.filename
or b[i].modified ~= v.modified then
return true
end
end
end
local function compare_file(a, b)
return a.filename < b.filename
end
-- "root" will by an absolute path without trailing '/'
-- "path" will be a path starting with '/' and without trailing '/'
-- or the empty string.
-- It will identifies a sub-path within "root.
-- The current path location will therefore always be: root .. path.
-- When recursing "root" will always be the same, only "path" will change.
-- Returns a list of file "items". In eash item the "filename" will be the
-- complete file path relative to "root" *without* the trailing '/'.
local function get_files(root, path, t)
coroutine.yield()
t = t or {}
local size_limit = config.file_size_limit * 10e5
local all = system.list_dir(root .. path) or {}
local dirs, files = {}, {}
local entries_count = 0
local max_entries = config.max_project_files
for _, file in ipairs(all) do
if not common.match_pattern(file, config.ignore_files) then
local file = path .. PATHSEP .. file
local info = system.get_file_info(root .. file)
if info and info.size < size_limit then
info.filename = strip_leading_path(file)
table.insert(info.type == "dir" and dirs or files, info)
entries_count = entries_count + 1
if entries_count > max_entries then break end
end
end
end
table.sort(dirs, compare_file)
for _, f in ipairs(dirs) do
table.insert(t, f)
if entries_count <= max_entries then
local subdir_t, subdir_count = get_files(root, PATHSEP .. f.filename, t)
entries_count = entries_count + subdir_count
end
end
table.sort(files, compare_file)
for _, f in ipairs(files) do
table.insert(t, f)
end
return t, entries_count
end
while true do
-- get project files and replace previous table if the new table is
-- different
for i = 1, #core.project_directories do
local dir = core.project_directories[i]
local t, entries_count = get_files(dir.name, "")
if diff_files(dir.files, t) then
if entries_count > config.max_project_files then
core.status_view:show_message("!", style.accent,
"Too many files in project directory: stopping reading at "..
config.max_project_files.." files according to config.max_project_files.")
end
dir.files = t
core.redraw = true
end
if dir.name == core.project_dir then
core.project_files = dir.files
end
end
-- wait for next scan
coroutine.yield(config.project_scan_rate)
end
end
local function project_files_iter(state)
local dir = core.project_directories[state.dir_index]
state.file_index = state.file_index + 1
while dir and state.file_index > #dir.files do
state.dir_index = state.dir_index + 1
state.file_index = 1
dir = core.project_directories[state.dir_index]
end
if not dir then return end
return dir.name, dir.files[state.file_index]
end
function core.get_project_files()
local state = { dir_index = 1, file_index = 0 }
return project_files_iter, state
end
function core.project_files_number()
local n = 0
for i = 1, #core.project_directories do
n = n + #core.project_directories[i].files
end
return n
end
-- create a directory using mkdir but may need to create the parent
-- directories as well.
local function create_user_directory()
local dirname_create = USERDIR
local basedir
local subdirs = {}
while dirname_create and dirname_create ~= "" do
local success_mkdir = system.mkdir(dirname_create)
if success_mkdir then break end
dirname_create, basedir = dirname_create:match("(.*)[/\\](.+)$")
if basedir then
subdirs[#subdirs + 1] = basedir
end
end
for _, dirname in ipairs(subdirs) do
dirname_create = dirname_create .. '/' .. dirname
if not system.mkdir(dirname_create) then
error("cannot create directory: \"" .. dirname_create .. "\"")
end
end
for _, modname in ipairs {'plugins', 'colors', 'fonts'} do
local subdirname = dirname_create .. '/' .. modname
if not system.mkdir(subdirname) then
error("cannot create directory: \"" .. subdirname .. "\"")
end
end
end
local function write_user_init_file(init_filename)
local init_file = io.open(init_filename, "w")
if not init_file then error("cannot create file: \"" .. init_filename .. "\"") end
init_file:write([[
-- put user settings here
-- this module will be loaded after everything else when the application starts
-- it will be automatically reloaded when saved
local core = require "core"
local keymap = require "core.keymap"
local config = require "core.config"
local style = require "core.style"
------------------------------ Themes ----------------------------------------
-- light theme:
-- core.reload_module("colors.summer")
--------------------------- Key bindings -------------------------------------
-- key binding:
-- keymap.add { ["ctrl+escape"] = "core:quit" }
------------------------------- Fonts ----------------------------------------
-- customize fonts:
-- style.font = renderer.font.load(DATADIR .. "/fonts/font.ttf", 13 * SCALE)
-- style.code_font = renderer.font.load(DATADIR .. "/fonts/monospace.ttf", 12 * SCALE)
--
-- font names used by lite:
-- style.font : user interface
-- style.big_font : big text in welcome screen
-- style.icon_font : icons
-- style.icon_big_font : toolbar icons
-- style.code_font : code
--
-- the function to load the font accept a 3rd optional argument like:
--
-- {antialiasing="grayscale", hinting="full"}
--
-- possible values are:
-- antialiasing: grayscale, subpixel
-- hinting: none, slight, full
------------------------------ Plugins ----------------------------------------
-- enable or disable plugin loading setting config entries:
-- enable trimwhitespace, otherwise it is disable by default:
-- config.trimwhitespace = true
--
-- disable detectindent, otherwise it is enabled by default
-- config.detectindent = false
]])
init_file:close()
end
function core.load_user_directory()
return core.try(function()
local stat_dir = system.get_file_info(USERDIR)
if not stat_dir then
create_user_directory()
end
local init_filename = USERDIR .. "/init.lua"
local stat_file = system.get_file_info(init_filename)
if not stat_file then
write_user_init_file(init_filename)
end
dofile(init_filename)
end)
end
function core.add_project_directory(path)
-- top directories has a file-like "item" but the item.filename
-- will be simply the name of the directory, without its path.
-- The field item.topdir will identify it as a top level directory.
path = normalize_path(path)
table.insert(core.project_directories, {
name = path,
item = {filename = common.basename(path), type = "dir", topdir = true},
files = {}
})
end
function core.remove_project_directory(path)
-- skip the fist directory because it is the project's directory
for i = 2, #core.project_directories do
local dir = core.project_directories[i]
if dir.name == path then
table.remove(core.project_directories, i)
return true
end
end
return false
end
function core.init()
command = require "core.command"
keymap = require "core.keymap"
RootView = require "core.rootview"
StatusView = require "core.statusview"
CommandView = require "core.commandview"
NagView = require "core.nagview"
DocView = require "core.docview"
Doc = require "core.doc"
if PATHSEP == '\\' then
USERDIR = common.normalize_path(USERDIR)
DATADIR = common.normalize_path(DATADIR)
EXEDIR = common.normalize_path(EXEDIR)
end
do
local recent_projects, window_position = load_session()
if window_position then
system.set_window_size(table.unpack(window_position))
end
core.recent_projects = recent_projects
end
local project_dir = core.recent_projects[1] or "."
local project_dir_explicit = false
local files = {}
local delayed_error
for i = 2, #ARGS do
local arg_filename = strip_trailing_slash(ARGS[i])
local info = system.get_file_info(arg_filename) or {}
if info.type == "file" then
local file_abs = system.absolute_path(arg_filename)
if file_abs then
table.insert(files, file_abs)
project_dir = file_abs:match("^(.+)[/\\].+$")
end
elseif info.type == "dir" then
project_dir = arg_filename
project_dir_explicit = true
else
delayed_error = string.format("error: invalid file or directory %q", ARGS[i])
end
end
core.frame_start = 0
core.clip_rect_stack = {{ 0,0,0,0 }}
core.log_items = {}
core.docs = {}
core.mode = 'standard'
core.threads = setmetatable({}, { __mode = "k" })
local project_dir_abs = system.absolute_path(project_dir)
local set_project_ok = project_dir_abs and core.set_project_dir(project_dir_abs)
if set_project_ok then
if project_dir_explicit then
update_recents_project("add", project_dir_abs)
end
else
if not project_dir_explicit then
update_recents_project("remove", project_dir)
end
project_dir_abs = system.absolute_path(".")
if not core.set_project_dir(project_dir_abs) then
system.show_fatal_error("Lite XL internal error", "cannot set project directory to cwd")
os.exit(1)
end
end
core.redraw = true
core.visited_files = {}
core.restart_request = false
core.root_view = RootView()
core.command_view = CommandView()
core.status_view = StatusView()
core.nag_view = NagView()
local cur_node = core.root_view.root_node
cur_node.is_primary_node = true
cur_node:split("up", core.nag_view, {y = true})
cur_node = cur_node.b
cur_node = cur_node:split("down", core.command_view, {y = true})
cur_node = cur_node:split("down", core.status_view, {y = true})
core.project_scan_thread_id = core.add_thread(project_scan_thread)
command.add_defaults()
local got_user_error = not core.load_user_directory()
local got_plugin_error = not core.load_plugins()
do
local pdir, pname = project_dir_abs:match("(.*)[/\\\\](.*)")
core.log("Opening project %q from directory %q", pname, pdir)
end
local got_project_error = not core.load_project_module()
for _, filename in ipairs(files) do
core.root_view:open_doc(core.open_doc(filename))
end
if delayed_error then
core.error(delayed_error)
end
if got_plugin_error or got_user_error or got_project_error then
command.perform("core:open-log")
end
end
function core.confirm_close_all(close_fn, ...)
local dirty_count = 0
local dirty_name
for _, doc in ipairs(core.docs) do
if doc:is_dirty() then
dirty_count = dirty_count + 1
dirty_name = doc:get_name()
end
end
if dirty_count > 0 then
local text
if dirty_count == 1 then
text = string.format("\"%s\" has unsaved changes. Quit anyway?", dirty_name)
else
text = string.format("%d docs have unsaved changes. Quit anyway?", dirty_count)
end
local args = {...}
local opt = {
{ font = style.font, text = "Yes" },
{ font = style.font, text = "No" }
}
core.nag_view:show("Unsaved Changes", text, opt, function(item)
if item.text == "Yes" then close_fn(table.unpack(args)) end
end)
else
close_fn(...)
end
end
local temp_uid = (system.get_time() * 1000) % 0xffffffff
local temp_file_prefix = string.format(".lite_temp_%08x", temp_uid)
local temp_file_counter = 0
local function delete_temp_files()
for _, filename in ipairs(system.list_dir(EXEDIR)) do
if filename:find(temp_file_prefix, 1, true) == 1 then
os.remove(EXEDIR .. PATHSEP .. filename)
end
end
end
function core.temp_filename(ext)
temp_file_counter = temp_file_counter + 1
return USERDIR .. PATHSEP .. temp_file_prefix
.. string.format("%06x", temp_file_counter) .. (ext or "")
end
-- override to perform an operation before quitting or entering the
-- current project
do
local do_nothing = function() end
core.on_quit_project = do_nothing
core.on_enter_project = do_nothing
end
core.doc_save_hooks = {}
function core.add_save_hook(fn)
core.doc_save_hooks[#core.doc_save_hooks + 1] = fn
end
function core.on_doc_save(filename)
for _, hook in ipairs(core.doc_save_hooks) do
hook(filename)
end
end
local function quit_with_function(quit_fn, force)
if force then
delete_temp_files()
core.on_quit_project()
save_session()
quit_fn()
else
core.confirm_close_all(quit_with_function, quit_fn, true)
end
end
function core.quit(force)
quit_with_function(os.exit, force)
end
function core.restart()
quit_with_function(function() core.restart_request = true end)
end
function core.load_plugins()
local no_errors = true
for _, root_dir in ipairs {USERDIR, DATADIR} do
local plugin_dir = root_dir .. "/plugins"
local files = system.list_dir(plugin_dir)
for _, filename in ipairs(files or {}) do
local basename = filename:match("(.-)%.lua$") or filename
if config[basename] ~= false then
local modname = "plugins." .. basename
local ok = core.try(require, modname)
if ok then core.log_quiet("Loaded plugin %q from %s", basename, plugin_dir) end
if not ok then
no_errors = false
end
end
end
end
return no_errors
end
function core.load_project_module()
local filename = ".lite_project.lua"
if system.get_file_info(filename) then
return core.try(function()
local fn, err = loadfile(filename)
if not fn then error("Error when loading project module:\n\t" .. err) end
fn()
core.log_quiet("Loaded project module")
end)
end
return true
end
function core.reload_module(name)
local old = package.loaded[name]
package.loaded[name] = nil
local new = require(name)
if type(old) == "table" then
for k, v in pairs(new) do old[k] = v end
package.loaded[name] = old
end
end
function core.set_visited(filename)
for i = 1, #core.visited_files do
if core.visited_files[i] == filename then
table.remove(core.visited_files, i)
break
end
end
table.insert(core.visited_files, 1, filename)
end
function core.set_active_view(view)
assert(view, "Tried to set active view to nil")
if core.active_view and core.active_view.force_focus then return end
if view ~= core.active_view then
if view.doc and view.doc.filename then
core.set_visited(view.doc.filename)
end
core.last_active_view = core.active_view
core.active_view = view
end
end
function core.add_thread(f, weak_ref)
local key = weak_ref or #core.threads + 1
local fn = function() return core.try(f) end
core.threads[key] = { cr = coroutine.create(fn), wake = 0 }
return key
end
function core.push_clip_rect(x, y, w, h)
local x2, y2, w2, h2 = table.unpack(core.clip_rect_stack[#core.clip_rect_stack])
local r, b, r2, b2 = x+w, y+h, x2+w2, y2+h2
x, y = math.max(x, x2), math.max(y, y2)
b, r = math.min(b, b2), math.min(r, r2)
w, h = r-x, b-y
table.insert(core.clip_rect_stack, { x, y, w, h })
renderer.set_clip_rect(x, y, w, h)
end
function core.pop_clip_rect()
table.remove(core.clip_rect_stack)
local x, y, w, h = table.unpack(core.clip_rect_stack[#core.clip_rect_stack])
renderer.set_clip_rect(x, y, w, h)
end
function core.open_doc(filename)
if filename then
-- try to find existing doc for filename
local abs_filename = system.absolute_path(filename)
for _, doc in ipairs(core.docs) do
if doc.filename
and abs_filename == system.absolute_path(doc.filename) then
return doc
end
end
end
-- no existing doc for filename; create new
local doc = Doc(filename)
table.insert(core.docs, doc)
core.log_quiet(filename and "Opened doc \"%s\"" or "Opened new doc", filename)
return doc
end
function core.get_views_referencing_doc(doc)
local res = {}
local views = core.root_view.root_node:get_children()
for _, view in ipairs(views) do
if view.doc == doc then table.insert(res, view) end
end
return res
end
local function log(icon, icon_color, fmt, ...)
local text = string.format(fmt, ...)
if icon then
core.status_view:show_message(icon, icon_color, text)
end
local info = debug.getinfo(2, "Sl")
local at = string.format("%s:%d", info.short_src, info.currentline)
local item = { text = text, time = os.time(), at = at }
table.insert(core.log_items, item)
if #core.log_items > config.max_log_items then
table.remove(core.log_items, 1)
end
return item
end
function core.log(...)
return log("i", style.text, ...)
end
function core.log_quiet(...)
return log(nil, nil, ...)
end
function core.error(...)
return log("!", style.accent, ...)
end
function core.try(fn, ...)
local err
local ok, res = xpcall(fn, function(msg)
local item = core.error("%s", msg)
item.info = debug.traceback(nil, 2):gsub("\t", "")
err = msg
end, ...)
if ok then
return true, res
end
return false, err
end
function core.on_event(type, ...)
local did_keymap = false
if type == "textinput" and (core.mode == 'insert' or core.mode == 'standard') then
core.root_view:on_text_input(...)
elseif type == "keypressed" then
did_keymap = keymap.on_key_pressed(core.mode, ...)
elseif type == "keyreleased" then
keymap.on_key_released(core.mode, ...)
elseif type == "mousemoved" then
core.root_view:on_mouse_moved(...)
elseif type == "mousepressed" then
core.root_view:on_mouse_pressed(...)
elseif type == "mousereleased" then
core.root_view:on_mouse_released(...)
elseif type == "mousewheel" then
core.root_view:on_mouse_wheel(...)
elseif type == "filedropped" then
local filename, mx, my = ...
local info = system.get_file_info(filename)
if info and info.type == "dir" then
system.exec(string.format("%q %q", EXEFILE, filename))
else
local ok, doc = core.try(core.open_doc, filename)
if ok then
local node = core.root_view.root_node:get_child_overlapping_point(mx, my)
node:set_active_view(node.active_view)
core.root_view:open_doc(doc)
end
end
elseif type == "focuslost" then
core.root_view:on_focus_lost(...)
elseif type == "quit" then
core.quit()
end
return did_keymap
end
local function get_title_filename(view)
local doc_filename = view.get_filename and view:get_filename() or view:get_name()
return (doc_filename ~= "---") and doc_filename or ""
end
local function compose_window_title(title)
return title == "" and "Lite XL" or title .. " - Lite XL"
end
function core.step()
-- handle events
local did_keymap = false
local mouse_moved = false
local mouse = { x = 0, y = 0, dx = 0, dy = 0 }
for type, a,b,c,d in system.poll_event do
if type == "mousemoved" then
mouse_moved = true
mouse.x, mouse.y = a, b
mouse.dx, mouse.dy = mouse.dx + c, mouse.dy + d
elseif type == "textinput" and did_keymap then
did_keymap = false
else
local _, res = core.try(core.on_event, type, a, b, c, d)
did_keymap = res or did_keymap
end
core.redraw = true
end
if mouse_moved then
core.try(core.on_event, "mousemoved", mouse.x, mouse.y, mouse.dx, mouse.dy)
end
local width, height = renderer.get_size()
-- update
core.root_view.size.x, core.root_view.size.y = width, height
core.root_view:update()
if not core.redraw then return false end
core.redraw = false
-- close unreferenced docs
for i = #core.docs, 1, -1 do
local doc = core.docs[i]
if #core.get_views_referencing_doc(doc) == 0 then
table.remove(core.docs, i)
core.log_quiet("Closed doc \"%s\"", doc:get_name())
end
end
-- update window title
local current_title = get_title_filename(core.active_view)
if current_title ~= core.window_title then
system.set_window_title(compose_window_title(current_title))
core.window_title = current_title
end
-- draw
renderer.begin_frame()
core.clip_rect_stack[1] = { 0, 0, width, height }
renderer.set_clip_rect(table.unpack(core.clip_rect_stack[1]))
core.root_view:draw()
renderer.end_frame()
return true
end
local run_threads = coroutine.wrap(function()
while true do
local max_time = 1 / config.fps - 0.004
local need_more_work = false
for k, thread in pairs(core.threads) do
-- run thread
if thread.wake < system.get_time() then
local _, wait = assert(coroutine.resume(thread.cr))
if coroutine.status(thread.cr) == "dead" then
if type(k) == "number" then
table.remove(core.threads, k)
else
core.threads[k] = nil
end
elseif wait then
thread.wake = system.get_time() + wait
else
need_more_work = true
end
end
-- stop running threads if we're about to hit the end of frame
if system.get_time() - core.frame_start > max_time then
coroutine.yield(true)
end
end
if not need_more_work then coroutine.yield(false) end
end
end)
function core.run()
local idle_iterations = 0
local frame_duration = 1 / config.fps
while true do
core.frame_start = system.get_time()
local did_redraw = core.step()
local need_more_work = run_threads()
if core.restart_request then break end
if not did_redraw and not need_more_work then
idle_iterations = idle_iterations + 1
-- do not wait of events at idle_iterations = 1 to give a chance at core.step to run
-- and set "redraw" flag.
if idle_iterations > 1 then
if system.window_has_focus() then
-- keep running even with no events to make the cursor blinks
system.wait_event(frame_duration)
else
system.wait_event()
end
end
else
idle_iterations = 0
local elapsed = system.get_time() - core.frame_start
system.sleep(math.max(0, frame_duration - elapsed))
end
end
end
function core.on_error(err)
-- write error to file
local fp = io.open(USERDIR .. "/error.txt", "wb")
fp:write("Error: " .. tostring(err) .. "\n")
fp:write(debug.traceback(nil, 4) .. "\n")
fp:close()
-- save copy of all unsaved documents
for _, doc in ipairs(core.docs) do
if doc:is_dirty() and doc.filename then
doc:save(doc.filename .. "~")
end
end
end
core.add_save_hook(function(filename)
local doc = core.active_view.doc
if doc and doc:is(Doc) and doc.filename == USERDIR .. PATHSEP .. "init.lua" then
core.reload_module("core.style")
core.load_user_directory()
end
end)
return core