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