diff options
author | Thijs Schreijer <thijs@thijsschreijer.nl> | 2024-05-06 11:44:47 +0200 |
---|---|---|
committer | Thijs Schreijer <thijs@thijsschreijer.nl> | 2024-05-20 12:43:55 +0200 |
commit | dcd5d62501e61e0f6901d4d4687ab56430a4b8a7 (patch) | |
tree | 4501938052c0f62279eaae66c34811d4b5232fa2 /examples | |
parent | 1d64b5790f26760cb830336ccca9d51474b73ae8 (diff) | |
download | luasystem-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.lua | 5 | ||||
-rw-r--r-- | examples/readline.lua | 476 |
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 | ||
8 | if sys.is_windows then | 8 | if 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 @@ | |||
1 | local sys = require("system") | ||
2 | |||
3 | |||
4 | -- Mapping of key-sequences to key-names | ||
5 | local 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 | |||
17 | if sys.windows then | ||
18 | key_names["\13"] = "enter" | ||
19 | else | ||
20 | key_names["\10"] = "enter" | ||
21 | end | ||
22 | |||
23 | |||
24 | -- Mapping of key-names to key-sequences | ||
25 | local key_sequences = {} | ||
26 | for k, v in pairs(key_names) do | ||
27 | key_sequences[v] = k | ||
28 | end | ||
29 | |||
30 | |||
31 | -- bell character | ||
32 | local function bell() | ||
33 | io.write("\7") | ||
34 | io.flush() | ||
35 | end | ||
36 | |||
37 | |||
38 | -- generate string to move cursor horizontally | ||
39 | -- positive goes right, negative goes left | ||
40 | local 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") | ||
45 | end | ||
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 | ||
72 | local function utf8size(b) | ||
73 | return b < 128 and 1 or b < 224 and 2 or b < 240 and 3 or b < 248 and 4 | ||
74 | end | ||
75 | |||
76 | |||
77 | |||
78 | local 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 | ||
109 | end | ||
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 | |||
130 | local readline = {} | ||
131 | readline.__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 | ||
142 | function 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 | ||
176 | end | ||
177 | |||
178 | |||
179 | |||
180 | -- draw the prompt and the input value, and position the cursor. | ||
181 | local 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() | ||
197 | end | ||
198 | |||
199 | |||
200 | local 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 | ||
330 | end | ||
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 | ||
339 | function readline:get_size() | ||
340 | return 1, #self.prompt + self.max_length * 2 | ||
341 | end | ||
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 | ||
350 | function readline:get_cursor() | ||
351 | return 1, #self.prompt + self.position | ||
352 | end | ||
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 | ||
364 | function 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() | ||
385 | end | ||
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 | ||
404 | function 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 | ||
424 | end | ||
425 | |||
426 | |||
427 | |||
428 | -- return readline | ||
429 | |||
430 | |||
431 | |||
432 | |||
433 | -- setup Windows console to handle ANSI processing | ||
434 | local of_in = sys.getconsoleflags(io.stdin) | ||
435 | local cp_in = sys.getconsolecp() | ||
436 | -- sys.setconsolecp(65001) | ||
437 | sys.setconsolecp(850) | ||
438 | local of_out = sys.getconsoleflags(io.stdout) | ||
439 | local cp_out = sys.getconsoleoutputcp() | ||
440 | sys.setconsoleoutputcp(65001) | ||
441 | sys.setconsoleflags(io.stdout, sys.getconsoleflags(io.stdout) + sys.COF_VIRTUAL_TERMINAL_PROCESSING) | ||
442 | sys.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 | ||
445 | local of_attr = sys.tcgetattr(io.stdin) | ||
446 | local of_block = sys.getnonblock(io.stdin) | ||
447 | sys.setnonblock(io.stdin, true) | ||
448 | sys.tcsetattr(io.stdin, sys.TCSANOW, { | ||
449 | lflag = of_attr.lflag - sys.L_ICANON - sys.L_ECHO, -- disable canonical mode and echo | ||
450 | }) | ||
451 | |||
452 | |||
453 | local 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 | |||
462 | local result, key = rl() | ||
463 | print("") -- newline after input, to move cursor down from the input line | ||
464 | print("Result (string): '" .. result .. "'") | ||
465 | print("Result (bytes):", result:byte(1,-1)) | ||
466 | print("Exit-Key (bytes):", key:byte(1,-1)) | ||
467 | |||
468 | |||
469 | -- Clean up afterwards | ||
470 | sys.setnonblock(io.stdin, false) | ||
471 | sys.setconsoleflags(io.stdout, of_out) | ||
472 | sys.setconsoleflags(io.stdin, of_in) | ||
473 | sys.tcsetattr(io.stdin, sys.TCSANOW, of_attr) | ||
474 | sys.setnonblock(io.stdin, of_block) | ||
475 | sys.setconsolecp(cp_in) | ||
476 | sys.setconsoleoutputcp(cp_out) | ||