aboutsummaryrefslogtreecommitdiff
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 @@
1--- An example class for reading a line of input from the user in a non-blocking way.
2-- It uses ANSI escape sequences to move the cursor and handle input.
3-- It can be used to read a line of input from the user, with a prompt.
4-- It can handle double-width UTF-8 characters.
5-- It can be used asynchroneously if `system.sleep` is patched to yield to a coroutine scheduler.
6
7local sys = require("system")
8
9
10-- Mapping of key-sequences to key-names
11local key_names = {
12 ["\27[C"] = "right",
13 ["\27[D"] = "left",
14 ["\127"] = "backspace",
15 ["\27[3~"] = "delete",
16 ["\27[H"] = "home",
17 ["\27[F"] = "end",
18 ["\27"] = "escape",
19 ["\9"] = "tab",
20 ["\27[Z"] = "shift-tab",
21}
22
23if sys.windows then
24 key_names["\13"] = "enter"
25else
26 key_names["\10"] = "enter"
27end
28
29
30-- Mapping of key-names to key-sequences
31local key_sequences = {}
32for k, v in pairs(key_names) do
33 key_sequences[v] = k
34end
35
36
37-- bell character
38local function bell()
39 io.write("\7")
40 io.flush()
41end
42
43
44-- generate string to move cursor horizontally
45-- positive goes right, negative goes left
46local function cursor_move_horiz(n)
47 if n == 0 then
48 return ""
49 end
50 return "\27[" .. (n > 0 and n or -n) .. (n > 0 and "C" or "D")
51end
52
53
54-- -- generate string to move cursor vertically
55-- -- positive goes down, negative goes up
56-- local function cursor_move_vert(n)
57-- if n == 0 then
58-- return ""
59-- end
60-- return "\27[" .. (n > 0 and n or -n) .. (n > 0 and "B" or "A")
61-- end
62
63
64-- -- log to the line above the current line
65-- local function log(...)
66-- local arg = { n = select("#", ...), ...}
67-- for i = 1, arg.n do
68-- arg[i] = tostring(arg[i])
69-- end
70-- arg = " " .. table.concat(arg, " ") .. " "
71
72-- io.write(cursor_move_vert(-1), arg, cursor_move_vert(1), cursor_move_horiz(-#arg))
73-- end
74
75
76-- UTF8 character size in bytes
77-- @tparam number b the byte value of the first byte of a UTF8 character
78local function utf8size(b)
79 return b < 128 and 1 or b < 224 and 2 or b < 240 and 3 or b < 248 and 4
80end
81
82
83
84local utf8parse do
85 local utf8_value_mt = {
86 __tostring = function(self)
87 return table.concat(self, "")
88 end,
89 }
90
91 -- Parses a UTF8 string into list of individual characters.
92 -- key 'chars' gets the length in UTF8 characters, whilst # returns the length
93 -- for display (to handle double-width UTF8 chars).
94 -- in the list the double-width characters are followed by an empty string.
95 -- @tparam string s the UTF8 string to parse
96 -- @treturn table the list of characters
97 function utf8parse(s)
98 local t = setmetatable({ chars = 0 }, utf8_value_mt)
99 local i = 1
100 while i <= #s do
101 local b = s:byte(i)
102 local w = utf8size(b)
103 local char = s:sub(i, i + w - 1)
104 t[#t + 1] = char
105 t.chars = t.chars + 1
106 if sys.utf8cwidth(char) == 2 then
107 -- double width character, add empty string to keep the length of the
108 -- list the same as the character width on screen
109 t[#t + 1] = ""
110 end
111 i = i + w
112 end
113 return t
114 end
115end
116
117
118
119-- inline tests for utf8parse
120-- do
121-- local t = utf8parse("a你b好c")
122-- assert(t[1] == "a")
123-- assert(t[2] == "你") -- double width
124-- assert(t[3] == "")
125-- assert(t[4] == "b")
126-- assert(t[5] == "好") -- double width
127-- assert(t[6] == "")
128-- assert(t[7] == "c")
129-- assert(#t == 7) -- size as displayed
130-- end
131
132
133
134-- readline class
135
136local readline = {}
137readline.__index = readline
138
139
140--- Create a new readline object.
141-- @tparam table opts the options for the readline object
142-- @tparam[opt=""] string opts.prompt the prompt to display
143-- @tparam[opt=80] number opts.max_length the maximum length of the input (in characters, not bytes)
144-- @tparam[opt=""] string opts.value the default value
145-- @tparam[opt=`#value`] number opts.position of the cursor in the input
146-- @tparam[opt={"\10"/"\13"}] table opts.exit_keys an array of keys that will cause the readline to exit
147-- @treturn readline the new readline object
148function readline.new(opts)
149 local value = utf8parse(opts.value or "")
150 local prompt = utf8parse(opts.prompt or "")
151 local pos = math.floor(opts.position or (#value + 1))
152 pos = math.max(math.min(pos, (#value + 1)), 1)
153 local len = math.floor(opts.max_length or 80)
154 if len < 1 then
155 error("max_length must be at least 1", 2)
156 end
157
158 if value.chars > len then
159 error("value is longer than max_length", 2)
160 end
161
162 local exit_keys = {}
163 for _, key in ipairs(opts.exit_keys or {}) do
164 exit_keys[key] = true
165 end
166 if exit_keys[1] == nil then
167 -- nothing provided, default to Enter-key
168 exit_keys[1] = key_sequences.enter
169 end
170
171 local self = {
172 value = value, -- the default value
173 max_length = len, -- the maximum length of the input
174 prompt = prompt, -- the prompt to display
175 position = pos, -- the current position in the input
176 drawn_before = false, -- if the prompt has been drawn
177 exit_keys = exit_keys, -- the keys that will cause the readline to exit
178 }
179
180 setmetatable(self, readline)
181 return self
182end
183
184
185
186-- draw the prompt and the input value, and position the cursor.
187local function draw(self, redraw)
188 if redraw or not self.drawn_before then
189 -- we are at start of prompt
190 self.drawn_before = true
191 else
192 -- we are at current cursor position, move to start of prompt
193 io.write(cursor_move_horiz(-(#self.prompt + self.position)))
194 end
195 -- write prompt & value
196 io.write(tostring(self.prompt) .. tostring(self.value))
197 -- clear remainder of input size
198 io.write(string.rep(" ", self.max_length - self.value.chars))
199 io.write(cursor_move_horiz(-(self.max_length - self.value.chars)))
200 -- move to cursor position
201 io.write(cursor_move_horiz(-(#self.value + 1 - self.position)))
202 io.flush()
203end
204
205
206local handle_key do -- keyboard input handler
207
208 local key_handlers
209 key_handlers = {
210 left = function(self)
211 if self.position == 1 then
212 bell()
213 return
214 end
215
216 local new_pos = self.position - 1
217 while self.value[new_pos] == "" do -- skip empty strings; double width chars
218 new_pos = new_pos - 1
219 end
220
221 io.write(cursor_move_horiz(-(self.position - new_pos)))
222 io.flush()
223 self.position = new_pos
224 end,
225
226 right = function(self)
227 if self.position == #self.value + 1 then
228 bell()
229 return
230 end
231
232 local new_pos = self.position + 1
233 while self.value[new_pos] == "" do -- skip empty strings; double width chars
234 new_pos = new_pos + 1
235 end
236
237 io.write(cursor_move_horiz(new_pos - self.position))
238 io.flush()
239 self.position = new_pos
240 end,
241
242 backspace = function(self)
243 if self.position == 1 then
244 bell()
245 return
246 end
247
248 while self.value[self.position - 1] == "" do -- remove empty strings; double width chars
249 io.write(cursor_move_horiz(-1))
250 self.position = self.position - 1
251 table.remove(self.value, self.position)
252 end
253 -- remove char itself
254 io.write(cursor_move_horiz(-1))
255 self.position = self.position - 1
256 table.remove(self.value, self.position)
257 self.value.chars = self.value.chars - 1
258 draw(self)
259 end,
260
261 home = function(self)
262 local new_pos = 1
263 io.write(cursor_move_horiz(new_pos - self.position))
264 self.position = new_pos
265 end,
266
267 ["end"] = function(self)
268 local new_pos = #self.value + 1
269 io.write(cursor_move_horiz(new_pos - self.position))
270 self.position = new_pos
271 end,
272
273 delete = function(self)
274 if self.position > #self.value then
275 bell()
276 return
277 end
278
279 key_handlers.right(self)
280 key_handlers.backspace(self)
281 end,
282 }
283
284
285 -- handles a single input key/ansi-sequence.
286 -- @tparam string key the key or ansi-sequence (from `system.readansi`)
287 -- @tparam string keytype the type of the key, either "char" or "ansi" (from `system.readansi`)
288 -- @treturn string status the status of the key handling, either "ok", "exit_key" or an error message
289 function handle_key(self, key, keytype)
290 if self.exit_keys[key] then
291 -- registered exit key
292 return "exit_key"
293 end
294
295 local handler = key_handlers[key_names[key] or true ]
296 if handler then
297 handler(self)
298 return "ok"
299 end
300
301 if keytype == "ansi" then
302 -- we got an ansi sequence, but dunno how to handle it, ignore
303 -- print("unhandled ansi: ", key:sub(2,-1), string.byte(key, 1, -1))
304 bell()
305 return "ok"
306 end
307
308 -- just a single key
309 if key < " " then
310 -- control character
311 bell()
312 return "ok"
313 end
314
315 if self.value.chars >= self.max_length then
316 bell()
317 return "ok"
318 end
319
320 -- insert the key into the value
321 if sys.utf8cwidth(key) == 2 then
322 -- double width character, insert empty string after it
323 table.insert(self.value, self.position, "")
324 table.insert(self.value, self.position, key)
325 self.position = self.position + 2
326 io.write(cursor_move_horiz(2))
327 else
328 table.insert(self.value, self.position, key)
329 self.position = self.position + 1
330 io.write(cursor_move_horiz(1))
331 end
332 self.value.chars = self.value.chars + 1
333 draw(self)
334 return "ok"
335 end
336end
337
338
339
340--- Get_size returns the maximum size of the input box (prompt + input).
341-- The size is in rows and columns. Columns is determined by
342-- the prompt and the `max_length * 2` (characters can be double-width).
343-- @treturn number the number of rows (always 1)
344-- @treturn number the number of columns
345function readline:get_size()
346 return 1, #self.prompt + self.max_length * 2
347end
348
349
350
351--- Get coordinates of the cursor in the input box (prompt + input).
352-- The coordinates are 1-based. They are returned as row and column, within the
353-- size as reported by `get_size`.
354-- @treturn number the row of the cursor (always 1)
355-- @treturn number the column of the cursor
356function readline:get_cursor()
357 return 1, #self.prompt + self.position
358end
359
360
361
362--- Set the coordinates of the cursor in the input box (prompt + input).
363-- The coordinates are 1-based. They are expected to be within the
364-- size as reported by `get_size`, and beyond the prompt.
365-- If the position is invalid, it will be corrected.
366-- Use the results to check if the position was adjusted.
367-- @tparam number row the row of the cursor (always 1)
368-- @tparam number col the column of the cursor
369-- @return results of get_cursor
370function readline:set_cursor(row, col)
371 local l_prompt = #self.prompt
372 local l_value = #self.value
373
374 if col < l_prompt + 1 then
375 col = l_prompt + 1
376 elseif col > l_prompt + l_value + 1 then
377 col = l_prompt + l_value + 1
378 end
379
380 while self.value[col - l_prompt] == "" do
381 col = col - 1 -- on an empty string, so move back to start of double-width char
382 end
383
384 local new_pos = col - l_prompt
385
386 cursor_move_horiz(self.position - new_pos)
387 io.flush()
388
389 self.position = new_pos
390 return self:get_cursor()
391end
392
393
394
395--- Read a line of input from the user.
396-- It will first print the `prompt` and then wait for input. Ensure the cursor
397-- is at the correct position before calling this function. This function will
398-- do all cursor movements in a relative way.
399-- Can be called again after an exit-key or timeout has occurred. Just make sure
400-- the cursor is at the same position where is was when it returned the last time.
401-- Alternatively the cursor can be set to the position of the prompt (the position
402-- the cursor was in before the first call), and the parameter `redraw` can be set
403-- to `true`.
404-- @tparam[opt=math.huge] number timeout the maximum time to wait for input in seconds
405-- @tparam[opt=false] boolean redraw if `true` the prompt will be redrawn (cursor must be at prompt position!)
406-- @treturn[1] string the input string as entered the user
407-- @treturn[1] string the exit-key used to exit the readline (see `new`)
408-- @treturn[2] nil when input is incomplete
409-- @treturn[2] string error message, the reason why the input is incomplete, `"timeout"`, or an error reading a key
410function readline:__call(timeout, redraw)
411 draw(self, redraw)
412 timeout = timeout or math.huge
413 local timeout_end = sys.gettime() + timeout
414
415 while true do
416 local key, keytype = sys.readansi(timeout_end - sys.gettime())
417 if not key then
418 -- error or timeout
419 return nil, keytype
420 end
421
422 local status = handle_key(self, key, keytype)
423 if status == "exit_key" then
424 return tostring(self.value), key
425
426 elseif status ~= "ok" then
427 error("unknown status received: " .. tostring(status))
428 end
429 end
430end
431
432
433
434-- return readline -- normally we'd return here, but for the example we continue
435
436
437
438
439local backup = sys.termbackup()
440
441-- setup Windows console to handle ANSI processing
442sys.setconsoleflags(io.stdout, sys.getconsoleflags(io.stdout) + sys.COF_VIRTUAL_TERMINAL_PROCESSING)
443sys.setconsoleflags(io.stdin, sys.getconsoleflags(io.stdin) + sys.CIF_VIRTUAL_TERMINAL_INPUT)
444-- set output to UTF-8
445sys.setconsoleoutputcp(sys.CODEPAGE_UTF8)
446
447-- setup Posix terminal to disable canonical mode and echo
448sys.tcsetattr(io.stdin, sys.TCSANOW, {
449 lflag = sys.tcgetattr(io.stdin).lflag - sys.L_ICANON - sys.L_ECHO,
450})
451-- setup stdin to non-blocking mode
452sys.setnonblock(io.stdin, true)
453
454
455local rl = readline.new{
456 prompt = "Enter something: ",
457 max_length = 60,
458 value = "Hello, 你-好 World 🚀!",
459 -- position = 2,
460 exit_keys = {key_sequences.enter, "\27", "\t", "\27[Z"}, -- enter, escape, tab, shift-tab
461}
462
463
464local result, key = rl()
465print("") -- newline after input, to move cursor down from the input line
466print("Result (string): '" .. result .. "'")
467print("Result (bytes):", result:byte(1,-1))
468print("Exit-Key (bytes):", key:byte(1,-1))
469
470
471-- Clean up afterwards
472sys.termrestore(backup)