diff options
Diffstat (limited to 'main.lua')
| -rw-r--r-- | main.lua | 2326 |
1 files changed, 2326 insertions, 0 deletions
diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..58a0841 --- /dev/null +++ b/main.lua @@ -0,0 +1,2326 @@ +local DEBUG = false +local LOG_FILE = "lume.log" +local log_fh = nil + +local function log(msg) + if not DEBUG then return end + if not log_fh then + log_fh = io.open(LOG_FILE, "a") + if not log_fh then return end + end + pcall(function() + log_fh:write(os.date("!%H:%M:%S") .. " " .. tostring(msg) .. "\n") + log_fh:flush() + end) +end + +local function close_log() + if log_fh then pcall(function() log_fh:close() end) end +end + +local function utf8_char_head(b) + return b < 0x80 or b >= 0xC0 +end + +local function utf8_char_len(b) + if b < 0x80 then return 1 + elseif b < 0xC0 then return 1 + elseif b < 0xE0 then return 2 + elseif b < 0xF0 then return 3 + else return 4 end +end + +local function utf8_decode_at(s, i) + local b = s:byte(i) + if not b then return nil, i + 1 end + local len = utf8_char_len(b) + local cp = b + if len == 2 then + cp = ((b & 0x1F) << 6) | ((s:byte(i+1) or 0x80) & 0x3F) + elseif len == 3 then + cp = ((b & 0x0F) << 12) | (((s:byte(i+1) or 0x80) & 0x3F) << 6) | ((s:byte(i+2) or 0x80) & 0x3F) + elseif len == 4 then + cp = ((b & 0x07) << 18) | (((s:byte(i+1) or 0x80) & 0x3F) << 12) + | (((s:byte(i+2) or 0x80) & 0x3F) << 6) | ((s:byte(i+3) or 0x80) & 0x3F) + end + return cp, i + len +end + +local function cp_to_utf8(cp) + if cp < 0x80 then + return string.char(cp) + elseif cp < 0x800 then + return string.char(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F)) + elseif cp < 0x10000 then + return string.char(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F)) + else + return string.char(0xF0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3F), + 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F)) + end +end + +local function cp_display_width(cp) + if cp < 0x20 then return 0 end + if cp < 0x80 then return 1 end + if (cp >= 0x1100 and cp <= 0x115F) + or (cp >= 0x2E80 and cp <= 0x303E) + or (cp >= 0x3040 and cp <= 0x33FF) + or (cp >= 0x3400 and cp <= 0x4DBF) + or (cp >= 0x4E00 and cp <= 0xA4CF) + or (cp >= 0xAC00 and cp <= 0xD7FF) + or (cp >= 0xF900 and cp <= 0xFAFF) + or (cp >= 0xFE10 and cp <= 0xFE6F) + or (cp >= 0xFF01 and cp <= 0xFF60) + or (cp >= 0xFFE0 and cp <= 0xFFE6) + or (cp >= 0x1F300 and cp <= 0x1F9FF) + or (cp >= 0x20000 and cp <= 0x2FFFD) + then return 2 end + return 1 +end + +local function line_display_width(line, byte_limit) + local w, i = 0, 1 + local lim = byte_limit or (#line + 1) + while i <= #line and i < lim do + local cp, ni = utf8_decode_at(line, i) + if not cp then break end + w = w + cp_display_width(cp) + i = ni + end + return w +end + +local function byte_prev(s, pos) + if pos <= 0 then return 0 end + local i = pos - 1 + while i > 0 and not utf8_char_head(s:byte(i) or 0) do i = i - 1 end + return i +end + +local function byte_next(s, pos) + if pos >= #s then return #s end + local b = s:byte(pos + 1) + if not b then return pos end + return math.min(pos + utf8_char_len(b), #s) +end + +local function get_lines(text) + local lines, s = {}, 1 + while true do + local nl = text:find("\n", s, true) + if nl then lines[#lines+1] = text:sub(s, nl-1); s = nl+1 + else lines[#lines+1] = text:sub(s); break end + end + if #lines == 0 then lines[1] = "" end + return lines +end + +local function lines_to_buffer(lines) return table.concat(lines, "\n") end + +local function clamp(v, lo, hi) + return v < lo and lo or v > hi and hi or v +end + +local function leading_spaces(line) + return line:match("^(%s*)") or "" +end + +local function trim_left(s) return s:match("^%s*(.*)") end + +local function path_join(a, b) + return (a:gsub("/+$", "") .. "/" .. b:gsub("^/+", "")) +end + +local function normalize_path(p) + p = p:gsub("//+", "/") + p = p:gsub("^%./", "") + return p +end + +local function escape_pattern(s) + return s:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") +end + +local function escape_replacement(s) + return s:gsub("%%", "%%%%") +end + +local function wch() + local ch = get_wchar() + if not ch or ch < 0 then return nil end + return ch +end + +local function clipboard_write(text) + local p = io.popen("pbcopy", "w") + if not p then return end + p:write(text); p:close() +end + +local function clipboard_read() + local p = io.popen("pbpaste", "r") + if not p then return "" end + local t = p:read("*a"); p:close() + return t or "" +end + +local KILL_RING_MAX = 30 + +local function kill_ring_push(ed, text) + if not text or text == "" then return end + local ring = ed.kill_ring + if ring[#ring] == text then return end + ring[#ring+1] = text + if #ring > KILL_RING_MAX then table.remove(ring, 1) end + ed.kill_ring_idx = #ring + clipboard_write(text) +end + +local function kill_ring_current(ed) + if #ed.kill_ring == 0 then return nil end + return ed.kill_ring[ed.kill_ring_idx] +end + +local function kill_ring_cycle(ed) + if #ed.kill_ring == 0 then return nil end + ed.kill_ring_idx = ((ed.kill_ring_idx - 2) % #ed.kill_ring) + 1 + return ed.kill_ring[ed.kill_ring_idx] +end + +local COLOR = { + NORMAL = 0, + KEYWORD = 2, + STRING = 3, + COMMENT = 4, + NUMBER = 5, + OPERATOR = 6, + MATCH_BR = 7, + MENU_SEL = 8, + SELECTION = 9, + GUTTER = 10, + RULER = 11, + SEARCH_MATCH = 12, + KEYWORD_DEF = 13, + KEYWORD_LOGIC= 14, + KEYWORD_VALUE= 15 +} + +local function setup_colors() + init_color_pair(COLOR.KEYWORD, 6, -1) + init_color_pair(COLOR.KEYWORD_DEF, 6, -1) + init_color_pair(COLOR.KEYWORD_LOGIC, 4, -1) + init_color_pair(COLOR.KEYWORD_VALUE, 1, -1) + init_color_pair(COLOR.STRING, 2, -1) + init_color_pair(COLOR.COMMENT, 8, -1) + init_color_pair(COLOR.NUMBER, 9, -1) + init_color_pair(COLOR.OPERATOR, 3, -1) + init_color_pair(COLOR.MATCH_BR, 3, 1) + init_color_pair(COLOR.MENU_SEL, 0, 6) + init_color_pair(COLOR.SELECTION, 0, 6) + init_color_pair(COLOR.GUTTER, 8, -1) + init_color_pair(COLOR.RULER, 8, -1) + init_color_pair(COLOR.SEARCH_MATCH, 0, 3) +end + +local highlighters = {} +local keyword_sets = {} +local comment_prefixes = {} +local filetype_exts = {} +local filetype_shebangs = {} + +local function register_filetype(name, exts, shebangs, kw_table, comment, hl_fn) + highlighters[name] = hl_fn + keyword_sets[name] = kw_table + comment_prefixes[name] = comment + filetype_exts[name] = exts + filetype_shebangs[name] = shebangs or {} +end + +local function get_filetype(fn, first_line) + for name, exts in pairs(filetype_exts) do + for _, ext in ipairs(exts) do + if fn:match(ext) then return name end + end + end + if first_line then + for name, shebangs in pairs(filetype_shebangs) do + for _, pat in ipairs(shebangs) do + if first_line:match(pat) then return name end + end + end + end + return nil +end + +local function push_span(t, col, len, color) + if len > 0 then t[#t+1] = {col=col, len=len, color=color} end +end + +do + local LUA_KW = { + ["and"]=true,["break"]=true,["do"]=true,["else"]=true, + ["elseif"]=true,["end"]=true,["false"]=true,["for"]=true, + ["function"]=true,["goto"]=true,["if"]=true,["in"]=true, + ["local"]=true,["nil"]=true,["not"]=true,["or"]=true, + ["repeat"]=true,["return"]=true,["then"]=true,["true"]=true, + ["until"]=true,["while"]=true, + } + local LUA_DEF = { + ["function"]=true,["local"]=true, + } + local LUA_LOGIC = { + ["and"]=true,["break"]=true,["do"]=true,["else"]=true, + ["elseif"]=true,["end"]=true,["for"]=true, + ["goto"]=true,["if"]=true,["in"]=true, + ["not"]=true,["or"]=true, + ["repeat"]=true,["return"]=true,["then"]=true, + ["until"]=true,["while"]=true, + } + local LUA_VALUE = { + ["true"]=true,["false"]=true,["nil"]=true, + } + local LUA_OPS = "()[]{},;:.+*/%^#&|~<>=~-" + + register_filetype("lua", {"%.lua$"}, {"lua$"}, LUA_KW, "--", function(line) + local spans, i, n = {}, 1, #line + while i <= n do + local ch = line:sub(i,i) + if ch == "-" and line:sub(i,i+1) == "--" then + push_span(spans, i-1, n-i+1, COLOR.COMMENT) + break + elseif ch == '"' or ch == "'" then + local q, j = ch, i+1 + while j <= n do + local c = line:sub(j,j) + if c == "\\" then j = j+2 + elseif c == q then j = j+1; break + else j = j+1 end + end + push_span(spans, i-1, j-i, COLOR.STRING) + i = j + elseif ch:match("%d") or (ch == "." and line:sub(i+1,i+1):match("%d")) then + local j = i + while j <= n and line:sub(j,j):match("[%d%.xXa-fA-F]") do j = j+1 end + push_span(spans, i-1, j-i, COLOR.NUMBER) + i = j + elseif ch:match("[%a_]") then + local j = i + while j <= n and line:sub(j,j):match("[%w_]") do j = j+1 end + local word = line:sub(i, j-1) + local color = LUA_DEF[word] and COLOR.KEYWORD_DEF + or LUA_LOGIC[word] and COLOR.KEYWORD_LOGIC + or LUA_VALUE[word] and COLOR.KEYWORD_VALUE + if color then push_span(spans, i-1, j-i, color) end + i = j + elseif LUA_OPS:find(ch, 1, true) then + push_span(spans, i-1, 1, COLOR.OPERATOR) + i = i+1 + else + i = i+1 + end + end + return spans + end) + + local C_KW = { + ["auto"]=true,["break"]=true,["case"]=true,["char"]=true, + ["const"]=true,["continue"]=true,["default"]=true,["do"]=true, + ["double"]=true,["else"]=true,["enum"]=true,["extern"]=true, + ["float"]=true,["for"]=true,["goto"]=true,["if"]=true, + ["inline"]=true,["int"]=true,["long"]=true,["register"]=true, + ["return"]=true,["short"]=true,["signed"]=true,["sizeof"]=true, + ["static"]=true,["struct"]=true,["switch"]=true,["typedef"]=true, + ["union"]=true,["unsigned"]=true,["void"]=true,["volatile"]=true, + ["while"]=true,["NULL"]=true,["true"]=true,["false"]=true, + } + local C_OPS = "()[]{},;:.+*/%^#&|~<>=~-" + + register_filetype("c", {"%.c$","%.h$","%.cc$","%.cpp$"}, {}, C_KW, "//", function(line) + local spans, i, n = {}, 1, #line + while i <= n do + local ch = line:sub(i,i) + if line:sub(i,i+1) == "//" then + push_span(spans, i-1, n-i+1, COLOR.COMMENT); break + elseif ch == '"' or ch == "'" then + local q, j = ch, i+1 + while j <= n do + local c = line:sub(j,j) + if c == "\\" then j = j+2 elseif c == q then j = j+1; break else j = j+1 end + end + push_span(spans, i-1, j-i, COLOR.STRING); i = j + elseif ch:match("%d") then + local j = i + while j <= n and line:sub(j,j):match("[%d%.xXuUlLa-fA-F]") do j = j+1 end + push_span(spans, i-1, j-i, COLOR.NUMBER); i = j + elseif ch:match("[%a_]") then + local j = i + while j <= n and line:sub(j,j):match("[%w_]") do j = j+1 end + if C_KW[line:sub(i,j-1)] then push_span(spans, i-1, j-i, COLOR.KEYWORD) end + i = j + elseif C_OPS:find(ch, 1, true) then + push_span(spans, i-1, 1, COLOR.OPERATOR); i = i+1 + else i = i+1 end + end + return spans + end) + + local SH_KW = { + ["if"]=true,["then"]=true,["else"]=true,["elif"]=true,["fi"]=true, + ["for"]=true,["while"]=true,["do"]=true,["done"]=true,["case"]=true, + ["esac"]=true,["in"]=true,["function"]=true,["return"]=true, + ["local"]=true,["export"]=true,["readonly"]=true,["unset"]=true, + ["true"]=true,["false"]=true,["exit"]=true,["echo"]=true, + } + + register_filetype("sh", {"%.sh$","%.bash$","%.zsh$"}, {"bash$","sh$","zsh$"}, SH_KW, "#", function(line) + local spans, i, n = {}, 1, #line + while i <= n do + local ch = line:sub(i,i) + if ch == "#" then + push_span(spans, i-1, n-i+1, COLOR.COMMENT); break + elseif ch == '"' or ch == "'" then + local q, j = ch, i+1 + while j <= n do + local c = line:sub(j,j) + if ch == '"' and c == "\\" then j = j+2 + elseif c == q then j = j+1; break + else j = j+1 end + end + push_span(spans, i-1, j-i, COLOR.STRING); i = j + elseif ch == "$" then + push_span(spans, i-1, 1, COLOR.OPERATOR); i = i+1 + elseif ch:match("%d") then + local j = i + while j <= n and line:sub(j,j):match("%d") do j = j+1 end + push_span(spans, i-1, j-i, COLOR.NUMBER); i = j + elseif ch:match("[%a_]") then + local j = i + while j <= n and line:sub(j,j):match("[%w_]") do j = j+1 end + if SH_KW[line:sub(i,j-1)] then push_span(spans, i-1, j-i, COLOR.KEYWORD) end + i = j + else i = i+1 end + end + return spans + end) +end + +local function get_highlighter(ft) return highlighters[ft] end +local function get_keywords(ft) + local kw = keyword_sets[ft] + if not kw then return {} end + local t = {}; for k in pairs(kw) do t[#t+1] = k end + return t +end +local function get_comment_prefix(ft) return comment_prefixes[ft] end + +local function spans_to_color_map(spans) + local colored = {} + for _, sp in ipairs(spans) do + for c = sp.col, sp.col + sp.len - 1 do colored[c] = sp.color end + end + return colored +end + +local indent_completers = {} + +indent_completers["lua"] = { + {pattern="^function%s", indent=" ", close="end"}, + {pattern="^local%s+function%s", indent=" ", close="end"}, + {pattern="=%s*function%s*%(.-%)%s*$", indent=" ", close="end"}, + {pattern="^if%s.+then$", indent=" ", close="end"}, + {pattern="^elseif%s.+then$", indent=" ", close=nil}, + {pattern="^else$", indent=" ", close=nil}, + {pattern="^for%s.+do$", indent=" ", close="end"}, + {pattern="^while%s.+do$", indent=" ", close="end"}, + {pattern="^do$", indent=" ", close="end"}, + {pattern="^repeat$", indent=" ", close="until "}, + {pattern="{%s*$", indent=" ", close="}"}, +} + +local function get_indent_completers(ft) + return indent_completers[ft] or {} +end + +local BRACKET_PAIRS = { + ["("]={open="(",close=")",dir= 1}, [")"]={open="(",close=")",dir=-1}, + ["["]={open="[",close="]",dir= 1}, ["]"]={open="[",close="]",dir=-1}, + ["{"]={open="{",close="}",dir= 1}, ["}"]={open="{",close="}",dir=-1}, +} + +local function find_matching_bracket(lines, row, col) + local ch = lines[row+1]:sub(col+1,col+1) + local p = BRACKET_PAIRS[ch] + if not p then return nil end + local depth, r = 0, row + while r >= 0 and r < #lines do + local line = lines[r+1] + local ci = (r == row) and col or (p.dir == 1 and 0 or #line-1) + local ei = p.dir == 1 and #line-1 or 0 + while (p.dir==1 and ci<=ei) or (p.dir==-1 and ci>=ei) do + local lc = line:sub(ci+1,ci+1) + if lc == p.open then depth = depth+1 end + if lc == p.close then depth = depth-1 end + if depth == 0 then return r, ci end + ci = ci + p.dir + end + r = r + p.dir + end +end + +local function make_snapshot(ed) + local snap = {} + for i,l in ipairs(ed.lines) do snap[i] = l end + return {lines=snap, cursor_row=ed.cursor_row, cursor_col=ed.cursor_col} +end + +local function push_undo(ed) + ed.undo_stack[#ed.undo_stack+1] = make_snapshot(ed) + if #ed.undo_stack > 200 then table.remove(ed.undo_stack, 1) end + ed.redo_stack = {} +end + +local function pop_undo(ed) + if #ed.undo_stack == 0 then ed.status_msg = "Nothing to undo"; return end + ed.redo_stack[#ed.redo_stack+1] = make_snapshot(ed) + local snap = table.remove(ed.undo_stack) + ed.lines = snap.lines + ed.cursor_row = snap.cursor_row + ed.cursor_col = snap.cursor_col + ed.modified = true + ed.mark = nil + ed.ephemeral = false + ed.status_msg = "Undo" +end + +local function pop_redo(ed) + if #ed.redo_stack == 0 then ed.status_msg = "Nothing to redo"; return end + ed.undo_stack[#ed.undo_stack+1] = make_snapshot(ed) + local snap = table.remove(ed.redo_stack) + ed.lines = snap.lines + ed.cursor_row = snap.cursor_row + ed.cursor_col = snap.cursor_col + ed.modified = true + ed.mark = nil + ed.ephemeral = false + ed.status_msg = "Redo" +end + +local function sel_ordered(ed) + if not ed.mark then return nil end + local mr, mc = ed.mark.row, ed.mark.col + local cr, cc = ed.cursor_row, ed.cursor_col + if mr < cr or (mr == cr and mc <= cc) then + return {r1=mr,c1=mc,r2=cr,c2=cc} + else + return {r1=cr,c1=cc,r2=mr,c2=mc} + end +end + +local function get_selection_text(ed) + local s = sel_ordered(ed) + if not s then return nil end + if s.r1 == s.r2 then + return ed.lines[s.r1+1]:sub(s.c1+1, s.c2) + end + local parts = {ed.lines[s.r1+1]:sub(s.c1+1)} + for r = s.r1+1, s.r2-1 do parts[#parts+1] = ed.lines[r+1] end + parts[#parts+1] = ed.lines[s.r2+1]:sub(1, s.c2) + return table.concat(parts, "\n") +end + +local function delete_selection(ed) + local s = sel_ordered(ed) + if not s then return end + push_undo(ed) + if s.r1 == s.r2 then + local line = ed.lines[s.r1+1] + ed.lines[s.r1+1] = line:sub(1,s.c1) .. line:sub(s.c2+1) + else + local first = ed.lines[s.r1+1]:sub(1,s.c1) + local last = ed.lines[s.r2+1]:sub(s.c2+1) + for _ = s.r1+1, s.r2 do table.remove(ed.lines, s.r1+2) end + ed.lines[s.r1+1] = first .. last + end + ed.cursor_row = s.r1 + ed.cursor_col = s.c1 + ed.mark = nil + ed.ephemeral = false + ed.modified = true +end + +local function indent_selection(ed, dedent) + local s = sel_ordered(ed) + if not s then return end + push_undo(ed) + for r = s.r1, s.r2 do + local line = ed.lines[r+1] + ed.lines[r+1] = dedent and line:gsub("^ ", "", 1) or (" " .. line) + end + ed.modified = true + ed.status_msg = dedent and "Dedented" or "Indented" +end + +local function col_in_selection(s, row, col) + if not s then return false end + if row < s.r1 or row > s.r2 then return false end + if s.r1 == s.r2 then return col >= s.c1 and col < s.c2 end + if row == s.r1 then return col >= s.c1 end + if row == s.r2 then return col < s.c2 end + return true +end + +local function clear_ephemeral(ed) + if ed.ephemeral then + ed.mark = nil + ed.ephemeral = false + end +end + +local function make_buffer(filename, lines) + local fn = normalize_path(filename or "") + local first_line = (lines and lines[1]) or "" + local ft = get_filetype(fn, first_line) + return { + filename = fn, + filetype = ft, + lines = lines or {""}, + modified = false, + cursor_row = 0, + cursor_col = 0, + col_offset = 0, + scroll_offset = 0, + mark = nil, + ephemeral = false, + undo_stack = {}, + redo_stack = {}, + highlighter = get_highlighter(ft), + completers = get_indent_completers(ft), + keywords = get_keywords(ft), + comment_prefix = get_comment_prefix(ft), + } +end + +local function save_editor_to_buffer(ed, buf) + buf.filename = ed.filename + buf.filetype = ed.filetype + buf.lines = ed.lines + buf.modified = ed.modified + buf.cursor_row = ed.cursor_row + buf.cursor_col = ed.cursor_col + buf.col_offset = ed.col_offset + buf.scroll_offset = ed.scroll_offset + buf.mark = ed.mark + buf.ephemeral = ed.ephemeral + buf.undo_stack = ed.undo_stack + buf.redo_stack = ed.redo_stack + buf.highlighter = ed.highlighter + buf.completers = ed.completers + buf.keywords = ed.keywords + buf.comment_prefix = ed.comment_prefix +end + +local function load_buffer_to_editor(ed, buf) + ed.filename = buf.filename + ed.filetype = buf.filetype + ed.lines = buf.lines + ed.modified = buf.modified + ed.cursor_row = buf.cursor_row + ed.cursor_col = buf.cursor_col + ed.col_offset = buf.col_offset or 0 + ed.scroll_offset = buf.scroll_offset + ed.mark = buf.mark + ed.ephemeral = buf.ephemeral or false + ed.undo_stack = buf.undo_stack or {} + ed.redo_stack = buf.redo_stack or {} + ed.highlighter = buf.highlighter + ed.completers = buf.completers + ed.keywords = buf.keywords + ed.comment_prefix = buf.comment_prefix +end + +local function collect_files(dir, results) + local entries = list_dir(dir) + if not entries then return end + for _, name in ipairs(entries) do + if name ~= "." and name ~= ".." and not name:match("^%.") then + local path = normalize_path(path_join(dir, name)) + local kind = stat_file(path) + if kind == "file" then results[#results+1] = path + elseif kind == "dir" then collect_files(path, results) end + end + end +end + +local function search_in_file(path, pattern) + local content, err = load_file(path) + if err then return {} end + local hits, lnum = {}, 1 + local is_lower = (pattern == pattern:lower()) + for line in (content.."\n"):gmatch("([^\n]*)\n") do + local target = is_lower and line:lower() or line + local s = 1 + while true do + local col = target:find(pattern, s, true) + if not col then break end + hits[#hits+1] = {file=path, line=lnum, col=col, text=line} + s = col + 1 + end + lnum = lnum + 1 + end + return hits +end + +local function yn_prompt(ed, label) + local maxw = ed.screen_cols - 1 + set_reverse(true) + print_at(ed.screen_rows-1, 0, string.rep(" ", math.max(0, maxw))) + print_at(ed.screen_rows-1, 0, (label .. " (y/n)"):sub(1, maxw)) + set_reverse(false) + refresh_screen() + local ch = wch() + return ch == string.byte("y") or ch == string.byte("Y") +end + +local function mini_prompt(ed, label, default) + local input = default or "" + while true do + local maxw = ed.screen_cols - 1 + local bar = label .. input + if #bar > maxw then bar = bar:sub(#bar - maxw + 1) end + set_reverse(true) + print_at(ed.screen_rows-1, 0, string.rep(" ", math.max(0, maxw))) + print_at(ed.screen_rows-1, 0, bar) + set_reverse(false) + refresh_screen() + local ch = wch() + if not ch then goto continue end + if ch == 7 then return nil end + if ch == 10 or ch == 13 then return input end + if ch == 127 or ch == 8 or ch == 263 then + input = input:sub(1, #input-1) + elseif ch >= 32 then + input = input .. cp_to_utf8(ch) + end + ::continue:: + end +end + +local function file_prompt(ed, label) + local input = "" + local matches = {} + local match_i = 0 + + local function refresh_matches(partial) + local dir, prefix + local slash = partial:match(".*/()") + if slash then + dir = partial:sub(1, slash-1) + prefix = partial:sub(slash) + else + dir = "." + prefix = partial + end + local entries = list_dir(dir ~= "" and dir or ".") or {} + matches = {} + for _, name in ipairs(entries) do + if name ~= "." and name ~= ".." and name:sub(1, #prefix) == prefix then + local full = dir ~= "" and normalize_path(path_join(dir, name)) or name + local kind = stat_file(full) + if kind == "dir" then full = full .. "/" end + matches[#matches+1] = full + end + end + table.sort(matches) + match_i = 0 + end + + refresh_matches("") + + while true do + local maxw = ed.screen_cols - 1 + local bar = label .. input + if #bar > maxw then bar = bar:sub(#bar - maxw + 1) end + set_reverse(true) + print_at(ed.screen_rows-1, 0, string.rep(" ", math.max(0, maxw))) + print_at(ed.screen_rows-1, 0, bar) + set_reverse(false) + + if #matches > 0 then + local max_show = math.min(6, #matches) + local popup_top = ed.screen_rows - 1 - max_show + for k = 1, max_show do + local mi = ((match_i - 1 + k - 1) % #matches) + 1 + local item = (" " .. matches[mi]):sub(1, maxw) + item = item .. string.rep(" ", math.max(0, maxw - #item)) + if mi == match_i then + set_color(COLOR.MENU_SEL, true) + print_at(popup_top + k - 1, 0, item) + set_color(COLOR.MENU_SEL, false) + else + set_reverse(true) + print_at(popup_top + k - 1, 0, item) + set_reverse(false) + end + end + end + + refresh_screen() + local ch = wch() + if not ch then goto continue end + + if ch == 7 then return nil end + if ch == 10 or ch == 13 then + return (match_i > 0 and matches[match_i] or input) + end + if ch == 9 then + if #matches == 0 then refresh_matches(input) end + if #matches > 0 then + match_i = (match_i % #matches) + 1 + input = matches[match_i] + end + elseif ch == 127 or ch == 8 or ch == 263 then + input = input:sub(1, #input-1) + match_i = 0 + refresh_matches(input) + elseif ch == 259 then + if #matches > 0 then + match_i = ((match_i - 2 + #matches) % #matches) + 1 + input = matches[match_i] + end + elseif ch == 258 then + if #matches > 0 then + match_i = (match_i % #matches) + 1 + input = matches[match_i] + end + elseif ch >= 32 then + input = input .. cp_to_utf8(ch) + match_i = 0 + refresh_matches(input) + end + ::continue:: + end +end + +local function ac_build(ed) + local line = ed.lines[ed.cursor_row+1] + local before = line:sub(1, ed.cursor_col) + local prefix = before:match("[%a_][%w_]*$") or "" + if #prefix < 2 then return nil end + + local cur_ft = ed.filetype + local pl = #prefix + local cur_freq, other_freq = {}, {} + + for bi, buf in ipairs(ed.buffers) do + local is_cur = (bi == ed.current_buf) + local is_same_ft = (buf.filetype == cur_ft) + if is_cur or is_same_ft then + local src = is_cur and cur_freq or other_freq + local blines = is_cur and ed.lines or buf.lines + for _, ln in ipairs(blines) do + for w in ln:gmatch("[%a_][%w_]+") do + if w ~= prefix and #w > pl and w:sub(1,pl) == prefix then + src[w] = (src[w] or 0) + 1 + end + end + end + end + end + + local kw_freq = {} + local kw_set = keyword_sets[cur_ft] or {} + for w in pairs(kw_set) do + if w ~= prefix and #w > pl and w:sub(1,pl) == prefix then + local total = (cur_freq[w] or 0) + (other_freq[w] or 0) + kw_freq[w] = total + end + end + for w in pairs(kw_freq) do cur_freq[w] = nil; other_freq[w] = nil end + for w in pairs(cur_freq) do other_freq[w] = nil end + + local function sorted_by_freq(t) + local list = {} + for w, f in pairs(t) do + list[#list+1] = {w = w, f = f} + end + table.sort(list, function(a, b) + if a.f ~= b.f then + return a.f < b.f + end + + return a.w < b.w + end) + + local out = {} + for _, v in ipairs(list) do + out[#out+1] = v.w + end + return out + end + + local candidates = {} + for _, w in ipairs(sorted_by_freq(other_freq)) do candidates[#candidates+1] = w end + for _, w in ipairs(sorted_by_freq(cur_freq)) do candidates[#candidates+1] = w end + for _, w in ipairs(sorted_by_freq(kw_freq)) do candidates[#candidates+1] = w end + + if #candidates == 0 then return nil end + return {candidates=candidates, sel=#candidates, prefix_len=pl} +end + +local function ac_apply(ed, ac) + local cand = ac.candidates[ac.sel] + local suffix = cand:sub(ac.prefix_len+1) + local l = ed.lines[ed.cursor_row+1] + ed.lines[ed.cursor_row+1] = l:sub(1,ed.cursor_col)..suffix..l:sub(ed.cursor_col+1) + ed.cursor_col = ed.cursor_col + #suffix + ed.modified = true +end + +local editor = { + lines = {""}, + modified = false, + filename = "", + filetype = nil, + scroll_offset = 0, + col_offset = 0, + cursor_row = 0, + cursor_col = 0, + screen_rows = 0, + screen_cols = 0, + undo_stack = {}, + redo_stack = {}, + kill_ring = {}, + kill_ring_idx = 0, + status_msg = "", + highlighter = nil, + completers = {}, + keywords = {}, + comment_prefix = nil, + last_search = "", + mark = nil, + ephemeral = false, + ac = nil, + prev_was_yank = false, + SCROLL_MARGIN = 5, + H_SCROLL_MARGIN= 5, + GUTTER_WIDTH = 5, + RULER_COL = 80, + buffers = {}, + current_buf = 1, + _quit = false, +} + +function editor:draw() + resize_terminal() + local new_rows, new_cols = get_screen_size() + self.screen_rows = new_rows + self.screen_cols = new_cols + + clear_screen() + local gw = self.GUTTER_WIDTH + local text_w = self.screen_cols - gw - 1 + local max_rows = self.screen_rows - 2 + local sel = sel_ordered(self) + local mbr, mbc = find_matching_bracket(self.lines, self.cursor_row, self.cursor_col) + + for i = 0, max_rows-1 do + local li = self.scroll_offset + i + 1 + if li > #self.lines then break end + local line = self.lines[li] + local row = li - 1 + + local gstr = tostring(li) + gstr = string.rep(" ", math.max(0, gw-1-#gstr)) .. gstr .. " " + set_color(COLOR.GUTTER, true) + if row == self.cursor_row then set_reverse(true) end + print_at(i, 0, gstr) + if row == self.cursor_row then set_reverse(false) end + set_color(COLOR.GUTTER, false) + + local spans = self.highlighter and self.highlighter(line) or {} + local colored = spans_to_color_map(spans) + local ruler_abs = self.RULER_COL - 1 + + if self.search_hits then + for _, h in ipairs(self.search_hits) do + if h.line == row then + for c = h.col, h.col + h.len - 1 do + if not col_in_selection(sel_ordered(self), row, c) then + colored[c] = COLOR.SEARCH_MATCH + end + end + end + end + end + + local disp_skipped = 0 + local byte_i = 1 + local screen_col = 0 + + while byte_i <= #line do + local cp, next_bi = utf8_decode_at(line, byte_i) + if not cp then break end + local cw = cp_display_width(cp) + local abs_byte = byte_i - 1 + + if disp_skipped < self.col_offset then + disp_skipped = disp_skipped + cw + byte_i = next_bi + goto next_char + end + + if screen_col >= text_w then break end + + local char_bytes = line:sub(byte_i, next_bi - 1) + if char_bytes == "" then char_bytes = " " end + + local color = colored[abs_byte] or COLOR.NORMAL + local in_sel = col_in_selection(sel, row, abs_byte) + local is_cur = (row == self.cursor_row and abs_byte == self.cursor_col) + local is_mbr = (mbr == row and mbc == abs_byte) + local is_ruler = (abs_byte == ruler_abs) and not is_cur + + if is_cur then + set_color(1, true); print_at(i, gw+screen_col, char_bytes); set_color(1, false) + elseif in_sel then + set_color(COLOR.SELECTION, true); print_at(i, gw+screen_col, char_bytes); set_color(COLOR.SELECTION, false) + elseif is_mbr then + set_color(COLOR.MATCH_BR, true); print_at(i, gw+screen_col, char_bytes); set_color(COLOR.MATCH_BR, false) + elseif is_ruler then + set_color(COLOR.RULER, true) + print_at(i, gw+screen_col, char_bytes == " " and "|" or char_bytes) + set_color(COLOR.RULER, false) + elseif color ~= COLOR.NORMAL then + set_color(color, true); print_at(i, gw+screen_col, char_bytes); set_color(color, false) + elseif char_bytes ~= " " then + print_at(i, gw+screen_col, char_bytes) + end + + screen_col = screen_col + cw + byte_i = next_bi + ::next_char:: + end + + if row == self.cursor_row and self.cursor_col >= #line then + local vis_end = screen_col + if vis_end < text_w then + set_color(1, true); print_at(i, gw+vis_end, " "); set_color(1, false) + end + end + if screen_col <= ruler_abs - self.col_offset and ruler_abs - self.col_offset < text_w then + set_color(COLOR.RULER, true) + print_at(i, gw + ruler_abs - self.col_offset, "|") + set_color(COLOR.RULER, false) + end + end + + if self.ac then + local ac = self.ac + local max_vis = math.min(8, #ac.candidates) + local vis_row = self.cursor_row - self.scroll_offset + local pop_row = vis_row + 1 + local is_below = true + if pop_row + max_vis > self.screen_rows - 2 then + pop_row = vis_row - max_vis + is_below = false + end + local pop_col = math.max(gw, gw + self.cursor_col - self.col_offset - ac.prefix_len) + local pop_w = 2 + for _, m in ipairs(ac.candidates) do if #m+2 > pop_w then pop_w = #m+2 end end + pop_w = math.min(pop_w, self.screen_cols - pop_col) + + for i = 1, max_vis do + local ci + local draw_row + + if is_below then + ci = #ac.candidates - i + 1 + draw_row = pop_row + i - 1 + else + ci = #ac.candidates - max_vis + i + draw_row = pop_row + i - 1 + end + + local item = + (" " .. + (ac.candidates[ci] or "") .. + string.rep(" ", math.max(0, pop_w))):sub(1, pop_w) + + if ci == ac.sel then + set_color(COLOR.MENU_SEL, true) + print_at(draw_row, pop_col, item) + set_color(COLOR.MENU_SEL, false) + else + set_reverse(true) + print_at(draw_row, pop_col, item) + set_reverse(false) + end + end + end + + set_reverse(true) + local pos = string.format(" %d:%d ", self.cursor_row+1, self.cursor_col+1) + local flags = self.modified and "[+]" or " " + local ft_str = self.filetype and (" [" .. self.filetype .. "]") or "" + local name = (self.filename ~= "" and self.filename or "[No Name]") .. ft_str + local si = self.mark and (self.ephemeral and " [SRCH]" or " [SEL]") or "" + local bufs = string.format("[%d/%d]", self.current_buf, #self.buffers) + local left = string.format(" %s %s %s%s %s", flags, bufs, name, si, self.status_msg) + local avail = self.screen_cols - 1 - #pos + if #left > avail then left = left:sub(1, avail) end + local pad = math.max(0, avail - #left) + local bar = left .. string.rep(" ", pad) .. pos + print_at(self.screen_rows-1, 0, bar) + set_reverse(false) + self.status_msg = "" + refresh_screen() +end + +function editor:adjust_scroll() + local vis = self.screen_rows - 2 + local mg = self.SCROLL_MARGIN + if self.cursor_row < self.scroll_offset + mg then + self.scroll_offset = math.max(0, self.cursor_row - mg) + elseif self.cursor_row >= self.scroll_offset + vis - mg then + self.scroll_offset = self.cursor_row - vis + mg + 1 + end + self.scroll_offset = clamp(self.scroll_offset, 0, math.max(0, #self.lines - vis)) + self:adjust_col_scroll() +end + +function editor:adjust_col_scroll() + local text_w = self.screen_cols - self.GUTTER_WIDTH - 1 + local mg = self.H_SCROLL_MARGIN + local line = self.lines[self.cursor_row+1] + local dc = line_display_width(line, self.cursor_col + 1) - self.col_offset + if dc < mg then + self.col_offset = math.max(0, line_display_width(line, self.cursor_col + 1) - mg) + elseif dc >= text_w - mg then + self.col_offset = line_display_width(line, self.cursor_col + 1) - text_w + mg + 1 + end + if self.col_offset < 0 then self.col_offset = 0 end +end + +function editor:recenter() + local vis = self.screen_rows - 2 + self.scroll_offset = clamp(self.cursor_row - math.floor(vis/2), 0, math.max(0, #self.lines - vis)) + self.col_offset = 0 + self.status_msg = "Recentered" +end + +function editor:_raw_insert(str) + local l = self.lines[self.cursor_row+1] + self.lines[self.cursor_row+1] = l:sub(1,self.cursor_col)..str..l:sub(self.cursor_col+1) + self.cursor_col = self.cursor_col + #str + self.modified = true +end + +function editor:insert_char(str) + if self.mark then + local txt = get_selection_text(self) + delete_selection(self) + if txt then kill_ring_push(self, txt) end + else + clear_ephemeral(self) + end + + local line = self.lines[self.cursor_row+1] + local next_char = line:sub(self.cursor_col+1, self.cursor_col+1) + + if (str == ")" or str == "}" or str == "]" or str == '"') and next_char == str then + self.cursor_col = self.cursor_col + 1 + return + end + + if str == "\t" then + push_undo(self) + self:_raw_insert(" ") + return + end + + local prev = line:sub(self.cursor_col, self.cursor_col) + if (str == " " or (str:match("%p") and str ~= "\t")) and prev ~= "" and not prev:match("%s") then + push_undo(self) + end + + self:_raw_insert(str) + + local auto_pairs = { ["("]=")", ["{"]="}", ["["]="]", ['"']='"' } + if auto_pairs[str] then + if next_char == "" or next_char:match("[%s%}%]%)]") then + self:_raw_insert(auto_pairs[str]) + self.cursor_col = self.cursor_col - 1 + end + end +end + +function editor:delete_char() + if self.mark then delete_selection(self); return end + clear_ephemeral(self) + local line = self.lines[self.cursor_row+1] + local before_cursor = line:sub(1, self.cursor_col) + local only_spaces_before = before_cursor:match("^%s*$") + local two_spaces = self.cursor_col >= 2 + and line:sub(self.cursor_col-1, self.cursor_col) == " " + and only_spaces_before + push_undo(self) + if two_spaces then + self.lines[self.cursor_row+1] = line:sub(1,self.cursor_col-2)..line:sub(self.cursor_col+1) + self.cursor_col = self.cursor_col - 2 + self.modified = true + elseif self.cursor_col > 0 then + local prev_start = byte_prev(line, self.cursor_col) + self.lines[self.cursor_row+1] = line:sub(1, prev_start) .. line:sub(self.cursor_col+1) + self.cursor_col = prev_start + self.modified = true + elseif self.cursor_row > 0 then + local prev = self.lines[self.cursor_row] + self.cursor_col = #prev + self.lines[self.cursor_row] = prev .. line + table.remove(self.lines, self.cursor_row+1) + self.cursor_row = self.cursor_row - 1 + self.modified = true + end +end + +function editor:delete_forward() + if self.mark then delete_selection(self); return end + clear_ephemeral(self) + local line = self.lines[self.cursor_row+1] + push_undo(self) + if self.cursor_col < #line then + local next_b = byte_next(line, self.cursor_col) + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col) .. line:sub(next_b+1) + self.modified = true + elseif self.cursor_row < #self.lines-1 then + self.lines[self.cursor_row+1] = line .. self.lines[self.cursor_row+2] + table.remove(self.lines, self.cursor_row+2) + self.modified = true + end +end + +function editor:kill_to_eol() + local line = self.lines[self.cursor_row+1] + push_undo(self) + if self.cursor_col < #line then + local killed = line:sub(self.cursor_col+1) + kill_ring_push(self, killed) + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col) + self.modified = true + self.status_msg = "Killed to EOL" + elseif self.cursor_row < #self.lines-1 then + kill_ring_push(self, "\n") + self.lines[self.cursor_row+1] = line .. self.lines[self.cursor_row+2] + table.remove(self.lines, self.cursor_row+2) + self.modified = true + self.status_msg = "Killed newline" + end +end + +function editor:kill_region() + local txt = get_selection_text(self) + if txt then + kill_ring_push(self, txt) + delete_selection(self) + self.status_msg = "Killed " .. #txt .. " chars" + else + clear_ephemeral(self) + self:kill_to_eol() + end +end + +function editor:copy_region() + local txt = get_selection_text(self) + if txt then + kill_ring_push(self, txt) + self.mark = nil + self.ephemeral = false + self.status_msg = "Copied " .. #txt .. " chars" + else + self.status_msg = "No selection" + end +end + +function editor:delete_word_back() + if self.mark then delete_selection(self); return end + clear_ephemeral(self) + push_undo(self) + local line = self.lines[self.cursor_row+1] + local c = self.cursor_col + while c > 0 do + local pc = byte_prev(line, c) + if not line:sub(pc+1, c):match("^%s") then break end + c = pc + end + while c > 0 do + local pc = byte_prev(line, c) + if line:sub(pc+1, c):match("^%s") then break end + c = pc + end + self.lines[self.cursor_row+1] = line:sub(1,c)..line:sub(self.cursor_col+1) + self.cursor_col = c + self.modified = true +end + +function editor:delete_word_forward() + if self.mark then delete_selection(self); return end + clear_ephemeral(self) + push_undo(self) + local line = self.lines[self.cursor_row+1] + local c = self.cursor_col + while c < #line do + local nc = byte_next(line, c) + if line:sub(c+1, nc):match("[%w_]") then break end + c = nc + end + while c < #line do + local nc = byte_next(line, c) + if not line:sub(c+1, nc):match("[%w_]") then break end + c = nc + end + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col) .. line:sub(c+1) + self.modified = true +end + +function editor:insert_newline_simple() + clear_ephemeral(self) + push_undo(self) + local line = self.lines[self.cursor_row+1] + local indent = leading_spaces(line) + local after = line:sub(self.cursor_col+1) + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col) + table.insert(self.lines, self.cursor_row+2, indent .. after) + self.cursor_row = self.cursor_row + 1 + self.cursor_col = #indent + self.modified = true +end + +function editor:insert_newline_smart() + clear_ephemeral(self) + push_undo(self) + local line = self.lines[self.cursor_row+1] + local indent = leading_spaces(line) + local after = line:sub(self.cursor_col+1) + local at_eol = after:match("^%s*$") + + if at_eol then + for _, c in ipairs(self.completers) do + if trim_left(line):match(c.pattern) then + local new_indent = indent .. c.indent + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col) + if c.close then + table.insert(self.lines, self.cursor_row+2, indent .. c.close) + end + table.insert(self.lines, self.cursor_row+2, new_indent) + self.cursor_row = self.cursor_row + 1 + self.cursor_col = #new_indent + self.modified = true + self.status_msg = c.close and ("Closed: " .. c.close) or "Indented" + return + end + end + end + + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col) + table.insert(self.lines, self.cursor_row+2, indent .. after) + self.cursor_row = self.cursor_row + 1 + self.cursor_col = #indent + self.modified = true +end + +function editor:tab_or_indent() + if self.mark then + indent_selection(self, false) + else + push_undo(self) + local spaces_needed = 2 - (self.cursor_col % 2) + self:_raw_insert(string.rep(" ", math.max(0, spaces_needed))) + end +end + +function editor:shift_tab() + if self.mark then + indent_selection(self, true) + else + local line = self.lines[self.cursor_row+1] + if line:sub(1,2) == " " then + push_undo(self) + self.lines[self.cursor_row+1] = line:sub(3) + self.cursor_col = math.max(0, self.cursor_col - 2) + self.modified = true + self.status_msg = "Dedented" + end + end +end + +function editor:yank() + clear_ephemeral(self) + local sys = clipboard_read() + if sys ~= "" and sys ~= kill_ring_current(self) then + kill_ring_push(self, sys) + end + local text = kill_ring_current(self) + if not text then self.status_msg = "Nothing to yank"; return end + if self.mark then delete_selection(self) end + push_undo(self) + local parts = get_lines(text) + local line = self.lines[self.cursor_row+1] + if #parts == 1 then + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col)..parts[1]..line:sub(self.cursor_col+1) + self.cursor_col = self.cursor_col + #parts[1] + self.modified = true + else + local head = line:sub(1, self.cursor_col) + local tail = line:sub(self.cursor_col+1) + self.lines[self.cursor_row+1] = head .. parts[1] + for idx = 2, #parts do + local content = parts[idx] + if idx == #parts then content = content .. tail end + table.insert(self.lines, self.cursor_row + idx, content) + end + self.cursor_row = self.cursor_row + #parts - 1 + self.cursor_col = #parts[#parts] + self.modified = true + end + self.status_msg = "Yanked" +end + +function editor:yank_pop() + if not self.prev_was_yank then + self.status_msg = "Previous command was not a yank"; return + end + local text = kill_ring_cycle(self) + if not text then self.status_msg = "Kill ring empty"; return end + pop_undo(self) + push_undo(self) + local parts = get_lines(text) + local line = self.lines[self.cursor_row+1] + if #parts == 1 then + self.lines[self.cursor_row+1] = line:sub(1, self.cursor_col)..parts[1]..line:sub(self.cursor_col+1) + self.cursor_col = self.cursor_col + #parts[1] + self.modified = true + else + local head = line:sub(1, self.cursor_col) + local tail = line:sub(self.cursor_col+1) + self.lines[self.cursor_row+1] = head .. parts[1] + for idx = 2, #parts do + local content = parts[idx] + if idx == #parts then content = content .. tail end + table.insert(self.lines, self.cursor_row + idx, content) + end + self.cursor_row = self.cursor_row + #parts - 1 + self.cursor_col = #parts[#parts] + self.modified = true + end + self.status_msg = "Yank-pop: " .. self.kill_ring_idx .. "/" .. #self.kill_ring +end + +function editor:toggle_comment() + local prefix = self.comment_prefix + if not prefix then self.status_msg = "No comment style for this filetype"; return end + + local s = sel_ordered(self) + local r1 = s and s.r1 or self.cursor_row + local r2 = s and s.r2 or self.cursor_row + + push_undo(self) + local esc = escape_pattern(prefix) + local all_commented = true + for r = r1, r2 do + if not self.lines[r+1]:match("^%s*" .. esc) then + all_commented = false; break + end + end + + for r = r1, r2 do + local line = self.lines[r+1] + if all_commented then + self.lines[r+1] = line:gsub(esc .. "%s?", "", 1) + else + local ind = leading_spaces(line) + self.lines[r+1] = ind .. prefix .. " " .. line:sub(#ind+1) + end + end + self.modified = true + self.status_msg = all_commented and "Uncommented" or "Commented" +end + +function editor:move_lines(dir) + local s = sel_ordered(self) + local r1 = s and s.r1 or self.cursor_row + local r2 = s and s.r2 or self.cursor_row + push_undo(self) + if dir == -1 and r1 > 0 then + local above = table.remove(self.lines, r1) + table.insert(self.lines, r2+1, above) + self.cursor_row = self.cursor_row - 1 + if self.mark then self.mark.row = self.mark.row - 1 end + self.status_msg = "Moved up" + elseif dir == 1 and r2 < #self.lines-1 then + local below = table.remove(self.lines, r2+2) + table.insert(self.lines, r1+1, below) + self.cursor_row = self.cursor_row + 1 + if self.mark then self.mark.row = self.mark.row + 1 end + self.status_msg = "Moved down" + end + self.modified = true + self:adjust_scroll() +end + +function editor:move_cursor(drow, dcol) + clear_ephemeral(self) + self.cursor_row = clamp(self.cursor_row + drow, 0, #self.lines-1) + if dcol ~= 0 then + local line = self.lines[self.cursor_row+1] + if dcol > 0 then + self.cursor_col = byte_next(line, self.cursor_col) + else + self.cursor_col = byte_prev(line, self.cursor_col) + end + end + self.cursor_col = clamp(self.cursor_col, 0, #self.lines[self.cursor_row+1]) +end + +function editor:move_to_line_start() + clear_ephemeral(self) + local line = self.lines[self.cursor_row+1] + local indent = #leading_spaces(line) + self.cursor_col = (self.cursor_col > indent) and indent or 0 +end + +function editor:move_to_line_end() + clear_ephemeral(self) + self.cursor_col = #self.lines[self.cursor_row+1] +end + +function editor:page_up() + clear_ephemeral(self) + self.cursor_row = clamp(self.cursor_row - (self.screen_rows-2), 0, #self.lines-1) + self:adjust_scroll() +end + +function editor:page_down() + clear_ephemeral(self) + self.cursor_row = clamp(self.cursor_row + (self.screen_rows-2), 0, #self.lines-1) + self:adjust_scroll() +end + +function editor:word_forward() + clear_ephemeral(self) + local line = self.lines[self.cursor_row+1] + local c = self.cursor_col + while c < #line do + local nc = byte_next(line, c) + if line:sub(c+1, nc):match("[%a_]") then break end + c = nc + end + while c < #line do + local nc = byte_next(line, c) + if not line:sub(c+1, nc):match("[%w_]") then break end + c = nc + end + self.cursor_col = c +end + +function editor:word_backward() + clear_ephemeral(self) + local line = self.lines[self.cursor_row+1] + local c = self.cursor_col + while c > 0 do + local pc = byte_prev(line, c) + if line:sub(pc+1, c):match("[%w_]") then break end + c = pc + end + while c > 0 do + local pc = byte_prev(line, c) + if not line:sub(pc+1, c):match("[%w_]") then break end + c = pc + end + self.cursor_col = c +end + +function editor:save() + if self.filename == "" then + local name = file_prompt(self, "Save as: ") + if not name or name == "" then self.status_msg = "Save cancelled"; return end + self.filename = normalize_path(name) + self.filetype = get_filetype(self.filename, self.lines[1]) + self.highlighter = get_highlighter(self.filetype) + self.completers = get_indent_completers(self.filetype) + self.keywords = get_keywords(self.filetype) + self.comment_prefix = get_comment_prefix(self.filetype) + set_title(self.filename) + self.buffers[self.current_buf].filename = self.filename + self.buffers[self.current_buf].filetype = self.filetype + end + for i, line in ipairs(self.lines) do + self.lines[i] = line:gsub("%s+$", "") + end + local buf = lines_to_buffer(self.lines) + if buf:sub(-1) ~= "\n" then buf = buf .. "\n" end + local ok, err = save_file(self.filename, buf) + if ok then + self.modified = false + self.status_msg = "Saved: " .. self.filename + else + self.status_msg = "Save failed: " .. tostring(err) + end +end + +function editor:revert_file() + if self.filename == "" then self.status_msg = "No file to revert"; return end + if self.modified then + if not yn_prompt(self, "Revert and lose changes?") then + self.status_msg = "Revert cancelled"; return + end + end + local content, err = load_file(self.filename) + if err then self.status_msg = "Revert failed: " .. err; return end + local lines = get_lines(content) + self.lines = lines + self.modified = false + self.cursor_row = clamp(self.cursor_row, 0, #lines-1) + self.cursor_col = clamp(self.cursor_col, 0, #lines[self.cursor_row+1]) + self.undo_stack = {} + self.redo_stack = {} + self.mark = nil + self.ephemeral = false + self.status_msg = "Reverted: " .. self.filename +end + +function editor:_open_file_as_buffer(filename) + local fn = normalize_path(filename) + for i, b in ipairs(self.buffers) do + if b.filename == fn then + self:switch_to_buffer(i) + return i + end + end + local lines = {""} + local content, err = load_file(fn) + if err then + self.status_msg = "New file: " .. fn + else + lines = get_lines(content) + self.status_msg = "Opened: " .. fn .. " (" .. #lines .. " lines)" + end + local buf = make_buffer(fn, lines) + self.buffers[#self.buffers+1] = buf + self:switch_to_buffer(#self.buffers) + return #self.buffers +end + +function editor:switch_to_buffer(idx) + save_editor_to_buffer(self, self.buffers[self.current_buf]) + self.current_buf = idx + load_buffer_to_editor(self, self.buffers[idx]) + set_title(self.filename ~= "" and self.filename or "[No Name]") + self.status_msg = "Buffer " .. idx .. "/" .. #self.buffers .. ": " .. + (self.filename ~= "" and self.filename or "[No Name]") +end + +function editor:open_file() + local name = file_prompt(self, "Open: ") + if not name or name == "" then self.status_msg = "Cancelled"; return end + self:_open_file_as_buffer(name) +end + +function editor:kill_buffer() + if self.modified then + if not yn_prompt(self, "Buffer modified, kill anyway?") then + self.status_msg = "Kill cancelled"; return + end + end + local killed = self.filename ~= "" and self.filename or "[No Name]" + table.remove(self.buffers, self.current_buf) + if #self.buffers == 0 then + self.buffers = {make_buffer("", {""})} + self.current_buf = 1 + else + self.current_buf = clamp(self.current_buf, 1, #self.buffers) + end + load_buffer_to_editor(self, self.buffers[self.current_buf]) + self.status_msg = "Killed: " .. killed +end + +function editor:buffer_picker() + save_editor_to_buffer(self, self.buffers[self.current_buf]) + + local vis = self.screen_rows - 2 + local sel = self.current_buf % #self.buffers + 1 + local offs = math.max(0, sel - math.floor(vis/2)) + + while true do + clear_screen() + set_reverse(true) + print_at(0, 0, string.format(" Buffers (%d) ", #self.buffers)) + set_reverse(false) + + for i = 1, vis-1 do + local bi = offs + i + if bi > #self.buffers then break end + local b = self.buffers[bi] + local name = b.filename ~= "" and b.filename or "[No Name]" + local line = string.format(" %s%s", b.modified and "[+] " or " ", name) + line = line:sub(1, self.screen_cols-1) + if bi == sel then + set_reverse(true); print_at(i, 0, line); set_reverse(false) + else + print_at(i, 0, line) + end + end + + set_reverse(true) + print_at(self.screen_rows-1, 0, " Enter: open k: kill C-n/C-p: navigate C-g: cancel ") + set_reverse(false) + refresh_screen() + + local ch = wch() + if not ch then goto continue end + if ch == 7 then return end + if ch == 10 or ch == 13 then self:switch_to_buffer(sel); return end + if ch == string.byte("k") then + if #self.buffers > 1 then + table.remove(self.buffers, sel) + if self.current_buf >= sel then + self.current_buf = clamp(self.current_buf-1, 1, #self.buffers) + end + sel = clamp(sel, 1, #self.buffers) + offs = math.max(0, sel - math.floor(vis/2)) + end + end + if (ch==259 or ch==16) and sel > 1 then + sel = sel-1; if sel < offs+1 then offs = offs-1 end + end + if (ch==258 or ch==14) and sel < #self.buffers then + sel = sel+1; if sel > offs+vis-1 then offs = offs+1 end + end + ::continue:: + end +end + +function editor:next_buffer() + if #self.buffers < 2 then self.status_msg = "Only one buffer"; return end + self:switch_to_buffer(self.current_buf % #self.buffers + 1) +end + +function editor:prev_buffer() + if #self.buffers < 2 then self.status_msg = "Only one buffer"; return end + self:switch_to_buffer((self.current_buf - 2 + #self.buffers) % #self.buffers + 1) +end + +function editor:goto_line_prompt() + local input = mini_prompt(self, "Go to line: ") + if not input then return end + local n = tonumber(input) + if n then + self.cursor_row = clamp(n-1, 0, #self.lines-1) + self.cursor_col = 0 + self:adjust_scroll() + self.status_msg = "Line " .. n + else + self.status_msg = "Invalid number" + end +end + +function editor:isearch() + local start_row = self.cursor_row + local start_col = self.cursor_col + local input = "" + local reverse = false + + local function build_hits(pat) + if pat == "" then + self.search_hits = nil + return {} + end + local hits = {} + local is_lower = (pat == pat:lower()) + for li, line in ipairs(self.lines) do + local s = 1 + while true do + local target = is_lower and line:lower() or line + local col = target:find(pat, s, true) + if not col then break end + hits[#hits+1] = {line=li-1, col=col-1, len=#pat} + s = col+1 + end + end + return hits + end + + local function reorder_hits(hits, r, c) + if #hits <= 1 then return hits end + local split = 1 + for i, h in ipairs(hits) do + if h.line > r or (h.line == r and h.col >= c) then split = i; break end + end + if split == 1 then return hits end + local out = {} + for i = split, #hits do out[#out+1] = hits[i] end + for i = 1, split - 1 do out[#out+1] = hits[i] end + return out + end + + local function jump_to(hits, idx) + if not idx or #hits == 0 then return end + local h = hits[idx] + self.cursor_row = h.line + self.mark = {row=h.line, col=h.col} + self.ephemeral = true + self.cursor_col = math.min(h.col + h.len, #self.lines[h.line+1]) + self:adjust_scroll() + end + + local hits = {} + self.search_hits = hits + local cur_idx = nil + + while true do + self:draw() + local maxw = self.screen_cols - 1 + local cnt = #hits > 0 and string.format(" [%d/%d]", cur_idx or 0, #hits) or "" + local dir_label = reverse and "Reverse search: " or "Search: " + local bar = (dir_label .. input .. cnt):sub(1, maxw) + set_reverse(true) + print_at(self.screen_rows-1, 0, string.rep(" ", math.max(0, maxw))) + print_at(self.screen_rows-1, 0, bar) + set_reverse(false) + refresh_screen() + + local ch = wch() + if not ch then goto continue end + + if ch == 7 then + self.search_hits = nil + self.cursor_row = start_row + self.cursor_col = start_col + self.mark = nil + self.ephemeral = false + self:adjust_scroll() + self.status_msg = "Search cancelled" + return + end + + if ch == 10 or ch == 13 then + self.search_hits = nil + if cur_idx and #hits > 0 then + self.last_search = input + self.status_msg = string.format("Found: %s [%d/%d]", input, cur_idx, #hits) + else + self.mark = nil + self.ephemeral = false + self.cursor_row = start_row + self.cursor_col = start_col + self.status_msg = input ~= "" and ("Not found: " .. input) or "Search cancelled" + end + return + end + + if ch == 19 then + reverse = false + if #hits > 0 then + cur_idx = cur_idx % #hits + 1 + jump_to(hits, cur_idx) + end + elseif ch == 18 then + reverse = true + if #hits > 0 then + cur_idx = ((cur_idx or 1) - 2 + #hits) % #hits + 1 + jump_to(hits, cur_idx) + end + elseif ch == 127 or ch == 8 or ch == 263 then + input = input:sub(1, #input-1) + hits = reorder_hits(build_hits(input), start_row, start_col) + self.search_hits = hits + cur_idx = #hits > 0 and 1 or nil + if cur_idx then jump_to(hits, cur_idx) + else + self.mark = nil + self.ephemeral = false + self.cursor_row = start_row + self.cursor_col = start_col + self:adjust_scroll() + end + elseif ch >= 32 then + input = input .. cp_to_utf8(ch) + hits = reorder_hits(build_hits(input), start_row, start_col) + self.search_hits = hits + cur_idx = #hits > 0 and 1 or nil + if cur_idx then jump_to(hits, cur_idx) + else + self.mark = nil + self.ephemeral = false + self.cursor_row = start_row + self.cursor_col = start_col + self:adjust_scroll() + self.status_msg = "Not found: " .. input + end + end + ::continue:: + end +end + +function editor:search_replace() + local default = get_selection_text(self) or "" + local pattern = mini_prompt(self, "Replace: ", default) + if not pattern or pattern == "" then return end + local replacement = mini_prompt(self, "With: ") + if replacement == nil then return end + + local count = 0 + push_undo(self) + local esc_rep = escape_replacement(replacement) + for i, line in ipairs(self.lines) do + local new, n = line:gsub(escape_pattern(pattern), esc_rep) + if n > 0 then + self.lines[i] = new + count = count + n + end + end + self.modified = count > 0 + self.status_msg = count > 0 and (count .. " replacement(s)") or "No matches" +end + +function editor:recursive_search() + local default = get_selection_text(self) or self.last_search + local pattern = mini_prompt(self, "Grep: ", default) + if not pattern or pattern == "" then return end + self.mark = nil + + self.status_msg = "Searching..." + self:draw() + + local files = {} + collect_files(".", files) + local all_hits = {} + for _, path in ipairs(files) do + for _, h in ipairs(search_in_file(path, pattern)) do + all_hits[#all_hits+1] = h + end + end + + if #all_hits == 0 then self.status_msg = "No matches: " .. pattern; return end + + local vis = self.screen_rows - 2 + local sel = 1 + local offs = 0 + + while true do + clear_screen() + set_reverse(true) + print_at(0, 0, string.format(" Grep: %s (%d matches) ", pattern, #all_hits)) + set_reverse(false) + + for i = 1, vis-1 do + local hi = offs + i + if hi > #all_hits then break end + local h = all_hits[hi] + local ft = get_filetype(h.file) + local hl = ft and get_highlighter(ft) + local spans = hl and hl(h.text) or {} + local colored = spans_to_color_map(spans) + local prefix = string.format(" %s:%d ", h.file, h.line) + local is_sel = (hi == sel) + + if is_sel then set_reverse(true) end + print_at(i, 0, string.rep(" ", math.max(0, self.screen_cols-1))) + print_at(i, 0, prefix:sub(1, self.screen_cols-1)) + if is_sel then set_reverse(false) end + + local px = #prefix + local tx = h.text + for ci = 0, math.min(#tx-1, self.screen_cols-1-px) do + local char = tx:sub(ci+1, ci+1) + local color = colored[ci] or COLOR.NORMAL + if is_sel then + set_reverse(true); print_at(i, px+ci, char); set_reverse(false) + elseif color ~= COLOR.NORMAL then + set_color(color, true); print_at(i, px+ci, char); set_color(color, false) + elseif char ~= " " then + print_at(i, px+ci, char) + end + end + end + + set_reverse(true) + print_at(self.screen_rows-1, 0, " Enter: jump C-g: cancel C-n/C-p: navigate ") + set_reverse(false) + refresh_screen() + + local ch = wch() + if not ch then goto continue end + if ch == 7 then break end + if ch == 10 or ch == 13 then + local h = all_hits[sel] + self:_open_file_as_buffer(h.file) + self.cursor_row = h.line - 1 + self.cursor_col = h.col - 1 + self:adjust_scroll() + self.status_msg = string.format("Jumped to %s:%d", h.file, h.line) + break + end + if (ch==259 or ch==16) and sel>1 then + sel=sel-1; if sel<offs+1 then offs=offs-1 end + end + if (ch==258 or ch==14) and sel<#all_hits then + sel=sel+1; if sel>offs+vis-1 then offs=offs+1 end + end + ::continue:: + end +end + +function editor:run_command() + local input = mini_prompt(self, "M-x: ") + if not input or input == "" then return end + local result = lua_eval(input) + self.status_msg = tostring(result) +end + +function editor:run_shell() + local input = mini_prompt(self, "Shell: ") + if not input or input == "" then return end + local p = io.popen(input .. " 2>&1", "r") + if not p then self.status_msg = "Shell failed"; return end + local out = p:read("*a"); p:close() + out = out:gsub("\n+$", "") + local maxw = self.screen_cols - 12 + self.status_msg = #out > maxw and (out:sub(1, maxw) .. "...") or (out ~= "" and out or "Done") +end + +function editor:init(filenames) + init_ncurses() + local rows, cols = get_screen_size() + self.screen_rows = rows + self.screen_cols = cols + setup_colors() + + if type(filenames) ~= "table" then filenames = {filenames} end + if #filenames == 0 then filenames = {""} end + + local buffers = {} + for _, fn in ipairs(filenames) do + fn = normalize_path(fn or "") + local lines = {""} + if fn ~= "" then + local content, err = load_file(fn) + if err then + self.status_msg = "New file: " .. fn + else + lines = get_lines(content) + self.status_msg = "Opened: " .. fn .. " (" .. #lines .. " lines)" + end + end + buffers[#buffers+1] = make_buffer(fn, lines) + end + + self.buffers = buffers + self.current_buf = 1 + load_buffer_to_editor(self, self.buffers[1]) + set_title(self.filename ~= "" and self.filename or "Lume") +end + +local KEY = { + ENTER = 10, CR = 13, + BS = 127, BS2 = 8, BS3 = 263, + DELETE = 330, + TAB = 9, SHIFT_TAB = 353, + UP = 259, DOWN = 258, LEFT = 260, RIGHT = 261, + HOME = 262, END = 360, + PGUP = 339, PGDN = 338, + ESC = 27, + CTRL_A=1, CTRL_B=2, CTRL_C=3, CTRL_D=4, CTRL_E=5, CTRL_F=6, + CTRL_G=7, CTRL_H=8, CTRL_K=11, CTRL_L=12, CTRL_N=14, CTRL_P=16, + CTRL_R=18, CTRL_S=19, CTRL_W=23, CTRL_X=24, CTRL_Y=25, CTRL_Z=26, + CTRL_SLASH=31, CTRL_SPACE=0, + CTRL_RBRACKET=29, CTRL_BACKSLASH=28, +} + +local function read_csi() + local seq = "" + for _ = 1, 20 do + local ch = wch() + if not ch then break end + seq = seq .. string.char(math.max(0, math.min(ch, 127))) + if ch == string.byte("~") or ch == string.byte("u") + or (ch >= 64 and ch <= 90 and ch ~= string.byte("[")) + then break end + end + return seq +end + +local function is_shift_enter(seq) + return seq == "13;2~" or seq == "13;2u" or seq == "27;2;13~" +end + +local function ac_handle(ed, ch) + local ac = ed.ac + if not ac then return false end + + local max_vis = math.min(8, #ac.candidates) + local vis_row = ed.cursor_row - ed.scroll_offset + local pop_row = vis_row + 1 + + local is_below = true + + if pop_row + max_vis > ed.screen_rows - 2 then + is_below = false + end + + if ch == KEY.CTRL_G then + ed.ac = nil + return true + end + + if ch == KEY.UP or ch == KEY.CTRL_P then + if is_below then + ac.sel = math.min(#ac.candidates, ac.sel + 1) + else + ac.sel = math.max(1, ac.sel - 1) + end + return true + end + + if ch == KEY.DOWN or ch == KEY.CTRL_N then + if is_below then + ac.sel = math.max(1, ac.sel - 1) + else + ac.sel = math.min(#ac.candidates, ac.sel + 1) + end + return true + end + + if ch == KEY.TAB then + push_undo(ed) + ac_apply(ed, ac) + ed.ac = nil + return true + end + + ed.ac = nil + return false +end + +local function make_keymaps(ed) + local cx_map = { + [KEY.CTRL_S] = function() ed:save() end, + [KEY.CTRL_R] = function() ed:revert_file() end, + [KEY.CTRL_C] = function() + local any_mod = false + for _, b in ipairs(ed.buffers) do + if b.modified or ed.modified then + any_mod = true + break + end + end + if any_mod then + if yn_prompt(ed, "Modified buffers exist. Quit?") then + ed._quit = true + else + ed.status_msg = "Quit cancelled" + end + else + ed._quit = true + end + end, + [KEY.CTRL_F] = function() ed:open_file() end, + [KEY.CTRL_K] = function() ed:kill_buffer() end, + [KEY.CTRL_B] = function() ed:buffer_picker() end, + } + + local meta_map = { + [string.byte("f")] = function() ed:word_forward(); ed:adjust_scroll() end, + [string.byte("b")] = function() ed:word_backward(); ed:adjust_col_scroll() end, + [string.byte("w")] = function() ed:copy_region() end, + [string.byte("d")] = function() ed:delete_word_forward() end, + [string.byte("x")] = function() ed:run_command() end, + [string.byte("!")] = function() ed:run_shell() end, + [string.byte("/")] = function() pop_redo(ed); ed:adjust_scroll() end, + [string.byte("p")] = function() ed:move_lines(-1) end, + [string.byte("n")] = function() ed:move_lines(1) end, + [string.byte(";")] = function() ed:toggle_comment() end, + [string.byte("%")] = function() ed:search_replace() end, + [string.byte("y")] = function() ed:yank_pop() end, + [string.byte("<")] = function() + ed.cursor_row=0; ed.cursor_col=0; ed:adjust_scroll(); ed.status_msg="Top" + end, + [string.byte(">")] = function() + ed.cursor_row=#ed.lines-1; ed.cursor_col=#ed.lines[#ed.lines] + ed:adjust_scroll(); ed.status_msg="Bottom" + end, + [string.byte("g")] = function() ed:goto_line_prompt() end, + [string.byte("a")] = function() + while ed.cursor_row > 0 do + ed.cursor_row = ed.cursor_row - 1 + if ed.lines[ed.cursor_row+1] == "" then break end + end + ed:adjust_scroll() + end, + [string.byte("e")] = function() + while ed.cursor_row < #ed.lines-1 do + ed.cursor_row = ed.cursor_row + 1 + if ed.lines[ed.cursor_row+1] == "" then break end + end + ed:adjust_scroll() + end, + [string.byte("m")] = function() + local r, c = find_matching_bracket(ed.lines, ed.cursor_row, ed.cursor_col) + if r then + ed.cursor_row, ed.cursor_col = r, c + ed:adjust_scroll() + ed.status_msg = "Jumped to bracket match" + else + ed.status_msg = "No bracket match" + end + end, + [string.byte("c")] = function() + clear_ephemeral(ed) + push_undo(ed) + table.insert(ed.lines, ed.cursor_row + 2, ed.lines[ed.cursor_row + 1]) + ed.cursor_row = ed.cursor_row + 1 + ed.modified = true + ed:adjust_scroll() + ed.status_msg = "Line duplicated" + end, + [KEY.BS] = function() ed:delete_word_back(); ed:adjust_scroll() end, + [KEY.BS2] = function() ed:delete_word_back(); ed:adjust_scroll() end, + [KEY.BS3] = function() ed:delete_word_back(); ed:adjust_scroll() end, + } + + local main_map = { + [KEY.CTRL_P] = function() ed:move_cursor(-1,0); ed:adjust_scroll() end, + [KEY.UP] = function() ed:move_cursor(-1,0); ed:adjust_scroll() end, + [KEY.CTRL_N] = function() ed:move_cursor( 1,0); ed:adjust_scroll() end, + [KEY.DOWN] = function() ed:move_cursor( 1,0); ed:adjust_scroll() end, + [KEY.CTRL_B] = function() ed:move_cursor(0,-1); ed:adjust_col_scroll() end, + [KEY.LEFT] = function() ed:move_cursor(0,-1); ed:adjust_col_scroll() end, + [KEY.CTRL_F] = function() ed:move_cursor(0, 1); ed:adjust_col_scroll() end, + [KEY.RIGHT] = function() ed:move_cursor(0, 1); ed:adjust_col_scroll() end, + [KEY.CTRL_A] = function() ed:move_to_line_start(); ed:adjust_col_scroll() end, + [KEY.HOME] = function() ed:move_to_line_start(); ed:adjust_col_scroll() end, + [KEY.CTRL_E] = function() ed:move_to_line_end(); ed:adjust_col_scroll() end, + [KEY.END] = function() ed:move_to_line_end(); ed:adjust_col_scroll() end, + [KEY.PGUP] = function() ed:page_up() end, + [KEY.PGDN] = function() ed:page_down() end, + [KEY.CTRL_SPACE] = function() + if ed.mark then ed.mark=nil; ed.ephemeral=false; ed.status_msg="Mark cleared" + else ed.mark={row=ed.cursor_row,col=ed.cursor_col}; ed.status_msg="Mark set" end + end, + [KEY.CTRL_G] = function() + ed.mark=nil; ed.ephemeral=false; ed.ac=nil; ed.status_msg="Cancelled" + end, + [KEY.BS] = function() ed:delete_char(); ed:adjust_scroll() end, + [KEY.BS2] = function() ed:delete_char(); ed:adjust_scroll() end, + [KEY.BS3] = function() ed:delete_char(); ed:adjust_scroll() end, + [KEY.CTRL_D] = function() ed:delete_forward() end, + [KEY.DELETE] = function() ed:delete_forward() end, + [KEY.CTRL_W] = function() + if ed.mark then ed:kill_region() + else ed:delete_word_back() ed:adjust_scroll() end + end, + [KEY.CTRL_K] = function() ed:kill_region(); ed:adjust_scroll() end, + [KEY.CTRL_Y] = function() ed:yank(); ed:adjust_scroll() end, + [KEY.TAB] = function() ed:tab_or_indent(); ed:adjust_scroll() end, + [KEY.SHIFT_TAB] = function() ed:shift_tab() end, + [KEY.ENTER] = function() ed:insert_newline_simple(); ed:adjust_scroll() end, + [KEY.CR] = function() ed:insert_newline_simple(); ed:adjust_scroll() end, + [KEY.CTRL_SLASH] = function() pop_undo(ed); ed:adjust_scroll() end, + [KEY.CTRL_L] = function() ed:recenter() end, + [KEY.CTRL_S] = function() ed:isearch() end, + [KEY.CTRL_R] = function() ed:recursive_search() end, + [KEY.CTRL_RBRACKET] = function() ed:next_buffer() end, + [KEY.CTRL_BACKSLASH] = function() ed:prev_buffer() end, + } + + return main_map, meta_map, cx_map +end + +function editor:run() + local main_map, meta_map, cx_map = make_keymaps(self) + local in_prefix = false + self._quit = false + self.prev_was_yank = false + + while not self._quit do + self:draw() + local ch = wch() + if not ch then goto continue end + log("key " .. tostring(ch)) + + local was_yank = self.prev_was_yank + + if ac_handle(self, ch) then + self.prev_was_yank = false + goto continue + end + + if ch == KEY.ESC then + local ch2 = wch() + if not ch2 then goto continue end + if ch2 == string.byte("[") then + local seq = read_csi() + if is_shift_enter(seq) then + self:insert_newline_smart(); self:adjust_scroll() + end + else + local fn = meta_map[ch2] + if fn then fn() + else self.status_msg = "Unknown: M-" .. (ch2 >= 32 and ch2 < 128 and string.char(ch2) or tostring(ch2)) end + end + self.prev_was_yank = (ch2 == string.byte("y")) and was_yank or false + + elseif ch == KEY.CTRL_X then + in_prefix = true + self.status_msg = "C-x-" + self.prev_was_yank = false + + elseif in_prefix then + in_prefix = false + local fn = cx_map[ch] + if fn then fn() + else self.status_msg = "C-x " .. tostring(ch) .. ": unknown" end + self.prev_was_yank = false + + else + local fn = main_map[ch] + if fn then + fn() + self.prev_was_yank = (ch == KEY.CTRL_Y) + elseif ch == 410 then + self.prev_was_yank = false + elseif ch >= 32 then + self:insert_char(cp_to_utf8(ch)) + self:adjust_scroll() + local ac = ac_build(self) + if ac then self.ac = ac end + self.prev_was_yank = false + else + self.prev_was_yank = false + end + end + + ::continue:: + end +end + +function editor:cleanup() + save_editor_to_buffer(self, self.buffers[self.current_buf]) + end_ncurses() + close_log() +end + +_G.ed = editor +_G.log = log + +local filenames = (arg and type(arg)=="table") and arg or {} +log("session start") +local ok, err = pcall(function() + editor:init(filenames) + editor:run() + editor:cleanup() +end) +if not ok then + editor:cleanup() + log("fatal: " .. tostring(err)) + io.stderr:write("Lume fatal: " .. tostring(err) .. "\n") +end |
