readline.lua
--- An example class for reading a line of input from the user in a non-blocking way. -- It uses ANSI escape sequences to move the cursor and handle input. -- It can be used to read a line of input from the user, with a prompt. -- It can handle double-width UTF-8 characters. -- It can be used asynchroneously if system.sleep is patched to yield to a coroutine scheduler. local sys = require("system") -- Mapping of key-sequences to key-names local key_names = { ["\27[C"] = "right", ["\27[D"] = "left", ["\127"] = "backspace", ["\27[3~"] = "delete", ["\27[H"] = "home", ["\27[F"] = "end", ["\27"] = "escape", ["\9"] = "tab", ["\27[Z"] = "shift-tab", } if sys.windows then key_names["\13"] = "enter" else key_names["\10"] = "enter" end -- Mapping of key-names to key-sequences local key_sequences = {} for k, v in pairs(key_names) do key_sequences[v] = k end -- bell character local function bell() io.write("\7") io.flush() end -- generate string to move cursor horizontally -- positive goes right, negative goes left local function cursor_move_horiz(n) if n == 0 then return "" end return "\27[" .. (n > 0 and n or -n) .. (n > 0 and "C" or "D") end -- -- generate string to move cursor vertically -- -- positive goes down, negative goes up -- local function cursor_move_vert(n) -- if n == 0 then -- return "" -- end -- return "\27[" .. (n > 0 and n or -n) .. (n > 0 and "B" or "A") -- end -- -- log to the line above the current line -- local function log(...) -- local arg = { n = select("#", ...), ...} -- for i = 1, arg.n do -- arg[i] = tostring(arg[i]) -- end -- arg = " " .. table.concat(arg, " ") .. " " -- io.write(cursor_move_vert(-1), arg, cursor_move_vert(1), cursor_move_horiz(-#arg)) -- end -- UTF8 character size in bytes -- @tparam number b the byte value of the first byte of a UTF8 character local function utf8size(b) return b < 128 and 1 or b < 224 and 2 or b < 240 and 3 or b < 248 and 4 end local utf8parse do local utf8_value_mt = { __tostring = function(self) return table.concat(self, "") end, } -- Parses a UTF8 string into list of individual characters. -- key 'chars' gets the length in UTF8 characters, whilst # returns the length -- for display (to handle double-width UTF8 chars). -- in the list the double-width characters are followed by an empty string. -- @tparam string s the UTF8 string to parse -- @treturn table the list of characters function utf8parse(s) local t = setmetatable({ chars = 0 }, utf8_value_mt) local i = 1 while i <= #s do local b = s:byte(i) local w = utf8size(b) local char = s:sub(i, i + w - 1) t[#t + 1] = char t.chars = t.chars + 1 if sys.utf8cwidth(char) == 2 then -- double width character, add empty string to keep the length of the -- list the same as the character width on screen t[#t + 1] = "" end i = i + w end return t end end -- inline tests for utf8parse -- do -- local t = utf8parse("a你b好c") -- assert(t[1] == "a") -- assert(t[2] == "你") -- double width -- assert(t[3] == "") -- assert(t[4] == "b") -- assert(t[5] == "好") -- double width -- assert(t[6] == "") -- assert(t[7] == "c") -- assert(#t == 7) -- size as displayed -- end -- readline class local readline = {} readline.__index = readline --- Create a new readline object. -- @tparam table opts the options for the readline object -- @tparam[opt=""] string opts.prompt the prompt to display -- @tparam[opt=80] number opts.max_length the maximum length of the input (in characters, not bytes) -- @tparam[opt=""] string opts.value the default value -- @tparam[opt=#value
] number opts.position of the cursor in the input -- @tparam[opt={"\10"/"\13"}] table opts.exit_keys an array of keys that will cause the readline to exit -- @treturn readline the new readline object function readline.new(opts) local value = utf8parse(opts.value or "") local prompt = utf8parse(opts.prompt or "") local pos = math.floor(opts.position or (#value + 1)) pos = math.max(math.min(pos, (#value + 1)), 1) local len = math.floor(opts.max_length or 80) if len < 1 then error("max_length must be at least 1", 2) end if value.chars > len then error("value is longer than max_length", 2) end local exit_keys = {} for _, key in ipairs(opts.exit_keys or {}) do exit_keys[key] = true end if exit_keys[1] == nil then -- nothing provided, default to Enter-key exit_keys[1] = key_sequences.enter end local self = { value = value, -- the default value max_length = len, -- the maximum length of the input prompt = prompt, -- the prompt to display position = pos, -- the current position in the input drawn_before = false, -- if the prompt has been drawn exit_keys = exit_keys, -- the keys that will cause the readline to exit } setmetatable(self, readline) return self end -- draw the prompt and the input value, and position the cursor. local function draw(self, redraw) if redraw or not self.drawn_before then -- we are at start of prompt self.drawn_before = true else -- we are at current cursor position, move to start of prompt io.write(cursor_move_horiz(-(#self.prompt + self.position))) end -- write prompt & value io.write(tostring(self.prompt) .. tostring(self.value)) -- clear remainder of input size io.write(string.rep(" ", self.max_length - self.value.chars)) io.write(cursor_move_horiz(-(self.max_length - self.value.chars))) -- move to cursor position io.write(cursor_move_horiz(-(#self.value + 1 - self.position))) io.flush() end local handle_key do -- keyboard input handler local key_handlers key_handlers = { left = function(self) if self.position == 1 then bell() return end local new_pos = self.position - 1 while self.value[new_pos] == "" do -- skip empty strings; double width chars new_pos = new_pos - 1 end io.write(cursor_move_horiz(-(self.position - new_pos))) io.flush() self.position = new_pos end, right = function(self) if self.position == #self.value + 1 then bell() return end local new_pos = self.position + 1 while self.value[new_pos] == "" do -- skip empty strings; double width chars new_pos = new_pos + 1 end io.write(cursor_move_horiz(new_pos - self.position)) io.flush() self.position = new_pos end, backspace = function(self) if self.position == 1 then bell() return end while self.value[self.position - 1] == "" do -- remove empty strings; double width chars io.write(cursor_move_horiz(-1)) self.position = self.position - 1 table.remove(self.value, self.position) end -- remove char itself io.write(cursor_move_horiz(-1)) self.position = self.position - 1 table.remove(self.value, self.position) self.value.chars = self.value.chars - 1 draw(self) end, home = function(self) local new_pos = 1 io.write(cursor_move_horiz(new_pos - self.position)) self.position = new_pos end, ["end"] = function(self) local new_pos = #self.value + 1 io.write(cursor_move_horiz(new_pos - self.position)) self.position = new_pos end, delete = function(self) if self.position > #self.value then bell() return end key_handlers.right(self) key_handlers.backspace(self) end, } -- handles a single input key/ansi-sequence. -- @tparam string key the key or ansi-sequence (from system.readansi) -- @tparam string keytype the type of the key, either "char" or "ansi" (from system.readansi) -- @treturn string status the status of the key handling, either "ok", "exit_key" or an error message function handle_key(self, key, keytype) if self.exit_keys[key] then -- registered exit key return "exit_key" end local handler = key_handlers[key_names[key] or true ] if handler then handler(self) return "ok" end if keytype == "ansi" then -- we got an ansi sequence, but dunno how to handle it, ignore -- print("unhandled ansi: ", key:sub(2,-1), string.byte(key, 1, -1)) bell() return "ok" end -- just a single key if key < " " then -- control character bell() return "ok" end if self.value.chars >= self.max_length then bell() return "ok" end -- insert the key into the value if sys.utf8cwidth(key) == 2 then -- double width character, insert empty string after it table.insert(self.value, self.position, "") table.insert(self.value, self.position, key) self.position = self.position + 2 io.write(cursor_move_horiz(2)) else table.insert(self.value, self.position, key) self.position = self.position + 1 io.write(cursor_move_horiz(1)) end self.value.chars = self.value.chars + 1 draw(self) return "ok" end end --- Get_size returns the maximum size of the input box (prompt + input). -- The size is in rows and columns. Columns is determined by -- the prompt and themax_length * 2
(characters can be double-width). -- @treturn number the number of rows (always 1) -- @treturn number the number of columns function readline:get_size() return 1, #self.prompt + self.max_length * 2 end --- Get coordinates of the cursor in the input box (prompt + input). -- The coordinates are 1-based. They are returned as row and column, within the -- size as reported byget_size
. -- @treturn number the row of the cursor (always 1) -- @treturn number the column of the cursor function readline:get_cursor() return 1, #self.prompt + self.position end --- Set the coordinates of the cursor in the input box (prompt + input). -- The coordinates are 1-based. They are expected to be within the -- size as reported byget_size
, and beyond the prompt. -- If the position is invalid, it will be corrected. -- Use the results to check if the position was adjusted. -- @tparam number row the row of the cursor (always 1) -- @tparam number col the column of the cursor -- @return results of get_cursor function readline:set_cursor(row, col) local l_prompt = #self.prompt local l_value = #self.value if col < l_prompt + 1 then col = l_prompt + 1 elseif col > l_prompt + l_value + 1 then col = l_prompt + l_value + 1 end while self.value[col - l_prompt] == "" do col = col - 1 -- on an empty string, so move back to start of double-width char end local new_pos = col - l_prompt cursor_move_horiz(self.position - new_pos) io.flush() self.position = new_pos return self:get_cursor() end --- Read a line of input from the user. -- It will first print theprompt
and then wait for input. Ensure the cursor -- is at the correct position before calling this function. This function will -- do all cursor movements in a relative way. -- Can be called again after an exit-key or timeout has occurred. Just make sure -- the cursor is at the same position where is was when it returned the last time. -- Alternatively the cursor can be set to the position of the prompt (the position -- the cursor was in before the first call), and the parameterredraw
can be set -- totrue
. -- @tparam[opt=math.huge] number timeout the maximum time to wait for input in seconds -- @tparam[opt=false] boolean redraw iftrue
the prompt will be redrawn (cursor must be at prompt position!) -- @treturn[1] string the input string as entered the user -- @treturn[1] string the exit-key used to exit the readline (seenew
) -- @treturn[2] nil when input is incomplete -- @treturn[2] string error message, the reason why the input is incomplete,"timeout"
, or an error reading a key function readline:__call(timeout, redraw) draw(self, redraw) timeout = timeout or math.huge local timeout_end = sys.gettime() + timeout while true do local key, keytype = sys.readansi(timeout_end - sys.gettime()) if not key then -- error or timeout return nil, keytype end local status = handle_key(self, key, keytype) if status == "exit_key" then return tostring(self.value), key elseif status ~= "ok" then error("unknown status received: " .. tostring(status)) end end end -- return readline -- normally we'd return here, but for the example we continue local backup = sys.termbackup() -- setup Windows console to handle ANSI processing sys.setconsoleflags(io.stdout, sys.getconsoleflags(io.stdout) + sys.COF_VIRTUAL_TERMINAL_PROCESSING) sys.setconsoleflags(io.stdin, sys.getconsoleflags(io.stdin) + sys.CIF_VIRTUAL_TERMINAL_INPUT) -- set output to UTF-8 sys.setconsoleoutputcp(sys.CODEPAGE_UTF8) -- setup Posix terminal to disable canonical mode and echo sys.tcsetattr(io.stdin, sys.TCSANOW, { lflag = sys.tcgetattr(io.stdin).lflag - sys.L_ICANON - sys.L_ECHO, }) -- setup stdin to non-blocking mode sys.setnonblock(io.stdin, true) local rl = readline.new{ prompt = "Enter something: ", max_length = 60, value = "Hello, 你-好 World 🚀!", -- position = 2, exit_keys = {key_sequences.enter, "\27", "\t", "\27[Z"}, -- enter, escape, tab, shift-tab } local result, key = rl() print("") -- newline after input, to move cursor down from the input line print("Result (string): '" .. result .. "'") print("Result (bytes):", result:byte(1,-1)) print("Exit-Key (bytes):", key:byte(1,-1)) -- Clean up afterwards sys.termrestore(backup)