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 TitleView 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 update_recents_project(action, dir_path_abs) local dirname = common.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 local function cleanup_recent_projects() local recents = core.recent_projects local i = 1 while i <= #recents do local info = system.get_file_info(recents[i]) if not info or info.type ~= "dir" then table.remove(recents, i) else i = i + 1 end 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.open_folder_project(dir_path_abs) core.root_view:close_all_docviews() core.project_entries = {} core.add_project_directory(dir_path_abs) update_recents_project("add", dir_path_abs) 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_entries do local dir = core.project_entries[i] if dir.item.type == 'dir' then 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. ".. "Either tweak this variable, or ignore certain files/directories by ".. "using the config.ignore_files variable in your user plugin or ".. "project config.") end dir.files = t core.redraw = true end end end -- wait for next scan coroutine.yield(config.project_scan_rate) end end local function project_files_iter(state) local dir = core.project_entries[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_entries[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_entries do n = n + #core.project_entries[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_file(path) path = common.normalize_path(path) local entry = { name = path, item = {filename = common.basename(path), type = "file", topdir = true}, files = {path} } table.insert(core.project_entries, entry) 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_path(path) local entry = { name = path, item = {filename = common.basename(path), type = "dir", topdir = true}, files = {} } table.insert(core.project_entries, entry) end function core.remove_project_entry(path) -- skip the fist directory because it is the project's directory for i = 2, #core.project_entries do local dir = core.project_entries[i] if dir.name == path then table.remove(core.project_entries, i) return true end end return false end local function whitespace_replacements() local r = renderer.replacements.new() r:add(" ", "·") r:add("\t", "»") return r end function core.init() command = require "core.command" keymap = require "core.keymap" 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_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 cleanup_recent_projects() core.log_items = {} core.docs = {} core.project_entries = {} local init_files = {} local delayed_errors = {} for i = 2, #ARGS do local filename = strip_trailing_slash(ARGS[i]) local info = system.get_file_info(filename) if info and info.type == "file" then filename = system.absolute_path(filename) if filename then core.add_project_file(filename) table.insert(init_files, filename) end elseif info and info.type == "dir" then filename = system.absolute_path(filename) if filename then core.add_project_directory(filename) update_recents_project("add", filename) end else local error_msg = string.format("error: invalid file or directory \"%s\"", ARGS[i]) table.insert(delayed_errors, error_msg) end end local init_message if #core.project_entries == 0 then if #core.recent_projects > 0 then init_message = string.format("Opening project from directory \"%s\"", core.recent_projects[1]) core.add_project_directory(core.recent_projects[1]) else init_message = string.format("Empty project: use the \"Core: Open File\" command to open a file or a directory.") end end core.threads = setmetatable({}, { __mode = "k" }) core.frame_start = 0 core.clip_rect_stack = {{ 0,0,0,0 }} core.window_mode = "normal" core.blink_start = system.get_time() core.blink_timer = core.blink_start core.redraw = true core.visited_files = {} core.restart_request = false core.replacements = whitespace_replacements() 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}) core.project_scan_thread_id = core.add_thread(project_scan_thread) command.add_defaults() for _, project_entry in ipairs(core.project_entries) do if project_entry.item.type == "dir" then core.log_quiet("Setting working directory to \"%s\"", project_entry.name) system.chdir(project_entry.name) core.working_dir = project_entry.name break end end if not core.working_dir then core.working_dir = system.absolute_path(".") end if init_message then core.log(init_message) end local got_user_error = not core.load_user_directory() local plugins_success, plugins_refuse_list = core.load_plugins() local got_project_error = not core.load_project_module() for _, filename in ipairs(init_files) do core.root_view:open_doc(core.open_doc(filename)) end for _, error_msg in ipairs(delayed_errors) do core.error(error_msg) end if not plugins_success or got_user_error or got_project_error then command.perform("core:open-log") end system.set_window_bordered(not config.borderless) core.title_view:configure_hit_test(config.borderless) core.title_view.visible = config.borderless 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/franko/lite-plugins.", table.concat(msg, ".\n\n")), opt, function(item) if item.text == "Exit" then os.exit(1) end end) 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", 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 = (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 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) if not info then return true end 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 1.16 equivalent to mod-version:1 version_match = (version == '1.16' and MOD_VERSION == "1") break end end f:close() return version_match end function core.load_plugins() local no_errors = true local refused_list = { userdir = {dir = USERDIR, plugins = {}}, datadir = {dir = DATADIR, plugins = {}}, } 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 local version_match = check_plugin_version(plugin_dir .. '/' .. filename) if not version_match then core.log_quiet("Version mismatch for plugin %q from %s", basename, plugin_dir) local ls = refused_list[root_dir == USERDIR and 'userdir' or 'datadir'].plugins ls[#ls + 1] = filename end if version_match and 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, 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.project_entries do local dir = core.project_entries[i] if common.path_belongs_to(filename, dir.name) then local dirpath = common.dirname(dir.name) filename = filename:sub(#dirpath + 2) end end filename = common.home_encode(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.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 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 -- 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.working_dir so relative filename are considered -- to be in core.working_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.working_dir_absolute_path(filename) if filename:match('^%a:\\') or filename:find('/', 1, true) == 1 then return filename else return core.working_dir .. PATHSEP .. filename end end function core.normalize_to_working_dir(filename) filename = common.normalize_path(filename) return core.working_dir_absolute_path(filename) end function core.open_doc(filename) local new_file = not filename or not system.get_file_info(filename) if filename then -- normalize filename and set absolute filename then -- try to find existing doc for filename filename = core.normalize_to_working_dir(filename) for _, doc in ipairs(core.docs) do if doc.filename and filename == doc.filename then return doc end end end -- no existing doc for filename; create new local doc = Doc(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(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" 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 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 == "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 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 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 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(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() 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 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.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