diff options
| author | raven <citrons@mondecitronne.com> | 2026-02-20 13:42:12 -0600 |
|---|---|---|
| committer | raven <citrons@mondecitronne.com> | 2026-02-20 13:46:59 -0600 |
| commit | 5b6196ebe67cf954bae8212c1a33b869da723e11 (patch) | |
| tree | dce33c06621847c3862e64bda914b1e8a450317d /tui/termfo/load.go | |
| parent | 05c068749740f9430d1fda7698c433697eef1652 (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.go | 368 |
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 + } +} |
