diff --git a/data/core/InputDoc.lua b/data/core/InputDoc.lua new file mode 100644 index 00000000..38d24988 --- /dev/null +++ b/data/core/InputDoc.lua @@ -0,0 +1,330 @@ +local Object = require "core.object" +local Highlighter = require "core.doc.highlighter" +local syntax = require "core.syntax" +local config = require "core.config" +local common = require "core.common" + + +local InputDoc = Object:extend() + + +local function split_lines(text) + local res = {} + for line in (text .. "\n"):gmatch("(.-)\n") do + table.insert(res, line) + end + return res +end + + +local function splice(t, at, remove, insert) + insert = insert or {} + local offset = #insert - remove + local old_len = #t + if offset < 0 then + for i = at - offset, old_len - offset do + t[i + offset] = t[i] + end + elseif offset > 0 then + for i = old_len, at, -1 do + t[i + offset] = t[i] + end + end + for i, item in ipairs(insert) do + t[at + i - 1] = item + end +end + + +function InputDoc:new(syntax) + self.syntax = syntax + self:reset() +end + + +function InputDoc:reset() + self.lines = { "\n" } + self.selection = { a = { line=1, col=1 }, b = { line=1, col=1 } } + self.undo_stack = { idx = 1 } + self.redo_stack = { idx = 1 } + -- self.clean_change_id = 1 + self.highlighter = Highlighter(self) +end + +function InputDoc:get_change_id() + return self.undo_stack.idx +end + + +function InputDoc:set_selection(line1, col1, line2, col2, swap) + assert(not line2 == not col2, "expected 2 or 4 arguments") + if swap then line1, col1, line2, col2 = line2, col2, line1, col1 end + line1, col1 = self:sanitize_position(line1, col1) + line2, col2 = self:sanitize_position(line2 or line1, col2 or col1) + self.selection.a.line, self.selection.a.col = line1, col1 + self.selection.b.line, self.selection.b.col = line2, col2 +end + + +local function sort_positions(line1, col1, line2, col2) + if line1 > line2 + or line1 == line2 and col1 > col2 then + return line2, col2, line1, col1, true + end + return line1, col1, line2, col2, false +end + + +function InputDoc:get_selection(sort) + local a, b = self.selection.a, self.selection.b + if sort then + return sort_positions(a.line, a.col, b.line, b.col) + end + return a.line, a.col, b.line, b.col +end + + +function InputDoc:has_selection() + local a, b = self.selection.a, self.selection.b + return not (a.line == b.line and a.col == b.col) +end + + +function InputDoc:sanitize_selection() + self:set_selection(self:get_selection()) +end + + +function InputDoc:sanitize_position(line, col) + line = common.clamp(line, 1, #self.lines) + col = common.clamp(col, 1, #self.lines[line]) + return line, col +end + + +local function position_offset_func(self, line, col, fn, ...) + line, col = self:sanitize_position(line, col) + return fn(self, line, col, ...) +end + + +local function position_offset_byte(self, line, col, offset) + line, col = self:sanitize_position(line, col) + col = col + offset + while line > 1 and col < 1 do + line = line - 1 + col = col + #self.lines[line] + end + while line < #self.lines and col > #self.lines[line] do + col = col - #self.lines[line] + line = line + 1 + end + return self:sanitize_position(line, col) +end + + +local function position_offset_linecol(self, line, col, lineoffset, coloffset) + return self:sanitize_position(line + lineoffset, col + coloffset) +end + + +function Doc:position_offset(line, col, ...) + if type(...) ~= "number" then + return position_offset_func(self, line, col, ...) + elseif select("#", ...) == 1 then + return position_offset_byte(self, line, col, ...) + elseif select("#", ...) == 2 then + return position_offset_linecol(self, line, col, ...) + else + error("bad number of arguments") + end +end + + +function Doc:get_text(line1, col1, line2, col2) + line1, col1 = self:sanitize_position(line1, col1) + line2, col2 = self:sanitize_position(line2, col2) + line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2) + if line1 == line2 then + return self.lines[line1]:sub(col1, col2 - 1) + end + local lines = { self.lines[line1]:sub(col1) } + for i = line1 + 1, line2 - 1 do + table.insert(lines, self.lines[i]) + end + table.insert(lines, self.lines[line2]:sub(1, col2 - 1)) + return table.concat(lines) +end + + +function Doc:get_char(line, col) + line, col = self:sanitize_position(line, col) + return self.lines[line]:sub(col, col) +end + + +local function push_undo(undo_stack, time, type, ...) + undo_stack[undo_stack.idx] = { type = type, time = time, ... } + undo_stack[undo_stack.idx - config.max_undos] = nil + undo_stack.idx = undo_stack.idx + 1 +end + + +local function pop_undo(self, undo_stack, redo_stack) + -- pop command + local cmd = undo_stack[undo_stack.idx - 1] + if not cmd then return end + undo_stack.idx = undo_stack.idx - 1 + + -- handle command + if cmd.type == "insert" then + local line, col, text = table.unpack(cmd) + self:raw_insert(line, col, text, redo_stack, cmd.time) + + elseif cmd.type == "remove" then + local line1, col1, line2, col2 = table.unpack(cmd) + self:raw_remove(line1, col1, line2, col2, redo_stack, cmd.time) + + elseif cmd.type == "selection" then + self.selection.a.line, self.selection.a.col = cmd[1], cmd[2] + self.selection.b.line, self.selection.b.col = cmd[3], cmd[4] + end + + -- if next undo command is within the merge timeout then treat as a single + -- command and continue to execute it + local next = undo_stack[undo_stack.idx - 1] + if next and math.abs(cmd.time - next.time) < config.undo_merge_timeout then + return pop_undo(self, undo_stack, redo_stack) + end +end + + +function Doc:raw_insert(line, col, text, undo_stack, time) + -- split text into lines and merge with line at insertion point + local lines = split_lines(text) + local before = self.lines[line]:sub(1, col - 1) + local after = self.lines[line]:sub(col) + for i = 1, #lines - 1 do + lines[i] = lines[i] .. "\n" + end + lines[1] = before .. lines[1] + lines[#lines] = lines[#lines] .. after + + -- splice lines into line array + splice(self.lines, line, 1, lines) + + -- push undo + local line2, col2 = self:position_offset(line, col, #text) + push_undo(undo_stack, time, "selection", self:get_selection()) + push_undo(undo_stack, time, "remove", line, col, line2, col2) + + -- update highlighter and assure selection is in bounds + self.highlighter:invalidate(line) + self:sanitize_selection() +end + + +function Doc:raw_remove(line1, col1, line2, col2, undo_stack, time) + -- push undo + local text = self:get_text(line1, col1, line2, col2) + push_undo(undo_stack, time, "selection", self:get_selection()) + push_undo(undo_stack, time, "insert", line1, col1, text) + + -- get line content before/after removed text + local before = self.lines[line1]:sub(1, col1 - 1) + local after = self.lines[line2]:sub(col2) + + -- splice line into line array + splice(self.lines, line1, line2 - line1 + 1, { before .. after }) + + -- update highlighter and assure selection is in bounds + self.highlighter:invalidate(line1) + self:sanitize_selection() +end + + +function Doc:insert(line, col, text) + self.redo_stack = { idx = 1 } + line, col = self:sanitize_position(line, col) + self:raw_insert(line, col, text, self.undo_stack, system.get_time()) +end + + +function Doc:remove(line1, col1, line2, col2) + self.redo_stack = { idx = 1 } + line1, col1 = self:sanitize_position(line1, col1) + line2, col2 = self:sanitize_position(line2, col2) + line1, col1, line2, col2 = sort_positions(line1, col1, line2, col2) + self:raw_remove(line1, col1, line2, col2, self.undo_stack, system.get_time()) +end + + +function Doc:undo() + pop_undo(self, self.undo_stack, self.redo_stack) +end + + +function Doc:redo() + pop_undo(self, self.redo_stack, self.undo_stack) +end + + +function Doc:text_input(text) + if self:has_selection() then + self:delete_to() + end + local line, col = self:get_selection() + self:insert(line, col, text) + self:move_to(#text) +end + + +function Doc:replace(fn) + local line1, col1, line2, col2, swap + local had_selection = self:has_selection() + if had_selection then + line1, col1, line2, col2, swap = self:get_selection(true) + else + line1, col1, line2, col2 = 1, 1, #self.lines, #self.lines[#self.lines] + end + local old_text = self:get_text(line1, col1, line2, col2) + local new_text, n = fn(old_text) + if old_text ~= new_text then + self:insert(line2, col2, new_text) + self:remove(line1, col1, line2, col2) + if had_selection then + line2, col2 = self:position_offset(line1, col1, #new_text) + self:set_selection(line1, col1, line2, col2, swap) + end + end + return n +end + + +function Doc:delete_to(...) + local line, col = self:get_selection(true) + if self:has_selection() then + self:remove(self:get_selection()) + else + local line2, col2 = self:position_offset(line, col, ...) + self:remove(line, col, line2, col2) + line, col = sort_positions(line, col, line2, col2) + end + self:set_selection(line, col) +end + + +function Doc:move_to(...) + local line, col = self:get_selection() + self:set_selection(self:position_offset(line, col, ...)) +end + + +function Doc:select_to(...) + local line, col, line2, col2 = self:get_selection() + line, col = self:position_offset(line, col, ...) + self:set_selection(line, col, line2, col2) +end + + +return Doc diff --git a/data/plugins/console.lua b/data/plugins/console.lua new file mode 100644 index 00000000..7196f4f7 --- /dev/null +++ b/data/plugins/console.lua @@ -0,0 +1,383 @@ +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(PLATFORM == "Windows" and ".bat"), + script2 = core.temp_filename(PLATFORM == "Windows" and ".bat"), + 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) + if PLATFORM == "Windows" then + write_file(files.script, opt.command .. "\n") + write_file(files.script2, string.format([[ + @echo off + call %q >%q 2>&1 + echo "" >%q + exit + ]], files.script, files.output, files.complete)) + system.exec(string.format("call %q", files.script2)) + else + write_file(files.script, string.format([[ + %s + touch %q + ]], opt.command, files.complete)) + system.exec(string.format("bash %q >%q 2>&1", files.script, files.output)) + end + + -- checks output file for change and reads + local last_size = 0 + local function check_output_file() + if PLATFORM == "Windows" then + local fp = io.open(files.output) + if fp then fp:close() end + end + 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 diff --git a/data/plugins/repl.lua b/data/plugins/repl.lua new file mode 100644 index 00000000..119be8d3 --- /dev/null +++ b/data/plugins/repl.lua @@ -0,0 +1,37 @@ +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" + + +local function new_node() { + return { input = "" } +} + +local nodes = { + new_node() +} + +local ReplView = View:extend() + +function ReplView:new() + ReplView.super.new(self) + self.scrollable = true + self.brightness = 0 + -- self:begin_search(text, fn) +end + +local function begin_repl() + local rv = ReplView() + core.root_view:get_active_node():add_view(rv) +end + +command.add(nil, { + ["repl:open"] = function() + begin_repl() + end +}) + diff --git a/notes-repl.md b/notes-repl.md new file mode 100644 index 00000000..783268bf --- /dev/null +++ b/notes-repl.md @@ -0,0 +1,14 @@ +## Steps + +- create an object similar to a Doc (core/doc/init.lua) but not tied to a file. + We may call it InputDoc. +- use DocView(s) (defined in core/docview.lua) but tied to an InputDoc instead + of to a Doc. To make this work InputDoc should implement the same methods + and behave in the same way. +- create a new object derived by View to "assemble" several InputDoc(s). + The InputDoc(s) should corresponds to the input and output snippet of the + REPL. + We may call this view ReplView. + The ReplView should draw its InputDoc(s) with some spacing and displatch + text input events mouve movements etc. + It could be similar to RootView.