├── .busted ├── examples ├── ttyctl-getch.lua └── descape-delegate.lua ├── luarocks └── dotty-scm-0.rockspec ├── spec ├── asciicodes_spec.lua ├── unidecode_spec.lua └── descape_spec.lua ├── README.md ├── .github └── workflows │ └── test.yml └── dotty ├── unidecode.lua ├── asciicodes.lua ├── ttyctl.lua └── descape.lua /.busted: -------------------------------------------------------------------------------- 1 | -- vim:set ft=lua: 2 | return { 3 | _all = { 4 | verbose = true, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/ttyctl-getch.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- ttyctl-getch.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local ttyctl = require "dotty.ttyctl" 10 | 11 | local function getch() 12 | local c 13 | ttyctl(io.stdout):with_cbreak(function () 14 | c = io.read(1) 15 | end) 16 | return c 17 | end 18 | 19 | io.write("Press any key to continue...") 20 | io.flush() 21 | getch() 22 | io.write("\n") 23 | 24 | io.write("Do you want to exit? [y/n]: ") 25 | io.flush() 26 | local response 27 | repeat 28 | response = getch() 29 | until response == "y" or response == "n" 30 | print(response) 31 | -------------------------------------------------------------------------------- /luarocks/dotty-scm-0.rockspec: -------------------------------------------------------------------------------- 1 | package = "dotty" 2 | version = "scm-0" 3 | source = { 4 | url = "git://github.com/aperezdc/lua-dotty" 5 | } 6 | description = { 7 | maintainer = "Adrián Pérez de Castro ", 8 | summary = "Idiomatic wrapper for terminal handling", 9 | homepage = "https://github.com/aperezdc/lua-dotty", 10 | license = "MIT/X11" 11 | } 12 | dependencies = { 13 | "lua >= 5.1", 14 | "luaposix >= 33", 15 | "bit32", 16 | } 17 | build = { 18 | type = "builtin", 19 | modules = { 20 | ["dotty.ttyctl"] = "dotty/ttyctl.lua", 21 | ["dotty.descape"] = "dotty/descape.lua", 22 | ["dotty.unidecode"] = "dotty/unidecode.lua", 23 | ["dotty.asciicodes"] = "dotty/asciicodes.lua", 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /spec/asciicodes_spec.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- asciicodes_spec.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local S = string.format 10 | 11 | describe("dotty.asciicodes", function () 12 | local A = require "dotty.asciicodes" 13 | 14 | it("string indexing returns codes", function () 15 | for name, _ in pairs(A) do 16 | if type(name) == "string" then 17 | assert.message(S("index %q does not produce a number", name)) 18 | .is_number(A[name]) 19 | end 20 | end 21 | end) 22 | 23 | it("numeric indexing returns strings", function () 24 | for i = 0, #A do 25 | assert.message(S("index 0x%02X does not produce a string", i)) 26 | .is_string(A[i]) 27 | end 28 | end) 29 | 30 | it("is consistent", function () 31 | for i = 0, #A do 32 | local name = A[i] 33 | assert.message(S("A[%q] is 0x%02X, expected 0x%02X", name, A[name], i)) 34 | .is_equal(i, A[name]) 35 | end 36 | end) 37 | 38 | end) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | lua-dotty 2 | ========= 3 | 4 | [![Test Status](https://github.com/aperezdc/lua-dotty/actions/workflows/test.yml/badge.svg)](https://github.com/aperezdc/lua-dotty/actions/workflows/test.yml) 5 | [![Coverage Status](https://coveralls.io/repos/github/aperezdc/lua-dotty/badge.svg?branch=master)](https://coveralls.io/github/aperezdc/lua-dotty?branch=master) 6 | 7 | Usage 8 | ----- 9 | 10 | ```lua 11 | local ttyctl = require "dotty.ttyctl" 12 | print("Press any key to continue...") 13 | ttyctl:with_cbreak(function () io.read(1) end) 14 | ``` 15 | 16 | ### More Examples 17 | 18 | * [examples/descape-delegate.lua](./examples/descape-delegate.lua): Shows how 19 | to use `dotty.ttycl`, `dotty.descape`, and `dotty.unidecode` to process 20 | UTF-8 input coming from a terminal which may contain terminal escape 21 | sequences. 22 | 23 | For the `dotty.ttyctl` module: 24 | 25 | * [examples/ttyctl-getch.lua](./examples/ttyctl-getch.lua): Implements a 26 | `getch()` function which waits for a single key press. 27 | 28 | 29 | Installation 30 | ------------ 31 | 32 | [LuaRocks](https://luarocks.org) is recommended for installation. 33 | 34 | The development version can be installed with: 35 | 36 | ```sh 37 | luarocks install --server=https://luarocks.org/dev dotty 38 | ``` 39 | 40 | 41 | Resources 42 | --------- 43 | 44 | * [ANSI escape code (Wikipedia)](https://en.wikipedia.org/wiki/ANSI_escape_code). 45 | * [Extended CSI sequences](http://www.leonerd.org.uk/hacks/fixterms/). 46 | * [vt100.net](http://www.vt100.net). 47 | -------------------------------------------------------------------------------- /spec/unidecode_spec.lua: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env lua 2 | -- 3 | -- unidecode_spec.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local function iter_bytes(s) 10 | return coroutine.wrap(function () 11 | for i = 1, #s do 12 | coroutine.yield(string.byte(s, i)) 13 | end 14 | end) 15 | end 16 | 17 | describe("dotty.unidecode()", function () 18 | local unidecode = require "dotty.unidecode" 19 | 20 | it("returns 7-bit ASCII unmodified", function () 21 | local reader = spy.new(function () end) 22 | for i = 0, 127 do 23 | assert.is_equal(string.char(i), unidecode(reader, i)) 24 | end 25 | -- We are passing the first byte, reader function won't be called. 26 | assert.spy(reader).called(0) 27 | end) 28 | 29 | it("accepts a couroutine reader", function () 30 | for i = 0, 127 do 31 | local reader = coroutine.wrap(function () 32 | coroutine.yield(i) 33 | end) 34 | assert.is_equal(string.char(i), unidecode(reader)) 35 | end 36 | end) 37 | 38 | it("errors on out-of-bounds bytes", function () 39 | local oob_bytes = 40 | { 192, 193, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255 } 41 | for _, byte in ipairs(oob_bytes) do 42 | assert.message(string.format("byte %d (0x%02X)", byte, byte)) 43 | .error_matches(function () unidecode(nil, byte) end, 44 | "^Invalid UTF8") 45 | end 46 | end) 47 | end) 48 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | - push 4 | - pull_request_target 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-24.04 9 | strategy: 10 | matrix: 11 | lua: 12 | - "5.4" 13 | - "5.3" 14 | - "5.2" 15 | - "5.1" 16 | - "luajit-2.1" 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Cache Dependencies 20 | uses: actions/cache@v4 21 | with: 22 | path: | 23 | .lua/ 24 | .luarocks/ 25 | key: ${{ runner.os }}-${{ matrix.lua }}-${{ hashFiles('.github/workflows/test.yml') }} 26 | - uses: leafo/gh-actions-lua@v11 27 | if: steps.cache.outputs.cache-hit != 'true' 28 | with: 29 | luaVersion: ${{ matrix.lua }} 30 | - uses: leafo/gh-actions-luarocks@v5 31 | with: 32 | luarocksVersion: "3.12.2" 33 | - name: Install Dependencies 34 | run: | 35 | luarocks install busted 36 | luarocks install cluacov 37 | luarocks install luacov-coveralls 38 | - name: Test 39 | run: | 40 | timeout 120 busted -c -o utfTerminal 41 | - name: Coverage Report 42 | run: | 43 | luacov-coveralls --dryrun -e '.luarocks/' -e examples/ -e spec/ -e luarocks/ -i dotty/ -o coveralls.json -v 44 | - name: Coveralls 45 | uses: coverallsapp/github-action@v2 46 | with: 47 | parallel: true 48 | file: coveralls.json 49 | finish: 50 | runs-on: ubuntu-24.04 51 | needs: [test] 52 | if: ${{ always() }} 53 | steps: 54 | - uses: coverallsapp/github-action@v2 55 | with: 56 | parallel-finished: true 57 | -------------------------------------------------------------------------------- /dotty/unidecode.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- unidecode.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- This module implements a UTF8 reader which works by reading a byte at 6 | -- a time from a provided "read" callback function, and detects invalid 7 | -- input sequences. The "read" callback can be coroutine.resume() in 8 | -- order to drive the parser externally. 9 | -- 10 | -- Distributed under terms of the MIT license. 11 | -- 12 | 13 | local error, s_char, s_format = error, string.char, string.format 14 | 15 | local function tail(c, c1, c2, c3) 16 | if c >= 0x80 or c <= 0xBF then 17 | return c 18 | end 19 | if c3 then 20 | error(s_format("Invalid UTF8-4 sequence: U+%02x%02x%02x%02x", c1, c2, c3, c)) 21 | elseif c2 then 22 | error(s_format("Invalid UTF8-3 sequence: U+%02x%02x%02x", c1, c2, c)) 23 | else 24 | error(s_format("Invalid UTF8-2 sequence: U+%02x%02x", c1, c)) 25 | end 26 | end 27 | 28 | local function decode(nextbyte, c1) 29 | if c1 == nil then c1 = nextbyte() end 30 | if c1 == nil then return end 31 | 32 | if c1 >= 0x00 and c1 <= 0x7F then -- UTF8-1 33 | return s_char(c1), 1 34 | end 35 | 36 | if c1 >= 0xC2 and c1 <= 0xDF then -- UTF8-2 37 | return s_char(c1, tail(nextbyte(), c1)) 38 | end 39 | 40 | if c1 == 0xE0 then -- UTF8-3 41 | local c2 = nextbyte() 42 | if c2 >= 0xA0 and c2 <= 0xBF then 43 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 44 | end 45 | error(s_format("Invalid UTF8-3 sequence: U+%02x%02x..", c1, c2)) 46 | elseif c1 == 0xED then 47 | local c2 = nextbyte() 48 | if c2 >= 0x80 and c2 <= 0x9F then 49 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 50 | end 51 | error(s_format("Invalid UTF8-3 sequence: U+%02x%02x..", c1, c2)) 52 | elseif c1 >= 0xE1 and c1 <= 0xEC then 53 | local c2 = tail(nextbyte(), c1) 54 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 55 | elseif c1 >= 0xEE and c1 <= 0xEF then 56 | local c2 = tail(nextbyte(), c1) 57 | return s_char(c1, c2, tail(nextbyte(), c1, c2)) 58 | end 59 | 60 | if c1 == 0xF0 then -- UTF8-4 61 | local c2 = nextbyte() 62 | if c2 >= 0x90 and c2 <= 0xBF then 63 | local c3 = tail(nextbyte(), c1, c2) 64 | return s_char(c1, c2, c3, tail(nextbyte(), c1, c2, c3)) 65 | end 66 | error(s_format("Invalid UTF8-4 sequence: U+%02x%02x..", c1, c2)) 67 | elseif c1 == 0xF4 then 68 | local c2 = nextbyte() 69 | if c2 >= 0x80 and c2 <= 0x8F then 70 | local c3 = tail(nextbyte(), c1, c2) 71 | return s_char(c1, c2, c3, tail(nextbyte(), c1, c2, c3)) 72 | end 73 | error(s_format("Invalid UTF8-4 sequence: U+%02x%02x..", c1, c2)) 74 | elseif c1 >= 0xF1 and c1 <= 0xF3 then 75 | local c2 = tail(nextbyte(), c1) 76 | local c3 = tail(nextbyte(), c1, c2) 77 | return s_char(c1, c2, c3, tail(nextbyte(), c1, c2, c3)) 78 | end 79 | 80 | error(s_format("Invalid UTF8-? sequence: U+%02x..", c1)) 81 | end 82 | 83 | return decode 84 | -------------------------------------------------------------------------------- /dotty/asciicodes.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- asciicodes.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- Distributed under terms of the MIT license. 6 | -- 7 | 8 | local A = { 9 | NUL = 0x00, 10 | SOH = 0x01, -- Start Of Heading. 11 | STX = 0x02, -- Start Of TeXt. 12 | ETX = 0x03, -- End of TeXt. 13 | EOT = 0x04, -- End Of Transmission. 14 | ENQ = 0x05, -- ENQuiry. 15 | ACK = 0x06, -- ACKnowledge. 16 | BELL = 0x07, -- Bell. 17 | BACKSPACE = 0x08, -- Backspace. 18 | TAB = 0x09, -- Horizontal Tab. 19 | NEWLINE = 0x0A, -- Line Feed. 20 | VT = 0x0B, -- Vertical Tab. 21 | FF = 0x0C, -- Form Feed. 22 | CR = 0x0D, -- Carriage Return. 23 | SO = 0x0E, -- Shift Out. 24 | SI = 0x0F, -- Shift In. 25 | 26 | DLE = 0x10, -- Data Link Escape. 27 | DC1 = 0x11, -- Device Control 1. 28 | DC2 = 0x12, -- Device Control 2. 29 | DC3 = 0x13, -- Device Control 3. 30 | DC4 = 0x14, -- Device Control 4. 31 | NAK = 0x15, -- Negative AcKnowledge. 32 | SYN = 0x16, -- SYNchronous idle. 33 | ETB = 0x17, -- End of Transmission Block. 34 | CAN = 0x18, -- CANcel. 35 | EM = 0x19, -- End of Medium. 36 | SUB = 0x1A, -- SUBstitute. 37 | ESC = 0x1B, -- ESCape. 38 | FS = 0x1C, -- File Separator. 39 | GS = 0x1D, -- Group Separator. 40 | RS = 0x1E, -- Record Separator. 41 | US = 0x1F, -- Unit Separator. 42 | 43 | SPACE = 0x20, 44 | BANG = 0x21, -- ! 45 | DQUOTE = 0x22, -- " 46 | HASH = 0x23, -- # 47 | DOLLAR = 0x24, -- $ 48 | PERCENT = 0x25, -- % 49 | AND = 0x26, -- & 50 | QUOTE = 0x27, -- ' 51 | LPAREN = 0x28, -- ( 52 | RPAREN = 0x29, -- ) 53 | STAR = 0x2A, -- * 54 | PLUS = 0x2B, -- + 55 | COMMA = 0x2C, -- , 56 | MINUS = 0x2D, -- - 57 | PERIOD = 0x2E, -- . 58 | SLASH = 0x2F, -- / 59 | 60 | -- Digits 0-9 (0x30 - 0x39, generated below). 61 | 62 | COLON = 0x3A, -- : 63 | SEMICOLON = 0x3B, -- ; 64 | LT = 0x3C, -- < 65 | EQUAL = 0x3D, -- = 66 | GT = 0x3E, -- > 67 | QMARK = 0x3F, -- ? 68 | AT = 0x40, -- @ 69 | 70 | -- Letters A-Z (0x41 - 0x5A, generated below). 71 | 72 | LBRACKET = 0x5B, -- [ 73 | BACKSLASH = 0x5C, -- \ 74 | RBRACKET = 0x5D, -- ] 75 | HAT = 0x5E, -- ^ 76 | UNDERSCORE = 0x5F, -- _ 77 | BACKTICK = 0x60, -- ` 78 | 79 | -- Letters a-z (0x61 - 0x7A, generated below). 80 | 81 | LBRACE = 0x7B, -- { 82 | BAR = 0x7C, -- | 83 | RBRACE = 0x7D, -- } 84 | TILDE = 0x7E, -- ~ 85 | DEL = 0x7F, -- DELete 86 | } 87 | 88 | for x = 0x30, 0x39 do A["DIGIT_" .. string.char(x)] = x end -- Digits 0-9 89 | for x = 0x41, 0x5A do A[string.char(x)] = x end -- Letters A-Z 90 | for x = 0x61, 0x7A do A[string.char(x)] = x end -- Letters a-z 91 | 92 | -- Generate reverse mapping (code to character name). 93 | do local R = {} 94 | for name, code in pairs(A) do R[code] = name end 95 | for i = 0, #R do A[i] = R[i] end 96 | end 97 | 98 | -- Add some convenience aliases. 99 | A.BEL, A.BS, A.HT = 0x07, 0x08, 0x09 100 | 101 | return A 102 | -------------------------------------------------------------------------------- /examples/descape-delegate.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- descape-delegate.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- Distributed under terms of the MIT license. 6 | -- 7 | 8 | local unidecode = require "dotty.unidecode" 9 | local descape = require "dotty.descape" 10 | local ttyctl = require "dotty.ttyctl" 11 | local ascii = require "dotty.asciicodes" 12 | local utf8 = require "dromozoa.utf8" 13 | local wcwidth = require "wcwidth" 14 | local inspect = require "inspect" 15 | 16 | -- 17 | -- Create a "delegate" to handle escape sequences. Whenever the decoder 18 | -- completes parsing a recognized terminal escape sequence, it invokes 19 | -- methods in the delegate. 20 | -- 21 | -- For this example, a reporter function which prints out the parameters 22 | -- passed to the handler is used for each supported event kind. Debugging 23 | -- and warning messages are handling as well and printed out colored. 24 | -- 25 | local delegate = {} 26 | 27 | if arg[1] == "debug" then 28 | function delegate:debug(message) 29 | io.write("\27[36m[debug] \27[37m" .. message .. "\27[0m\n\r") 30 | io.flush() 31 | end 32 | end 33 | 34 | function delegate:warning(message) 35 | io.write("\27[33m[warn]\27[0m " .. message .. "\n\r") 36 | io.flush() 37 | end 38 | 39 | -- Factory which installs a reporter method in the delegate. 40 | local inspect_options = { newline = " ", indent = "" } 41 | local function reporter(name) 42 | delegate[name] = function (self, ...) 43 | io.write("\27[1m" .. name .. "\27[0m [") 44 | io.write(tostring(select("#", ...))) 45 | io.write("]:") 46 | for i = 1, select("#", ...) do 47 | io.write(" " .. inspect(select(i, ...), inspect_options)) 48 | end 49 | io.write("\n\r") 50 | io.flush() 51 | end 52 | end 53 | 54 | -- Install reporter handlers. 55 | for _, name in ipairs { 56 | "device_status_reported", 57 | "device_attributes_reported", 58 | "cursor_position_reported", 59 | "key_up", "key_down", "key_right", "key_left", 60 | "key_f1", "key_f2", "key_f3", "key_f4", "key_f5", "key_f6", 61 | "key_f7", "key_f8", "key_f9", "key_f10", "key_f11", "key_f12", 62 | "key_home", "key_end", "key_pageup", "key_pagedown", 63 | "key_insert", "key_delete", 64 | "key", 65 | } do reporter(name) end 66 | 67 | 68 | -- 69 | -- This reader is a coroutine which yields one byte of input each time 70 | -- it it resumed, until the input is consumed, and then it returns EOT. 71 | -- 72 | local bytereader = coroutine.wrap(function () 73 | while true do 74 | local ch = io.read(1) 75 | if ch == nil then 76 | return ascii.EOT 77 | end 78 | coroutine.yield(ch:byte()) 79 | end 80 | end) 81 | 82 | -- 83 | -- Wrap the bytes reader into another which uses descape.decode() to handle 84 | -- decoding of terminal escape sequences. This reader is then used as the 85 | -- reader passed to unidecode(). This arrangement allows for terminal escape 86 | -- sequences to appear in the middle of UTF8 multibyte sequences: when a 87 | -- escape sequence begins, it will be consumed by descape.decode(). The UTF8 88 | -- decoder never gets to "see" the bytes which are part of terminal escape 89 | -- sequences. 90 | -- 91 | local unicodereader = coroutine.wrap(function () 92 | while true do 93 | local ch = descape.decode(bytereader, delegate) 94 | if ch ~= nil then 95 | if ch == ascii.EOT then 96 | return ch 97 | end 98 | coroutine.yield(ch) 99 | end 100 | end 101 | end) 102 | 103 | 104 | -- 105 | -- Use dotty.ttyctl to set the terminal in cbreak mode. This way 106 | -- input is processed as soon as it arrives to the input buffer. 107 | -- 108 | ttyctl(io.stdout):with_cbreak(function () 109 | io.write("Press Ctrl-D to end the input loop.\n\r") 110 | 111 | -- Request some information from the terminal usin CSI escape sequences. 112 | io.write("\27[c") -- What Are You? 113 | io.write("\27[5n") -- DSR (Device Status Report), report status 114 | io.write("\27[6n") -- DSR (Device Status Report), report active position 115 | io.flush() 116 | 117 | while true do 118 | local input = unidecode(unicodereader) 119 | local rune = utf8.codepoint(input) 120 | if rune == ascii.EOT then 121 | io.write("EOT\n\r") 122 | break 123 | end 124 | -- Leave one extra cell empty after a double-width character. 125 | local space = (wcwidth(rune) == 2) and " " or "" 126 | io.write(string.format("\27[1;32minput\27[0;0m: '%s%s' (U+%X, %d)\n\r", 127 | input, space, rune, rune)) 128 | io.flush() 129 | end 130 | end) 131 | -------------------------------------------------------------------------------- /dotty/ttyctl.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- ttyctl.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- Distributed under terms of the MIT license. 6 | -- 7 | 8 | local posix = require "posix" 9 | local bit = (function () 10 | local try_modules = { "bit", "bit32" } 11 | for _, name in ipairs(try_modules) do 12 | local ok, mod = pcall(require, name) 13 | if ok then return mod end 14 | end 15 | error("no 'bit'-compatible module found (tried: " .. 16 | table.concat(try_modules, ", ") .. ")") 17 | end)() 18 | 19 | local type, pairs, tostring = type, pairs, tostring 20 | 21 | 22 | local function ensure(ok, err, ...) 23 | if not ok then error(err, 2) end 24 | return ok, err, ... 25 | end 26 | 27 | local function deepcopy(t) 28 | local n = {} 29 | for name, value in pairs(t) do 30 | n[name] = (type(value) == "table") and deepcopy(value) or value 31 | end 32 | return n 33 | end 34 | 35 | local function file_descriptor(fd) 36 | if type(fd) == "number" then 37 | return fd 38 | end 39 | if type(fd) == "table" then 40 | if type(fd.pollfd) == "function" then 41 | return fd:pollfd() -- Used by cqueues. 42 | end 43 | if type(fd.fileno) == "function" then 44 | return fd:fileno() 45 | end 46 | end 47 | return posix.fileno(fd) 48 | end 49 | 50 | 51 | local ttyctl = {} 52 | ttyctl.__index = ttyctl 53 | 54 | setmetatable(ttyctl, { __call = function (self, output) 55 | local fd = file_descriptor(output) 56 | if not posix.isatty(fd) then 57 | error(posix.errno(posix.ENOTTY)) 58 | end 59 | return setmetatable({ 60 | output = output, 61 | __fd = fd, 62 | saved_state = false, 63 | mode = "none", 64 | }, ttyctl) 65 | end }) 66 | 67 | function ttyctl:__tostring() 68 | return "dotty.ttyctl<" .. tostring(self.output) .. ">" 69 | end 70 | 71 | local tty_modes = { 72 | cbreak = function (self, state) 73 | -- Input modes: no break, no CR to NL, no parity check, no strip char, 74 | -- no start/stop output control. 75 | state.iflag = bit.band(state.iflag, bit.bnot(bit.bor(posix.BRKINT, 76 | posix.ICRNL, 77 | posix.INPCK, 78 | posix.ISTRIP, 79 | posix.IXON))) 80 | -- Output modes: disable postprocessing 81 | state.oflag = bit.band(state.oflag, bit.bnot(posix.OPOST)) 82 | -- Control modes: use 8-bit characters. 83 | state.cflag = bit.bor(state.cflag, posix.CS8) 84 | -- Local modes: echo off, canononical off, no extended functions, 85 | -- no signal characters (Ctrl-Z, Ctrl-C) 86 | state.lflag = bit.band(state.lflag, bit.bnot(bit.bor(posix.ECHO, 87 | posix.ICANON, 88 | posix.IEXTEN, 89 | posix.ISIG))) 90 | -- Return condition: no timeout, one byte at a time 91 | state.cc[posix.VTIME] = 0 92 | state.cc[posix.VMIN] = 1 93 | end, 94 | } 95 | 96 | local function tty_restore(self) 97 | if self.mode == "none" then 98 | return true 99 | end 100 | assert(self.saved_state) 101 | if posix.tcsetattr(self.__fd, posix.TCSANOW, self.saved_state) ~= 0 then 102 | return false, posix.errno() 103 | end 104 | self.saved_state = false 105 | self.mode = "none" 106 | return true 107 | end 108 | 109 | local function tty_configure(self, mode) 110 | if self.mode == mode then 111 | return true 112 | end 113 | 114 | local mode_func = tty_modes[mode] 115 | if not mode_func then 116 | return false, "invalid mode requested" 117 | end 118 | 119 | ensure(tty_restore(self)) 120 | 121 | self.saved_state = ensure(posix.tcgetattr(self.__fd)) 122 | local state = deepcopy(self.saved_state) 123 | assert(pcall(mode_func, self, state)) 124 | 125 | if posix.tcsetattr(self.__fd, posix.TCSANOW, state) ~= 0 then 126 | self.saved_state = false 127 | return false, posix.errno() 128 | end 129 | self.mode = mode 130 | return true 131 | end 132 | 133 | ttyctl.restore = tty_restore 134 | 135 | function ttyctl:cbreak() 136 | return tty_configure(self, "cbreak") 137 | end 138 | 139 | function ttyctl:with_cbreak(f, ...) 140 | ensure(tty_configure(self, "cbreak")) 141 | local ok, err = pcall(f, ...) 142 | ensure(tty_restore(self)) 143 | if not ok then 144 | error(err) 145 | end 146 | end 147 | 148 | return ttyctl 149 | -------------------------------------------------------------------------------- /spec/descape_spec.lua: -------------------------------------------------------------------------------- 1 | -- #! /usr/bin/env lua 2 | -- -- 3 | -- descape_spec.lua 4 | -- Copyright (C) 2016 Adrian Perez 5 | -- 6 | -- Distributed under terms of the MIT license. 7 | -- 8 | 9 | local function iter_bytes(s) 10 | return coroutine.wrap(function () 11 | for i = 1, #s do 12 | coroutine.yield(string.byte(s, i)) 13 | end 14 | end) 15 | end 16 | 17 | -- Rici Lake's interp() from http://lua-users.org/wiki/StringInterpolation 18 | local function interpolate(s, tab) 19 | return (s:gsub('%%%((%a%w*)%)([-0-9%.]*[cdeEfgGiouxXsq])', 20 | function(k, fmt) return tab[k] and ("%"..fmt):format(tab[k]) or 21 | '%('..k..')'..fmt end)) 22 | end 23 | 24 | 25 | describe("dotty.descape.decode", function () 26 | local decode = require "dotty.descape" .decode 27 | 28 | local function prefixed_keys(prefix, keys) 29 | return coroutine.wrap(function () 30 | for name, code in pairs(keys) do 31 | coroutine.yield(name, prefix .. code) 32 | end 33 | end) 34 | end 35 | 36 | local modifiers = { 37 | [";2"] = { ctrl = false, shift = true, alt = false }, 38 | [";3"] = { ctrl = false, shift = false, alt = true }, 39 | [";4"] = { ctrl = false, shift = true, alt = true }, 40 | [";5"] = { ctrl = true, shift = false, alt = false }, 41 | [";6"] = { ctrl = true, shift = true, alt = false }, 42 | [";7"] = { ctrl = true, shift = false, alt = true }, 43 | } 44 | local function keys_with_modifiers(format, keys) 45 | return coroutine.wrap(function () 46 | for name, code in pairs(keys) do 47 | for mod_code, mods in pairs(modifiers) do 48 | local sequence = interpolate(format, 49 | { code = code, modifier = mod_code }) 50 | coroutine.yield(name, sequence, mods) 51 | end 52 | end 53 | end) 54 | end 55 | 56 | local function test_delegate_keys(generator) 57 | return function () 58 | for handler, escape_sequence, modifiers in generator do 59 | local delegate = {} 60 | stub(delegate, handler) 61 | decode(iter_bytes(escape_sequence), delegate) 62 | local msg = string.format("escape %q for %s", 63 | escape_sequence, handler) 64 | assert.stub(delegate[handler]).message(msg).called_with(delegate, 65 | modifiers or { ctrl = false, alt = false, shift = false }, 66 | match.is_number()) 67 | end 68 | end 69 | end 70 | 71 | local arrow_keys = { 72 | key_up = "A", key_down = "B", key_right = "C", key_left = "D", 73 | } 74 | it("handles VT52 arrow key escapes", 75 | test_delegate_keys(prefixed_keys("\27", arrow_keys))) 76 | it("handles VT100 arrow key escapes", 77 | test_delegate_keys(prefixed_keys("\27O", arrow_keys))) 78 | it("handles CSI arrow key escapes", 79 | test_delegate_keys(keys_with_modifiers("\27[1%(modifier)s%(code)s", 80 | arrow_keys))) 81 | 82 | local vtXXX_f1_f4_keys = { 83 | key_f1 = "P", key_f2 = "Q", key_f3 = "R", key_f4 = "S" 84 | } 85 | it("handles VT52 F1-F4 key escapes", 86 | test_delegate_keys(prefixed_keys("\27", vtXXX_f1_f4_keys))) 87 | it("handles VT100 F1-F4 key escapes", 88 | test_delegate_keys(prefixed_keys("\27O", vtXXX_f1_f4_keys))) 89 | 90 | local vtXXX_home_end_keys = { 91 | key_end = "F", key_home = "H" 92 | } 93 | it("handles VT52 Home/End key escapes", 94 | test_delegate_keys(prefixed_keys("\27", vtXXX_home_end_keys))) 95 | it("handles VT100 Home/End key escapes", 96 | test_delegate_keys(prefixed_keys("\27O", vtXXX_home_end_keys))) 97 | it("handles CSI Home/End key escapes", 98 | test_delegate_keys(keys_with_modifiers("\27[1%(modifier)s%(code)s", 99 | vtXXX_home_end_keys))) 100 | 101 | local csi_tilde_keys = { 102 | key_insert = "2", key_delete = "3", 103 | key_pageup = "5", key_pagedown = "6", 104 | key_home = "7", key_end = "8", 105 | key_f1 = "11", key_f2 = "12", key_f3 = "13", key_f4 = "14", 106 | key_f5 = "15", key_f6 = "17", key_f7 = "18", key_f8 = "19", 107 | key_f9 = "20", key_f10 = "21", key_f11 = "23", key_f12 = "24", 108 | } 109 | it("handles CSI-~ function keys", 110 | test_delegate_keys(keys_with_modifiers("\27[%(code)s%(modifier)s~", 111 | csi_tilde_keys))) 112 | 113 | it("handles extended CSI-u keys", function () 114 | local delegate = {} 115 | stub(delegate, "key") 116 | decode(iter_bytes("\27[8230u"), delegate) 117 | assert.stub(delegate.key).called_with(delegate, 118 | { ctrl = false, alt = false, shift = false }, 119 | 0x2026) -- U+2026 / 8230 is '…' 120 | decode(iter_bytes("\27[8230;5u"), delegate) 121 | assert.stub(delegate.key).called_with(delegate, 122 | { ctrl = true, alt = false, shift = false }, 123 | 0x2026) 124 | end) 125 | 126 | it("accepts 0x9B as single-byte CSI", function () 127 | local delegate = {} 128 | stub(delegate, "key_up") 129 | decode(iter_bytes(string.char(0x9B) .. "1;1A"), delegate) 130 | assert.stub(delegate.key_up).called_with(delegate, 131 | { ctrl = false, alt = false, shift = false }, 1) 132 | end) 133 | 134 | it("handles DSR reports", function () 135 | local delegate = {} 136 | stub(delegate, "device_status_reported") 137 | decode(iter_bytes("\27[0n"), delegate) 138 | assert.stub(delegate.device_status_reported).called_with(delegate, 0) 139 | -- Try omitting the optional parameter. 140 | decode(iter_bytes("\27[n"), delegate) 141 | assert.stub(delegate.device_status_reported).called_with(delegate, 0) 142 | end) 143 | 144 | it("handles DSR cursor reports", function () 145 | local delegate = {} 146 | stub(delegate, "cursor_position_reported") 147 | decode(iter_bytes("\27[12;5R"), delegate) 148 | assert.stub(delegate.cursor_position_reported) 149 | .called_with(delegate, 12, 5) 150 | -- Try omitting the optional parameters. 151 | decode(iter_bytes("\27[42R"), delegate) 152 | assert.stub(delegate.cursor_position_reported) 153 | .called_with(delegate, 42, 1) 154 | decode(iter_bytes("\27[R"), delegate) 155 | assert.stub(delegate.cursor_position_reported) 156 | .called_with(delegate, 1, 1) 157 | end) 158 | 159 | it("handles What Are You? reports", function () 160 | local delegate = {} 161 | stub(delegate, "device_attributes_reported") 162 | for _, num in ipairs { 0, 1, 2, 3, 4, 5, 6, 7 } do 163 | decode(iter_bytes(string.format("\27[?1;%dc", num)), delegate) 164 | assert.stub(delegate.device_attributes_reported) 165 | .called_with(delegate, num) 166 | end 167 | end) 168 | 169 | it("works without a delegate", function () 170 | decode(iter_bytes("\27[31;1m")) 171 | end) 172 | 173 | it("handles unterminated escape sequences", function () 174 | for _, sequence in ipairs { 175 | "\27", -- Only ESC prefix 176 | "\27[", -- Only CSI prefix 177 | "\27[1", -- CSI prefix + parameter 178 | "\27[1;", -- CSI prefix + parameter + separator 179 | "\27O", -- VT100/ANSI with second byte missing 180 | } do 181 | assert.message(string.format("unterminated sequence %q", sequence)) 182 | .not_has_error(function () 183 | decode(iter_bytes(sequence)) 184 | end) 185 | end 186 | end) 187 | 188 | it("restarts after a CAN/SUB character", function () 189 | for _, sequence in ipairs { 190 | "\27\24\27[n", -- CAN 191 | "\27\26\27[n", -- SUB 192 | "\27[\24\27[n", -- CAN 193 | "\27[\26\27[n", -- SUB 194 | } do 195 | local delegate = {} 196 | stub(delegate, "device_status_reported") 197 | local msg = string.format("CAN/SUB sequence %q", sequence) 198 | assert.message(msg).not_has_error(function () 199 | decode(iter_bytes(sequence), delegate) 200 | end) 201 | assert.stub(delegate.device_status_reported) 202 | .message(msg).called_with(delegate, match.is_number()) 203 | end 204 | end) 205 | 206 | local iter_keys = function (s) 207 | return coroutine.wrap(function () 208 | for i = 1, #s do 209 | coroutine.yield(s:sub(i, i)) 210 | end 211 | end) 212 | end 213 | 214 | it("recognizes Alt+", function () 215 | -- All caps except the ones in VT-52 keypad/F1-F4 escapes. 216 | -- TODO: Check whether "O" can be added here. 217 | local flags = { shift = true, ctrl = false, alt = true } 218 | for key in iter_keys "EGIJKLMNTUVWXYZ" do 219 | local delegate = {} 220 | stub(delegate, "key") 221 | local seq = "\27" .. key 222 | local msg = string.format("Alt+%s sequence (%q)", key, seq) 223 | assert.message(msg).not_has_error(function () 224 | decode(iter_bytes(seq), delegate) 225 | end) 226 | assert.stub(delegate.key).message(msg) 227 | .called_with(delegate, flags, key:lower():byte()) 228 | end 229 | 230 | local flags = { shift = false, ctrl = false, alt = true } 231 | for key in iter_keys "abcdefghijklmnopqrstuvwxyz" do 232 | local delegate = {} 233 | stub(delegate, "key") 234 | local seq = "\27" .. key 235 | local msg = string.format("Alt+%s sequence (%q)", key, seq) 236 | assert.message(msg).not_has_error(function () 237 | decode(iter_bytes(seq), delegate) 238 | end) 239 | assert.stub(delegate.key).message(msg) 240 | .called_with(delegate, flags, key:byte()) 241 | end 242 | end) 243 | 244 | it("recognizes Alt+Ctrl+", function () 245 | local flags = { shift = false, ctrl = true, alt = true } 246 | for keycode = 1, 26 do 247 | local delegate = {} 248 | stub(delegate, "key") 249 | local seq = "\27" .. string.char(keycode) 250 | local key = keycode + 96 -- Code 1 as ASCII "a" (97) 251 | local msg = string.format("Alt+Ctrl+%s sequence (%q)", string.char(key), seq) 252 | assert.message(msg).not_has_error(function () 253 | decode(iter_bytes(seq), delegate) 254 | end) 255 | assert.stub(delegate.key).message(msg) 256 | .called_with(delegate, flags, key) 257 | end 258 | end) 259 | 260 | local function decode_loop(input, delegate) 261 | local nextbyte = iter_bytes(input) 262 | local result = "" 263 | while true do 264 | local c = decode(nextbyte, delegate) 265 | if c == nil then 266 | return result 267 | end 268 | result = result .. string.char(c) 269 | end 270 | end 271 | 272 | it("can be used to strip escape sequences", function () 273 | for expected, inputs in pairs { 274 | [""] = { 275 | "", "\27", "\27[1A", "\27\27", "\27[1A\27", "\27\27[1A", 276 | "\27\24", "\27[\24", "\27[?\24", "\27[1;32\24", -- CAN 277 | "\27\26", "\27[\26", "\27[?\26", "\27[1;32\26", -- SUB 278 | }, 279 | ["foobar"] = { 280 | "foobar\27", -- Unterminated escape sequence 281 | "\27*foobar", -- Discard one trailing character (prefix) 282 | "foo\27*bar", -- Discard one trailing character (middle) 283 | "foobar\27*", -- Discard one trailing character (suffix) 284 | "\27:*foobar", -- Discard two trailing characters (prefix) 285 | "foo\27:*bar", -- Discard two trailing characters (middle) 286 | "foobar\27:*", -- Discard two trailing characters (suffix) 287 | "\27[1;31mfoobar", -- Valid CSI sequence (prefix) 288 | "foo\27[1;31mbar", -- Valid CSI sequence (middle) 289 | "foobar\27[1;31m", -- Valid CSI sequence (suffix) 290 | "\27\24foobar", -- CAN (prefix) 291 | "foo\27\24bar", -- CAN (middle) 292 | "foobar\27\24", -- CAN (middle) 293 | "\27\26foobar", -- SUB (prefix) 294 | "foo\27\26bar", -- SUB (middle) 295 | "foobar\27\26", -- SUB (middle) 296 | }, 297 | } do 298 | for _, input in ipairs(inputs) do 299 | local msg = string.format("input %q", input) 300 | assert.message(msg).equal(expected, decode_loop(input)) 301 | end 302 | end 303 | end) 304 | 305 | it("catches errors in handlers", function () 306 | local delegate = {} 307 | function delegate:key_up(modifiers, count) 308 | error("Evil666") 309 | end 310 | assert.error_matches(function () 311 | decode(iter_bytes("\27OA"), delegate) 312 | end, "Evil666") 313 | stub(delegate, "error") 314 | assert.error_matches(function () 315 | decode(iter_bytes("\27OA"), delegate) 316 | end, "Evil666") 317 | assert.stub(delegate.error).called_with(delegate, match.is_string()) 318 | end) 319 | end) 320 | -------------------------------------------------------------------------------- /dotty/descape.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- descape.lua 3 | -- Copyright (C) 2016 Adrian Perez 4 | -- 5 | -- Parses input from a (TTY-alike) terminal. 6 | -- 7 | -- Distributed under terms of the MIT license. 8 | -- 9 | 10 | local ascii = require "dotty.asciicodes" 11 | local ESC, CAN, SUB, QMARK = ascii.ESC, ascii.CAN, ascii.SUB, ascii.QMARK 12 | local SEMICOLON, LBRACKET = ascii.SEMICOLON, ascii.LBRACKET 13 | local DIGIT_0, DIGIT_9 = ascii.DIGIT_0, ascii.DIGIT_9 14 | local ASCII_A, ASCII_Z, ASCII_a, ASCII_z = ascii.A, ascii.Z, ascii.a, ascii.z 15 | 16 | local s_char, getmetatable = string.char, getmetatable 17 | local error, pcall, type, t_insert = error, pcall, type, table.insert 18 | local unpack, pack = table.unpack or unpack, table.pack or function (...) 19 | local n = select("#", ...) 20 | local t = { n = n } 21 | for i = 1, n do 22 | t[i] = select(1, ...) 23 | end 24 | return t 25 | end 26 | 27 | local function callable(f) 28 | return type(f) == "function" or (type(f) == "table" 29 | and type(getmetatable(f).__call) == "function") 30 | end 31 | 32 | local function d_error(delegate, format, ...) 33 | local message = format:format(...) 34 | if delegate and callable(delegate.error) then 35 | delegate:error(message) 36 | end 37 | -- Raise the error anyway, just in case the delegate does not. 38 | error(message) 39 | end 40 | 41 | local function d_warning(delegate, format, ...) 42 | if delegate and callable(delegate.warning) then 43 | delegate:warning(format:format(...)) 44 | end 45 | end 46 | 47 | local function d_debug(delegate, format, ...) 48 | if delegate and callable(delegate.debug) then 49 | delegate:debug(format:format(...)) 50 | end 51 | end 52 | 53 | local function invoke_pack(f, ...) 54 | return pack(f(...)) 55 | end 56 | 57 | local function d_invoke_ret(delegate, name, ...) 58 | local handler = delegate[name] 59 | if handler then 60 | local ok, p = pcall(invoke_pack, handler, delegate, ...) 61 | if ok then return unpack(p) end 62 | d_error(delegate, "error in delegate handler %q: %s", name, p[1]) 63 | else 64 | d_warning(delegate, "no delegate handler for %q", name) 65 | end 66 | end 67 | 68 | local function d_invoke(delegate, name, ...) 69 | if delegate then 70 | local handler = delegate[name] 71 | if handler then 72 | local ok, err = pcall(handler, delegate, ...) 73 | if not ok then 74 | d_error(delegate, "error in delegate handler %q: %s", name, err) 75 | end 76 | else 77 | d_warning(delegate, "no delegate handler for %q", name) 78 | end 79 | end 80 | end 81 | 82 | local decode, decode_escape -- Forward declarations. 83 | 84 | 85 | -- See: http://www.leonerd.org.uk/hacks/fixterms/ 86 | local csi_tilde_translation = { 87 | [2] = "key_insert", 88 | [3] = "key_delete", 89 | [5] = "key_pageup", 90 | [6] = "key_pagedown", 91 | [7] = "key_home", 92 | [8] = "key_end", 93 | [11] = "key_f1", 94 | [12] = "key_f2", 95 | [13] = "key_f3", 96 | [14] = "key_f4", 97 | [15] = "key_f5", 98 | [17] = "key_f6", 99 | [18] = "key_f7", 100 | [19] = "key_f8", 101 | [20] = "key_f9", 102 | [21] = "key_f10", 103 | [23] = "key_f11", 104 | [24] = "key_f12", 105 | } 106 | 107 | local function csi_add_modifier_flags(params, handler_name) 108 | local t = { shift = false, ctrl = false, alt = false } 109 | local code = params[2] 110 | if code == 2 then 111 | t.shift = true 112 | elseif code == 3 then 113 | t.alt = true 114 | elseif code == 4 then 115 | t.shift, t.alt = true, true 116 | elseif code == 5 then 117 | t.ctrl = true 118 | elseif code == 6 then 119 | t.shift, t.ctrl = true, true 120 | elseif code == 7 then 121 | t.ctrl, t.alt = true, true 122 | end 123 | return handler_name, t, params[1] or 1 124 | end 125 | 126 | local csi_final_chars = { 127 | [ascii.R] = function (params) 128 | return "cursor_position_reported", params[1] or 1, params[2] or 1 129 | end, 130 | 131 | [ascii.x] = function (params) 132 | if params.n ~= 7 then 133 | assert(params.n == 6) 134 | for i = 6, 1, -1 do 135 | params[i + 1] = params[i] 136 | end 137 | params[1], params.n = 0, 7 138 | end 139 | return "terminal_parameters_reported", unpack(params) 140 | end, 141 | 142 | [ascii.n] = function (params) 143 | return "device_status_reported", (params.n == 1) and params[1] or 0 144 | end, 145 | 146 | [ascii.u] = function (p) return csi_add_modifier_flags(p, "key") end, 147 | [ascii.A] = function (p) return csi_add_modifier_flags(p, "key_up") end, 148 | [ascii.B] = function (p) return csi_add_modifier_flags(p, "key_down") end, 149 | [ascii.C] = function (p) return csi_add_modifier_flags(p, "key_right") end, 150 | [ascii.D] = function (p) return csi_add_modifier_flags(p, "key_left") end, 151 | [ascii.F] = function (p) return csi_add_modifier_flags(p, "key_end") end, 152 | [ascii.H] = function (p) return csi_add_modifier_flags(p, "key_home") end, 153 | [ascii.TILDE] = function (params) 154 | local handler_name = csi_tilde_translation[params[1]] 155 | if handler_name then 156 | return csi_add_modifier_flags(params, handler_name) 157 | end 158 | end, 159 | } 160 | 161 | local csi_imm_final_chars = { 162 | [ascii.QMARK] = { 163 | [ascii.c] = function (params) 164 | -- XXX: Is it okay to ignore params[1]? 165 | return "device_attributes_reported", params[2] or 0 166 | end, 167 | }, 168 | } 169 | 170 | local function decode_csi_sequence(nextbyte, delegate) 171 | local c = nextbyte() 172 | if c == nil then return end 173 | if c == ESC then return decode_escape(nextbyte, delegate) end 174 | if c == SUB or c == CAN then return decode(nextbyte, delegate) end 175 | 176 | local imm -- "Intermediate" chracter: ESC [ IMM … 177 | if c == QMARK then 178 | d_debug(delegate, "decode_csi_sequence: '%c' (0x%02X) QMARK", c, c) 179 | imm, c = c, nextbyte() 180 | if c == nil then return end 181 | if c == ESC then return decode_escape(nextbyte, delegate) end 182 | if c == SUB or c == CAN then return decode(nextbyte, delegate) end 183 | end 184 | 185 | local params = {} 186 | while c >= DIGIT_0 and c <= DIGIT_9 do 187 | d_debug(delegate, "decode_csi_sequence: '%c' (0x%02X) BEGIN", c, c) 188 | 189 | -- Discard leading zeroes. 190 | while c == DIGIT_0 do 191 | c = nextbyte() 192 | if c == nil then return end 193 | d_debug(delegate, "decode_csi_sequence: '%c' (0x%02X) 0-DISCARD", c, c) 194 | if c == ESC then return decode_escape(nextbyte, delegate) end 195 | if c == SUB or c == CAN then return decode(nextbyte, delegate) end 196 | end 197 | 198 | local result = 0 199 | while c >= DIGIT_0 and c <= DIGIT_9 do 200 | result = result * 10 + c - DIGIT_0 201 | c = nextbyte() 202 | if c == nil then return end 203 | d_debug(delegate, "decode_csi_sequence: '%c' (0x%02X) LOOP r=%d", 204 | c, c, result) 205 | if c == ESC then return decode_escape(nextbyte, delegate) end 206 | if c == SUB or c == CAN then return decode(nextbyte, delegate) end 207 | end 208 | t_insert(params, result) 209 | 210 | -- Advance 211 | if c == SEMICOLON then 212 | c = nextbyte() 213 | if c == nil then return end 214 | if c == ESC then return decode_escape(nextbyte, delegate) end 215 | if c == SUB or c == CAN then return decode(nextbyte, delegate) end 216 | end 217 | end 218 | 219 | d_debug(delegate, "decode_csi_sequence: '%c' (0x%02X) #param=%d imm=0x%02X", 220 | c, c, #params, imm == nil and 0 or imm) 221 | 222 | local handler_name = imm and csi_imm_final_chars[imm][c] 223 | or csi_final_chars[c] 224 | if handler_name then 225 | -- A function handler might mangle params in-place. 226 | if type(handler_name) == "function" then 227 | d_invoke(delegate, handler_name(params)) 228 | else 229 | d_invoke(delegate, handler_name, unpack(params)) 230 | end 231 | elseif c >= 0 then 232 | if imm then 233 | d_warning(delegate, 234 | "no CSI sequence handler for %q (0x%02X), imm %q (0x%02X)", 235 | s_char(c), c, s_char(imm), imm) 236 | else 237 | d_warning(delegate, 238 | "no CSI sequence handler for %q (0x%02X)", 239 | s_char(c), c) 240 | end 241 | end 242 | return decode(nextbyte, delegate) 243 | end 244 | 245 | local simple_escapes = { [ascii.O] = {} } 246 | 247 | local function add_vt52_and_ansi(byte, name) 248 | local handler = function (nextbyte, delegate) 249 | d_invoke(delegate, csi_add_modifier_flags({}, name)) 250 | return decode(nextbyte, delegate) 251 | end 252 | simple_escapes[byte] = handler -- VT52 mode. 253 | simple_escapes[ascii.O][byte] = handler -- ANSI+CursorKey mode. 254 | end 255 | 256 | add_vt52_and_ansi(ascii.A, "key_up") 257 | add_vt52_and_ansi(ascii.B, "key_down") 258 | add_vt52_and_ansi(ascii.C, "key_right") 259 | add_vt52_and_ansi(ascii.D, "key_left") 260 | add_vt52_and_ansi(ascii.F, "key_end") 261 | add_vt52_and_ansi(ascii.H, "key_home") 262 | add_vt52_and_ansi(ascii.P, "key_f1") 263 | add_vt52_and_ansi(ascii.Q, "key_f2") 264 | add_vt52_and_ansi(ascii.R, "key_f3") 265 | add_vt52_and_ansi(ascii.S, "key_f4") 266 | add_vt52_and_ansi = nil 267 | 268 | 269 | local DISCARD_ESCAPE = 0x30 270 | local DISCARD_CONTROL = 0x40 271 | 272 | -- According to ANSI X3.64, format is "ESC I ... I F" where: 273 | -- 274 | -- * I: An intermediate character in an escape sequence or a control 275 | -- sequence, where I is from 40 (octal) to 57 (octal) inclusive. 276 | -- 277 | -- * F: A final character in: 278 | -- - An escape sequence, where F is from 60 (octal) to 176 (octal) 279 | -- inclusive. 280 | -- - A control sequence, where F is from 100 (octal) to 176 (octal) 281 | -- inclusive. 282 | -- 283 | -- For simplicity, we discard any characters up to a "final character", 284 | -- or a CAN or SUB character is find (any of which cancel the escape 285 | -- sequence). 286 | -- 287 | local function discard(nextbyte, delegate, c, f_lo) 288 | d_debug(delegate, "discard f_lo=0x%02X: '%c' (0x%02X)", f_lo, c, c) 289 | while c ~= CAN and c ~= SUB and c >= f_lo and c <= 0x7E do 290 | c = nextbyte() 291 | if c == nil then return end 292 | d_debug(delegate, "discard f_lo=0x%02X: '%c' (0x%02X)", f_lo, c, c) 293 | if c == ESC then return decode_escape(nextbyte, delegate) end 294 | end 295 | return decode(nextbyte, delegate) 296 | end 297 | 298 | -- This was forward-declared 299 | decode_escape = function (nextbyte, delegate) 300 | local c = nextbyte() 301 | if c == nil then return end 302 | d_debug(delegate, "decode_escape: '%c' (0x%02X)", c, c) 303 | if c == ESC then return decode_escape(nextbyte, delegate) end 304 | 305 | local handler = simple_escapes[c] 306 | while type(handler) == "table" do 307 | local c1 = nextbyte() 308 | if c1 == nil then return end 309 | d_debug(delegate, "decode_escape: '%c' (0x%02X) - NESTED", c1, c1) 310 | if c1 == ESC then return decode_escape(nextbyte, delegate) end 311 | if c1 == SUB or c1 == CAN then return decode(nextbyte, delegate) end 312 | handler = handler[c1] 313 | end 314 | if handler then 315 | return handler(nextbyte, delegate) 316 | end 317 | 318 | -- 319 | -- Alt+ is received 320 | -- 321 | if c >= ASCII_A and c <= ASCII_Z then 322 | -- 323 | -- XXX: Some of the Alt+ escapes will be recognized as VT-52 324 | -- lingo for keypad arrow keys and F1-F4. There is no easy 325 | -- solution which would allow disabling recognition of the 326 | -- VT-52 sequences in order to have them reported here without 327 | -- doing terminal detection. Then again, probably developers 328 | -- should not try to use keybindings like Alt+A to save their 329 | -- applications from breaking in terminals which do use VT-52 330 | -- escapes for reporting the extended keys. Ugly. 331 | -- 332 | local t = { shift = true, ctrl = false, alt = true } 333 | d_invoke(delegate, "key", t, c + (ASCII_a - ASCII_A)) 334 | return decode(nextbyte, delegate) 335 | end 336 | if c >= ASCII_a and c <= ASCII_z or c >= DIGIT_0 and c <= DIGIT_9 then 337 | -- 338 | -- XXX: Unfortunately, ASCII codes for the numeric row may vary 339 | -- depending on the keyboard layout selected by the user, so 340 | -- we can only accurately set the "shift" flag for letters. 341 | -- Moreover, their ASCII codes fall into the category of 342 | -- characters which "cancel" a escape sequence (see comment 343 | -- next to the discard() function). 344 | -- 345 | local t = { shift = false, ctrl = false, alt = true } 346 | d_invoke(delegate, "key", t, c) 347 | return decode(nextbyte, delegate) 348 | end 349 | 350 | -- 351 | -- Ctrl+Alt+ is received 352 | -- The codes 0x01 to 0x1A correspond to keys A-Z. 353 | -- 354 | -- TODO: Handle non-alphabetic keys. 355 | -- 356 | if c >= 0x01 and c <= 0x1A --[[ 032 ]] then 357 | local t = { shift = false, ctrl = true, alt = true } 358 | d_invoke(delegate, "key", t, c + ASCII_a - 1) 359 | return decode(nextbyte, delegate) 360 | end 361 | 362 | return discard(nextbyte, delegate, c, DISCARD_ESCAPE) 363 | end 364 | 365 | -- Unterminated escape sequence followed by another escape: ESC ESC … 366 | simple_escapes[ascii.O][ESC] = decode_escape 367 | simple_escapes[ESC] = decode_escape 368 | 369 | -- CSI sequence: ESC [ … 370 | simple_escapes[LBRACKET] = decode_csi_sequence 371 | 372 | -- This was forward-declared 373 | decode = function (nextbyte, delegate) 374 | local byte = nextbyte() 375 | if byte ~= nil then 376 | if byte == ESC then 377 | d_debug(delegate, "decode: begin escape sequence") 378 | return decode_escape(nextbyte, delegate) 379 | elseif byte == 0x9B then -- Single-byte CSI 380 | d_debug(delegate, "decode: single-byte CSI sequence") 381 | return decode_csi_sequence(nextbyte, delegate) 382 | end 383 | end 384 | return byte 385 | end 386 | 387 | 388 | ascii = nil 389 | return { 390 | decode_csi_sequence = decode_csi_sequence, 391 | decode_escape = decode_escape, 392 | decode = decode, 393 | } 394 | --------------------------------------------------------------------------------