-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. local std = require("std") local term = require("term") local theme_mod = require("theme") local config = require("lem.config") local viewport = require("lem.viewport") local render = require("lem.render") local mode_mod = require("lem.mode") local keymap_mod = require("lem.keymap") local motion_mod = require("lem.motion") local text_obj = require("lem.text_object") local ops = require("lem.operator") local command = require("lem.command") local buffer_list = require("lem.buffer_list") local highlight = require("lem.highlight") local diagnostic = require("lem.diagnostic") local completion_mod = require("lem.completion") -- ── cursor helpers ──────────────────────────────────────────────── -- Count display characters on a line, expanding tabs to tabstop spaces -- (std.utf.len() can't be used directly: it filters out C0 controls, -- so tabs — and any stray control bytes — would not be counted). local line_char_count = function(buf, line, tabstop) return motion_mod.line_display_width(buf:get_line(line), tabstop) end -- True for plain printable keys; filters chords like CTRL+x and named -- keys like ENTER / F1. local is_printable_key = function(key) return not (#key > 1 and key:find("+", 1, true)) and not key:match("^%u[%u%d]") end -- Terminals without the kitty keyboard protocol deliver these keys as -- raw control bytes; normalize them to the named keys LEM dispatches on. -- (A KKBP terminal already reports them as named keys.) local LEGACY_KEY_MAP = { ["\r"] = "ENTER", ["\n"] = "ENTER", ["\t"] = "TAB", ["\127"] = "BACKSPACE", ["\27"] = "ESC", } local clamp_cursor = function(editor) local buf = editor.__state.buf local mode = editor.__state.mode:current() local total = buf:line_count() if buf.__cursor[1] < 1 then buf.__cursor[1] = 1 end if buf.__cursor[1] > total then buf.__cursor[1] = total end local clen = line_char_count(buf, buf.__cursor[1], editor.cfg.tabstop) if buf.__cursor[2] < 1 then buf.__cursor[2] = 1 end if mode == "insert" then if buf.__cursor[2] > clen + 1 then buf.__cursor[2] = clen + 1 end else local max_col = clen > 0 and clen or 1 if buf.__cursor[2] > max_col then buf.__cursor[2] = max_col end end end -- Get byte position and byte length for a display column in a line. -- Tab-aware via decompose_line. local char_byte_info = function(text, col, tabstop) local chars = motion_mod.decompose_line(text, tabstop) if col < 1 or #chars == 0 then return 0, 0 end if col > #chars then return #text, 0 end return chars[col].byte_pos, chars[col].byte_len end -- ── motion execution (returns new line, col) ────────────────────── -- Returns (line, col, linewise, inclusive) -- inclusive=true means operator includes the target char (e, $, f, %) -- inclusive=false means operator stops before target char (w, b, 0, ^) -- `count` may be nil ("no explicit count"): repetition motions treat it -- as 1, while G/gg fall back to their countless targets (last/first line). local exec_motion = function(e, key, count) local buf = e.__state.buf local ts = e.cfg.tabstop local line, col = buf.__cursor[1], buf.__cursor[2] local linewise = false local inclusive = false local n = count or 1 if key == "h" or key == "LEFT" then local chars = motion_mod.decompose_line(buf:get_line(line), ts) for _ = 1, n do if col <= 1 then break end col = motion_mod.col_left(chars, col) end elseif key == "l" or key == "RIGHT" then local chars = motion_mod.decompose_line(buf:get_line(line), ts) for _ = 1, n do if col > #chars then break end col = motion_mod.col_right(chars, col) end elseif key == "j" or key == "DOWN" then line = line + n local total = buf:line_count() if line > total then line = total end linewise = true elseif key == "k" or key == "UP" then line = line - n if line < 1 then line = 1 end linewise = true elseif key == "w" then line, col = motion_mod.word_forward(buf, line, col, n, false, ts) elseif key == "W" then line, col = motion_mod.word_forward(buf, line, col, n, true, ts) elseif key == "b" then line, col = motion_mod.word_backward(buf, line, col, n, false, ts) elseif key == "B" then line, col = motion_mod.word_backward(buf, line, col, n, true, ts) elseif key == "e" then line, col = motion_mod.word_end(buf, line, col, n, false, ts) inclusive = true elseif key == "E" then line, col = motion_mod.word_end(buf, line, col, n, true, ts) inclusive = true elseif key == "0" then col = 1 elseif key == "$" or key == "END" then col = math.max(1, line_char_count(buf, line, ts)) inclusive = true elseif key == "^" or key == "HOME" then local text = buf:get_line(line) local indent = text:match("^%s*") or "" col = std.utf.len(indent) + 1 elseif key == "{" then line = motion_mod.paragraph_backward(buf, line, n) col = 1 linewise = true elseif key == "}" then line = motion_mod.paragraph_forward(buf, line, n) col = 1 linewise = true elseif key == "%" then line, col = motion_mod.bracket_match(buf, line, col, ts) inclusive = true elseif key == "G" then line = count and math.max(1, math.min(count, buf:line_count())) or buf:line_count() col = 1 linewise = true elseif key == "gg" then line = count and math.max(1, math.min(count, buf:line_count())) or 1 col = 1 linewise = true else return nil -- not a motion end return line, col, linewise, inclusive end -- ── operator ranges and application ─────────────────────────────── -- Build the correct range from cursor + motion result + inclusive flag local build_op_range = function(buf, new_line, new_col, linewise, inclusive, tabstop) local cl, cc = buf.__cursor[1], buf.__cursor[2] local tl, tc = new_line, new_col if linewise then return { cl, 1, tl, math.max(1, line_char_count(buf, tl, tabstop)) }, true end -- Normalize direction local forward = (tl > cl) or (tl == cl and tc > cc) local backward = (tl < cl) or (tl == cl and tc < cc) if forward and not inclusive then -- Exclusive forward: stop before target char -- Special case: at end of last line (can't go further), keep inclusive if tl == buf:line_count() and tc == line_char_count(buf, tl, tabstop) then -- noop: treat as inclusive at end of file elseif tc > 1 then tc = tc - 1 elseif tl > 1 then tl = tl - 1 tc = math.max(1, line_char_count(buf, tl, tabstop)) end elseif backward and not inclusive then -- Exclusive backward: don't include cursor char -- Range is from target to cursor-1 cc = cc - 1 if cc < 1 then cc = 1 end end return { cl, cc, tl, tc }, false end local apply_operator = function(e, op_key, range, linewise, reg) local regs = e.__state.registers local text, lw if op_key == "d" then text, lw = ops.op_delete(e, range, linewise) ops.reg_put(regs, reg or '"', text, lw) elseif op_key == "c" then text, lw = ops.op_change(e, range, linewise) ops.reg_put(regs, reg or '"', text, lw) elseif op_key == "y" then text, lw = ops.op_yank(e, range, linewise) ops.reg_put(regs, reg or '"', text, lw) -- The yank register "0 (and its unnamed mirror) only tracks -- unnamed yanks; explicit registers don't touch it (vim). if not reg or reg == '"' then ops.reg_put(regs, "0", text, lw) end e.__state.rdr:set_message(#text .. " bytes yanked") elseif op_key == ">" then ops.op_indent(e, range) elseif op_key == "<" then ops.op_dedent(e, range) end end -- Resolve an f/F/t/T find into a target position + operator inclusivity. -- Forward finds are inclusive, backward finds exclusive (vim). Returns -- nil when the counted target char doesn't exist on the line. local resolve_find = function(e, cmd_key, ch, count) local buf = e.__state.buf local forward = (cmd_key == "f" or cmd_key == "t") local till = (cmd_key == "t" or cmd_key == "T") local fn = forward and motion_mod.find_char_forward or motion_mod.find_char_backward local l, c = fn(buf, buf.__cursor[1], buf.__cursor[2], ch, till, e.cfg.tabstop, count) return l, c, forward end -- Record a dot-repeat recipe (motion-based, text-object, or line-wise). -- For change ops, also arm a capture of the upcoming insert session so -- `.` can replay delete + typed text (see the insert-mode ESC handler). local record_dot = function(e, recipe) e.__state.last_op = recipe e.__state.change_capture = nil if recipe.op == "c" then local buf = e.__state.buf e.__state.change_capture = { recipe = recipe, start_off = motion_mod.cursor_to_offset(buf, buf.__cursor[1], buf.__cursor[2], e.cfg.tabstop), } end end local replay_find_operator = function(e, recipe) local buf = e.__state.buf local count = recipe.count or 1 local cmd_key = recipe.find_key local ch = recipe.find_char if not cmd_key or not ch then return end local l, c, inclusive = resolve_find(e, cmd_key, ch, count) if l and (l ~= buf.__cursor[1] or c ~= buf.__cursor[2]) then local range, lw = build_op_range(buf, l, c, false, inclusive, e.cfg.tabstop) apply_operator(e, recipe.op, range, lw, recipe.reg) e.__state.last_find = { type = cmd_key, char = ch, count = count } end end -- After replaying a `c` recipe (which re-entered insert mode), insert -- the captured session text and return to normal mode. local finish_change_replay = function(e, recipe) local buf = e.__state.buf local text = recipe.insert_text if text and #text > 0 then local off = motion_mod.cursor_to_offset(buf, buf.__cursor[1], buf.__cursor[2], e.cfg.tabstop) buf:insert(off, text) local nl, nc = motion_mod.offset_to_cursor(buf, off + #text, e.cfg.tabstop) buf.__cursor[1] = nl buf.__cursor[2] = nc end e.__state.change_capture = nil e.__state.mode:enter_normal(e) end -- ── dot-repeatable simple commands ─────────────────────────────── local do_x = function(e, n, reg) local buf = e.__state.buf local line = buf.__cursor[1] local clen = line_char_count(buf, line, e.cfg.tabstop) if clen == 0 then return end local col = buf.__cursor[2] local end_col = math.min(col + n - 1, clen) local range = { line, col, line, end_col } local text = ops.op_delete(e, range, false) ops.reg_put(e.__state.registers, reg or '"', text, false) end local do_X = function(e, reg) local buf = e.__state.buf if buf.__cursor[2] <= 1 then return end local range = { buf.__cursor[1], buf.__cursor[2] - 1, buf.__cursor[1], buf.__cursor[2] - 1 } local text = ops.op_delete(e, range, false) ops.reg_put(e.__state.registers, reg or '"', text, false) end local do_tilde = function(e) local buf = e.__state.buf local range = { buf.__cursor[1], buf.__cursor[2], buf.__cursor[1], buf.__cursor[2] } ops.op_toggle_case(e, range) buf.__cursor[2] = buf.__cursor[2] + 1 end local do_replace = function(e, ch) local buf = e.__state.buf local text = buf:get_line(buf.__cursor[1]) local chars = motion_mod.decompose_line(text, e.cfg.tabstop) if #chars == 0 or buf.__cursor[2] > #chars then return end local c = chars[buf.__cursor[2]] local line_off = buf.__pt:line_offset(buf.__cursor[1]) -- Phantom-space position inside a tab run: tab takes 1 byte. local blen = c.byte_len > 0 and c.byte_len or 1 buf:group_open() buf:delete(line_off + c.byte_pos, blen) buf:insert(line_off + c.byte_pos, ch) buf:group_close() end local do_J = function(e) local buf = e.__state.buf if buf.__cursor[1] >= buf:line_count() then return end local line_off = buf.__pt:line_offset(buf.__cursor[1]) local line_len = buf.__pt:line_length(buf.__cursor[1]) local cur_text = buf:get_line(buf.__cursor[1]) local next_text = buf:get_line(buf.__cursor[1] + 1) local next_trimmed = next_text:match("^%s*(.*)") local join_off = line_off + line_len - 1 local del_len = 1 + (#next_text - #next_trimmed) buf:group_open() buf:delete(join_off, del_len) if #next_trimmed > 0 then buf:insert(join_off, " ") end buf:group_close() buf.__cursor[2] = motion_mod.line_display_width(cur_text, e.cfg.tabstop) + 1 end local do_p = function(e) ops.paste_after(e, e.__state.active_reg or '"') e.__state.active_reg = nil end local do_P = function(e) ops.paste_before(e, e.__state.active_reg or '"') e.__state.active_reg = nil end -- Dot-repeat dispatch table for simple (non-motion, non-text-object) -- commands. Called with (editor, last_recipe); single-arg functions just -- ignore the recipe. Only commands that need data from the recipe -- (x = count, r = replacement char) need a wrapper. local simple_cmds = { x = function(e, r) do_x(e, r.count or 1, r.reg) end, X = function(e, r) do_X(e, r.reg) end, ["~"] = do_tilde, r = function(e, r) do_replace(e, r.char) end, J = do_J, p = do_p, P = do_P, } -- ── normal mode key bindings ────────────────────────────────────── -- Simple motions that just move the cursor; shared by normal and visual -- modes (the latter extends the selection by moving the cursor). local motion_handler = function(key) return function(e2, n) local line, col = exec_motion(e2, key, n) if line then local buf = e2.__state.buf if key == "j" or key == "k" or key == "DOWN" or key == "UP" then if e2.__state.target_col then buf.__cursor[2] = e2.__state.target_col else e2.__state.target_col = buf.__cursor[2] end buf.__cursor[1] = line else buf.__cursor[1] = line buf.__cursor[2] = col e2.__state.target_col = nil end end end end local MOTION_KEYS = { "h", "l", "j", "k", "w", "W", "b", "B", "e", "E", "0", "$", "^", "{", "}", "%" } local MOTION_NAMED_KEYS = { "LEFT", "RIGHT", "UP", "DOWN", "HOME", "END" } local setup_normal_keys = function(km) for _, k in ipairs(MOTION_KEYS) do km:register("normal", k, motion_handler(k)) end for _, k in ipairs(MOTION_NAMED_KEYS) do km:register("normal", k, motion_handler(k)) end -- File positions with special count handling km:register("normal", "G", function(e2, n, raw) local buf = e2.__state.buf if raw > 0 then buf.__cursor[1] = n else buf.__cursor[1] = buf:line_count() end buf.__cursor[2] = 1 e2.__state.target_col = nil end) km:register("normal", "g", { g = function(e2, n, raw) e2.__state.buf.__cursor[1] = (raw > 0) and n or 1 e2.__state.buf.__cursor[2] = 1 e2.__state.target_col = nil end, }) -- Page movement local page_down = function(e2) e2.__state.buf.__cursor[1] = e2.__state.buf.__cursor[1] + (e2.__state.vp.__state.text_rows - e2.cfg.page_scroll_overlap) end local page_up = function(e2) e2.__state.buf.__cursor[1] = e2.__state.buf.__cursor[1] - (e2.__state.vp.__state.text_rows - e2.cfg.page_scroll_overlap) end km:register("normal", "PAGE_DOWN", page_down) km:register("normal", "CTRL+f", page_down) km:register("normal", "PAGE_UP", page_up) km:register("normal", "CTRL+b", page_up) -- Insert mode entry km:register("normal", "i", function(e2) e2.__state.mode:enter_insert(e2) end) km:register("normal", "a", function(e2) local buf = e2.__state.buf if line_char_count(buf, buf.__cursor[1], e2.cfg.tabstop) > 0 then buf.__cursor[2] = buf.__cursor[2] + 1 end e2.__state.mode:enter_insert(e2) end) km:register("normal", "I", function(e2) local buf = e2.__state.buf local text = buf:get_line(buf.__cursor[1]) local indent = text:match("^%s*") or "" buf.__cursor[2] = std.utf.len(indent) + 1 e2.__state.mode:enter_insert(e2) end) km:register("normal", "A", function(e2) local buf = e2.__state.buf buf.__cursor[2] = line_char_count(buf, buf.__cursor[1], e2.cfg.tabstop) + 1 e2.__state.mode:enter_insert(e2) end) km:register("normal", "o", function(e2) local buf = e2.__state.buf local line = buf.__cursor[1] local line_off = buf.__pt:line_offset(line) local content = buf:get_line(line) local indent = "" if e2.cfg.autoindent then indent = content:match("^%s*") or "" end e2.__state.mode:enter_insert(e2) buf:insert(line_off + #content, "\n" .. indent) buf.__cursor[1] = line + 1 buf.__cursor[2] = motion_mod.line_display_width(indent, e2.cfg.tabstop) + 1 end) km:register("normal", "O", function(e2) local buf = e2.__state.buf local line = buf.__cursor[1] local line_off = buf.__pt:line_offset(line) local indent = "" if e2.cfg.autoindent then indent = buf:get_line(line):match("^%s*") or "" end e2.__state.mode:enter_insert(e2) buf:insert(line_off, indent .. "\n") buf.__cursor[1] = line buf.__cursor[2] = motion_mod.line_display_width(indent, e2.cfg.tabstop) + 1 end) -- ── operators (enter operator-pending) ──────────────────────── local start_operator = function(op_key) return function(e2, n, raw) e2.__state.pending_op = op_key e2.__state.pending_count = n e2.__state.pending_raw = raw or 0 -- 0 = no explicit count typed e2.__state.op_count = 0 e2.__state.pending_reg = e2.__state.active_reg e2.__state.active_reg = nil end end for _, op in ipairs({ "d", "c", "y" }) do km:register("normal", op, start_operator(op)) end km:register("normal", ">", start_operator(">")) km:register("normal", "<", start_operator("<")) -- x: delete char under cursor (like dl) km:register("normal", "x", function(e2, n) local reg = e2.__state.active_reg do_x(e2, n, reg) record_dot(e2, { simple = "x", count = n, reg = reg }) end) -- X: delete char before cursor (like dh) km:register("normal", "X", function(e2) local reg = e2.__state.active_reg do_X(e2, reg) record_dot(e2, { simple = "X", reg = reg }) end) -- p/P: paste km:register("normal", "p", function(e2) do_p(e2) record_dot(e2, { simple = "p" }) end) km:register("normal", "P", function(e2) do_P(e2) record_dot(e2, { simple = "P" }) end) -- Undo / Redo km:register("normal", "u", function(e2) if e2.__state.buf:undo() then e2.__state.rdr:set_message("Undone") else e2.__state.rdr:set_message("Already at oldest change") end end) km:register("normal", "CTRL+r", function(e2) if e2.__state.buf:redo() then e2.__state.rdr:set_message("Redone") else e2.__state.rdr:set_message("Already at newest change") end end) -- r{c}: replace char km:register("normal", "r", function(e2) e2.__state.waiting_char = "replace" end) -- J: join lines km:register("normal", "J", function(e2) do_J(e2) record_dot(e2, { simple = "J" }) end) -- ~: toggle case of char under cursor km:register("normal", "~", function(e2) do_tilde(e2) record_dot(e2, { simple = "~" }) end) -- */#: search word under cursor km:register("normal", "*", function(e2) local buf = e2.__state.buf local ts = e2.cfg.tabstop local word = motion_mod.word_under_cursor(buf, buf.__cursor[1], buf.__cursor[2], ts) if word then e2.__state.search_pattern = word e2.__state.search_dir = "forward" local l, c = motion_mod.search_forward(buf, buf.__cursor[1], buf.__cursor[2], word, ts) if l then buf.__cursor[1] = l buf.__cursor[2] = c else e2.__state.rdr:set_message("Pattern not found: " .. word, "warning") end end end) km:register("normal", "#", function(e2) local buf = e2.__state.buf local ts = e2.cfg.tabstop local word = motion_mod.word_under_cursor(buf, buf.__cursor[1], buf.__cursor[2], ts) if word then e2.__state.search_pattern = word e2.__state.search_dir = "backward" local l, c = motion_mod.search_backward(buf, buf.__cursor[1], buf.__cursor[2], word, ts) if l then buf.__cursor[1] = l buf.__cursor[2] = c else e2.__state.rdr:set_message("Pattern not found: " .. word, "warning") end end end) -- n/N: next/prev search match local search_next = function(e2, invert) local pat = e2.__state.search_pattern if not pat then e2.__state.rdr:set_message("No search pattern") return end local buf = e2.__state.buf local backward = (e2.__state.search_dir == "backward") if invert then backward = not backward end local search_fn = backward and motion_mod.search_backward or motion_mod.search_forward local l, c = search_fn(buf, buf.__cursor[1], buf.__cursor[2], pat, e2.cfg.tabstop) if l then buf.__cursor[1] = l buf.__cursor[2] = c else e2.__state.rdr:set_message("Pattern not found: " .. pat, "warning") end end km:register("normal", "n", function(e2) search_next(e2, false) end) km:register("normal", "N", function(e2) search_next(e2, true) end) -- m{a-z}: set mark km:register("normal", "m", function(e2) e2.__state.waiting_char = "mark_set" end) -- `{a-z}: jump to mark (exact) km:register("normal", "`", function(e2) e2.__state.waiting_char = "mark_jump" end) -- '{a-z}: jump to mark (line start) km:register("normal", "'", function(e2) e2.__state.waiting_char = "mark_jump_line" end) -- f/F/t/T: char find km:register("normal", "f", function(e2, n) e2.__state.waiting_char = "find_f" e2.__state.waiting_count = n end) km:register("normal", "F", function(e2, n) e2.__state.waiting_char = "find_F" e2.__state.waiting_count = n end) km:register("normal", "t", function(e2, n) e2.__state.waiting_char = "find_t" e2.__state.waiting_count = n end) km:register("normal", "T", function(e2, n) e2.__state.waiting_char = "find_T" e2.__state.waiting_count = n end) -- ;/,: repeat/reverse char find. The repeat takes its own count -- (e.g. `3;`), independent of the original find's count. local FIND_REVERSE = { f = "F", F = "f", t = "T", T = "t" } local repeat_find = function(e2, reverse, n) local lf = e2.__state.last_find if not lf then return end local buf = e2.__state.buf local ftype = reverse and FIND_REVERSE[lf.type] or lf.type local forward = (ftype == "f" or ftype == "t") local till = (ftype == "t" or ftype == "T") local fn = forward and motion_mod.find_char_forward or motion_mod.find_char_backward -- A till repeat starts on the char adjacent to the target; nudge -- the scan start past it so `;` makes progress (vim). local scan_col = buf.__cursor[2] if till then scan_col = forward and scan_col + 1 or scan_col - 1 end local l, c = fn(buf, buf.__cursor[1], scan_col, lf.char, till, e2.cfg.tabstop, n or 1) if l then buf.__cursor[1] = l buf.__cursor[2] = c end end km:register("normal", ";", function(e2, n) repeat_find(e2, false, n) end) km:register("normal", ",", function(e2, n) repeat_find(e2, true, n) end) -- Visual mode entry (the sub-mode lives in editor.__state.visual.type) km:register("normal", "v", function(e2) local buf = e2.__state.buf e2.__state.visual = { type = "char", anchor_line = buf.__cursor[1], anchor_col = buf.__cursor[2], } e2.__state.mode:enter_visual() end) km:register("normal", "V", function(e2) local buf = e2.__state.buf e2.__state.visual = { type = "line", anchor_line = buf.__cursor[1], anchor_col = buf.__cursor[2], } e2.__state.mode:enter_visual() end) -- Register prefix: "{reg} km:register("normal", '"', function(e2) e2.__state.waiting_char = "register" end) -- Save and quit km:register("normal", "Z", { Z = function(e2) local buf = e2.__state.buf if buf:is_modified() then local ok, err = buf:save() if not ok then e2.__state.rdr:set_message(err or "Save failed", "error") return end end local other = e2.__state.buf_list:other_modified() if other then e2.__state.rdr:set_message( "No write since last change in buffer " .. other .. " (use :q! to override)", "error" ) return end e2.__state.running = false end, Q = function(e2) e2.__state.running = false end, }) -- Dot-repeat: replays the last operator from current cursor position km:register("normal", ".", function(e2) local last = e2.__state.last_op if not last then return end local buf2 = e2.__state.buf if last.motion_key then -- last.count may be nil: "no explicit count" (bare dG / dgg) local nl, nc, lw, incl = exec_motion(e2, last.motion_key, last.count) if nl then local range, rlw = build_op_range(buf2, nl, nc, lw, incl, e2.cfg.tabstop) apply_operator(e2, last.op, range, rlw, last.reg) end elseif last.find_key then replay_find_operator(e2, last) elseif last.text_obj_prefix then local obj = text_obj.resolve( buf2, buf2.__cursor[1], buf2.__cursor[2], last.text_obj_prefix, last.text_obj_key, e2.cfg.tabstop ) if obj then apply_operator(e2, last.op, obj, false, last.reg) end elseif last.linewise_self then local count = last.count or 1 local line = buf2.__cursor[1] local end_line = math.min(line + count - 1, buf2:line_count()) local range = { line, 1, end_line, math.max(1, line_char_count(buf2, end_line, e2.cfg.tabstop)) } apply_operator(e2, last.op, range, true, last.reg) elseif last.simple then local fn = simple_cmds[last.simple] if fn then fn(e2, last) end end -- A replayed change re-entered insert mode: insert the captured -- session text and seal the edit. (When the replayed motion or -- find failed, no operator ran and the mode is still normal.) if last.op == "c" and e2.__state.mode:current() == "insert" then finish_change_replay(e2, last) end end) -- ── command mode entry ──────────────────────────────────────── km:register("normal", ":", function(e2) e2.__state.command_buffer = "" e2.__state.command_prefix = ":" e2.__state.mode:enter_command() end) km:register("normal", "/", function(e2) e2.__state.command_buffer = "" e2.__state.command_prefix = "/" e2.__state.mode:enter_command() end) km:register("normal", "?", function(e2) e2.__state.command_buffer = "" e2.__state.command_prefix = "?" e2.__state.mode:enter_command() end) -- CTRL+p: file chooser km:register("normal", "CTRL+p", function(e2) local widgets = require("term.widgets") local path = widgets.file_chooser({ title = "Open file" }) -- Widget's cleanup restored main screen; re-enter alt screen for -- the editor (the original screen state still handles final restore) term.alt_screen() term.clear() if path and #path > 0 then local msg, err = command.execute(e2, "e " .. path) if err then e2.__state.rdr:set_message(err, "error") elseif msg then e2.__state.rdr:set_message(msg) end end end) -- SHIFT+CTRL+p: file chooser, insert into current buffer (like :r) km:register("normal", "SHIFT+CTRL+p", function(e2) local widgets = require("term.widgets") local path = widgets.file_chooser({ title = "Insert file" }) term.alt_screen() term.clear() if path and #path > 0 then local msg, err = command.insert_file_after_cursor(e2, path) if err then e2.__state.rdr:set_message(err, "error") elseif msg then e2.__state.rdr:set_message(msg) end end end) -- CTRL+n: next buffer km:register("normal", "CTRL+n", function(e2) local bl = e2.__state.buf_list if bl and bl:count() > 1 then bl:next() e2.__state.buf = bl:current() end end) end -- ── insert mode key bindings ────────────────────────────────────── local insert_char = function(e, ch) local buf = e.__state.buf local offset = motion_mod.cursor_to_offset(buf, buf.__cursor[1], buf.__cursor[2], e.cfg.tabstop) buf:insert(offset, ch) buf.__cursor[2] = buf.__cursor[2] + std.utf.len(ch) end local setup_insert_keys = function(km) km:register("insert", "ESC", function(e2) -- Complete a pending change-op capture: remember the text typed -- during this insert session so `.` can replay it. local cap = e2.__state.change_capture if cap then e2.__state.change_capture = nil local buf = e2.__state.buf local end_off = motion_mod.cursor_to_offset(buf, buf.__cursor[1], buf.__cursor[2], e2.cfg.tabstop) if end_off > cap.start_off then cap.recipe.insert_text = buf.__pt:get_text(cap.start_off, end_off - cap.start_off) end end e2.__state.mode:enter_normal(e2) e2.__state.target_col = nil end) local insert_backspace = function(e2) local buf = e2.__state.buf if buf.__cursor[2] <= 1 then if buf.__cursor[1] > 1 then local prev_clen = line_char_count(buf, buf.__cursor[1] - 1, e2.cfg.tabstop) local line_off = buf.__pt:line_offset(buf.__cursor[1]) buf:delete(line_off - 1, 1) buf.__cursor[1] = buf.__cursor[1] - 1 buf.__cursor[2] = prev_clen + 1 end return end local text = buf:get_line(buf.__cursor[1]) local boff = char_byte_info(text, buf.__cursor[2], e2.cfg.tabstop) local prev_boff = char_byte_info(text, buf.__cursor[2] - 1, e2.cfg.tabstop) local del_len = boff - prev_boff local line_off = buf.__pt:line_offset(buf.__cursor[1]) if del_len > 0 then buf:delete(line_off + prev_boff, del_len) buf.__cursor[2] = buf.__cursor[2] - 1 else -- Cursor parked on a phantom tab column (e.g. after UP/DOWN): -- both columns map into the same tab; delete the tab byte and -- snap the cursor to the run's start column. local chars = motion_mod.decompose_line(text, e2.cfg.tabstop) if chars[buf.__cursor[2] - 1] then local run_start = motion_mod.col_left(chars, buf.__cursor[2]) buf:delete(line_off + chars[buf.__cursor[2] - 1].byte_pos, 1) buf.__cursor[2] = run_start end end end km:register("insert", "BACKSPACE", insert_backspace) km:register("insert", "CTRL+h", insert_backspace) km:register("insert", "DELETE", function(e2) local buf = e2.__state.buf local text = buf:get_line(buf.__cursor[1]) local clen = line_char_count(buf, buf.__cursor[1], e2.cfg.tabstop) if buf.__cursor[2] > clen then if buf.__cursor[1] < buf:line_count() then local line_off = buf.__pt:line_offset(buf.__cursor[1]) local line_blen = buf.__pt:line_length(buf.__cursor[1]) buf:delete(line_off + line_blen - 1, 1) end return end local boff, blen = char_byte_info(text, buf.__cursor[2], e2.cfg.tabstop) local line_off = buf.__pt:line_offset(buf.__cursor[1]) -- blen == 0: phantom tab column — delete the tab byte it belongs to buf:delete(line_off + boff, blen > 0 and blen or 1) end) km:register("insert", "ENTER", function(e2) local buf = e2.__state.buf local offset = motion_mod.cursor_to_offset(buf, buf.__cursor[1], buf.__cursor[2], e2.cfg.tabstop) local indent = "" if e2.cfg.autoindent then indent = buf:get_line(buf.__cursor[1]):match("^%s*") or "" end buf:insert(offset, "\n" .. indent) buf.__cursor[1] = buf.__cursor[1] + 1 buf.__cursor[2] = std.utf.len(indent) + 1 end) km:register("insert", "TAB", function(e2) local buf = e2.__state.buf local offset = motion_mod.cursor_to_offset(buf, buf.__cursor[1], buf.__cursor[2], e2.cfg.tabstop) if e2.cfg.expandtab then local col = buf.__cursor[2] local spaces_needed = e2.cfg.tabstop - ((col - 1) % e2.cfg.tabstop) buf:insert(offset, string.rep(" ", spaces_needed)) buf.__cursor[2] = buf.__cursor[2] + spaces_needed else local col = buf.__cursor[2] local tab_width = e2.cfg.tabstop - ((col - 1) % e2.cfg.tabstop) buf:insert(offset, "\t") buf.__cursor[2] = col + tab_width end end) for _, k in ipairs({ "LEFT", "RIGHT", "UP", "DOWN" }) do km:register("insert", k, function(e2) local buf = e2.__state.buf if k == "LEFT" then local chars = motion_mod.decompose_line(buf:get_line(buf.__cursor[1]), e2.cfg.tabstop) buf.__cursor[2] = motion_mod.col_left(chars, buf.__cursor[2]) elseif k == "RIGHT" then local chars = motion_mod.decompose_line(buf:get_line(buf.__cursor[1]), e2.cfg.tabstop) buf.__cursor[2] = motion_mod.col_right(chars, buf.__cursor[2]) elseif k == "UP" then buf.__cursor[1] = buf.__cursor[1] - 1 elseif k == "DOWN" then buf.__cursor[1] = buf.__cursor[1] + 1 end end) end km:register("insert", "CTRL+w", function(e2) local buf = e2.__state.buf if buf.__cursor[2] <= 1 then return end local text = buf:get_line(buf.__cursor[1]) local chars = motion_mod.decompose_line(text, e2.cfg.tabstop) local col = buf.__cursor[2] local target = col - 1 while target >= 1 and chars[target] and motion_mod.is_space(chars[target].ch) do target = target - 1 end while target >= 1 and chars[target] and not motion_mod.is_space(chars[target].ch) do target = target - 1 end if target < col - 1 then local start_boff = target >= 1 and (chars[target].byte_pos + chars[target].byte_len) or 0 local end_boff = char_byte_info(text, col, e2.cfg.tabstop) local line_off = buf.__pt:line_offset(buf.__cursor[1]) buf:delete(line_off + start_boff, end_boff - start_boff) buf.__cursor[2] = target + 1 end end) km:register("insert", "CTRL+u", function(e2) local buf = e2.__state.buf if buf.__cursor[2] <= 1 then return end local text = buf:get_line(buf.__cursor[1]) local boff = char_byte_info(text, buf.__cursor[2], e2.cfg.tabstop) local line_off = buf.__pt:line_offset(buf.__cursor[1]) buf:delete(line_off, boff) buf.__cursor[2] = 1 end) local scroll_forward = function(e2) e2.__state.completion:scroll("forward") end local scroll_backward = function(e2) e2.__state.completion:scroll("backward") end km:register("insert", "CTRL+n", scroll_forward) km:register("insert", "CTRL+DOWN", scroll_forward) km:register("insert", "CTRL+p", scroll_backward) km:register("insert", "CTRL+UP", scroll_backward) end -- ── visual mode key bindings ────────────────────────────────────── local visual_range = function(e) local buf = e.__state.buf local vis = e.__state.visual if not vis then return nil end local al, ac = vis.anchor_line, vis.anchor_col local cl, cc = buf.__cursor[1], buf.__cursor[2] local linewise = (vis.type == "line") -- Normalize local fl, fc, tl, tc = al, ac, cl, cc if tl < fl or (tl == fl and tc < fc) then fl, fc, tl, tc = tl, tc, fl, fc end return { fl, fc, tl, tc }, linewise end local setup_visual_keys = function(km) -- Motions extend the selection by moving the cursor; same handlers -- as normal mode (including j/k target-column stickiness). for _, k in ipairs(MOTION_KEYS) do km:register("visual", k, motion_handler(k)) end for _, k in ipairs(MOTION_NAMED_KEYS) do km:register("visual", k, motion_handler(k)) end km:register("visual", "G", function(e2, n, raw) e2.__state.buf.__cursor[1] = (raw > 0) and math.min(n, e2.__state.buf:line_count()) or e2.__state.buf:line_count() e2.__state.buf.__cursor[2] = 1 end) km:register("visual", "g", { g = function(e2, n, raw) e2.__state.buf.__cursor[1] = (raw > 0) and math.min(n, e2.__state.buf:line_count()) or 1 e2.__state.buf.__cursor[2] = 1 end, }) -- o: swap cursor and anchor km:register("visual", "o", function(e2) local vis = e2.__state.visual local buf = e2.__state.buf local al, ac = vis.anchor_line, vis.anchor_col vis.anchor_line = buf.__cursor[1] vis.anchor_col = buf.__cursor[2] buf.__cursor[1] = al buf.__cursor[2] = ac end) -- v/V: switch visual sub-mode km:register("visual", "v", function(e2) if e2.__state.visual.type == "char" then e2.__state.visual = nil e2.__state.mode:enter_normal(e2) else e2.__state.visual.type = "char" end end) km:register("visual", "V", function(e2) if e2.__state.visual.type == "line" then e2.__state.visual = nil e2.__state.mode:enter_normal(e2) else e2.__state.visual.type = "line" end end) -- ESC: cancel visual km:register("visual", "ESC", function(e2) e2.__state.visual = nil e2.__state.mode:enter_normal(e2) end) -- Register prefix: "{reg} (so `v...."+y` works, not just `"+v...y`) km:register("visual", '"', function(e2) e2.__state.waiting_char = "register" end) -- Operators on selection. Visual operations are not dot-repeatable; -- clear any older recipe so `.` doesn't silently replay it instead. local vis_op = function(op_key) return function(e2) local range, linewise = visual_range(e2) if range then apply_operator(e2, op_key, range, linewise, e2.__state.active_reg) e2.__state.active_reg = nil e2.__state.last_op = nil e2.__state.change_capture = nil end e2.__state.visual = nil if e2.__state.mode:current() ~= "insert" then e2.__state.mode:enter_normal(e2) end end end km:register("visual", "d", vis_op("d")) km:register("visual", "x", vis_op("d")) km:register("visual", "c", vis_op("c")) km:register("visual", "y", vis_op("y")) km:register("visual", ">", vis_op(">")) km:register("visual", "<", vis_op("<")) -- ~: toggle case of selection km:register("visual", "~", function(e2) local range, linewise = visual_range(e2) if range then ops.op_toggle_case(e2, range, linewise) e2.__state.last_op = nil end e2.__state.visual = nil e2.__state.mode:enter_normal(e2) end) end -- ── waiting-char dispatch ───────────────────────────────────────── local handle_waiting_char = function(e, key, cmd_key) local wtype = e.__state.waiting_char e.__state.waiting_char = nil local count = e.__state.waiting_count or 1 e.__state.waiting_count = nil local buf = e.__state.buf if wtype == "replace" then do_replace(e, key) record_dot(e, { simple = "r", char = key }) elseif wtype == "mark_set" then if cmd_key:match("^[a-z]$") then buf:set_mark(cmd_key, buf.__cursor[1], buf.__cursor[2]) end elseif wtype == "mark_jump" then local mark = buf:get_mark(cmd_key) if mark then buf.__cursor[1] = mark[1] buf.__cursor[2] = mark[2] end elseif wtype == "mark_jump_line" then local mark = buf:get_mark(cmd_key) if mark then buf.__cursor[1] = mark[1] local text2 = buf:get_line(mark[1]) local indent = text2:match("^%s*") or "" buf.__cursor[2] = std.utf.len(indent) + 1 end elseif wtype == "find_f" or wtype == "find_F" or wtype == "find_t" or wtype == "find_T" then local fkey = wtype:sub(-1) local l, c = resolve_find(e, fkey, key, count) if l then buf.__cursor[1] = l buf.__cursor[2] = c end -- The find is remembered for ;/, even when it failed (vim) e.__state.last_find = { type = fkey, char = key, count = count } elseif wtype == "register" then if cmd_key:match('[%w"_+]') then e.__state.active_reg = cmd_key end end end -- ── operator-pending dispatch ───────────────────────────────────── -- Blocking variant of term.simple_get() for nested reads inside -- operator-pending. Raw mode uses VMIN=0/VTIME=1, so simple_get() returns -- nil every 100 ms when idle; the main loop retries, but nested reads -- (argument char for f/t, text object key, gg second key) need to wait -- for an actual keypress or the pending operator gets silently dropped. local blocking_get = function() while true do local key, base_key = term.simple_get() if key then return key, base_key end end end local handle_operator_pending = function(e, cmd_key) local buf = e.__state.buf -- Count accumulation: digits accumulate without cancelling the operator local byte = cmd_key:byte(1) if #cmd_key == 1 and byte >= 48 and byte <= 57 then local digit = byte - 48 if digit > 0 or e.__state.op_count > 0 then e.__state.op_count = e.__state.op_count * 10 + digit return -- keep pending_op alive end -- digit==0 with no count → fall through as '0' motion end local op_key = e.__state.pending_op local reg = e.__state.pending_reg local pre_count = e.__state.pending_count or 1 local pre_raw = e.__state.pending_raw or 0 local post_raw = e.__state.op_count local post_count = post_raw if post_count == 0 then post_count = 1 end local count = pre_count * post_count local explicit_count = (pre_raw > 0 or post_raw > 0) e.__state.pending_op = nil e.__state.pending_reg = nil e.__state.pending_count = nil e.__state.pending_raw = nil -- ESC cancels if cmd_key == "ESC" then return end -- Double-key: dd, yy, cc, >>, << → operate on [count] lines if cmd_key == op_key then local line = buf.__cursor[1] local end_line = math.min(line + count - 1, buf:line_count()) local range = { line, 1, end_line, math.max(1, line_char_count(buf, end_line, e.cfg.tabstop)) } apply_operator(e, op_key, range, true, reg) record_dot(e, { op = op_key, linewise_self = true, count = count, reg = reg }) return end -- Text object: i/a prefix if cmd_key == "i" or cmd_key == "a" then local obj_key, obj_base = blocking_get() if obj_key then obj_key = obj_base or obj_key local obj = text_obj.resolve(buf, buf.__cursor[1], buf.__cursor[2], cmd_key, obj_key, e.cfg.tabstop) if obj then apply_operator(e, op_key, obj, false, reg) record_dot(e, { op = op_key, text_obj_prefix = cmd_key, text_obj_key = obj_key, reg = reg }) end end return end -- gg special case (two-key motion needs its own blocking read) if cmd_key == "g" then local k2, k2_base = blocking_get() k2 = k2_base or k2 if k2 == "g" then local motion_count = explicit_count and count or nil local nl, nc, lw, incl = exec_motion(e, "gg", motion_count) local range, rlw = build_op_range(buf, nl, nc, lw, incl, e.cfg.tabstop) apply_operator(e, op_key, range, rlw, reg) record_dot(e, { op = op_key, motion_key = "gg", count = motion_count, reg = reg }) end return end -- f/F/t/T: char find in operator-pending if cmd_key == "f" or cmd_key == "F" or cmd_key == "t" or cmd_key == "T" then local ch = blocking_get() if ch and std.utf.len(ch) == 1 then local l, c, inclusive = resolve_find(e, cmd_key, ch, count) if l and (l ~= buf.__cursor[1] or c ~= buf.__cursor[2]) then local range, lw = build_op_range(buf, l, c, false, inclusive, e.cfg.tabstop) apply_operator(e, op_key, range, lw, reg) record_dot(e, { op = op_key, find_key = cmd_key, find_char = ch, count = count, reg = reg }) e.__state.last_find = { type = cmd_key, char = ch, count = count } end end return end -- Motion local motion_key = cmd_key local motion_count = count if (cmd_key == "G") and not explicit_count then motion_count = nil -- bare dG targets the last line end -- Vim special case: cw/cW on a word character behaves like ce/cE if op_key == "c" and (cmd_key == "w" or cmd_key == "W") then local cur_text = buf:get_line(buf.__cursor[1]) local cur_chars = motion_mod.decompose_line(cur_text, e.cfg.tabstop) local cur_col = buf.__cursor[2] if cur_col <= #cur_chars and motion_mod.is_word_char(cur_chars[cur_col].ch) then motion_key = (cmd_key == "w") and "e" or "E" end end local new_line, new_col, linewise, inclusive = exec_motion(e, motion_key, motion_count) if new_line then -- A j/k motion that couldn't move (first/last line) fails the -- operation instead of degenerating to a one-line range (vim). if (motion_key == "j" or motion_key == "DOWN" or motion_key == "k" or motion_key == "UP") and new_line == buf.__cursor[1] then return end local range, lw = build_op_range(buf, new_line, new_col, linewise, inclusive, e.cfg.tabstop) apply_operator(e, op_key, range, lw, reg) record_dot(e, { op = op_key, motion_key = motion_key, count = motion_count, reg = reg }) end end -- ── main loop ───────────────────────────────────────────────────── local do_render = function(editor) local rdr = editor.__state.rdr local buf = editor.__state.buf local vp = editor.__state.vp rdr.__state.tss = editor.__state.get_tss() rdr.__state.mode = editor.__state.mode:current() -- Refresh highlight context when buffer or filetype changes. -- Filetype alone flips on :set filetype=X, which keeps the same buffer -- but needs a fresh highlighter (and a diagnostics re-run). if editor.__state.last_hl_buf ~= buf or editor.__state.last_hl_filetype ~= buf.cfg.filetype then editor.__state.highlight_ctx = highlight.new_context(buf.cfg.filetype) editor.__state.last_hl_buf = buf editor.__state.last_hl_filetype = buf.cfg.filetype editor.__state.diag_version = -1 editor.__state.diag_result = nil end rdr.__state.highlight_ctx = editor.__state.highlight_ctx rdr.__state.tabstop = editor.cfg.tabstop -- Pass visual selection state to renderer if editor.__state.visual then rdr.__state.visual = { type = editor.__state.visual.type, anchor_line = editor.__state.visual.anchor_line, anchor_col = editor.__state.visual.anchor_col, cursor_line = buf.__cursor[1], cursor_col = buf.__cursor[2], } else rdr.__state.visual = nil end -- Bracket pair highlight rdr.__state.bracket_pair = motion_mod.bracket_pair(buf, buf.__cursor[1], buf.__cursor[2], editor.cfg.tabstop) -- Diagnostics (only re-validate when buffer content changes) if editor.__state.diag_version ~= buf.__version then editor.__state.diag_version = buf.__version editor.__state.diag_result = diagnostic.validate(buf) end rdr.__state.diagnostic = editor.__state.diag_result -- Show diagnostic message when cursor is on the error line if editor.__state.diag_result and buf.__cursor[1] == editor.__state.diag_result.line then if not rdr.__state.message then rdr:set_message(editor.__state.diag_result.message, "error") end end local bl = editor.__state.buf_list rdr.__state.buf_idx = bl.__state.current rdr.__state.buf_count = bl:count() local total_lines = buf:line_count() local gw = vp:gutter_width(total_lines) vp:scroll_to_cursor(buf, total_lines, gw) rdr:full_render(buf, total_lines, gw) -- Ghost-text completion overlay (insert mode only) local comp = editor.__state.completion if comp then local primary_gs = comp:active() and comp:ghost_state() or nil if primary_gs then rdr:render_completion_ghost(primary_gs, buf, total_lines, gw) end end -- In command mode, render the command line input instead of messages if editor.__state.mode:current() == "command" and editor.__state.command_prefix then local prefix = editor.__state.command_prefix local cb = editor.__state.command_buffer or "" rdr:render_message(prefix .. cb, "info") -- Position cursor in command line local cmd_row = vp.__state.screen_lines local cmd_col = std.utf.len(prefix) + std.utf.len(cb) + 1 term.show_cursor() term.go(cmd_row, cmd_col) else term.show_cursor() rdr:position_cursor(buf, total_lines, gw) end end local run = function(files, opts) opts = opts or {} local cfg = config.new() local buf_list = buffer_list.new() local open_errors = {} for _, path in ipairs(files or {}) do local ok, oerr = buf_list:open(path) if not ok then open_errors[#open_errors + 1] = path .. ": " .. tostring(oerr) end end if buf_list:count() == 0 then buf_list:new_empty() else buf_list:switch(1) end local buf = buf_list:current() if opts.jump_line and opts.jump_line > 0 then buf.__cursor[1] = opts.jump_line end local vp = viewport.new({ number = cfg.number, relativenumber = cfg.relativenumber, scrolloff = cfg.scrolloff, wrap = cfg.wrap, tabstop = cfg.tabstop, }) local get_tss = theme_mod.subscribe("shell", "lem") local rdr = render.new(vp, get_tss) local mode = mode_mod.new() local km = keymap_mod.new() -- Everything between entering the alt screen and screen:done() runs -- under pcall: an error anywhere in setup or the main loop must not -- leave the shell stranded in raw-mode alt screen (LEM runs as a -- `fork = "never"` builtin). local screen = term.alt_screen() local run_ok, run_err = pcall(function() term.clear() local editor = { cfg = cfg, __state = { buf = buf, buf_list = buf_list, vp = vp, rdr = rdr, mode = mode, km = km, get_tss = get_tss, running = true, registers = ops.new_registers(), pending_op = nil, pending_count = nil, pending_raw = nil, op_count = 0, pending_reg = nil, active_reg = nil, waiting_char = nil, waiting_count = nil, visual = nil, target_col = nil, last_find = nil, last_op = nil, change_capture = nil, search_pattern = nil, search_dir = "forward", command_buffer = nil, command_prefix = nil, highlight_ctx = highlight.new_context(buf.cfg.filetype), last_hl_buf = buf, last_hl_filetype = buf.cfg.filetype, diag_version = -1, diag_result = nil, completion = nil, }, } editor.__state.completion = completion_mod.new(editor) setup_normal_keys(km) setup_insert_keys(km) setup_visual_keys(km) if #open_errors > 0 then rdr:set_message(table.concat(open_errors, "; "), "error") end pcall(clamp_cursor, editor) pcall(do_render, editor) -- Insert a bracketed paste as literal text. Bypasses dispatch_key / -- is_printable_key entirely: pasted content that happens to look like a -- key name (e.g. "LHS 3558", or anything containing "+") must never be -- mistaken for a keystroke. Active only in the text-entry modes; ignored -- in normal/visual so a paste can't run stray commands. local handle_paste = function(text) if not text or text == "" then return end -- Normalize line endings so only "\n" reaches the buffer / a single -- command line. text = text:gsub("\r\n", "\n"):gsub("\r", "\n") local current_mode = mode:current() if current_mode == "insert" then -- Insert into the undo group already opened by enter_insert (like -- insert_char / the ENTER handler) — don't open/close our own, or -- we'd prematurely close the session group and break undo grouping -- for the rest of the insert session. local buf = editor.__state.buf local offset = motion_mod.cursor_to_offset(buf, buf.__cursor[1], buf.__cursor[2], editor.cfg.tabstop) buf:insert(offset, text) -- Advance the cursor past the inserted text, multi-line aware. local newlines = select(2, text:gsub("\n", "")) if newlines == 0 then buf.__cursor[2] = buf.__cursor[2] + std.utf.len(text) else local last = text:match("[^\n]*$") or "" buf.__cursor[1] = buf.__cursor[1] + newlines buf.__cursor[2] = std.utf.len(last) + 1 end elseif current_mode == "command" then -- The `:`/`/` line is single-line; flatten any newlines. local oneline = text:gsub("\n", " ") editor.__state.command_buffer = (editor.__state.command_buffer or "") .. oneline end end local dispatch_key = function(key, base_key) rdr:clear_message() local current_mode = mode:current() key = LEGACY_KEY_MAP[key] or key local cmd_key = base_key or key -- layout-independent command key -- Waiting for a character (r, f, m, etc.) if editor.__state.waiting_char then if std.utf.len(key) == 1 then handle_waiting_char(editor, key, cmd_key) else editor.__state.waiting_char = nil editor.__state.waiting_count = nil end -- Operator-pending mode elseif editor.__state.pending_op and current_mode == "normal" then handle_operator_pending(editor, cmd_key) -- Insert mode elseif current_mode == "insert" then local comp = editor.__state.completion local is_scroll_key = (key == "CTRL+n" or key == "CTRL+DOWN" or key == "CTRL+p" or key == "CTRL+UP") if key == "TAB" and comp:active() then -- TAB accepts the primary ghost; falls through to the normal -- tab handler when no primary session is active. comp:promote() else local handled = km:dispatch(editor, "insert", key) if not handled and is_printable_key(key) then insert_char(editor, key) end end if not is_scroll_key then comp:refresh() end -- Command mode elseif current_mode == "command" then if key == "ESC" then editor.__state.mode:enter_normal(editor) editor.__state.command_buffer = nil editor.__state.command_prefix = nil elseif key == "ENTER" then local prefix = editor.__state.command_prefix local input = editor.__state.command_buffer or "" editor.__state.mode:enter_normal(editor) editor.__state.command_buffer = nil editor.__state.command_prefix = nil if prefix == ":" then local msg, cmderr = command.execute(editor, input) if cmderr then rdr:set_message(cmderr, "error") elseif msg then rdr:set_message(msg) end elseif prefix == "/" then if #input > 0 then editor.__state.search_pattern = input editor.__state.search_dir = "forward" local cbuf = editor.__state.buf local l, c = motion_mod.search_forward( cbuf, cbuf.__cursor[1], cbuf.__cursor[2], input, editor.cfg.tabstop ) if l then cbuf.__cursor[1] = l cbuf.__cursor[2] = c else rdr:set_message("Pattern not found: " .. input, "warning") end end elseif prefix == "?" then if #input > 0 then editor.__state.search_pattern = input editor.__state.search_dir = "backward" local cbuf = editor.__state.buf local l, c = motion_mod.search_backward( cbuf, cbuf.__cursor[1], cbuf.__cursor[2], input, editor.cfg.tabstop ) if l then cbuf.__cursor[1] = l cbuf.__cursor[2] = c else rdr:set_message("Pattern not found: " .. input, "warning") end end end elseif key == "BACKSPACE" or key == "CTRL+h" then local cb = editor.__state.command_buffer or "" if #cb > 0 then -- Remove last UTF-8 character editor.__state.command_buffer = std.utf.sub(cb, 1, -2) else -- Empty buffer + backspace = cancel editor.__state.mode:enter_normal(editor) editor.__state.command_buffer = nil editor.__state.command_prefix = nil end elseif is_printable_key(key) then editor.__state.command_buffer = (editor.__state.command_buffer or "") .. key end -- Visual mode elseif current_mode == "visual" then km:dispatch(editor, "visual", cmd_key) -- Register prefixes are consume-once: anything that didn't use -- the pending register cancels it (it was armed via the -- waiting-char path, which doesn't come through here). editor.__state.active_reg = nil -- Normal mode else km:dispatch(editor, "normal", cmd_key) editor.__state.active_reg = nil end end -- Reset to a safe state and surface `err` in the status bar. local handle_error = function(err) editor.__state.pending_op = nil editor.__state.pending_count = nil editor.__state.pending_raw = nil editor.__state.pending_reg = nil editor.__state.op_count = 0 editor.__state.waiting_char = nil editor.__state.waiting_count = nil editor.__state.active_reg = nil editor.__state.change_capture = nil editor.__state.visual = nil km:reset() if mode:current() ~= "normal" then editor.__state.mode:enter_normal(editor) end rdr:set_message(tostring(err), "error") end local step = function() local key, base_key, is_paste = term.simple_get() if is_paste then handle_paste(key) clamp_cursor(editor) if editor.__state.running then do_render(editor) end elseif key then dispatch_key(key, base_key) clamp_cursor(editor) if editor.__state.running then do_render(editor) end elseif term.resized() then vp:update_dimensions() term.clear() clamp_cursor(editor) do_render(editor) end end -- Wrap each iteration so a Lua error anywhere under `step` becomes a -- status-bar message instead of escaping run() — which would kill the -- shell, since LEM runs as a `fork = "never"` builtin. while editor.__state.running do local ok, err = pcall(step) if not ok then pcall(handle_error, err) pcall(do_render, editor) end end if mode:current() == "insert" then pcall(function() editor.__state.buf:group_close() end) end end) screen:done() if not run_ok then return nil, tostring(run_err) end return 0 end -- Edit a transient content blob in LEM. Writes `content` to a tmpfile, -- runs the editor on it, reads the file back, and cleans up. Returns -- the post-edit content on success, or (nil, err) on failure. If the -- user quits without saving, the original content is returned. -- -- opts.hint -- prefix for the tmpfile name -- opts.ext -- extension (with or without leading dot) so LEM picks -- the right filetype (e.g. "json" for highlighting + -- diagnostic validation) -- opts.jump_line -- initial cursor line local edit_content = function(content, opts) opts = opts or {} local hint = opts.hint or "lem_edit" local suffix = "" if opts.ext and opts.ext ~= "" then suffix = "." .. opts.ext:gsub("^%.", "") end local tmpfile = "/tmp/" .. hint .. "_" .. std.nanoid() .. suffix local wok, werr = std.fs.write_file(tmpfile, content or "") if not wok then return nil, werr or "tmpfile write failed" end -- The content being edited may be sensitive; don't leave it -- world-readable in /tmp for the duration of the session. std.fs.chmod(tmpfile, "0600") local ok, status, run_err = pcall(run, { tmpfile }, { jump_line = opts.jump_line }) local result, read_err if ok and status then result, read_err = std.fs.read_file(tmpfile) end std.fs.remove(tmpfile) if not ok then return nil, tostring(status) end if not status then return nil, run_err or "editor failed" end if not result then return nil, read_err or "tmpfile read failed" end return result end return { run = run, edit_content = edit_content, }