写一个类似 less(1) 的 Pager

2020-05-26T23:40:29+08:00

Pager 是终端下查看文件的工具,以 less(1) 为代表,相当于一个只读的文本编辑器,而且是个全屏的终端应用。我一直好奇 less(1) 究竟是怎么工作的,终于自己动手实现了一个简单的 Pager,能翻页,不支持搜索(想想就复杂)。

怎么同时读取 STDIN 和 终端输入?

没用到重定向和管道时,程序的 STDIN 和 STDOUT 都指向终端设备,所以可以用 stdin/stdout 得到窗口大小、改变光标位置、获得输入等等。但是当有管道或者重定向介入时,STDIN/STDOUT 不再指向终端了,比如:

ls | less
less < Makefile

less 的 STDIN 绑定的不是终端了,就不能从 STDIN 重读取用户输入(按键事件)。

相关问题:How does less take data from stdin while still be able to read commands from user? - Unix & Linux Stack Exchange

解决办法打开 /dev/tty,然后从它读取用户按键事件。

怎么读取终端按键?

终端默认会缓冲用户输入,不会把每一次按键事件都发给应用,解决办法是进入 Raw 模式。注意程序退出时应该退出 Raw 模式,不然后面终端会被弄乱(用 reset(1) 可以重置终端)。

怎么保留原来终端的输出?

全屏程序会产生一大堆垃圾输出,如果不处理退出时会留在用户终端中,用 Alternate Screen 能解决这个问题,向终端发送 ESC [ ?1049h 进入 Alternate Screen、ESC [ ?1049l 退出。

一个简单的 Pager

使用:

$ cc pager.c -o pager
$ seq 100 | ./pager

按 q 退出。滚屏快捷键:n/p 按行、SPC/b 按屏、g/G 开关结尾。

代码:

/* pager.c --- write a pager */
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/ttycom.h>
#include <termios.h>
#include <unistd.h>
#include <errno.h>

struct termios orig_termios;
int rows;                       /* 屏幕总共多少行 */

char** buffer = NULL;
int linum = 0;                  /* 文件总共有多少行 */

int rowoff = 0;                 /* 屏幕第一行和文件第一行的偏移 */
int maxRowoff = 0;

static void
die(char *s)
{
    perror(s);
    exit(EXIT_FAILURE);
}

static void
disableRawMode()
{
    write(STDOUT_FILENO, "\x1b[?25h", 6);   /* show cursor */
    write(STDOUT_FILENO, "\x1b[?1049l", 8); /* leave alternative screen */

    if (tcsetattr(STDOUT_FILENO, TCSAFLUSH, &orig_termios) == -1)
        die("tcsetattr");
}

static void
enableRawMode()
{
    write(STDOUT_FILENO, "\x1b[?25l", 6);   /* hide cursor */
    write(STDOUT_FILENO, "\x1b[?1049h", 8); /* enter alternative screen */

    if (tcgetattr(STDOUT_FILENO, &orig_termios) == -1)
        die("tcgetattr");
    if (atexit(disableRawMode) == -1)
        die("atexit");

    struct termios t = orig_termios;
    t.c_lflag &= ~(ICANON | ECHO | ISIG | IEXTEN);
    t.c_iflag &= ~(ICRNL | INLCR | IXON);
    t.c_oflag &= ~OPOST;
    t.c_cc[VMIN] = 1;
    t.c_cc[VTIME] = 0;

    if (tcsetattr(STDOUT_FILENO, TCSAFLUSH, &t) == -1)
        die("tcsetattr");
}

static void
addline(char* line, size_t len)
{
    buffer = realloc(buffer, sizeof(*buffer) * (linum + 1));

    int at = linum;
    buffer[at] = malloc(len + 1);
    memcpy(buffer[at], line, len);
    buffer[at][len] = '\0';

    linum++;
}

static char
readkey()
{
    int nread;
    char c;
    while ((nread = read(STDIN_FILENO, &c, 1)) != 1) {
        if (nread == -1 && errno != EAGAIN)
            die("read");
    }
    return c;
}

static void
redisplay()
{
    write(STDOUT_FILENO, "\x1b[H", 3);    /* goto 0,0 */

    for (int i = 0; i < rows; i++) {
        int j = i + rowoff;
        if (j < linum)
            write(STDOUT_FILENO, buffer[j], strlen(buffer[j]));
        write(STDOUT_FILENO, "\x1b[K", 3); /* clear rest line */
        if (i < rows - 1)
            write(STDOUT_FILENO, "\r\n", 2);
    }
}

int
main(void)
{
    for(;;) {
        char* line = NULL;
        size_t len = 0;
        ssize_t nread = getline(&line, &len, stdin);
        if (nread == -1) {
            if (ferror(stdin)) {
                fprintf(stderr, "getline failed\n");
                exit(EXIT_FAILURE);
            }
            break;              /* eof */
        }
        if (nread > 0 && line[nread - 1] == '\n')
            nread--;
        addline(line, nread);
    }

    int fd = open("/dev/tty", O_RDONLY);
    if (fd == -1)
        die("open tty");
    if (dup2(fd, STDIN_FILENO) == -1)
        die("dup2");

    struct winsize ws;
    if (ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == -1)
        die("ioctl TIOCSWINSZ");
    if (ws.ws_row == 0)
        die("can't get window size");
    rows = ws.ws_row;

    enableRawMode();


    if (linum > rows)
        maxRowoff = linum - rows;

    for(;;) {
        redisplay();

        switch (readkey()) {
        case 'q':
            exit(EXIT_SUCCESS);
        case 'n':
            if (rowoff+1 <= maxRowoff)
                rowoff++;
            break;
        case 'p':
            if (rowoff-1 >= 0)
                rowoff--;
            break;
        case ' ':               /* 翻页 */
            for (int i = 0; i < rows; i++) {
                if (rowoff+1 <= maxRowoff)
                    rowoff++;
                else
                    break;
            }
            break;
        case 'b':               /* 往回翻页 */
            for (int i = 0; i < rows; i++) {
                if (rowoff-1 >= 0)
                    rowoff--;
                else
                    break;
            }
            break;
        case 'g':               /* 开头 */
            rowoff = 0;
            break;
        case 'G':               /* 结尾 */
            rowoff = maxRowoff;
            break;
        }
    }
}

上面不少 ANSI escape code 都是从 Build Your Own Text Editor 了解到的,这是一个很棒的教程,能自己动手从 0 实现一个文本编辑器想想就很酷。