3
\$\begingroup\$

I searched around and found these 7 ways to determine the width and height of the terminal:

1. [RELIABLE]    - ioctl() and struct winsize.
2. [RELIABLE]    - ioctl() and struct ttysize.
3. [UNRELIABLE]  - LINES and COLUMNS environment variables.
4. [UNRELIABLE]  - getmaxyx() from curses.h.
5. [UNRELIABLE]  - tgetnum() from terminfo database.
6. [RELIABLE]    - tigetnum() from terminfo database.
7. [RELIABLE]    - ANSI/VT100 escape sequences.

struct ttysize is obsolete, and any platform that has struct winsize will also have struct ttysize (or so the StackOverflow answers say). But one prerequisite for (1) and (2) is that at least one of stdin, stdout, and stderr should be connected to the terminal.

(3) is unreliable because the environment variables are not guaranteed to exist, and would/might not be up to date if the user changes the terminal size.

(4) and (5) are unreliable because of the same reason, and they also have an unnecessary dependency on libncurses.

(6) is reliable, and also works even if none from stdin, stdout, and stderr is connected to the terminal, but like (4) and (5) makes the application dependent on libncurses.

As of (7), like @Toby says in the comments, it is only reliable if you know the terminal is one that uses ANSI escapes.

I had asked a question on StackOverflow on how to programmatically determine if the terminal supports ANSI escape sequences, and got the following comments:

Do you want to check if the output is connected to a terminal or not? If it's connected to a terminal, the chances that it will support VT100 (or "ANSI") sequences are infinitesimally close to 100%. – Some programmer dude

Many decades ago there were a wide variety of terminals in use, and only some of them supported ANSI escape sequences. These days physical terminals are rare, almost everything is a virtual terminal, and they all use ANSI. There may be some differences in precisely which features they support, but all the basic operations (cursor positioning, highlighting, most coloring) should work. – Barmar

If you want to exclude the curses library you are SOL. The terminfo database is the way to know what your terminal can do. If you wish to query a specific ability, then you can do that. Otherwise you simply cannot know. Though I suppose you could keep a list of terminals that you know work, and hope that $TERM is correct enough. – Dúthomhas

There is no way of checking that other than looking up the value of TERM in a database of known terminals, and then only if you are willing to trust that TERM is set correctly. – n. m. could be an AI

After which, I did not find it worthwhile to try to detect whether the terminal support ANSI escape sequences or not without using the terminfo database - for which I would have to link with libncurses. So one prerequisite of using the text editor is that the terminal must support ANSI/VT100 escape sequences.

Code:

From the above, I have picked (1) and (2) because the application is a text editor, and all of stdin, stdout, and stderr would be guaranteed (by the editor) to be connected to the terminal. The following code also provides facilities to enable/disable terminal raw mode. (It does almost the same as what emacs does, or at least as far as I can recall.)

term.h:

#ifndef TERM_H
#define TERM_H 1

#include <termios.h>

typedef struct win_info {
    unsigned int rows;
    unsigned int cols;
} WinInfo;

typedef enum term_codes {
    TERM_SUCCESS,
    TERM_IOCTL_UNSUPPORTED,
    TERM_TCGETATTR_FAILED,
    TERM_TCSETATTR_FAILED,
} TermCodes;

/**
 * Determines the height and width of the terminal window.
 *
 * On success, TERM_SUCCESS is returned and wi holds the rows and cols. 
 * Else, it remains unmodified and an error code is returned. 
 * 
 * The function uses ioctl() to obtain the terminal data. If this function 
 * fails, one can resort to VT100/ANSI escape sequences to determine the size
 * of the terminal. */
TermCodes term_get_winsize(WinInfo wi[static 1]);

/**
 * Enables terminal's raw mode.
 *
 * On success, saves the original terminal attributes in old, and returns 
 * TERM_SUCCESS. Else returns an TERM_TCSETATTR_FAILED or TERM_TCGETATTR_FAILED,
 * and leaves old unmodified. */
TermCodes term_enable_raw_mode(struct termios old[static 1]);

/**
 * Disables terminal's raw mode and restores the attributes in old.
 *
 * Returns TERM_SUCCESS on success, or TERM_TCSETATTR_FAILED elsewise. */
TermCodes term_disable_raw_mode(struct termios old[static 1]);

#endif /* TERM_H */

term.c:

#undef _POSIX_C_SOURCE
#undef _XOPEN_SOURCE

#define _POSIX_C_SOURCE     200819L 
#define _XOPEN_SOURCE       700

#include "term.h"

#include <sys/ioctl.h>
#include <termios.h>
#include <unistd.h>

TermCodes term_get_winsize(WinInfo wi[static 1])
{
#if defined(TIOCGWINSZ)
    struct winsize ws;

    if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) {
        wi->rows = ws.ws_row;
        wi->cols = ws.ws_col;
        return TERM_SUCCESS;
    }
#elif defined(TIOCGSIZE) 
    struct ttysize ts;

    if (ioctl(STDOUT_FILENO, TIOCGSIZE, &ts) == 0) {
        wi->rows = ts.ts_row;
        wi->cols = ts.ts_col;
        return TERM_SUCCESS;
    }
#endif  /* defined(TIOCGWINSZ) */
    return TERM_IOCTL_UNSUPPORTED; 
}

TermCodes term_enable_raw_mode(struct termios old[static 1])
{
    /* Save the old terminal attributes. */
    if (tcgetattr(STDIN_FILENO, old) == -1) {
        return TERM_TCGETATTR_FAILED;
    }

    struct termios new = *old;

    /* input modes - no break, no CR to NL, no parity check, no strip char,
     * no start/stop output control. */
    new.c_iflag &= ~(0u | IGNBRK | BRKINT | PARMRK | INPCK | ISTRIP | ICRNL | 
                    INLCR | IGNCR | IXON);

    /* local modes - echoing off, canonical off, no extended functions, 
     * no signal chars (^Z, ^C) */
    new.c_lflag &= ~(0u | ISIG | IEXTEN | ECHO | ICANON);

    /* control modes - set 8 bit chars. */
    new.c_cflag |= (0u | CS8);

    /* output modes - disable post processing. */
    new.c_oflag &= ~(0u | OPOST);

    /* control chars - set return condition: min number of bytes and timer.
     * We want read(2) to return every single byte, without timeout. */
    new.c_cc[VMIN] = 1;         /* 1 byte */
    new.c_cc[VTIME] = 0;        /* No timer */

    /* Change attributes when output has drained; also flush pending input. */
    return tcsetattr(STDIN_FILENO, TCSAFLUSH, &new) == -1 
        ? TERM_TCSETATTR_FAILED 
        : TERM_SUCCESS;
}

TermCodes term_disable_raw_mode(struct termios old[static 1]) 
{
    return tcsetattr(STDIN_FILENO, TCSAFLUSH, old) == -1 
        ? TERM_TCSETATTR_FAILED 
        : TERM_SUCCESS;
}

And some tests for two of the these three routines:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <termios.h>
#include <unistd.h>

#include "term.h"

/* Current versions of gcc and clang support -std=c2x which sets 
 * __STDC_VERSION__ to this placeholder value. GCC 14.1 does not set
 * __STDC_VERSION__ to 202311L with the std=c23 flag, but Clang 18.1 does. */
#define C23_PLACEHOLDER 202000L
    
#if defined(__STDC_VERSION__) && __STDC_VERSION >= C23_PLACEHOLDER
    #define NORETURN    [[noreturn]]
#elif defined(_MSC_VER)
    #define NORETURN    __declspec(noreturn)
#elif defined(__GNUC__) || defined(__clang__) || defined(__INTEL_LLVM_COMPILER)
    #define NORETURN    __attribute__((noreturn))
#else
    #define NORETURN    _Noreturn
#endif

NORETURN static void cassert(const char cond[static 1], 
                             const char file[static 1],
                             size_t line)
{
    fflush(stdout);
    fprintf(stderr, "Assertion failed: '%s' at %s, line %zu.\n", cond, file, line);
    exit(EXIT_FAILURE);
}

#define test(cond) do { \
    if (!(cond)) { cassert(#cond, __FILE__, __LINE__); } } while (false)

static struct termios old;

bool test_term_enable_raw_mode(void)
{
    test(term_enable_raw_mode(&old) == TERM_SUCCESS);

    struct termios curr;

    if (tcgetattr(STDIN_FILENO, &curr) == -1) {
        perror("tcgetattr()");
        return false;
    }

    test((curr.c_iflag & IGNBRK) == 0);
    test((curr.c_iflag & BRKINT) == 0);
    test((curr.c_iflag & PARMRK) == 0);
    test((curr.c_iflag & INPCK) == 0);
    test((curr.c_iflag & ISTRIP) == 0);
    test((curr.c_iflag & ICRNL) == 0);
    test((curr.c_iflag & INLCR) == 0);
    test((curr.c_iflag & IGNCR) == 0);
    test((curr.c_iflag & IXON) == 0);

    test((curr.c_lflag & ISIG) == 0);
    test((curr.c_lflag & IEXTEN) == 0);
    test((curr.c_lflag & ECHO) == 0);
    test((curr.c_lflag & ICANON) == 0);

    test((curr.c_cflag & CS8) != 0);

    test((curr.c_oflag & OPOST) == 0);

    test(curr.c_cc[VMIN] == 1);
    test(curr.c_cc[VTIME] == 0);

    return true;
}

bool test_term_disable_raw_mode(void)
{
    test(term_disable_raw_mode(&old) == TERM_SUCCESS);

    struct termios curr;

    if (tcgetattr(STDIN_FILENO, &curr) == -1) {
        perror("tcgetattr()");
        return false;
    }

    test(memcmp(&old, &curr, sizeof curr) == 0);
    return true;
}

int main(void)
{
    return test_term_enable_raw_mode() && test_term_disable_raw_mode() 
           ? EXIT_SUCCESS
           : EXIT_FAILURE;
}

And term_get_winsize()'s usage:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include "term.h"

int main(void)
{
    WinInfo wi;

    if (term_get_winsize(&wi) != TERM_SUCCESS) {
        fprintf(stderr, "error: failed to get terminal size: %s.\n", strerror(errno));
        return EXIT_FAILURE;
    }

    printf("Height: %u, Width: %u.\n", wi.rows, wi.cols);
    return EXIT_SUCCESS;
}

If the above term_get_winsize() fails, the text editor falls back to using ANSI escape sequences.

Review Request:

Anything. Everything.

Am I overlooking anything? Do you see any problems? Any errors I have not handled?

Would the following sequence:

  • ioctl() and struct winsize/ttysize.
  • ANSI/VT100 escape sequences. (Here write() and read() et cetera can fail. Is it even worthwhile to continue on write/read errors?).
  • LINES and COLUMNS environment variables. (Toby suggests this in the comments.)

be good enough? Moreover, what should I do if all of these fail?

What do I test term_get_winsize() against? The same ioctl()?

Note 1: I am aware that ncurses is portable and would abstract away these things , but I am not looking to learn a new library at the moment.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ I'd say that case (7) is reliable only if you know the terminal is one that uses ANSI escapes. Other kinds of terminal do exist, you know! (3) may be a good fallback when other methods fail - many shells handle SIGWINCH to update these variables. \$\endgroup\$ Commented Jun 17 at 10:39

2 Answers 2

2
\$\begingroup\$

Use tcgetwinsize():

POSIX 2024 added tcgetwinsize() and tcsetwinsize() functions in termios.h. They get and set the terminal size respectively.

See: NetBSD man page. Sortix also implements it.

\$\endgroup\$
1
\$\begingroup\$

After Toby's following comment:

I'd say that case (7) is reliable only if you know the terminal is one that uses ANSI escapes. Other kinds of terminal do exist, you know! (3) may be a good fallback when other methods fail - many shells handle SIGWINCH to update these variables.

I did some experimenting with how different shells treat the LINES and COLUMNS variables on receiving SIGWINCH:

Shell Version Update?
sh/ash/dash 0.5.11 No
bash 5.1 Yes
csh 20110502-7 No
fish 3.3.1 Yes
git-bash 2.42.0.windows.2 Yes
ksh 20211217 Yes
ksh93u+m 1.0.0~beta.2-1 Yes
tcsh 6.21.00-1.1 Yes
zsh 5.8.1-1 Yes
Cygwin's shell (GNU bash) 5.2.21 Yes

Note: For csh and tcsh, LINES and COLUMNS had to be exported with the setenv command, and for the rest, with the export command.

So in case (1), (2), and (7) fail, (3) is a good fallback, except that LINES and COLUMNS may need to be manually exported by the user.


term_get_winsize() can be made more generic:

Currently, the function assumes that STDOUT_FILENO is connected to a terminal, which OP's application guarantees, but others might not, so it might be better to check whether any from STDOUT_FILENO, STDIN_FILENO, and STDERR_FILENO is connected to the terminal.

For instance, if STDERR_FILENO was connected to the terminal before calling term_get_winsize() but STDOUT_FILENO was not, the current call to ioctl() would fail.

This is not a problem for OP's application, but might be worth considering for any other application using term_get_winsize().


Updated code:

After taking the first point into consideration, this is what the final code looks like:

#undef _POSIX_C_SOURCE
#undef _XOPEN_SOURCE

#define _POSIX_C_SOURCE     200819L
#define _XOPEN_SOURCE       700

#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <sys/ioctl.h>
#include <termios.h>
#include <unistd.h>

#include "io.h"
#include "sequences.h"
#include "term.h"

static bool parse_long(const char s[static 1], int base, long val[static 1])
{
    char *endptr;
    errno = 0;
    const long i = strtol(s, &endptr, base);
    
    if (endptr == s || *endptr != '\0' || errno != 0) {
        return false;
    }

    *val = i;
    return true;
}

static TermCodes get_cursor_pos(WinInfo wi[static 1])
{
    /* The cursor is positioned at the bottom right of the window. We can learn 
     * how many rows and columns there must be on the screen by querying the
     * position of the cursor. */
    ssize_t len = (ssize_t) strlen("\x1b[6n");

    if (write_eintr(STDOUT_FILENO, "\x1b[6n", len) != len) {
        return TERM_FAILURE;
    }

    /* The reply is an escape sequence of the form '\x1b[24;80R', we will
     * read it into a buffer until read() returns EOF or until we get to
     * the 'R' character. */
    char buf[32];   /* Should be more than enough. */

    for (size_t i = 0; i < sizeof buf - 1; ++i) {
        if (read_eintr(STDIN_FILENO, &buf[i], 1) != 1 || buf[i] == 'R') {
            break;
        }
    }
    *buf = '\0';

    /* Skip the escape character and the left square brace. */
    return memcmp(buf, "\x1b[", 2) != 0
        || sscanf(&buf[2], "%u;%u", &wi->rows, &wi->cols) != 2
        ? TERM_FAILURE
        : TERM_SUCCESS;
}

TermCodes term_get_winsize(WinInfo wi[static 1])
{
#if defined(TIOCGWINSZ)
    struct winsize ws;

    if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) {
        wi->rows = ws.ws_row;
        wi->cols = ws.ws_col;
        return TERM_SUCCESS;
    }
#elif defined(TIOCGSIZE) 
    struct ttysize ts;

    if (ioctl(STDOUT_FILENO, TIOCGSIZE, &ts) == 0) {
        wi->rows = ts.ts_row;
        wi->cols = ts.ts_col;
        return TERM_SUCCESS;
    }
#endif /* defined(TIOCGWINSZ) */

    /* ioctl() failed. Fallback to VT100/ANSI escape sequences. */
    ssize_t len = (ssize_t) strlen("\x1b[999C\x1b[999B");

    if (write_eintr(STDOUT_FILENO, "\x1b[999C\x1b[999B", len) == len) {
        if (get_cursor_pos(wi) == TERM_SUCCESS) {
            return TERM_SUCCESS;
        }
    }
    
    /* write() or get_cursor_pos() failed as well. Now as a last resort, check
     * LINES and COLUMNS environment variables. 
     * Though note that these variables are not reliable, are not guaranteed 
     * to exist, and might not be up to date if the user changes the terminal 
     * size. If set, the sh, ash, dash, csh shells do not update LINES and
     * COLUMNS, but bash, fish, zsh, ksh, ksh93u+m, and tcsh handle SIGWINCH to
     * update these variables. */
    const char *const rows = getenv("LINES");
    const char *const cols = getenv("COLUMNS");

    if (rows != nullptr && cols != nullptr) {
        long r;
        long c;
        bool res = parse_long(rows, 10, &r) && parse_long(cols, 10, &c);
    
        if (!res || r > UINT_MAX || c > UINT_MAX) {
            return TERM_FAILURE;
        }

        wi->rows = (unsigned int) r;
        wi->cols = (unsigned int) c;
        return TERM_SUCCESS;
    }
    return TERM_FAILURE; 
}

Explanation:

write_eintr() and read_eintr() (declared in "io.h") are wrappers around write() and read() that call these functions in a loop until all the bytes have been written, and also continue if errno has the value EINTR. For EINTR, see: c - Checking if errno != EINTR: what does it mean?.

For converting COLUMNS and LINES, strtol() is used instead of sscanf(), as LINES and COLUMNS may be set to arbitrary values and sscanf() would invoke undefined behavior if the values are out of range. strtol() does not have this problem. We then further need to verify that wi->rows and wi->cols (which are of type unsigned int) can hold the values of LINES and COLUMNS (which are of type long).

OP's (my) code has used:

static constexpr char POS_CUR_BOTTOM_RIGHT[] = "\x1b[999C\x1b[999B";
static constexpr char GET_CUR_POS[]          = "\x1b[6n";

instead of duplicating the string literals everywhere, which hasn't been shown in the above sample implementation.

Compilers ought to optimize strlen("\x1b[6n") to sizeof("\x1b[6n") - 1, but if some does not, one can define a function-like macro or static inline function named STRLITLEN() that would hide away the sizeof(...) - 1.

And if all the 4 methods fail, then perhaps one's system has greater problems to worry about.

Aside: The jq tool uses the isatty() function to determine whether to color the output on systems other than Windows. It does no special check. Relevant code here: main.c.

\$\endgroup\$
0

Not the answer you're looking for? Browse other questions tagged or ask your own question.