summaryrefslogtreecommitdiff
path: root/tui/termfo/load.go
diff options
context:
space:
mode:
authorraven <citrons@mondecitronne.com>2026-02-20 13:42:12 -0600
committerraven <citrons@mondecitronne.com>2026-02-20 13:46:59 -0600
commit5b6196ebe67cf954bae8212c1a33b869da723e11 (patch)
treedce33c06621847c3862e64bda914b1e8a450317d /tui/termfo/load.go
parent05c068749740f9430d1fda7698c433697eef1652 (diff)
support builtin terminfo
copy termfo into the repository and modify it to embed an xterm terminfo to as a fallback
Diffstat (limited to 'tui/termfo/load.go')
-rw-r--r--tui/termfo/load.go368
1 files changed, 368 insertions, 0 deletions
diff --git a/tui/termfo/load.go b/tui/termfo/load.go
new file mode 100644
index 0000000..4ceba63
--- /dev/null
+++ b/tui/termfo/load.go
@@ -0,0 +1,368 @@
+package termfo
+
+import (
+ "bytes"
+ "embed"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+
+ "citrons.xyz/talk/tui/termfo/caps"
+ "citrons.xyz/talk/tui/termfo/keys"
+)
+
+const (
+ headerMagic = 0o0432
+ headerMagicExt = 0o01036
+ headerSize = 12
+)
+
+// Loaders is a list of additional loader callbacks.
+//
+// See the documentation on New() for the exact loading order.
+//
+// Note that access to this isn't synchronized; it usually shouldn't be needed.
+var Loaders = []func(string) *Terminfo {loadBuiltin}
+
+func loadTerminfo(term string) (*Terminfo, error) {
+ if term == "" {
+ return nil, errors.New("TERM not set")
+ }
+
+ ti, fp, err := findTerminfo(term)
+ if err != nil {
+ return nil, fmt.Errorf("terminfo: %w", err)
+ }
+ if ti != nil {
+ return ti, nil
+ }
+
+ defer fp.Close()
+ return readTi(fp)
+}
+
+//go:embed terminfo/*
+var builtin embed.FS
+func loadBuiltin(term string) *Terminfo {
+ fp, err := builtin.Open("terminfo/" + term)
+ if err != nil {
+ return nil
+ }
+ ti, err := readTi(fp.(io.ReadSeeker))
+ if err != nil {
+ return nil
+ }
+ return ti
+}
+
+// See doc on New() for loading order.
+func findTerminfo(term string) (*Terminfo, *os.File, error) {
+ if terminfo := os.Getenv("TERMINFO"); terminfo != "" {
+ return fromPath(term, terminfo)
+ }
+
+ if _, ok := os.LookupEnv("NO_BUILTIN_TERMINFO"); !ok {
+ for _, l := range Loaders {
+ if ti := l(term); ti != nil {
+ return ti, nil, nil
+ }
+ }
+ }
+
+ if h := os.Getenv("HOME"); h != "" {
+ if _, fp, err := fromPath(term, h+"/.terminfo"); err == nil {
+ return nil, fp, nil
+ }
+ }
+
+ if dirs := os.Getenv("TERMINFO_DIRS"); dirs != "" {
+ for _, dir := range strings.Split(dirs, ":") {
+ if dir == "" {
+ dir = "/usr/share/terminfo"
+ }
+ if _, fp, err := fromPath(term, dir); err == nil {
+ return nil, fp, nil
+ }
+ }
+ }
+
+ if _, fp, err := fromPath(term, "/lib/terminfo"); err == nil {
+ return nil, fp, nil
+ }
+ return fromPath(term, "/usr/share/terminfo")
+}
+
+func fromPath(term, path string) (*Terminfo, *os.File, error) {
+ if _, err := os.Open(path); err != nil {
+ return nil, nil, err
+ }
+
+ fp, err := os.Open(path + "/" + term[0:1] + "/" + term) // e.g. x/xterm
+ if err == nil {
+ return nil, fp, nil
+ }
+
+ // 68/xterm; as used on Darwin/macOS.
+ fp, err = os.Open(path + "/" + hex.EncodeToString([]byte(term[:1])) + "/" + term)
+ return nil, fp, err
+}
+
+func readTi(fp io.ReadSeeker) (*Terminfo, error) {
+ // Read the header.
+ var header struct{ Magic, SizeNames, CountBools, CountNums, StrOffets, SizeTbl int16 }
+ if err := binary.Read(fp, binary.LittleEndian, &header); err != nil {
+ return nil, fmt.Errorf("terminfo: reading header: %w", err)
+ }
+
+ // The regular format has 16bit numbers, the "extended number format" is
+ // 32bits. It looks like tic will only compile them with 32bit numbers if
+ // needed, so both are common.
+ intSize := int16(2)
+ switch header.Magic {
+ case headerMagic:
+ case headerMagicExt:
+ intSize = 4
+ default:
+ return nil, fmt.Errorf("terminfo: unexpected magic number in header: 0o%o", header.Magic)
+ }
+
+ tiData := struct {
+ names []byte
+ bools []bool
+ align []byte
+ nums []byte // Can be 16 or 32 bit, will convert later.
+ strOffs []int16
+ strTbl []byte
+ }{
+ make([]byte, header.SizeNames),
+ make([]bool, header.CountBools),
+ make([]byte, align(header.SizeNames+header.CountBools)),
+ make([]byte, header.CountNums*intSize),
+ make([]int16, header.StrOffets),
+ make([]byte, header.SizeTbl),
+ }
+ err := readM(fp, &tiData.names, &tiData.bools, &tiData.align, &tiData.nums, &tiData.strOffs, &tiData.strTbl)
+ if err != nil {
+ return nil, fmt.Errorf("terminfo: reading data: %w", err)
+ }
+
+ // Terminal names separated by "|", with the last entry being the
+ // description. Ends with NUL.
+ snames := strings.Split(string(tiData.names[:len(tiData.names)-1]), "|")
+ ti := &Terminfo{
+ Name: snames[0],
+ Desc: snames[len(snames)-1],
+ Bools: make(map[*caps.Cap]struct{}, 8),
+ Numbers: make(map[*caps.Cap]int32, 8),
+ Strings: make(map[*caps.Cap]string, 32),
+ Keys: make(map[string]keys.Key, len(keys.Keys)),
+ IntSize: int(intSize),
+// Location: fp.Name(),
+ }
+ if len(snames) > 2 {
+ ti.Aliases = snames[1 : len(snames)-1]
+ }
+
+ // Booleans are one byte per value.
+ for i, b := range tiData.bools {
+ if b {
+ ti.Bools[caps.TableBools[i]] = struct{}{}
+ }
+ }
+ // Numbers can be 16 or 32bits, depending on the header. -1 means it's not
+ // present in the file.
+ for i, n := range toNum(tiData.nums, int(intSize)) {
+ if n > -1 {
+ ti.Numbers[caps.TableNums[i]] = n
+ }
+ }
+ // strOffs are offsets to an entry in strTbl; the table entries are ended by
+ // NULL bytes. -1 means the entry is missing.
+ for i, s := range tiData.strOffs {
+ if s > -1 {
+ ti.Strings[caps.TableStrs[i]] = string(tiData.strTbl[s : int(s)+bytes.IndexByte(tiData.strTbl[s:], 0)])
+ }
+ }
+
+ // The "extended storage format" has another header after the string table,
+ // which may or may not be present.
+
+ if tell, _ := fp.Seek(0, io.SeekCurrent); tell%2 != 0 {
+ fp.Read(make([]byte, 1))
+ }
+
+ var extHeader struct{ CountBools, CountNums, CountStrs, UsedStrs, SizeTbl int16 }
+ if err := binary.Read(fp, binary.LittleEndian, &extHeader); err != nil {
+ if errors.Is(err, io.EOF) { // No header: no problem.
+ return ti, nil
+ }
+ return nil, fmt.Errorf("terminfo: reading extended header: %w", err)
+ }
+ extData := struct {
+ bools []bool
+ align []byte
+ nums []byte
+ strOffs []int16
+ strTbl []byte
+ }{
+ make([]bool, extHeader.CountBools),
+ make([]byte, align(extHeader.CountBools)),
+ make([]byte, extHeader.CountNums*intSize),
+ make([]int16, extHeader.UsedStrs),
+ make([]byte, extHeader.SizeTbl),
+ }
+ if err := readM(fp, &extData.bools, &extData.align, &extData.nums, &extData.strOffs, &extData.strTbl); err != nil {
+ return nil, fmt.Errorf("terminfo: reading extended data: %w", err)
+ }
+
+ // The strings table includes both string values and the names of the
+ // extended capablities; CountStrs is the number of string values, UsedStrs
+ // is total number of strings.
+ startNames := -1
+ extStrs := make([]string, 0, extHeader.CountStrs)
+ for i := int16(0); i < extHeader.CountStrs; i++ {
+ s := extData.strOffs[i]
+ if s > -1 {
+ e := int(s) + bytes.IndexByte(extData.strTbl[s:], 0)
+ startNames = e
+ extStrs = append(extStrs, string(extData.strTbl[s:e]))
+ }
+ }
+
+ startNames++
+ ti.Extended = make([]*caps.Cap, extHeader.UsedStrs-extHeader.CountStrs)
+ for i := int16(0); i < extHeader.UsedStrs-extHeader.CountStrs; i++ {
+ s := extData.strOffs[i+extHeader.CountStrs] + int16(startNames)
+ e := int(s) + bytes.IndexByte(extData.strTbl[s:], 0)
+ name := string(extData.strTbl[s:e])
+
+ var c *caps.Cap
+ // TODO: it list AX and G0 in the file, but infocmp lists it as OTbs and
+ // OTpt? Hmm. Not sure where it gets that from.
+ for _, v := range caps.TableStrs {
+ if v.Short == name {
+ c = v
+ break
+ }
+ }
+ if c == nil {
+ for _, v := range caps.TableNums {
+ c = v
+ }
+ }
+ if c == nil {
+ for _, v := range caps.TableBools {
+ c = v
+ }
+ }
+ if c == nil {
+ c = &caps.Cap{Short: name, Long: name, Desc: "extended user-defined"}
+ }
+ ti.Extended[i] = c
+ }
+
+ // Don't need to check the value here, as it's never false or -1.
+ for i := range extData.bools {
+ ti.Bools[ti.Extended[i]] = struct{}{}
+ }
+ for i, n := range toNum(extData.nums, int(intSize)) {
+ ti.Numbers[ti.Extended[i+len(extData.bools)]] = n
+ }
+ for i, s := range extStrs {
+ ti.Strings[ti.Extended[i+len(extData.bools)+len(extData.nums)/int(intSize)]] = s
+ }
+ return ti, nil
+}
+
+// From term(5): "Between the boolean section and the number section, a null
+// byte will be inserted, if necessary, to ensure that the number section begins
+// on an even byte (this is a relic of the PDP-11's word-addressed architecture,
+// originally designed in to avoid IOT traps induced by addressing a word on an
+// odd byte boundary). All short integers are aligned on a short word boundary."
+func align(n int16) int {
+ if n%2 != 0 {
+ return 1
+ }
+ return 0
+}
+
+func readM(fp io.Reader, data ...any) error {
+ for _, d := range data {
+ if err := binary.Read(fp, binary.LittleEndian, d); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func toNum(read []byte, intSize int) []int32 {
+ nums := make([]int32, 0, len(read)/intSize)
+ for i := 0; i < len(read); i += intSize {
+ n := int32(read[i]) | int32(read[i+1])<<8
+ if intSize == 4 {
+ n |= int32(read[i+2])<<16 | int32(read[i+3])<<24
+ } else if n == 65535 { // -1 in int16; we need to add them as it's all offset based.
+ n = -1
+ }
+ nums = append(nums, n)
+ }
+ return nums
+}
+
+// This adds "PC-style function keys" modifiers, as Xterm does it. When a
+// modifier is used the character after the CSI is replaced with a modifier code
+// or inserted before the final ~. For example (CSI prefix omitted):
+//
+// F1 F5 Up
+// Regular OP 15~ OA
+// Ctrl 1;5P 15;5~ 1;5A
+// Shift 1;2P 15;2~ 1;2A
+// Alt 1;3P 15;3~ 1;3A
+//
+// Modifier codes:
+//
+// 2 Shift
+// 3 Alt
+// 4 Shift + Alt
+// 5 Ctrl
+// 6 Shift + Ctrl
+// 7 Alt + Ctrl
+// 8 Shift + Alt + Ctrl
+//
+// We don't do anything with meta.
+//
+// You tell me why it works like this... My guess that in 19verylongago it was
+// easier to do some bit banging like this on a very simple terminal (by the
+// standard of the last 30 years anyway), and now we're still stuck with this.
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.pdf
+//
+// Anyway, pre-compute a list here so it's easier to check later. It adds about
+// 0.08ms startup time, which isn't too bad, and on the upside you'll save up to
+// a whopping 0.04ms latency on evey key stroke.
+func addModifierKeys(ti *Terminfo, seq string, k keys.Key) {
+ switch {
+ case strings.HasPrefix(seq, "\x1b[") && seq[len(seq)-1] == '~':
+ noTilde := seq[:len(seq)-1]
+ ti.Keys[noTilde+";2~"] = k | keys.Shift
+ ti.Keys[noTilde+";3~"] = k | keys.Alt
+ ti.Keys[noTilde+";4~"] = k | keys.Shift | keys.Alt
+ ti.Keys[noTilde+";5~"] = k | keys.Ctrl
+ ti.Keys[noTilde+";6~"] = k | keys.Shift | keys.Ctrl
+ ti.Keys[noTilde+";7~"] = k | keys.Ctrl | keys.Alt
+ ti.Keys[noTilde+";8~"] = k | keys.Shift | keys.Ctrl | keys.Alt
+ case strings.HasPrefix(seq, "\x1bO") && len(seq) == 3:
+ noCSI := seq[2:]
+ ti.Keys["\x1b[1;2"+noCSI] = k | keys.Shift
+ ti.Keys["\x1b[1;3"+noCSI] = k | keys.Alt
+ ti.Keys["\x1b[1;4"+noCSI] = k | keys.Shift | keys.Alt
+ ti.Keys["\x1b[1;5"+noCSI] = k | keys.Ctrl
+ ti.Keys["\x1b[1;6"+noCSI] = k | keys.Shift | keys.Ctrl
+ ti.Keys["\x1b[1;7"+noCSI] = k | keys.Ctrl | keys.Alt
+ ti.Keys["\x1b[1;8"+noCSI] = k | keys.Shift | keys.Ctrl | keys.Alt
+ }
+}