local Object = require "core.object" local config = require "core.config" local common = require "core.common" local Doc = 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 local new_len = old_len + offset 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 Doc:new(filename) self:reset() if filename then self:load(filename) end end function Doc: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 end function Doc:load(filename) local fp = assert( io.open(filename, "rb") ) self:reset() self.filename = filename self.lines = {} for line in fp:lines() do if line:byte(-1) == 13 then line = line:sub(1, -2) self.crlf = true end table.insert(self.lines, line .. "\n") end if #self.lines == 0 then table.insert(self.lines, "\n") end fp:close() end function Doc:save(filename) filename = filename or assert(self.filename, "no filename set to default to") local fp = assert( io.open(filename, "wb") ) for _, line in ipairs(self.lines) do if self.crlf then line = line:gsub("\n", "\r\n") end fp:write(line) end fp:close() self.filename = filename or self.filename self:clean() end function Doc:get_name() return self.filename or "unsaved" end function Doc:is_dirty() return self.clean_change_id ~= self:get_change_id() end function Doc:clean() self.clean_change_id = self:get_change_id() end function Doc:get_change_id() return self.undo_stack.idx end function Doc: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 Doc: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 Doc:has_selection() local a, b = self.selection.a, self.selection.b return not (a.line == b.line and a.col == b.col) end function Doc:sanitize_selection() self:set_selection(self:get_selection()) end function Doc: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 push_undo local function insert(self, undo_stack, time, line, col, text) line, col = self:sanitize_position(line, col) -- 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(self, undo_stack, time, "selection", self:get_selection()) push_undo(self, undo_stack, time, "remove", line, col, line2, col2) end local function remove(self, undo_stack, time, 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) -- push undo local text = self:get_text(line1, col1, line2, col2) push_undo(self, undo_stack, time, "selection", self:get_selection()) push_undo(self, 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 }) end function Doc:insert(...) insert(self, self.undo_stack, system.get_time(), ...) self:sanitize_selection() self.redo_stack = { idx = 1 } end function Doc:remove(...) remove(self, self.undo_stack, system.get_time(), ...) self:sanitize_selection() self.redo_stack = { idx = 1 } end function push_undo(self, 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 insert(self, redo_stack, cmd.time, table.unpack(cmd)) elseif cmd.type == "remove" then remove(self, redo_stack, cmd.time, table.unpack(cmd)) 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: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