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 seloffs+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