summaryrefslogtreecommitdiff
path: root/examples/readline.lua
diff options
context:
space:
mode:
Diffstat (limited to 'examples/readline.lua')
-rw-r--r--examples/readline.lua472
1 files changed, 472 insertions, 0 deletions
diff --git a/examples/readline.lua b/examples/readline.lua
new file mode 100644
index 0000000..ff215dd
--- /dev/null
+++ b/examples/readline.lua
@@ -0,0 +1,472 @@
+--- 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 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 -- 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)