about summary refs log tree commit diff
path: root/main.lua
diff options
context:
space:
mode:
Diffstat (limited to 'main.lua')
-rw-r--r--main.lua2326
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