Skip to content

Instantly share code, notes, and snippets.

@b0mbie
Created July 4, 2023 19:49
Show Gist options
  • Select an option

  • Save b0mbie/7c58f8db2682015a5049d4ce9355988c to your computer and use it in GitHub Desktop.

Select an option

Save b0mbie/7c58f8db2682015a5049d4ce9355988c to your computer and use it in GitHub Desktop.
Terminal control library in pure Lua.
--
-- 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