local core = require "core" local keymap = require "core.keymap" local command = require "core.command" local common = require "core.common" local config = require "core.config" local style = require "core.style" local View = require "core.view" config.console_size = 250 * SCALE config.max_console_lines = 200 config.autoscroll_console = true local files = { script = core.temp_filename(".sh"), script2 = core.temp_filename(".sh"), output = core.temp_filename(), complete = core.temp_filename(), } local console = {} local views = {} local pending_threads = {} local thread_active = false local output = nil local output_id = 0 local visible = false function console.clear() output = { { text = "", time = 0 } } end local function read_file(filename, offset) local fp = io.open(filename, "rb") fp:seek("set", offset or 0) local res = fp:read("*a") fp:close() return res end local function write_file(filename, text) local fp = io.open(filename, "w") fp:write(text) fp:close() end local function lines(text) return (text .. "\n"):gmatch("(.-)\n") end local function push_output(str, opt) local first = true for line in lines(str) do if first then line = table.remove(output).text .. line end line = line:gsub("\x1b%[[%d;]+m", "") -- strip ANSI colors table.insert(output, { text = line, time = os.time(), icon = line:find(opt.error_pattern) and "!" or line:find(opt.warning_pattern) and "i", file_pattern = opt.file_pattern, }) if #output > config.max_console_lines then table.remove(output, 1) for view in pairs(views) do view:on_line_removed() end end first = false end output_id = output_id + 1 core.redraw = true end local function init_opt(opt) local res = { command = "", file_pattern = "[^?:%s]+%.[^?:%s]+", error_pattern = "error", warning_pattern = "warning", on_complete = function() end, } for k, v in pairs(res) do res[k] = opt[k] or v end return res end function console.run(opt) opt = init_opt(opt) local function thread() -- init script file(s) write_file(files.script, string.format([[ %s touch %q ]], opt.command, files.complete)) local shell = os.getenv("SHELL") or '/bin/sh' system.exec(string.format("%q %q >%q 2>&1", shell, files.script, files.output)) -- checks output file for change and reads local last_size = 0 local function check_output_file() local info = system.get_file_info(files.output) if info and info.size > last_size then local text = read_file(files.output, last_size) push_output(text, opt) last_size = info.size end end -- read output file until we get a file indicating completion while not system.get_file_info(files.complete) do check_output_file() coroutine.yield(0.1) end check_output_file() if output[#output].text ~= "" then push_output("\n", opt) end push_output("!DIVIDER\n", opt) -- clean up and finish for _, file in pairs(files) do os.remove(file) end opt.on_complete() -- handle pending thread local pending = table.remove(pending_threads, 1) if pending then core.add_thread(pending) else thread_active = false end end -- push/init thread if thread_active then table.insert(pending_threads, thread) else core.add_thread(thread) thread_active = true end -- make sure static console is visible if it's the only ConsoleView local count = 0 for _ in pairs(views) do count = count + 1 end if count == 1 then visible = true end end local ConsoleView = View:extend() function ConsoleView:new() ConsoleView.super.new(self) self.scrollable = true self.hovered_idx = -1 views[self] = true end function ConsoleView:try_close(...) ConsoleView.super.try_close(self, ...) views[self] = nil end function ConsoleView:get_name() return "Console" end function ConsoleView:get_line_height() return style.code_font:get_height() * config.line_height end function ConsoleView:get_line_count() return #output - (output[#output].text == "" and 1 or 0) end function ConsoleView:get_scrollable_size() return self:get_line_count() * self:get_line_height() + style.padding.y * 2 end function ConsoleView:get_visible_line_range() local lh = self:get_line_height() local min = math.max(1, math.floor(self.scroll.y / lh)) return min, min + math.floor(self.size.y / lh) + 1 end function ConsoleView:on_mouse_moved(mx, my, ...) ConsoleView.super.on_mouse_moved(self, mx, my, ...) self.hovered_idx = 0 for i, item, x,y,w,h in self:each_visible_line() do if mx >= x and my >= y and mx < x + w and my < y + h then if item.text:find(item.file_pattern) then self.hovered_idx = i end break end end end local function resolve_file(name) if system.get_file_info(name) then return name end local filenames = {} for _, f in ipairs(core.project_files) do table.insert(filenames, f.filename) end local t = common.fuzzy_match(filenames, name) return t[1] end function ConsoleView:on_line_removed() local diff = self:get_line_height() self.scroll.y = self.scroll.y - diff self.scroll.to.y = self.scroll.to.y - diff end function ConsoleView:on_mouse_pressed(...) local caught = ConsoleView.super.on_mouse_pressed(self, ...) if caught then return end local item = output[self.hovered_idx] if item then local file, line, col = item.text:match(item.file_pattern) local resolved_file = resolve_file(file) if not resolved_file then core.error("Couldn't resolve file \"%s\"", file) return end core.try(function() core.set_active_view(core.last_active_view) local dv = core.root_view:open_doc(core.open_doc(resolved_file)) if line then dv.doc:set_selection(line, col or 0) dv:scroll_to_line(line, false, true) end end) end end function ConsoleView:each_visible_line() return coroutine.wrap(function() local x, y = self:get_content_offset() local lh = self:get_line_height() local min, max = self:get_visible_line_range() y = y + lh * (min - 1) + style.padding.y max = math.min(max, self:get_line_count()) for i = min, max do local item = output[i] if not item then break end coroutine.yield(i, item, x, y, self.size.x, lh) y = y + lh end end) end function ConsoleView:update(...) if self.last_output_id ~= output_id then if config.autoscroll_console then self.scroll.to.y = self:get_scrollable_size() end self.last_output_id = output_id end ConsoleView.super.update(self, ...) end function ConsoleView:draw() self:draw_background(style.background) local icon_w = style.icon_font:get_width("!") for i, item, x, y, w, h in self:each_visible_line() do local tx = x + style.padding.x local time = os.date("%H:%M:%S", item.time) local color = style.text if self.hovered_idx == i then color = style.accent renderer.draw_rect(x, y, w, h, style.line_highlight) end if item.text == "!DIVIDER" then local w = style.font:get_width(time) renderer.draw_rect(tx, y + h / 2, w, math.ceil(SCALE * 1), style.dim) else tx = common.draw_text(style.font, style.dim, time, "left", tx, y, w, h) tx = tx + style.padding.x if item.icon then common.draw_text(style.icon_font, color, item.icon, "left", tx, y, w, h) end tx = tx + icon_w + style.padding.x common.draw_text(style.code_font, color, item.text, "left", tx, y, w, h) end end self:draw_scrollbar(self) end -- init static bottom-of-screen console local view = ConsoleView() local node = core.root_view:get_active_node() node:split("down", view, true) function view:update(...) local dest = visible and config.console_size or 0 self:move_towards(self.size, "y", dest) ConsoleView.update(self, ...) end local last_command = "" command.add(nil, { ["console:reset-output"] = function() output = { { text = "", time = 0 } } end, ["console:open-console"] = function() local node = core.root_view:get_active_node() node:add_view(ConsoleView()) end, ["console:toggle"] = function() visible = not visible end, ["console:run"] = function() core.command_view:set_text(last_command, true) core.command_view:enter("Run Console Command", function(cmd) console.run { command = cmd } last_command = cmd end) end }) keymap.add { ["ctrl+."] = "console:toggle", ["ctrl+shift+."] = "console:run", } -- for `workspace` plugin: package.loaded["plugins.console.view"] = ConsoleView console.clear() return console