From dcd5d62501e61e0f6901d4d4687ab56430a4b8a7 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Mon, 6 May 2024 11:44:47 +0200 Subject: add example for reading a line from the terminal, non-blocking Handles utf8, and character width --- examples/compat.lua | 5 +- examples/readline.lua | 476 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 examples/readline.lua (limited to 'examples') diff --git a/examples/compat.lua b/examples/compat.lua index c00d44a..a59d964 100644 --- a/examples/compat.lua +++ b/examples/compat.lua @@ -5,12 +5,15 @@ local sys = require "system" -if sys.is_windows then +if sys.windows then -- Windows holds multiple copies of environment variables, to ensure `getenv` -- returns what `setenv` sets we need to use the `system.getenv` instead of -- `os.getenv`. os.getenv = sys.getenv -- luacheck: ignore + -- Set console output to UTF-8 encoding. + sys.setconsoleoutputcp(65001) + -- Set up the terminal to handle ANSI escape sequences on Windows. if sys.isatty(io.stdout) then sys.setconsoleflags(io.stdout, sys.getconsoleflags(io.stdout) + sys.COF_VIRTUAL_TERMINAL_PROCESSING) diff --git a/examples/readline.lua b/examples/readline.lua new file mode 100644 index 0000000..f1e6258 --- /dev/null +++ b/examples/readline.lua @@ -0,0 +1,476 @@ +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 +-- @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 the `max_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 by `get_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 by `get_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 the `prompt` 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 parameter `redraw` can be set +-- to `true`. +-- @tparam[opt=math.huge] number timeout the maximum time to wait for input in seconds +-- @tparam[opt=false] boolean redraw if `true` 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 (see `new`) +-- @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 + + + + +-- setup Windows console to handle ANSI processing +local of_in = sys.getconsoleflags(io.stdin) +local cp_in = sys.getconsolecp() +-- sys.setconsolecp(65001) +sys.setconsolecp(850) +local of_out = sys.getconsoleflags(io.stdout) +local cp_out = sys.getconsoleoutputcp() +sys.setconsoleoutputcp(65001) +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) + +-- setup Posix terminal to use non-blocking mode, and disable line-mode +local of_attr = sys.tcgetattr(io.stdin) +local of_block = sys.getnonblock(io.stdin) +sys.setnonblock(io.stdin, true) +sys.tcsetattr(io.stdin, sys.TCSANOW, { + lflag = of_attr.lflag - sys.L_ICANON - sys.L_ECHO, -- disable canonical mode and echo +}) + + +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.setnonblock(io.stdin, false) +sys.setconsoleflags(io.stdout, of_out) +sys.setconsoleflags(io.stdin, of_in) +sys.tcsetattr(io.stdin, sys.TCSANOW, of_attr) +sys.setnonblock(io.stdin, of_block) +sys.setconsolecp(cp_in) +sys.setconsoleoutputcp(cp_out) -- cgit v1.2.3-55-g6feb