diff --git a/CHANGELOG.md b/CHANGELOG.md index 07205da0..e38700ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,9 @@ when auto-detecting URLs. * [SGR-Pixels (1016) mouse extended coordinates](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates) is now supported (https://codeberg.org/dnkl/foot/issues/762). - +* `XTGETTCAP` - builtin terminfo. See + [README.md::XTGETTCAP](README.md#xtgettcap) for details + (https://codeberg.org/dnkl/foot/issues/846). ### Changed diff --git a/README.md b/README.md index 1be27ab6..16600d59 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ The fast, lightweight and minimalistic Wayland terminal emulator. 1. [DPI and font size](#dpi-and-font-size) 1. [Supported OSCs](#supported-oscs) 1. [Programmatically checking if running in foot](#programmatically-checking-if-running-in-foot) +1. [XTGETTCAP](#xtgettcap) 1. [Credits](#Credits) 1. [Bugs](#bugs) 1. [Contact](#contact) @@ -432,6 +433,54 @@ e.g. “1.8.2” for a regular release, or “1.8.2-36-g7db8e06f” for a git build. +# XTGETTCAP + +`XTGETTCAP` is an escape sequence initially introduced by XTerm, and +also implemented (and extended, to some degree) by Kitty. + +It allows querying the terminal for terminfo +capabilities. Applications using this feature do not need to use the +classic, file-based, terminfo definition. For example, if all +applications used this feature, you would no longer have to install +foot’s terminfo on remote hosts you SSH into. + +XTerm’s implementation (as of XTerm-370) only supports querying key +(as in keyboard keys) capabilities, and three custom capabilities: + +* `TN` - terminal name +* `Co` - number of colors (alias for the `colors` capability) +* `RGB` - number of bits per color channel (different semantics from + the `RGB` capability in file-based terminfo definitions!). + +Kitty has extended this, and also supports querying all integer and +string capabilities. + +Foot supports this, and extends it even further, to also include +boolean capabilities. This means foot’s entire terminfo can be queried +via `XTGETTCAP`. + +Note that both Kitty and foot handles **responses** to +multi-capability queries slightly differently, compared to XTerm. + +XTerm will send a single DCS reply, with `;`-separated +capability/value pairs. There are a couple of issues with this: + +* The success/fail flag in the beginning of the response is always `1` + (success), unless the very **first** queried capability is invalid. +* XTerm will not respond **at all** to an invalid capability, unless + it’s the first one in the `XTGETTCAP` query. +* XTerm will end the response at the first invalid capability. + +In other words, if you send a large multi-capability query, you will +only get responses up to, but not including, the first invalid +capability. All subsequent capabilities will be dropped. + +Kitty and foot on the other hand, send one DCS response for **each** +capability in the multi query. This allows us to send a proper +success/fail flag for each queried capability. Responses for **all** +queried capabilities are **always** sent. No queries are ever dropped. + + # Credits * [Ordoviz](https://codeberg.org/Ordoviz), for designing and diff --git a/dcs.c b/dcs.c index 79b04df9..1a388863 100644 --- a/dcs.c +++ b/dcs.c @@ -1,10 +1,14 @@ #include "dcs.h" +#include #define LOG_MODULE "dcs" #define LOG_ENABLE_DBG 0 #include "log.h" +#include "foot-terminfo.h" #include "sixel.h" +#include "util.h" #include "vt.h" +#include "xmalloc.h" static void bsu(struct terminal *term) @@ -30,6 +34,185 @@ esu(struct terminal *term) term_disable_app_sync_updates(term); } +/* Decode hex-encoded string *inline*. NULL terminates */ +static char * +hex_decode(const char *s, size_t len) +{ + if (len % 2) + return NULL; + + char *hex = xmalloc(len / 2 + 1); + char *o = hex; + + /* TODO: error checking */ + for (size_t i = 0; i < len; i += 2) { + uint8_t nib1 = hex2nibble(*s); s++; + uint8_t nib2 = hex2nibble(*s); s++; + + if (nib1 == HEX_DIGIT_INVALID || nib2 == HEX_DIGIT_INVALID) + goto err; + + *o = nib1 << 4 | nib2; o++; + } + + *o = '\0'; + return hex; + +err: + free(hex); + return NULL; +} + +UNITTEST +{ + /* Verify table is sorted */ + const char *p = terminfo_capabilities; + size_t left = sizeof(terminfo_capabilities); + + const char *last_cap = NULL; + + while (left > 0) { + const char *cap = p; + const char *val = cap + strlen(cap) + 1; + + size_t size = strlen(cap) + 1 + strlen(val) + 1;; + xassert(size <= left); + p += size; + left -= size; + + if (last_cap != NULL) + xassert(strcmp(last_cap, cap) < 0); + + last_cap = cap; + } +} + +static bool +lookup_capability(const char *name, const char **value) +{ + const char *p = terminfo_capabilities; + size_t left = sizeof(terminfo_capabilities); + + while (left > 0) { + const char *cap = p; + const char *val = cap + strlen(cap) + 1; + + size_t size = strlen(cap) + 1 + strlen(val) + 1;; + xassert(size <= left); + p += size; + left -= size; + + int r = strcmp(cap, name); + if (r == 0) { + *value = val; + return true; + } else if (r > 0) + break; + } + + *value = NULL; + return false; +} + +static void +xtgettcap_reply(struct terminal *term, const char *hex_cap_name, size_t len) +{ + char *name = hex_decode(hex_cap_name, len); + if (name == NULL) + goto err; + +#if 0 + const struct foot_terminfo_entry *entry = + bsearch(name, terminfo_capabilities, ALEN(terminfo_capabilities), + sizeof(*entry), &terminfo_entry_compar); +#endif + const char *value; + bool valid_capability = lookup_capability(name, &value); + xassert(!valid_capability || value != NULL); + + LOG_DBG("XTGETTCAP: cap=%s (%.*s), value=%s", + name, (int)len, hex_cap_name, + valid_capability ? value : ""); + + if (!valid_capability) + goto err; + + if (value[0] == '\0') { + /* Boolean */ + term_to_slave(term, "\033P1+r", 5); + term_to_slave(term, hex_cap_name, len); + term_to_slave(term, "\033\\", 2); + goto out; + } + + /* + * Reply format: + * \EP 1 + r cap=value \E\\ + * Where ‘cap’ and ‘value are hex encoded ascii strings + */ + char *reply = xmalloc( + 5 + /* DCS 1 + r (\EP1+r) */ + len + /* capability name, hex encoded */ + 1 + /* ‘=’ */ + strlen(value) * 2 + /* capability value, hex encoded */ + 2 + /* ST (\E\\) */ + 1); + + int idx = sprintf(reply, "\033P1+r%.*s=", (int)len, hex_cap_name); + + for (const char *c = value; *c != '\0'; c++) { + uint8_t nib1 = (uint8_t)*c >> 4; + uint8_t nib2 = (uint8_t)*c & 0xf; + + reply[idx] = nib1 >= 0xa ? 'A' + nib1 - 0xa : '0' + nib1; idx++; + reply[idx] = nib2 >= 0xa ? 'A' + nib2 - 0xa : '0' + nib2; idx++; + } + + reply[idx] = '\033'; idx++; + reply[idx] = '\\'; idx++; + term_to_slave(term, reply, idx); + + free(reply); + goto out; + +err: + term_to_slave(term, "\033P0+r", 5); + term_to_slave(term, hex_cap_name, len); + term_to_slave(term, "\033\\", 2); + +out: + free(name); +} + +static void +xtgettcap_unhook(struct terminal *term) +{ + size_t left = term->vt.dcs.idx; + + const char *const end = (const char *)&term->vt.dcs.data[left]; + const char *p = (const char *)term->vt.dcs.data; + + while (true) { + const char *sep = memchr(p, ';', left); + size_t cap_len; + + if (sep == NULL) { + /* Last capability */ + cap_len = end - p; + } else { + cap_len = sep - p; + } + + xtgettcap_reply(term, p, cap_len); + + left -= cap_len + 1; + p += cap_len + 1; + + if (sep == NULL) + break; + } +} + void dcs_hook(struct terminal *term, uint8_t final) { @@ -67,6 +250,14 @@ dcs_hook(struct terminal *term, uint8_t final) break; } break; + + case '+': + switch (final) { + case 'q': /* XTGETTCAP */ + term->vt.dcs.unhook_handler = &xtgettcap_unhook; + break; + } + break; } } @@ -93,7 +284,8 @@ ensure_size(struct terminal *term, size_t required_size) void dcs_put(struct terminal *term, uint8_t c) { - LOG_DBG("PUT: %c", c); + /* LOG_DBG("PUT: %c", c); */ + if (term->vt.dcs.put_handler != NULL) term->vt.dcs.put_handler(term, c); else { diff --git a/doc/foot-ctlseqs.7.scd b/doc/foot-ctlseqs.7.scd index 1dee1550..b290fe4f 100644 --- a/doc/foot-ctlseqs.7.scd +++ b/doc/foot-ctlseqs.7.scd @@ -679,6 +679,9 @@ and are terminated by *\\E\\* (ST). : Begin application synchronized updates | \\EP = 2 s \\E\\ : End application synchronized updates +| \\EP + q \\E\\ +: Query builtin terminfo database (XTGETTCAP) + # FOOTNOTE diff --git a/doc/foot.1.scd b/doc/foot.1.scd index 9f7211b4..90851965 100644 --- a/doc/foot.1.scd +++ b/doc/foot.1.scd @@ -373,6 +373,53 @@ distro package for foot's terminfo entries, you can install foot's terminfo entries manually, by copying *foot* and *foot-direct* to *~/.terminfo/f/*. +# XTGETTCAP + +*XTGETTCAP* is an escape sequence initially introduced by XTerm, and +also implemented (and extended, to some degree) by Kitty. + +It allows querying the terminal for terminfo classic, file-based, +terminfo definition. For example, if all applications used this +feature, you would no longer have to install foot’s terminfo on remote +hosts you SSH into. + +XTerm’s implementation (as of XTerm-370) only supports querying key +(as in keyboard keys) capabilities, and three custom capabilities: + +- TN - terminal name +- Co - number of colors (alias for the colors capability) +- RGB - number of bits per color channel (different semantics from + the RGB capability in file-based terminfo definitions!). + +Kitty has extended this, and also supports querying all integer and +string capabilities. + +Foot supports this, and extends it even further, to also include +boolean capabilities. This means foot’s entire terminfo can be queried +via *XTGETTCAP*. + +Note that both Kitty and foot handles responses to multi-capability +queries slightly differently, compared to XTerm. + +XTerm will send a single DCS reply, with ;-separated +capability/value pairs. There are a couple of issues with this: + +- The success/fail flag in the beginning of the response is always 1 + (success), unless the very first queried capability is invalid. +- XTerm will not respond at all to an invalid capability, unless it’s + the first one in the XTGETTCAP query. +- XTerm will end the response at the first invalid capability. + +In other words, if you send a large multi-capability query, you will +only get responses up to, but not including, the first invalid +capability. All subsequent capabilities will be dropped. + +Kitty and foot on the other hand, send one DCS response for each +capability in the multi query. This allows us to send a proper +success/fail flag for each queried capability. Responses for all +queried capabilities are always sent. No queries are ever dropped. + + # ENVIRONMENT The following environment variables are used by foot: diff --git a/meson.build b/meson.build index 29f27ec9..fb2c8937 100644 --- a/meson.build +++ b/meson.build @@ -136,6 +136,16 @@ version = custom_target( output: 'version.h', command: [env, 'LC_ALL=C', generate_version_sh, meson.project_version(), '@CURRENT_SOURCE_DIR@', '@OUTPUT@']) +python = find_program('python3', native: true) +generate_builtin_terminfo_py = files('scripts/generate-builtin-terminfo.py') +foot_terminfo = files('foot.info') +builtin_terminfo = custom_target( + 'generate_builtin_terminfo', + output: 'foot-terminfo.h', + command: [python, generate_builtin_terminfo_py, + '@default_terminfo@', foot_terminfo, 'foot', '@OUTPUT@'] +) + common = static_library( 'common', 'log.c', 'log.h', @@ -213,7 +223,7 @@ executable( 'url-mode.c', 'url-mode.h', 'user-notification.c', 'user-notification.h', 'wayland.c', 'wayland.h', - wl_proto_src + wl_proto_headers, version, + builtin_terminfo, wl_proto_src + wl_proto_headers, version, dependencies: [math, threads, libepoll, pixman, wayland_client, wayland_cursor, xkb, fontconfig, utf8proc, tllist, fcft], link_with: pgolib, diff --git a/scripts/generate-builtin-terminfo.py b/scripts/generate-builtin-terminfo.py new file mode 100755 index 00000000..c8d3be4b --- /dev/null +++ b/scripts/generate-builtin-terminfo.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 + +import argparse +import re +import sys + +from typing import Dict, Union + + +class Capability: + def __init__(self, name: str, value: Union[bool, int, str]): + self._name = name + self._value = value + + @property + def name(self) -> str: + return self._name + + @property + def value(self) -> Union[bool, int, str]: + return self._value + + def __lt__(self, other): + return self._name < other._name + + def __le__(self, other): + return self._name <= other._name + + def __eq__(self, other): + return self._name == other._name + + def __ne__(self, other): + return self._name != other._name + + def __gt__(self, other): + return self._name > other._name + + def __ge__(self, other): + return self._name >= other._name + + +class BoolCapability(Capability): + def __init__(self, name: str): + super().__init__(name, True) + + +class IntCapability(Capability): + pass + + +class StringCapability(Capability): + def __init__(self, name: str, value: str): + # Expand \E to literal ESC in non-parameterized capabilities + if '%' not in value: + value = re.sub(r'\\E([0-7])', r'\\033" "\1', value) + value = re.sub(r'\\E', r'\\033', value) + else: + # Need to double-escape \E in C string literals + value = value.replace('\\E', '\\\\E') + + # Don’t escape ‘:’ + value = value.replace('\\:', ':') + + super().__init__(name, value) + + +class Fragment: + def __init__(self, name: str, description: str): + self._name = name + self._description = description + self._caps = {} + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + @property + def caps(self) -> Dict[str, Capability]: + return self._caps + + def add_capability(self, cap: Capability): + assert cap.name not in self._caps + self._caps[cap.name] = cap + + def del_capability(self, name: str): + del self._caps[name] + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('source_entry_name') + parser.add_argument('source', type=argparse.FileType('r')) + parser.add_argument('target_entry_name') + parser.add_argument('target', type=argparse.FileType('w')) + + opts = parser.parse_args() + source_entry_name = opts.source_entry_name + target_entry_name = opts.target_entry_name + source = opts.source + target = opts.target + + lines = [] + for l in source.readlines(): + l = l.strip() + if l.startswith('#'): + continue + lines.append(l) + + fragments = {} + cur_fragment = None + + for m in re.finditer( + r'(?P(?P[-+\w@]+)\|(?P.+?),)|' + r'(?P(?P\w+),)|' + r'(?P(?P\w+)#(?P(0x)?[0-9a-fA-F]+),)|' + r'(?P(?P\w+)=(?P(.+?)),)', + ''.join(lines)): + + if m.group('name') is not None: + name = m.group('entry_name') + description = m.group('entry_desc') + + assert name not in fragments + fragments[name] = Fragment(name, description) + cur_fragment = fragments[name] + + elif m.group('bool_cap') is not None: + name = m.group('bool_name') + cur_fragment.add_capability(BoolCapability(name)) + + elif m.group('int_cap') is not None: + name = m.group('int_name') + value = int(m.group('int_val'), 0) + cur_fragment.add_capability(IntCapability(name, value)) + + elif m.group('str_cap') is not None: + name = m.group('str_name') + value = m.group('str_val') + cur_fragment.add_capability(StringCapability(name, value)) + + else: + assert False + + # Expand ‘use’ capabilities + for frag in fragments.values(): + for cap in frag.caps.values(): + if cap.name == 'use': + use_frag = fragments[cap.value] + for use_cap in use_frag.caps.values(): + frag.add_capability(use_cap) + + + frag.del_capability(cap.name) + break + + entry = fragments[source_entry_name] + + try: + entry.del_capability('RGB') + except KeyError: + pass + + entry.add_capability(IntCapability('Co', 256)) + entry.add_capability(StringCapability('TN', target_entry_name)) + entry.add_capability(IntCapability('RGB', 8)) # 8 bits per channel + + terminfo_parts = [] + for cap in sorted(entry.caps.values()): + name = cap.name + value = str(cap.value) + + # Escape ‘“‘ + name = name.replace('"', '\"') + value = value.replace('"', '\"') + + terminfo_parts.append(name) + if isinstance(cap, BoolCapability): + terminfo_parts.append('') + else: + terminfo_parts.append(value) + + terminfo = '\\0" "'.join(terminfo_parts) + + target.write('#pragma once\n') + target.write('\n') + target.write(f'static const char terminfo_capabilities[] = "{terminfo}";') + target.write('\n') + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/uri.c b/uri.c index e747991d..39073bde 100644 --- a/uri.c +++ b/uri.c @@ -9,30 +9,9 @@ #define LOG_ENABLE_DBG 0 #include "log.h" #include "debug.h" +#include "util.h" #include "xmalloc.h" -enum { - HEX_DIGIT_INVALID = 16 -}; - -static uint8_t -hex2nibble(char c) -{ - switch (c) { - case '0': case '1': case '2': case '3': case '4': - case '5': case '6': case '7': case '8': case '9': - return c - '0'; - - case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': - return c - 'a' + 10; - - case 'A': case 'B': case 'C': case 'D': case 'E': case 'F': - return c - 'A' + 10; - } - - return HEX_DIGIT_INVALID; -} - bool uri_parse(const char *uri, size_t len, char **scheme, char **user, char **password, char **host, @@ -118,7 +97,7 @@ uri_parse(const char *uri, size_t len, LOG_DBG("user: \"%.*s\"", (int)user_len, start); } - + start = user_pw_end + 1; left = len - (start - uri); auth_left -= user_pw_len + 1; diff --git a/util.h b/util.h index aa9fc8ba..683dbd4a 100644 --- a/util.h +++ b/util.h @@ -35,3 +35,25 @@ sdbm_hash(const char *s) return hash; } + +enum { + HEX_DIGIT_INVALID = 16 +}; + +static inline uint8_t +hex2nibble(char c) +{ + switch (c) { + case '0': case '1': case '2': case '3': case '4': + case '5': case '6': case '7': case '8': case '9': + return c - '0'; + + case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': + return c - 'a' + 10; + + case 'A': case 'B': case 'C': case 'D': case 'E': case 'F': + return c - 'A' + 10; + } + + return HEX_DIGIT_INVALID; +}