hotty-lua
hotty-lua is the Lua SDK for hotty, HTML over the TTY. With it, a Lua program shows small HTML documents, called surfaces, on rectangles of cells, and hears what the user does in them. It is written for two places: Neovim plugins, and plx scripts.
It speaks hotty 0.4: a host's replies and events carry msgpack bodies
(SPEC §3.3), a delta is one list of ops (SPEC §6.1), keys are named as
myelin key names 0.1 names them (SPEC §10.4), and its query lists the
version it speaks (a=q:n=1:v=0.4, SPEC §4). A terminal of another hotty
version, a 0.3 one included, is no host to it, and the program draws in
cells. Upgrading from 0.3 and
from 0.2 list what callers change.
It meets SDK.md in two layers:
require("hotty")is the wire layer (SDK.md §3): it builds commands, decodes what the host sends, cuts hotty sequences out of a byte stream, decides whether the terminal is a host, and names keys and keymaps. It does no I/O. It is Lua 5.1 and the standard library only, so it runs under Neovim's LuaJIT, plain LuaJIT, PUC Lua 5.1, and gopher-lua, which plx embeds and which has nobitlibrary.require("hotty.nvim")is the SDK layer for Neovim (SDK.md §4.2): surfaces anchored to buffer positions, placed where their text is after every redraw.require("hotty.plx")is the SDK layer for plx scripts (SDK.md §4.2): surfaces at the cells of the script's tool or rail, which plx's relay moves with it. plx's own docs have how a script uses it, and the limits of gopher-lua that shape this code.
local hotty = require("hotty")
-- A document, placed at screen cell (4, 2): what a program writes.
local out = hotty.doc("demo-card", "<button id=go>Go</button>")
.. hotty.place_at("demo-card", 4, 2, { cols = 20, rows = 1 })
-- What the host sends back, one OSC sequence at a time.
local dec = hotty.decoder()
local result, msg = dec:feed("\27]7279;a=ev:s=demo-card:e=click:t=go\27\\")
local ev = result == hotty.COMPLETE and msg:event()
if ev and ev.kind == hotty.EVENT_CLICK then
-- ev.surface == "demo-card", ev.target == "go"
end
The wire layer
The names are SDK.md's canonical ones in snake case (Appendix A). Options
are a table, absence is nil, and a function that fails returns nil and
an error.
| SDK.md | hotty |
|---|---|
| constants (§3.1) | NUMBER, CHUNK, MAX_SIZE, MAX_NAME, VERSION; EVENT_CLICK …, FEATURES, FEATURE_MENU …, MOD_SHIFT, MOD_ALT, MOD_CTRL, MOD_SUPER, EINVAL …, OP_MORPH …, REPLY_ALWAYS, REPLY_ON_ERROR, NO_REPLY; SCROLL_VERTICAL, SCROLL_HORIZONTAL; REVEAL_START …, MENU_DEPTH |
| Control (§3.2) | hotty.control(pairs), :get(k) (value, present), :with(k, v) |
| Encode (§3.3) | hotty.encode(control, payload, opts) |
| commands (§3.4) | query ({ late = true }), withdraw_late, doc, place, place_at, hide, delta, set_text, set_var, set_attr, remove_attr, morph_to, reveal, res, del_res, del, del_all, detach, focus, blur, edit, menu, menu_show, menu_hide, touch, sync |
| ops (§3.4.3) | morph, inner, replace, append, prepend, before, after, remove, attr, unattr, text, var, reveal_op: each returns an op, a table with op, target, key and value; delta(surface, ops, opts) sends a list of them as one delta |
| reply options (§3.4.2) | the last argument: { n = 4, q = 2 }; doc also takes detached = true and scroll = hotty.SCROLL_VERTICAL. q goes out only where the wire does not imply it |
| Placement (§3.4.1) | a table: cols, rows, window = { x, y, w, h }, z, press, fit, hover (true is on, false is off, nil sends nothing: they belong to the surface), keep_cursor |
| a host or a relay | delta_of(msg) (the ops, or nil and an EINVAL error with op the index at fault), menu_of(msg), menu_show_of(msg), menu_level_of(tree, platform, seq), menu_find(tree, name), read_key(platform, name) |
| names (§3.5) | valid_name(s), surface_name(s) |
| Decoder (§3.6) | hotty.decoder(), :feed(seq) (result, message), .invalid |
| Scanner (§3.7) | hotty.scanner({ da1 = true }), :feed(bytes) (segments), :flush(), :holding(), :in_sequence(), .invalid |
| Detector (§3.8) | hotty.detector({ n = 1, late = false }), :start(now), :da1(now), :reply(r, now), :tick(now), :finish(now); .state, .caps, .decided, .done, .deadline |
| keys (§3.10) | parse_key(name), read_key(platform, name), decode_keys(input), key_text(key), parse_keymap(platform, value), resolve(platform, multiline, value…), platform_of(caps), default_edit(mod), keymap:binds(key), keymap:lookup(key), keymap:selects(key), keymap:program(key), keymap:prefix(key), keymap:scroll(key), keymap:format(); TERMINAL_KEYS, ACTIONS, MOVES, SCROLL_ACTIONS, INSERT |
| messages (§3.9) | msg:reply(), msg:event(), msg:body(); reply:caps(), reply:err(), reply.op; event:value(), :checked(), :fields(), :mods(), :link(), :fit_rows(), :drag(), :hover(), :area(), :count(), :view(), :command(), :changed(); hotty.caps(body), caps:merge(changed), caps.cell_css, caps.features and the other fields; caps:has(feature), :light() |
Where Lua differs:
endis a keyword, so the Detector's End isfinish.detector["end"]is the same function.- Segments are tables,
{ kind = "pass" | "osc" | "da1", data = … }. - A message's control is a table of keys to values, and
msg.keyslists the keys in the order they came. link()returns two values (href, url).drag(),hover(),area()andview()return tables;view()'s is{ areas = { [id] = area }, at_end = bool }, from the wire'sinandend, which are keywords.fields()is a list of{ name, value }pairs, andmods()the modifier bits (MOD_SHIFT…), 0 when there are none.caps.cell_cssis{ w, h }in CSS pixels,nilwhen the host said none.reply.opand an error'sopare the index of the op at fault as the wire has it, from 0,nilwhen there is none.- Op builders take the names SDK.md's Appendix A gives Lua:
hotty.text,hotty.attr,hotty.varand the rest, andreveal_opforRevealOp. An op is a plain table,{ op = "text", target = "n", value = "1" }; itsvalueisnilfor an op that takes none. - A menu is a table,
{ label, items = { { key, label, name, enabled, items } } };enabledis true whennil.menu_ofreads one the same way, with""for what the body leaves out andenabledfalse where it is absent. - Touch areas are tables,
{ c, r, w, h, drag }: the screen's cells where a finger drags as the mouse (SPEC §9.6), counted from 0, and"x","y"or"xy".touch(areas)names them for the screen it is sent on, andtouch()leaves none. - The scroll axes are also
scroll_verticalandscroll_horizontal, as SDK.md's Appendix A spells them for Lua; every other constant here is in upper case. decode_keysputsfalsewhere the input is no key, sincenilwould end the list.- Characters are code points, CR LF one: Lua has no grapheme
segmentation, so
decode_keysand the Field spliteand a combining accent in two, and the runner skips the vectors that requiregraphemes(SDK.md §4.6). Letters are written small, andkey_textanddecode_keyscapitalise them, by the simple case mappings of ASCII, Latin-1, Latin Extended, Greek, Cyrillic, Armenian and the full-width Latin letters, from a table inhotty.keys: the runtimes'string.lowerdiffers beyond ASCII. - A platform is a table,
{ mod = "ctrl" | "super", edit = … }, withnilfor the zero Platform (ctrl, the default edit shortcuts);platform_of(caps)makes one.parse_keyrefusesmod;read_keyreads it. - zlib. Lua has none.
encodecompresses only when given a compressor (opts.compress, from bytes to zlib bytes; SDK.md §3.3 has it run at zlib's default level, 6), and a host never compresses what it sends (SPEC §3.3). The Decoder inflateso=zitself, with a small inflate in Lua, for a relay or a test that reads a program's output. It stops at the decoder'smax(opts.max, 16 MiB by default), as it stops joining the chunks of a message that would decode past it. - msgpack. Lua has none either. A host's bodies are msgpack (SPEC
§3.3):
hotty.msgpack.decodereads them, in Lua. A delta's ops, a menu and touch areas are written bydelta,menuandtouch, each str, int, array and map in the smallest form of its header.msg:body()is the body it decoded, a map, as a table with no metatable, and its kinds. - Numbers are doubles in Lua 5.1, LuaJIT and gopher-lua, so
7and7.0are one number, and a table is a map or an array. Sohotty.msgpack.decodemarks what a Lua value cannot show (a float, a bin, an array, a timestamp, an extension of another type), and each field of a body is read by its type (SDK.md §3.9), as in every other SDK:7.0in an int field,2in a float one, or[]in a map one, and the body does not decode. An int further than 2^53 − 1 from 0 does not decode either.docs/plx.mdhas the contract.
Upgrading from 0.3
What a caller changes (SDK.md Appendix D). A program, its data-keys and
menus, and its tests' fake hosts move together:
- Every key's name changes to myelin key names 0.1:
Control+aisctrl+a,Metaissuper,ArrowLeftisarrow_left,Enterisenter,Spaceisspace.hotty.MODIFIERSissuper ctrl alt shift. mod, notMod. A binding writesmod+s;modwithctrlorsuperis refused. A host'smodis"ctrl"or"super", and itseditnames keys the new way.parse_keyrefusesmod; a binding read on a platform goes throughread_key(platform, name), and the keymap functions do it themselves.- A capital is no Shift.
Greads asg, so Shift with a letter isshift+g.key_text(key)is what a key types:shift+atypesA. A field typeshotty.key_text(key), not the key. - Shift falls back only from a key that is not a character. An unbound
shift+arrow_leftis looked up asarrow_left;shift+gis a key of its own.keymap:binds(key)says whether a keymap binds a key, with that fallback, for a host that names a press twice, by its place and by what it types (ctrl+key_w,ctrl+w). MOD_CONTROLandMOD_METAareMOD_CTRLandMOD_SUPER, with the same bits.- The version is
"0.4"alone: a 0.3 host is no host.
Upgrading from 0.2
What a caller changes (SDK.md Appendix C):
delta(surface, op, target, key, payload, opts)isdelta(surface, ops, opts), the ops built byhotty.text,hotty.attrand the rest. A frame's changes to one surface go in one delta. The one-op helpers (set_text,set_var,set_attr,remove_attr,morph_to,reveal) stay, and so do the sessions'surface:set_text(…)and the like;surface:delta(op, target, key, payload, opts)issurface:delta(ops, opts).press,fitandhoverbelong to the surface. In a placement,nilsends nothing and leaves the flag as it was,trueturns it on (p=1) andfalseoff (p=0). A program that placed a surface withoutpressto stop its presses now sendspress = false. The sessions send them when they change.- Ask by feature.
caps:supports,:sends,:drags,:hoversand:views, and the fieldsops,events,passthrough,scroll,steps,drop,count,keysandmenuare gone:caps:has(feature)answers forpassthrough,hover,countandmenu. - The cell is in CSS pixels.
caps.cellandcaps:cell_css()arecaps.cell_css, a table{ w, h }ornil; there is noresizeevent orev:size(): a new font is acapsevent, whichcaps:mergefolds in. The sessions keepcapscurrent (term.caps,session.caps) and callon_caps(caps, changed). - A submit's
ev:fields()is a list of{ name, value }pairs, in order, not a table by name. - A drag's
keysareev:mods(), bits; clicks and presses carry them. hotty.menu(level)ishotty.menu(tree)withnameand nesteditemsin place ofseq,groupand the level a key reaches; send the tree when what the program can do changes,menu_show(keys)for each key of a sequence, andmenu_hide()when it ends. A key sequence is the program's: bind its leader toprefixin the keymap.qis left out where the wire implies it.{ n = 4 }alone asks for the reply; a fake host answers a command withoutqonly with an error.
A field in cells
hotty.field(opts) is SDK.md §4.6's Field: a text field's value, caret and
selection, edited as a host edits one on a surface, for a program that
draws its fields in cells. With the same keymap on both sides, a field
edits the same in cells and on a surface, Shift selecting with a move:
local km = hotty.resolve(hotty.platform_of(caps), false, hotty.TERMINAL_KEYS) -- and data-keys="…" on the surface's root
local f = hotty.field({ value = "foo bar" }) -- opts: value, caret, anchor, multiline, password, rows
for _, key in ipairs(hotty.decode_keys(input)) do
local action = key and km:lookup(key)
if action == hotty.INSERT then
f:type(hotty.key_text(key)) -- in place of the selection
elseif action and km:selects(key) then
f:extend(action) -- shift+arrow_left, ctrl+shift+end: the caret moves, the anchor stays
elseif action and action ~= "submit" then
f:do_action(action) -- true when the value changed
end
end
local from, to = f:selection() -- equal when nothing is selected
do is a keyword, so the Field's Do is do_action; f["do"] is the
same function.
The selection runs from f.anchor, the end where it began, to the
caret: nothing is selected when the anchor is nil or at the caret.
f:select(anchor, caret) sets both, for a selection made with the pointer,
and f:select(p, p) puts the caret at p with nothing selected.
Neovim
Neovim owns the terminal, so hotty.nvim reads nothing itself. The TUI hands
over each OSC sequence and each DA1 answer as TermResponse, without its
terminator, and the Decoder and the Detector take them from there; a plugin
writes with nvim_ui_send. It needs Neovim 0.12 (nvim_ui_send).
local hotty, hn = require("hotty"), require("hotty.nvim")
local session = hn.session({
prefix = "myplugin", -- surface names: myplugin-<name>
on_ready = function(mode, caps) end, -- "native", or "text": draw in cells
late = true, -- text may turn native later, as in a multiplexer's pane (SPEC §4)
})
session:surface("card", {
html = "<button id=go>Go</button> <span id=out></span>",
anchor = { buf = 0, row = 4, below = true }, -- the rows below line 5
cols = 40,
rows = 2,
keep = true,
on_event = function(ev, surface)
if ev.kind == hotty.EVENT_CLICK and ev.target == "go" then
surface:set_text("out", "gone")
end
end,
})
examples/nvim/click.lua is that, whole.
- The terminal (
hn.term()) is one per Neovim. It detects whether the terminal is a host, and again when the terminal can have changed: a UI attached (UIEnter), or a return from suspension (VimResume), which left the alternate screen and its surfaces with it. A host that does not speakVERSIONanswers the query withEVERSION, and is text, as no host is (SPEC §4). It numbers requests (term:request(build, cb), 3 s) and fences (term:fence(cb), 1 s), and hands every message no wait took to the sessions and toterm:listen(fn). - A session (
hn.session(opts)) holds a plugin's surfaces. A surface's anchor is a buffer position ({ buf, row, col }, an extmark, so it moves with edits), the rows below a line ({ buf, row, below = true }, with room made by virtual lines), or a screen cell ({ screen = { x, y } }). - Layout. After the editor draws, the session places each surface at
its anchor's cell, clipped to its window's text with a placement window,
and sends only what changed: the document once, a placement when it moved,
hide(withkeep) ordelwhen it went out of view. The passes run onvim.schedulefrom the editor's events (WinScrolled,WinResized,VimResized,WinEnter,TextChanged, …) and from every redraw (a decoration provider'son_end), sincenvim_ui_send's bytes go out at once while the TUI writes its frame when it flushes: sent from the event itself, a placement would land before the cells it goes with. - Floating windows and the popup menu are cells, and every placement is
above the cells (SPEC §5.2), so a surface they would cover is hidden while
they do (
occlude = falseturns that off). - Flags. A surface's
press,fitandhoverare sent with the placement when they change, not with every one (SPEC §5.2). - What the host loses (
ENOENTin answer to a placement) is sent again. A resize places every surface again: the TUI starts its redraw with an erase in display, which takes placements with it (SDK.md §4.3).session:relayout(true)places every surface again, changed or not. - Not yet: Ctrl-L. Ctrl-L (and
:mode) erases the screen too, and Neovim raises no event for it, so a surface stays gone until something moves it, or until the program callsrelayout(true). Which event should tell the adapter is an open question. - Rows auto (
rows = nil): the first placement is numbered, and the rows the host chose are kept;fit = truekeeps them current. - Closing.
session:close()deletes the surfaces,session:detach_all()leaves them on the screen, detached. Sessions close onVimLeavePre.
Conformance
make check is the gate. It runs, under luajit, lua5.1, nvim -l and glua
(gopher-lua v1.1.2, as plx embeds it, built with mise's Go):
- the conformance vectors' SDK sections,
tests/vectors.lua: wire, build, encode, decode, scan, detect, keys, keymap and edit, every vector but the few that requiregraphemes; - the unit tests,
tests/unit.lua: what the vectors leave out; - hotty.plx's tests,
tests/plx.lua, against a fake plx-script (actxwith the primitives, andplx.afteron a clock the test turns), and the plx example in it.
And under nvim -l, tests/nvim/run.lua: each test runs a Neovim in a
pseudo-terminal, plays its terminal with a fake host
(tests/nvim/fakehost.lua, the test host of SDK.md §4.4 as far as these
tests need it), drives it over RPC, and reads what the host was sent.
HOTTY_TEST=<part of a name> runs some of them.
CI (.github/workflows/ci.yml) runs make check on GitHub's hosted
Ubuntu, with Neovim 0.12.5, and checks that the vectors are the spec's.
make e2e, outside the gate, runs the click example in hottyterm, a native
host, on the private headless display of hotty-blitz's
scripts/headless.sh: it clicks the button through a virtual pointer,
presses Ctrl-L, scrolls and splits, and leaves a screenshot of each in
$OUT.
The vectors are a copy of the spec's (tests/vectors.json). With a checkout
of neuroplastio/hotty at HOTTY_DIR (../../hotty/main by default), the
runner fails when the copy differs; make vectors updates it.
Licence
Apache-2.0.