Created
July 4, 2023 19:49
-
-
Save b0mbie/7c58f8db2682015a5049d4ce9355988c to your computer and use it in GitHub Desktop.
Terminal control library in pure Lua.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- | |
| -- tty.lua | |
| -- | |
| -- Written by [aka]bomb, 2023 | |
| -- | |
| -- Permission is hereby granted, free of charge, to any person obtaining a copy of | |
| -- this software and associated documentation files (the "Software"), to deal in | |
| -- the Software without restriction, including without limitation the rights to | |
| -- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies | |
| -- of the Software, and to permit persons to whom the Software is furnished to do | |
| -- so, subject to the following conditions: | |
| -- | |
| -- The above copyright notice and this permission notice shall be included in all | |
| -- copies or substantial portions of the Software. | |
| -- | |
| -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| -- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| -- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| -- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| -- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| -- SOFTWARE. | |
| --- @class tty | |
| --- https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | |
| local tty = {} | |
| local error = error | |
| local math_ceil = math.ceil | |
| local select = select | |
| local setmetatable = setmetatable | |
| local string_char = string.char | |
| local tostring = tostring | |
| local type = type | |
| local esc = '\x1B' | |
| tty.keys = { | |
| [esc..esc] = "escape", | |
| [esc.."[A"] = "up", | |
| [esc.."[B"] = "down", | |
| [esc.."[C"] = "right", | |
| [esc.."[D"] = "left", | |
| [esc.."[F"] = "end", | |
| [esc.."[H"] = "home", | |
| [esc.."[1~"] = "home", | |
| [esc.."[2~"] = "insert", | |
| [esc.."[3~"] = "delete", | |
| [esc.."[4~"] = "end", | |
| [esc.."[5~"] = "pgup", | |
| [esc.."[6~"] = "pgdn", | |
| [esc.."[7~"] = "home", | |
| [esc.."[8~"] = "end", | |
| ["\x7F"] = "backspace" -- ASCII delete | |
| } | |
| local tty_keys = tty.keys | |
| -- Set regular Latin characters. | |
| for i = 65, 90 do | |
| local c, cu = string_char(i), string_char(i + 32) | |
| tty_keys[c] = c | |
| tty_keys[cu] = cu | |
| -- Alt-sequences. | |
| tty_keys[esc .. c] = "M-" .. c | |
| tty_keys[esc .. cu] = "M-" .. cu | |
| -- Ctrl-sequences (start from 0x01). | |
| tty_keys[string_char(i - 64)] = "C-" .. cu | |
| end | |
| tty_keys['\t'] = "tab" | |
| tty_keys['\n'] = "newline" | |
| tty_keys['\r'] = "return" | |
| local function KeyName(c) return tty_keys[c] or c end | |
| --- @class tty.Color | |
| --- @field [1] integer | |
| --- @field [2] integer | |
| --- @field [3] integer | |
| --- @class tty.ConsoleState | |
| --- @field x integer: The last set X position of the cursor. | |
| --- @field y integer: The last set Y position of the cursor. | |
| --- @field foreground tty.Color: The last set foreground color. | |
| --- @field background tty.Color: The last set background color. | |
| --- @field weight? "regular"|"bold"|"thin": The last set font weight. | |
| --- @field italic boolean?: The last set italics state. | |
| --- @field strikethrough boolean?: The last set strikethrough state. | |
| --- @field font integer?: The last set font number state. | |
| --- @class tty.Console | |
| --- @field stream tty.Stream | |
| --- @field state tty.ConsoleState | |
| local Console = {} | |
| --- Write data to the Console's screen. | |
| --- The Console's Stream must output the data `...` in such a way that it | |
| --- appears concatenated. | |
| function Console:write(...) | |
| self.stream:write(...) | |
| return self | |
| end | |
| local CSIRound = math_ceil | |
| local function CSISerialize(value) | |
| if type(value) == "number" then | |
| return tostring(CSIRound(value)) | |
| end | |
| return tostring(value) | |
| end | |
| local function CSIConcat(value, ...) | |
| if select('#', ...) < 1 then | |
| return CSISerialize(value) | |
| end | |
| return CSISerialize(value) .. ';' .. CSIConcat(...) | |
| end | |
| --- Write a CSI (Control Sequence Introducer) sequence to the Console. | |
| --- @param cmd string: The "command" for the sequence. | |
| --- @param ... integer?: Any integer parameters to put before `cmd`, joining them with `';'`. | |
| function Console:csi(cmd, ...) | |
| return self:write(esc .. '[', CSIConcat(...), cmd) | |
| end | |
| --- Move the cursor relative to the current position on the Console's screen. | |
| --- @param yy integer?: The relative Y position (movement) of the cursor. | |
| --- @param xx integer?: The relative X position (movement) of the cursor. | |
| function Console:move(yy, xx) | |
| local state = self.state | |
| if yy and yy ~= 0 then | |
| if yy < 0 then | |
| yy = CSIRound(-yy) | |
| self:csi('A', yy) | |
| state.y = state.y - yy | |
| else | |
| yy = CSIRound(yy) | |
| self:csi('B', yy) | |
| state.y = state.y + yy | |
| end | |
| end | |
| if xx and xx ~= 0 then | |
| if xx < 0 then | |
| xx = CSIRound(-xx) | |
| self:csi('D', xx) | |
| state.x = state.x - xx | |
| else | |
| xx = CSIRound(xx) | |
| self:csi('C', xx) | |
| state.x = state.x + xx | |
| end | |
| end | |
| return self | |
| end | |
| --- Move the cursor up or down a number of lines. | |
| --- @param n integer?: The number of lines to move up or down (default: `0`). | |
| function Console:line(n) | |
| if not n or n == 0 then return end | |
| if n > 0 then | |
| self:csi('E', n) | |
| else | |
| self:csi('F', -n) | |
| end | |
| end | |
| --- Move the cursor to the specified position on the Console's screen, starting | |
| --- with `1, 1` at the upper-left corner. | |
| --- @param y integer: The new Y position of the cursor. | |
| --- @param x integer: The new X position of the cursor. | |
| function Console:moveto(y, x) | |
| local state = self.state | |
| state.x, state.y = x, y | |
| return self:csi('H', y, x) | |
| end | |
| --- Move the cursor to the specified position on the Console's screen, and | |
| --- immediately write the data `data`. | |
| --- @param data any: The data to write at the specified position. | |
| --- @param x integer: The new X position of the cursor. | |
| --- @param y integer: The new Y position of the cursor. | |
| function Console:put(data, x, y) | |
| self:moveto(y, x) | |
| return self:write(data) | |
| end | |
| --- Clear a part of the Console's screen. | |
| --- If `mode` is `0`, clear from cursor to end of screen. | |
| --- If `mode` is `1`, clear from cursor to beginning of the screen. | |
| --- If `mode` is `2`, clear entire screen (and move cursor to upper-left corner | |
| --- on DOS `ANSI.SYS`). | |
| --- If `mode` is `3`, clear entire screen and delete all lines saved in the | |
| --- scrollback buffer (xterm). | |
| --- @param mode integer?: The mode to clear the screen in (default: 2). | |
| function Console:clear(mode) | |
| mode = mode or 2 -- Clear entire screen by default. | |
| return self:csi('J', mode) | |
| end | |
| --- Reset the Console's graphical state. | |
| function Console:reset() | |
| return self:csi('m', 0) | |
| end | |
| --- Set the Console's foreground color. | |
| function Console:forecolor(...) | |
| if select('#', ...) == 0 then return self:csi('m', 39) end | |
| local color = self.state.foreground | |
| color[1], color[2], color[3] = ... | |
| return self:csi('m', 38, 2, ...) | |
| end | |
| --- Set the Console's background color. | |
| function Console:backcolor(...) | |
| if select('#', ...) == 0 then return self:csi('m', 49) end | |
| local color = self.state.background | |
| color[1], color[2], color[3] = ... | |
| return self:csi('m', 48, 2, ...) | |
| end | |
| --- Read from the Console's Stream. | |
| function Console:read(...) | |
| return self.stream:read(...) | |
| end | |
| --- Read a key from the console. This function only works if the input accepts | |
| --- terminal input sequences. | |
| --- For sequences and some special characters, this returns a friendly name. | |
| --- For everything else, this returns the actual string read. | |
| --- @return string | |
| function Console:readkey() | |
| local key = self:read(1) | |
| -- Check if not an escape sequence. | |
| if key ~= esc then return KeyName(key) end | |
| key = key .. self:read(1) | |
| -- Check if escape sequence exists. | |
| if tty_keys[key] then return KeyName(key) end | |
| local digit = self:read(1) | |
| -- Check if keycode sequence. | |
| if not digit:match("%d") then return KeyName(key .. digit) end | |
| -- <esc> '[' (<keycode>) (';'<modifier>) '~' | |
| -- -> keycode sequence, <keycode> and <modifier> are decimal numbers and | |
| -- default to 1 (vt) | |
| local char | |
| key = key .. digit | |
| repeat | |
| char = self:read(1) | |
| key = key .. char | |
| until char == '~' | |
| -- Sequence ended here... | |
| return KeyName(key) | |
| end | |
| --- Set the Console font's weight to bold - enable the "bold" effect. | |
| function Console:bold() | |
| self.state.weight = "bold" | |
| return self:csi('m', 1) | |
| end | |
| --- Set the Console font's weight to thin - enable the "thin" or "faint" effect. | |
| function Console:thin() | |
| self.state.weight = "thin" | |
| return self:csi('m', 2) | |
| end | |
| --- Set the Console font's weight to regular - disable any "bold" or "thin" | |
| --- effects. | |
| function Console:regular() | |
| self.state.weight = "regular" | |
| return self:csi('m', 22) | |
| end | |
| --- Enable the italic effect. | |
| function Console:italic() | |
| self.state.italic = true | |
| return self:csi('m', 3) | |
| end | |
| --- Disable the italic effect. | |
| function Console:unitalic() | |
| self.state.italic = false | |
| return self:csi('m', 23) | |
| end | |
| --- Enable the underline effect. | |
| function Console:underline() | |
| self.state.underline = true | |
| return self:csi('m', 4) | |
| end | |
| --- Disable the underline effect. | |
| function Console:ununderline() | |
| self.state.underline = false | |
| return self:csi('m', 24) | |
| end | |
| --- Enable the "crossed-out" effect. | |
| function Console:strikethrough() | |
| self.state.strikethrough = true | |
| return self:csi('m', 9) | |
| end | |
| --- Disable the "crossed-out" effect. | |
| function Console:unstrikethrough() | |
| self.state.strikethrough = false | |
| return self:csi('m', 29) | |
| end | |
| --- Set the Console's font. Set font to `0` to use the standard font. | |
| --- @param n integer: The font index. | |
| --- @return self | |
| function Console:font(n) | |
| n = n or 0 | |
| if not (n >= 0 and n <= 9) then | |
| --- @diagnostic disable-next-line: return-type-mismatch | |
| return error("alternative font index must be in range of 1..9", 2) | |
| end | |
| self.state.font = n | |
| return self:csi('m', 10 + n) | |
| end | |
| --- @class tty.TextObject | |
| --- @field data any?: The data to write to the screen. | |
| --- @field foreground? tty.Color|"reset": The new foreground color. | |
| --- @field background? tty.Color|"reset": The new background color. | |
| --- @field weight? "regular"|"bold"|"thin": The new font weight. | |
| --- @field italic boolean?: The new state of italics. | |
| --- @field underline boolean?: The new state of underline. | |
| --- @field strikethrough boolean?: The new state of strikethrough. | |
| --- @field font integer?: The new font number. | |
| --- @field movex integer?: The relative X movement for the text. | |
| --- @field movey integer?: The relative Y movement for the text. | |
| --- @param self tty.Console | |
| --- @param textObject tty.TextObject | |
| local function PresentTextObject(self, textObject, i) | |
| -- Set color. | |
| local fg, bg = textObject.foreground, textObject.background | |
| if fg then | |
| if fg ~= "reset" then | |
| self:forecolor(fg[1], fg[2], fg[3]) | |
| else | |
| self:forecolor() | |
| end | |
| end | |
| if bg then | |
| if bg ~= "reset" then | |
| self:backcolor(bg[1], bg[2], bg[3]) | |
| else | |
| self:backcolor() | |
| end | |
| end | |
| -- Set font number. | |
| local font = textObject.font | |
| if font ~= nil then self:font(font) end | |
| -- Set font weight. | |
| local weight = textObject.weight | |
| if weight then | |
| if weight == "regular" then | |
| self:regular() | |
| elseif weight == "bold" then | |
| self:bold() | |
| elseif weight == "thin" then | |
| self:thin() | |
| else | |
| return error(("%d: invalid weight: %s"):format(i, weight), 2) | |
| end | |
| end | |
| -- Set italic. | |
| local italic = textObject.italic | |
| if italic ~= nil then | |
| if italic then self:italic() else self:unitalic() end | |
| end | |
| -- Set underline. | |
| local underline = textObject.underline | |
| if underline ~= nil then | |
| if underline then self:underline() else self:ununderline() end | |
| end | |
| -- Set strikethrough. | |
| local strikethrough = textObject.strikethrough | |
| if strikethrough ~= nil then | |
| if strikethrough then self:strikethrough() else self:unstrikethrough() end | |
| end | |
| -- Move cursor. | |
| local movex, movey = textObject.movex, textObject.movey | |
| if movex or movey then self:move(movey, movex) end | |
| -- Write data. | |
| if textObject.data ~= nil then self:write(textObject.data) end | |
| end | |
| --- Process an array of `tty.Text`. | |
| --- @param text any[]|tty.TextObject[]|string[] | |
| function Console:text(text) | |
| for i = 1, #text do | |
| local textObject = text[i] | |
| if type(textObject) == "string" then | |
| self:write(textObject) | |
| else | |
| PresentTextObject(self, textObject, i) | |
| end | |
| end | |
| return self | |
| end | |
| --- Write `pre` to Console screen and call `read` with `"prompt"` as the only | |
| --- parameter. | |
| --- @param pre any?: The data to write before starting to read. | |
| function Console:prompt(pre) | |
| if pre then self:write(pre) end | |
| return self:read("prompt") | |
| end | |
| local Console_mt = { __index = Console; __name = "tty.Console"; } | |
| --- Create a new Console interface with `stream` as the Input/Output interface | |
| --- for the screen. | |
| --- @param stream tty.Stream | |
| --- @return tty.Console | |
| function tty.Console(stream) | |
| --- @class tty.Console | |
| return setmetatable({ | |
| stream = stream; | |
| --- @type tty.ConsoleState | |
| state = { | |
| foreground = {}, background = {}; | |
| x = 1, y = 1; | |
| }; | |
| }, Console_mt) | |
| end | |
| --- @class tty.Stream | |
| --- @field write function | |
| --- @field read function | |
| --- @class tty.FileStream: tty.Stream | |
| --- @field writefd file* | |
| --- @field readfd file* | |
| local FileStream = {} | |
| function FileStream:write(...) | |
| return self.writefd:write(...) | |
| end | |
| function FileStream:read(mode, ...) | |
| if mode == "prompt" then mode = 'l' end | |
| return self.readfd:read(mode, ...) | |
| end | |
| local FileStream_mt = { __index = FileStream; __name = "tty.FileStream"; } | |
| --- Create a `tty.Stream` with the `write` and `read` methods pointing to | |
| --- `writefd` and `readfd`. | |
| --- @param writefd file*|tty.Stream|table: The output stream (or file descriptor). | |
| --- @param readfd file*|tty.Stream|table: The input stream (or file descriptor). | |
| --- @return tty.FileStream | |
| function tty.FileStream(writefd, readfd) | |
| return setmetatable({ | |
| writefd = writefd; | |
| readfd = readfd; | |
| }, FileStream_mt) | |
| end | |
| tty.io = tty.Console{ | |
| write = function (_, ...) | |
| return io.stdout:write(...) | |
| end; | |
| read = function (_, mode, ...) | |
| if mode == "prompt" then mode = 'l' end | |
| return io.stdin:read(mode, ...) | |
| end; | |
| } | |
| return tty |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment