require "core.strict" require "core.regex" local common = require "core.common" local config = require "core.config" local style = require "core.style" local command local keymap local dirwatch local RootView local StatusView local TitleView local CommandView local NagView local DocView local Doc local core = {} local function load_session() local ok, t = pcall(dofile, USERDIR .. "/session.lua") return ok and t or {} 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())), ", window_mode=", common.serialize(system.get_window_mode()), ", previous_find=", common.serialize(core.previous_find), ", previous_replace=", common.serialize(core.previous_replace), "}\n") fp:close() end end local function update_recents_project(action, dir_path_abs) local dirname = common.normalize_volume(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.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 = common.normalize_volume(new_dir) core.project_directories = {} end return chdir_ok end local function reload_customizations() -- The logic is: -- - the core.style and config modules are reloaded with the purpose of applying -- the new user's and project's module configs -- - inside the core.config the existing fields in config.plugins are preserved -- because they are reserved to plugins configuration and plugins are already -- loaded. -- - plugins are not reloaded or unloaded local plugins_save = {} for k, v in pairs(config.plugins) do plugins_save[k] = v end core.reload_module("core.style") core.reload_module("core.config") core.load_user_directory() core.load_project_module() for k, v in pairs(plugins_save) do config.plugins[k] = v end 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() reload_customizations() update_recents_project("add", dir_path_abs) core.add_project_directory(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 function core.project_subdir_is_shown(dir, filename) return not dir.files_limit or dir.shown_subdir[filename] end local function show_max_files_warning(dir) local message = dir.slow_filesystem and "Filesystem is too slow: project files will not be indexed." or "Too many files in project directory: stopped reading at ".. config.max_project_files.." files. For more information see ".. "usage.md at https://github.com/lite-xl/lite-xl." if core.status_view then core.status_view:show_message("!", style.accent, message) end end local function file_search(files, info) local filename, type = info.filename, info.type local inf, sup = 1, #files while sup - inf > 8 do local curr = math.floor((inf + sup) / 2) if system.path_compare(filename, type, files[curr].filename, files[curr].type) then sup = curr - 1 else inf = curr end end while inf <= sup and not system.path_compare(filename, type, files[inf].filename, files[inf].type) do if files[inf].filename == filename then return inf, true end inf = inf + 1 end return inf, false end local function files_info_equal(a, b) return a.filename == b.filename and a.type == b.type end -- for "a" inclusive from i1 + 1 and i1 + n local function files_list_match(a, i1, n, b) if n ~= #b then return false end for i = 1, n do if not files_info_equal(a[i1 + i], b[i]) then return false end end return true end -- arguments like for files_list_match local function files_list_replace(as, i1, n, bs, hook) local m = #bs local i, j = 1, 1 while i <= m or i <= n do local a, b = as[i1 + i], bs[j] if i > n or (j <= m and not files_info_equal(a, b) and not system.path_compare(a.filename, a.type, b.filename, b.type)) then table.insert(as, i1 + i, b) i, j, n = i + 1, j + 1, n + 1 if hook and hook.insert then hook.insert(b) end elseif j > m or system.path_compare(a.filename, a.type, b.filename, b.type) then if hook and hook.remove then hook.remove(as[i1 + i]) end table.remove(as, i1 + i) n = n - 1 else i, j = i + 1, j + 1 end end end local function project_subdir_bounds(dir, filename) local index, n = 0, #dir.files for i, file in ipairs(dir.files) do local file = dir.files[i] if file.filename == filename then index, n = i, #dir.files - i for j = 1, #dir.files - i do if not common.path_belongs_to(dir.files[i + j].filename, filename) then n = j - 1 break end end return index, n, file end end end -- Predicate function to inhibit directory recursion in get_directory_files -- based on a time limit and the number of files. local function timed_max_files_pred(dir, filename, entries_count, t_elapsed) local n_limit = entries_count <= config.max_project_files local t_limit = t_elapsed < 20 / config.fps return n_limit and t_limit and core.project_subdir_is_shown(dir, filename) end -- Should be called on any directory that registers a change. -- Uses relative paths at the project root. local function refresh_directory(topdir, target, expanded) local index, n, directory if target == "" then index, n = 1, #topdir.files directory = "" else index, n = project_subdir_bounds(topdir, target) index = index + 1 n = index + n - 1 directory = (PATHSEP .. target) end if index then local files local change = false if topdir.files_limit then -- If we have the folders literally open on the side panel. files = expanded and dirwatch.get_directory_files(topdir, topdir.name, directory, {}, 0, core.project_subdir_is_shown) or {} change = true else -- If we're expecting to keep track of everything, go through the list and iteratively deal with directories. files = dirwatch.get_directory_files(topdir, topdir.name, directory, {}, 0, function() return false end) end local new_idx, old_idx = 1, index local new_directories = {} local last_dir = nil while old_idx <= n or new_idx <= #files do local old_info, new_info = topdir.files[old_idx], files[new_idx] if not new_info or not old_info or not last_dir or old_info.filename:sub(1, #last_dir + 1) ~= last_dir .. "/" then if not new_info or not old_info or not files_info_equal(new_info, old_info) then change = true if not old_info or (new_info and system.path_compare(new_info.filename, new_info.type, old_info.filename, old_info.type)) then table.insert(topdir.files, old_idx, new_info) new_idx = new_idx + 1 old_idx = old_idx + 1 if new_info.type == "dir" then table.insert(new_directories, new_info) end n = n + 1 else table.remove(topdir.files, old_idx) if old_info.type == "dir" then topdir.watch:unwatch(target .. PATHSEP .. old_info.filename) end n = n - 1 end else new_idx = new_idx + 1 old_idx = old_idx + 1 end if old_info and old_info.type == "dir" then last_dir = old_info.filename end else old_idx = old_idx + 1 end end for i, v in ipairs(new_directories) do topdir.watch:watch(target) if refresh_directory(topdir, target .. PATHSEP .. v.filename) then change = true end end if change then core.redraw = true topdir.is_dirty = true end return change 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 = common.normalize_volume(path) local topdir = { name = path, item = {filename = common.basename(path), type = "dir", topdir = true}, files_limit = false, is_dirty = true, shown_subdir = {}, watch_thread = nil, watch = dirwatch.new() } table.insert(core.project_directories, topdir) local fstype = PLATFORM == "Linux" and system.get_fs_type(topdir.name) or "unknown" topdir.force_scans = (fstype == "nfs" or fstype == "fuse") local t, complete, entries_count = dirwatch.get_directory_files(topdir, topdir.name, "", {}, 0, timed_max_files_pred) topdir.files = t if not complete then topdir.slow_filesystem = not complete and (entries_count <= config.max_project_files) topdir.files_limit = true show_max_files_warning(topdir) refresh_directory(topdir, "", true) else for i,v in ipairs(t) do if v.type == "dir" then topdir.watch:watch(path .. PATHSEP .. v.filename) end end end topdir.watch:watch(topdir.name) -- each top level directory gets a watch thread. if the project is small, or -- if the ablity to use directory watches hasn't been compromised in some way -- either through error, or amount of files, then this should be incredibly -- quick; essentially one syscall per check. Otherwise, this may take a bit of -- time; the watch will yield in this coroutine after 0.01 second, for 0.1 seconds. topdir.watch_thread = core.add_thread(function() while true do topdir.watch:check(function(target) if target == topdir.name then return refresh_directory(topdir, "", true) end local dirpath = target:sub(#topdir.name + 2) local abs_dirpath = topdir.name .. PATHSEP .. dirpath if dirpath then -- check if the directory is in the project files list, if not exit. local dir_index, dir_match = file_search(topdir.files, {filename = dirpath, type = "dir"}) if not dir_match or not core.project_subdir_is_shown(topdir, topdir.files[dir_index].filename) then return end end return refresh_directory(topdir, dirpath, true) end, 0.01, 0.01) coroutine.yield(0.05) end end) if path == core.project_dir then core.project_files = topdir.files end core.redraw = true return topdir end -- The function below is needed to reload the project directories -- when the project's module changes. local function rescan_project_directories() local save_project_dirs = {} local n = #core.project_directories for i = 1, n do local dir = core.project_directories[i] save_project_dirs[i] = {name = dir.name, shown_subdir = dir.shown_subdir} end core.project_directories = {} for i = 1, n do -- add again the directories in the project local dir = core.add_project_directory(save_project_dirs[i].name) if dir.files_limit then -- We need to sort the list of shown subdirectories so that higher level -- directories are populated first. We use the function system.path_compare -- because it order the entries in the appropriate order. -- TODO: we may consider storing the table shown_subdir as a sorted table -- since the beginning. local subdir_list = {} for subdir in pairs(save_project_dirs[i].shown_subdir) do table.insert(subdir_list, subdir) end table.sort(subdir_list, function(a, b) return system.path_compare(a, "dir", b, "dir") end) for _, subdir in ipairs(subdir_list) do local show = save_project_dirs[i].shown_subdir[subdir] for j = 1, #dir.files do if dir.files[j].filename == subdir then -- The instructions below match when happens in TreeView:on_mouse_pressed. -- We perform the operations only once iff the subdir is in dir.files. -- In theory set_show below may fail and return false but is it is listed -- there it means it succeeded before so we are optimistically assume it -- will not fail for the sake of simplicity. core.update_project_subdir(dir, subdir, show) break end end end end end end function core.project_dir_by_name(name) for i = 1, #core.project_directories do if core.project_directories[i].name == name then return core.project_directories[i] end end end function core.update_project_subdir(dir, filename, expanded) assert(dir.files_limit, "function should be called only when directory is in files limit mode") dir.shown_subdir[filename] = expanded local index, n, file = project_subdir_bounds(dir, filename) if index then local new_files = expanded and dirwatch.get_directory_files(dir, dir.name, PATHSEP .. filename, {}, 0, core.project_subdir_is_shown) or {} files_list_replace(dir.files, index, n, new_files) dir.is_dirty = true return true end end -- Find files and directories recursively reading from the filesystem. -- Filter files and yields file's directory and info table. This latter -- is filled to be like required by project directories "files" list. local function find_files_rec(root, path) local all = system.list_dir(root .. path) or {} for _, file in ipairs(all) do local file = path .. PATHSEP .. file local info = system.get_file_info(root .. file) if info then info.filename = strip_leading_path(file) if info.type == "file" then coroutine.yield(root, info) elseif not common.match_pattern(common.basename(info.filename), config.ignore_files) then find_files_rec(root, PATHSEP .. info.filename) end end end end -- Iterator function to list all project files local function project_files_iter(state) local dir = core.project_directories[state.dir_index] if state.co then -- We have a coroutine to fetch for files, use the coroutine. -- Used for directories that exceeds the files nuumber limit. local ok, name, file = coroutine.resume(state.co, dir.name, "") if ok and name then return name, file else -- The coroutine terminated, increment file/dir counter to scan -- next project directory. state.co = false state.file_index = 1 state.dir_index = state.dir_index + 1 dir = core.project_directories[state.dir_index] end else -- Increase file/dir counter 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 end if not dir then return end if dir.files_limit then -- The current project directory is files limited: create a couroutine -- to read files from the filesystem. state.co = coroutine.create(find_files_rec) return project_files_iter(state) 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 if core.project_directories[i].files_limit then return end 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 success, err = common.mkdirp(USERDIR) if not success then error("cannot create directory \"" .. USERDIR .. "\": " .. err) end for _, modname in ipairs {'plugins', 'colors', 'fonts'} do local subdirname = USERDIR .. PATHSEP .. 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/FiraSans-Regular.ttf", 14 * SCALE) -- style.code_font = renderer.font.load(DATADIR .. "/fonts/JetBrainsMono-Regular.ttf", 14 * 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 plugins.trimwhitespace, otherwise it is disable by default: -- config.plugins.trimwhitespace = true -- -- disable detectindent, otherwise it is enabled by default -- config.plugins.detectindent = false ]]) init_file:close() end function core.write_init_project_module(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 project's module settings here. -- This module will be loaded when opening a project, after the user module -- configuration. -- It will be automatically reloaded when saved. local config = require "core.config" -- you can add some patterns to ignore files within the project -- config.ignore_files = {"^%.", } -- Patterns are normally applied to the file's or directory's name, without -- its path. See below about how to apply filters on a path. -- -- Here some examples: -- -- "^%." match any file of directory whose basename begins with a dot. -- -- When there is an '/' or a '/$' at the end the pattern it will only match -- directories. When using such a pattern a final '/' will be added to the name -- of any directory entry before checking if it matches. -- -- "^%.git/" matches any directory named ".git" anywhere in the project. -- -- If a "/" appears anywhere in the pattern except if it appears at the end or -- is immediately followed by a '$' then the pattern will be applied to the full -- path of the file or directory. An initial "/" will be prepended to the file's -- or directory's path to indicate the project's root. -- -- "^/node_modules/" will match a directory named "node_modules" at the project's root. -- "^/build.*/" match any top level directory whose name begins with "build" -- "^/subprojects/.+/" match any directory inside a top-level folder named "subprojects". -- You may activate some plugins on a pre-project base to override the user's settings. -- config.plugins.trimwitespace = true ]]) 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.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 local function configure_borderless_window() system.set_window_bordered(not config.borderless) core.title_view:configure_hit_test(config.borderless) core.title_view.visible = config.borderless end local function add_config_files_hooks() -- auto-realod style when user's module is saved by overriding Doc:Save() local doc_save = Doc.save local user_filename = system.absolute_path(USERDIR .. PATHSEP .. "init.lua") function Doc:save(filename, abs_filename) local module_filename = system.absolute_path(".lite_project.lua") doc_save(self, filename, abs_filename) if self.abs_filename == user_filename or self.abs_filename == module_filename then reload_customizations() rescan_project_directories() configure_borderless_window() end end end function core.init() command = require "core.command" keymap = require "core.keymap" dirwatch = require "core.dirwatch" RootView = require "core.rootview" StatusView = require "core.statusview" TitleView = require "core.titleview" CommandView = require "core.commandview" NagView = require "core.nagview" DocView = require "core.docview" Doc = require "core.doc" if PATHSEP == '\\' then USERDIR = common.normalize_volume(USERDIR) DATADIR = common.normalize_volume(DATADIR) EXEDIR = common.normalize_volume(EXEDIR) end do local session = load_session() if session.window_mode == "normal" then system.set_window_size(table.unpack(session.window)) elseif session.window_mode == "maximized" then system.set_window_mode("maximized") end core.recent_projects = session.recents or {} core.previous_find = session.previous_find or {} core.previous_replace = session.previous_replace or {} 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 -- on macOS we can get an argument like "-psn_0_52353" that we just ignore. if not ARGS[i]:match("^-psn") then delayed_error = string.format("error: invalid file or directory %q", ARGS[i]) end end end core.frame_start = 0 core.clip_rect_stack = {{ 0,0,0,0 }} core.log_items = {} core.docs = {} core.cursor_clipboard = {} core.cursor_clipboard_whole_line = {} core.window_mode = "normal" core.threads = setmetatable({}, { __mode = "k" }) core.blink_start = system.get_time() core.blink_timer = core.blink_start local got_user_error, got_project_error = not core.load_user_directory() local project_dir_abs = system.absolute_path(project_dir) -- We prevent set_project_dir below to effectively add and scan the directory becaese tha -- project module and its ignore files is not yet loaded. 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, function() got_project_error = not core.load_project_module() end) 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.quit_request = false core.root_view = RootView() core.command_view = CommandView() core.status_view = StatusView() core.nag_view = NagView() core.title_view = TitleView() local cur_node = core.root_view.root_node cur_node.is_primary_node = true cur_node:split("up", core.title_view, {y = true}) cur_node = cur_node.b 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}) command.add_defaults() local plugins_success, plugins_refuse_list = core.load_plugins() do local pdir, pname = project_dir_abs:match("(.*)[/\\\\](.*)") core.log("Opening project %q from directory %s", pname, pdir) end -- We add the project directory now because the project's module is loaded. core.add_project_directory(project_dir_abs) -- We assume we have just a single project directory here. Now that StatusView -- is there show max files warning if needed. if core.project_directories[1].files_limit then show_max_files_warning(core.project_directories[1]) end 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 not plugins_success or got_user_error or got_project_error then command.perform("core:open-log") end configure_borderless_window() if #plugins_refuse_list.userdir.plugins > 0 or #plugins_refuse_list.datadir.plugins > 0 then local opt = { { font = style.font, text = "Exit", default_no = true }, { font = style.font, text = "Continue" , default_yes = true } } local msg = {} for _, entry in pairs(plugins_refuse_list) do if #entry.plugins > 0 then msg[#msg + 1] = string.format("Plugins from directory \"%s\":\n%s", common.home_encode(entry.dir), table.concat(entry.plugins, "\n")) end end core.nag_view:show( "Refused Plugins", string.format( "Some plugins are not loaded due to version mismatch.\n\n%s.\n\n" .. "Please download a recent version from https://github.com/lite-xl/lite-xl-plugins.", table.concat(msg, ".\n\n")), opt, function(item) if item.text == "Exit" then os.exit(1) end end) end add_config_files_hooks() end function core.confirm_close_docs(docs, close_fn, ...) local dirty_count = 0 local dirty_name for _, doc in ipairs(docs or 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", default_yes = true }, { font = style.font, text = "No" , default_no = true } } 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 = math.floor(system.get_time() * 1000) % 0xffffffff local temp_file_prefix = string.format(".lite_temp_%08x", tonumber(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 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_docs(core.docs, quit_with_function, quit_fn, true) end end function core.quit(force) quit_with_function(function() core.quit_request = true end, force) end function core.restart() quit_with_function(function() core.restart_request = true end) end local function check_plugin_version(filename) local info = system.get_file_info(filename) if info ~= nil and info.type == "dir" then filename = filename .. "/init.lua" info = system.get_file_info(filename) end if not info or not filename:match("%.lua$") then return false end local f = io.open(filename, "r") if not f then return false end local version_match = false for line in f:lines() do local mod_version = line:match('%-%-.*%f[%a]mod%-version%s*:%s*(%d+)') if mod_version then version_match = (mod_version == MOD_VERSION) break end -- The following pattern is used for backward compatibility only -- Future versions will look only at the mod-version tag. local version = line:match('%-%-%s*lite%-xl%s*(%d+%.%d+)$') if version then -- we consider the version tag 2.0 equivalent to mod-version:2 version_match = (version == '2.0' and MOD_VERSION == "2") break end end f:close() return true, version_match end function core.load_plugins() local no_errors = true local refused_list = { userdir = {dir = USERDIR, plugins = {}}, datadir = {dir = DATADIR, plugins = {}}, } local files, ordered = {}, {} for _, root_dir in ipairs {DATADIR, USERDIR} do local plugin_dir = root_dir .. "/plugins" for _, filename in ipairs(system.list_dir(plugin_dir) or {}) do if not files[filename] then table.insert(ordered, filename) end files[filename] = plugin_dir -- user plugins will always replace system plugins end end table.sort(ordered) for _, filename in ipairs(ordered) do local plugin_dir, basename = files[filename], filename:match("(.-)%.lua$") or filename local is_lua_file, version_match = check_plugin_version(plugin_dir .. '/' .. filename) if is_lua_file then if not version_match then core.log_quiet("Version mismatch for plugin %q from %s", basename, plugin_dir) local list = refused_list[plugin_dir:find(USERDIR, 1, true) == 1 and 'userdir' or 'datadir'].plugins table.insert(list, filename) end if version_match and config.plugins[basename] ~= false then local ok = core.try(require, "plugins." .. basename) 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, refused_list 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 view ~= core.active_view then if core.active_view and core.active_view.force_focus then core.next_active_view = view return end core.next_active_view = nil 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.show_title_bar(show) core.title_view.visible = show end function core.add_thread(f, weak_ref, ...) local key = weak_ref or #core.threads + 1 local args = {...} local fn = function() return core.try(f, table.unpack(args)) 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.normalize_to_project_dir(filename) filename = common.normalize_path(filename) if common.path_belongs_to(filename, core.project_dir) then filename = common.relative_path(core.project_dir, filename) end return filename end -- The function below works like system.absolute_path except it -- doesn't fail if the file does not exist. We consider that the -- current dir is core.project_dir so relative filename are considered -- to be in core.project_dir. -- Please note that .. or . in the filename are not taken into account. -- This function should get only filenames normalized using -- common.normalize_path function. function core.project_absolute_path(filename) if filename:match('^%a:\\') or filename:find('/', 1, true) == 1 then return filename else return core.project_dir .. PATHSEP .. filename end end function core.open_doc(filename) local new_file = not filename or not system.get_file_info(filename) local abs_filename if filename then -- normalize filename and set absolute filename then -- try to find existing doc for filename filename = core.normalize_to_project_dir(filename) abs_filename = core.project_absolute_path(filename) for _, doc in ipairs(core.docs) do if doc.abs_filename and abs_filename == doc.abs_filename then return doc end end end -- no existing doc for filename; create new local doc = Doc(filename, abs_filename, new_file) 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(level, show, fmt, ...) local text = string.format(fmt, ...) if show then local s = style.log[level] core.status_view:show_message(s.icon, s.color, text) end local info = debug.getinfo(2, "Sl") local at = string.format("%s:%d", info.short_src, info.currentline) local item = { level = level, 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("INFO", true, ...) end function core.log_quiet(...) return log("INFO", false, ...) end function core.error(...) return log("ERROR", true, ...) end function core.get_log(i) if i == nil then local r = {} for _, item in ipairs(core.log_items) do table.insert(r, core.get_log(item)) end return table.concat(r, "\n") end local item = type(i) == "number" and core.log_items[i] or i local text = string.format("%s [%s] %s at %s", os.date(nil, item.time), item.level, item.text, item.at) if item.info then text = string.format("%s\n%s\n", text, item.info) end return text 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 local scheduled_rescan = {} function core.has_pending_rescan() for _ in pairs(scheduled_rescan) do return true end end function core.on_event(type, ...) local did_keymap = false if type == "textinput" then core.root_view:on_text_input(...) elseif type == "keypressed" then did_keymap = keymap.on_key_pressed(...) elseif type == "keyreleased" then keymap.on_key_released(...) elseif type == "mousemoved" then core.root_view:on_mouse_moved(...) elseif type == "mousepressed" then if not core.root_view:on_mouse_pressed(...) then did_keymap = keymap.on_mouse_pressed(...) end elseif type == "mousereleased" then core.root_view:on_mouse_released(...) elseif type == "mousewheel" then if not core.root_view:on_mouse_wheel(...) then did_keymap = keymap.on_mouse_wheel(...) end elseif type == "resized" then core.window_mode = system.get_window_mode() elseif type == "minimized" or type == "maximized" or type == "restored" then core.window_mode = type == "restored" and "normal" or type elseif type == "filedropped" then if not core.root_view:on_file_dropped(...) 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 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 function core.compose_window_title(title) return title == "" and "Lite XL" or title .. " - Lite XL" end function core.step() -- handle events local did_keymap = false for type, a,b,c,d in system.poll_event do if type == "textinput" and did_keymap then did_keymap = false elseif type == "mousemoved" then core.try(core.on_event, type, a, b, c, d) else local _, res = core.try(core.on_event, type, a, b, c, d) did_keymap = res or did_keymap end core.redraw = true 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) doc:on_close() 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(core.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 while true do core.frame_start = system.get_time() local did_redraw = core.step() local need_more_work = run_threads() or core.has_pending_rescan() if core.restart_request or core.quit_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 local t = system.get_time() - core.blink_start local h = config.blink_period / 2 local dt = math.ceil(t / h) * h - t system.wait_event(dt + 1 / config.fps) else system.wait_event() end end else idle_iterations = 0 local elapsed = system.get_time() - core.frame_start system.sleep(math.max(0, 1 / config.fps - elapsed)) end end end function core.blink_reset() core.blink_start = system.get_time() end function core.request_cursor(value) core.cursor_change_req = value 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 return core