From fe9ab1c922df844d4824d5595c685f69e9bdbc38 Mon Sep 17 00:00:00 2001 From: Alessandro Mauri Date: Tue, 6 Jul 2021 14:35:54 +0200 Subject: [PATCH] added console plugin --- TODO | 10 +- data/plugins/console.lua | 369 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 data/plugins/console.lua diff --git a/TODO b/TODO index e54bfc1..e866dad 100644 --- a/TODO +++ b/TODO @@ -7,10 +7,14 @@ [x] makefile: add install and uninstall [ ] makefile fix permissions of installed data dir [ ] SDL: disable key not recognized messages -[ ] use system fonts +[ ] font: use system fonts +[ ] font: implement hashmap of already rendered glyphs and only re-draw the + ones that are not in that map [ ] resize text on the fly -[ ] add menu to exec arbitrary command +[x] add menu to exec arbitrary command -> added console [ ] that command can be what is selected +[ ] console: accept input +[ ] doc: detect file changes with hash [ ] add a cross to close tab [ ] prevent overscrolling on treeview [x] user config file and/or init.lua @@ -33,6 +37,8 @@ [x] ctrl+n -> next_find [x] ctrl+shift+n -> prev_find [x] f3 -> new file + [ ] selected + tab == indent up + [ ] selected +shift+tab == indent down [x] change alt+ to ctrl+ [ ] add multi cursor system [ ] add binding to open up a cheatsheet of keybinding, with a diff --git a/data/plugins/console.lua b/data/plugins/console.lua new file mode 100644 index 0000000..634df42 --- /dev/null +++ b/data/plugins/console.lua @@ -0,0 +1,369 @@ +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