使用 ncurses 对文本窗口进行编程

作者:Jim Hall

如何使用 ncurses 来操作您的终端屏幕。

在我关于使用 ncurses 库为文本控制台编程的文章系列中,我向您展示了如何在屏幕上绘制文本并使用基本的文本属性。我的 Sierpinski 三角形的例子(参见“ncurses 入门”)和一个简单的 Quest 冒险游戏(参见“使用 ncurses 在终端中创建冒险游戏”)一次使用了整个屏幕。

但是,如果将屏幕分成几部分更有意义呢? 例如,冒险游戏可能会划分屏幕,一部分用于游戏地图,另一部分用于玩家的状态。许多程序将屏幕组织成多个部分——例如,Emacs 编辑器使用编辑窗格、状态栏和命令栏。您可能需要以类似的方式划分程序的显示区域。有一种简单的方法可以做到这一点,那就是使用 ncurses 中的窗口函数。这是任何 curses 兼容库的标准部分。

简易赛尼特

您可能会将“窗口”与图形环境联系起来,但这里的情况并非如此。在 ncurses 中,“窗口”是将屏幕划分为逻辑区域的一种手段。一旦您定义了一个窗口,您就不需要跟踪它在屏幕上的位置;您只需使用一组 ncurses 函数绘制到您的窗口即可。

为了演示,让我用一种意想不到的方式定义一个游戏板。 古埃及游戏赛尼特使用一个由 30 个方格组成的棋盘,排列成三行十列。两位玩家以倒“S”形移动他们的棋子绕棋盘移动,因此棋盘看起来像这样

1 2 3 4 5 6 7 8 9 10
20 19 18 17 16 15 14 13 12 11
21 22 23 24 25 26 27 28 29 30

如果没有窗口函数,您将不得不跟踪每个棋子的行和列,并分别绘制它们。由于棋盘以倒“S”形图案排列,因此每次更新棋盘上的每个方格时,您总是需要进行奇怪的数学运算来正确地定位行和列。但是使用窗口函数,ncurses 允许您一次定义方格,包括它们的位置,然后在以后通过逻辑标识符引用这些窗口。

ncurses 函数 newwin() 允许您在屏幕上的特定位置定义具有一定尺寸的文本窗口


WINDOW *newwin(int nlines, int ncols, int begin_y,
 ↪int begin_x);

newwin() 函数返回类型为 WINDOW* 的指针,您可以将其存储在数组中以供以后引用。要创建一个赛尼特棋盘,您可以使用全局数组 BOARD[30],并编写一个函数来使用窗口定义赛尼特棋盘的 30 个方格


#define SQ_HEIGHT 5
#define SQ_WIDTH 8

WINDOW *BOARD[30];

void create_board(void)
{
    int i;
    int starty, startx;

    starty = 0;
    for (i = 0; i < 10; i++) {
        startx = i * SQ_WIDTH;
        BOARD[i] = newwin(SQ_HEIGHT, SQ_WIDTH, starty,
         ↪startx);
    }

    starty = SQ_HEIGHT;
    for (i = 10; i < 20; i++) {
        startx = (19 - i) * SQ_WIDTH;
        BOARD[i] = newwin(SQ_HEIGHT, SQ_WIDTH, starty,
         ↪startx);
    }

    starty = 2 * SQ_HEIGHT;
    for (i = 20; i < 30; i++) {
        startx = (i - 20) * SQ_WIDTH;
        BOARD[i] = newwin(SQ_HEIGHT, SQ_WIDTH, starty,
         ↪startx);
    }

    /* put border on each window and refresh */

    for (i = 0; i < 30; i++) {
        box(BOARD[sq], '2', '2');
        wrefresh(BOARD[sq]);
    }
}

此函数的第一个部分使用 newwin() 来定义 30 个方格。我将其分为三个部分,以使其明显可见,第二行实际上是倒着计数的。

ncurses 中的文本窗口不会创建“框架”来在屏幕上显示窗口。如果您想绘制框架,可以使用两个函数之一来完成。在这里,在定义窗口之后,该函数然后调用 ncurses 函数 box() 在屏幕上绘制正方形。通常,box() 函数接受用于垂直和水平边框的字符的参数;如果您将零作为任一或两个参数传递,ncurses 将使用默认的线条绘制字符。

请注意,您需要使用窗口特定的 wrefresh() 函数来刷新窗口,而不是通常的 refresh() 函数来更新屏幕。

赛尼特游戏中,几个方格具有一定的含义。根据常见的赛尼特规则,玩家必须停在第 26 格(窗口数组元素 BOARD[25])才能移出棋盘。第 27 格(数组元素 BOARD[26])是一个陷阱,会将玩家送回第 15 格(BOARD[14])。第 28 格和第 29 格(分别为 BOARD[27] 和 BOARD[28])要求玩家掷出正好“3”或“2”才能将其棋子移出棋盘。

为了表示这些特殊方格,我将 draw_board() 末尾的 box()wrefresh() 函数替换为对我自己的函数 draw_square() 的调用。此函数为特殊方格绘制不同的边框


/* put border on each window and refresh */

    for (i = 0; i < 30; i++) {
        draw_square(i);
    }
}

void draw_square(int sq)
{
    switch (sq) {
    case 14:                    /* revive square */
        wborder(BOARD[sq], '#', '#', '#', '#', '#', '#',
         ↪'#', '#');
        break;

    case 25:                    /* stop square */
        box(BOARD[sq], 'X', 'x');
        break;

    case 26:                    /* water square */
        box(BOARD[sq], 'O', 'o');
        break;

    case 27:                    /* 3-move square */
        box(BOARD[sq], '3', '3');
        break;

    case 28:                    /* 2-move square */
        box(BOARD[sq], '2', '2');
        break;


    default:
        box(BOARD[sq], 0, 0);
    }

    wrefresh(BOARD[sq]);
}

draw_square() 函数显示了在文本窗口上绘制框架的两种方法。我之前介绍了 box() 函数。另一种方法是使用 wborder() 函数,该函数为窗口的左、右、上和下边缘以及左上角、右上角、左下角和右下角采用单独的参数。与 box() 一样,如果您将零作为任何或所有参数传递,ncurses 将使用默认的线条绘制字符。

查看示例输出,了解使用不同参数调用 box()wborder() 之间的区别。我在我的赛尼特程序中有意使用了不同的方法来展示一些组合。例如,“停止”方格的顶部和底部边框使用小写字母“x”,左侧和右侧边框使用大写字母“X”。“复活”方格使用 wborder() 填充角,而其他方格则使用 box() 更简单地绘制。请注意,使用字符参数调用 box() 仍然会绘制角部的线条图形字符。

""

图 1. 突出显示第一个方格的赛尼特棋盘

""

图 2. 突出显示第 29 个方格的赛尼特棋盘

由于这使用了窗口函数,draw_square() 函数不需要知道每个方格的位置。所有这些都由 ncurses 作为构成棋盘上 30 个方格的 30 个窗口的属性来跟踪。一旦程序定义了窗口(包括它们的位置),绘制到每个方格就变成了简单地调用关联的“w”函数,引用要绘制到的窗口。

例如,要在窗口中绘制字符,您可以使用 waddch() 函数,或者如果您想在窗口中的特定位置绘制,则可以使用 mvwaddch() 函数。大多数 curses 函数都有一个在特定窗口上操作的“w”伙伴函数,例如这些


int move(int y, int x);
int wmove(WINDOW *win, int y, int x);

int addch(const chtype ch);
int waddch(WINDOW *win, const chtype ch);

int mvaddch(int y, int x, const chtype ch);
int mvwaddch(WINDOW *win, int y, int x, const chtype ch);

完整程序

现在您已经了解了如何使用窗口创建 30 个独立的绘图区域,让我们来看一个简单的程序,该程序绘制一个赛尼特棋盘并允许用户使用加号和减号键浏览方格。在较高的层次上,该程序遵循以下步骤

  1. 初始化 curses 环境。
  2. 定义并在赛尼特棋盘上绘制 30 个方格。
  3. 循环:1) 从键盘获取一个键;2) 相应地将玩家的位置调整到上一个或下一个方格;以及 3) 重复。
  4. 完成后,关闭 curses 环境并退出。

这是程序


/* senet.c */

#include <stdlib.h>
#include <ncurses.h>

#define SQ_HEIGHT 5
#define SQ_WIDTH 8

void create_board(void);
void destroy_board(void);

void draw_square(int sq);
void highlight_square(int sq);

WINDOW *BOARD[30];

int main(int argc, char **argv)
{
    int key;
    int sq;

    /* initialize curses */

    initscr();
    noecho();
    cbreak();

    if ((LINES < 24) || (COLS < 80)) {
        endwin();
        puts("Your terminal needs to be at least 80x24");
        exit(2);
    }

    /* print welcome text */

    clear();

    mvprintw(LINES - 1, (COLS - 5) / 2, "Senet");
    refresh();


    /* draw board */

    create_board();

    /* loop: '+' to increment squares, '-'
       to decrement squares */

    sq = 0;
    highlight_square(sq);

    do {
        key = getch();

        switch (key) {
        case '+':
        case '=':
            if (sq < 29) {
                draw_square(sq);
                highlight_square(++sq);
            }
            break;

        case '-':
        case '_':
            if (sq > 0) {
                draw_square(sq);
                highlight_square(--sq);
            }
        }
    } while ((key != 'q') && (key != 'Q'));

    /* when done, free up the board, and exit */

    destroy_board();

    endwin();
    exit(0);
}

void create_board(void)
{
    int i;
    int starty, startx;

    starty = 0;
    for (i = 0; i < 10; i++) {
        startx = i * SQ_WIDTH;
        BOARD[i] = newwin(SQ_HEIGHT, SQ_WIDTH, starty,
         ↪startx);
    }

    starty = SQ_HEIGHT;
    for (i = 10; i < 20; i++) {
        startx = (19 - i) * SQ_WIDTH;
        BOARD[i] = newwin(SQ_HEIGHT, SQ_WIDTH, starty,
         ↪startx);
    }

    starty = 2 * SQ_HEIGHT;
    for (i = 20; i < 30; i++) {
        startx = (i - 20) * SQ_WIDTH;
        BOARD[i] = newwin(SQ_HEIGHT, SQ_WIDTH, starty,
         ↪startx);
    }


    /* put border on each window and refresh */

    for (i = 0; i < 30; i++) {
        draw_square(i);
    }
}

void destroy_board(void)
{
    int i;

    /* erase every box and delete each window */

    for (i = 0; i < 30; i++) {
        wborder(BOARD[i], ' ', ' ', ' ', ' ', ' ', ' ', '
         ↪', ' ');
        wrefresh(BOARD[i]);

        delwin(BOARD[i]);
    }
}

void draw_square(int sq)
{
    switch (sq) {
    case 14:                    /* revive square */
        wborder(BOARD[sq], '#', '#', '#', '#', '#', '#',
         ↪'#', '#');
        break;

    case 25:                    /* stop square */
        box(BOARD[sq], 'X', 'x');
        break;

    case 26:                    /* water square */
        box(BOARD[sq], 'O', 'o');
        break;

    case 27:                    /* 3-move square */
        box(BOARD[sq], '3', '3');
        break;

    case 28:                    /* 2-move square */
        box(BOARD[sq], '2', '2');
        break;

    default:
        box(BOARD[sq], 0, 0);
    }

    wrefresh(BOARD[sq]);
}

void highlight_square(int sq)
{
    wattron(BOARD[sq], A_BOLD);
    draw_square(sq);
    wattroff(BOARD[sq], A_BOLD);
}

这只是赛尼特游戏的基本框架。它所做的只是生成一个游戏板,并允许用户浏览所有方格。为了使重点放在 ncurses 中的窗口函数上,我省略了所有的游戏玩法和规则。

该程序仅使用几个函数

  • void create_board(void); — 将 30 个方格定义为文本窗口并在屏幕上绘制它们。
  • void destroy_board(void); — 擦除 30 个方格并删除窗口。
  • void draw_square(int sq); — 在棋盘上绘制单个方格。此函数根据赛尼特中使用的特殊方格绘制不同的轮廓。
  • void highlight_square(int sq); — 在用户浏览棋盘上的每个方格时,突出显示一个方格。为了简单起见,这里使用了 A_BOLD 属性而不是颜色。
自主学习

此程序是如何使用 nurses 窗口函数在屏幕上定义单独区域的简单示例。示例程序是一个游戏,但您可以将其用作您自己的程序的起点。任何需要更新屏幕多个区域的程序都可以使用窗口函数。

ncurses 库提供了一组丰富的功能来更新和访问文本模式下的屏幕。虽然图形用户界面非常酷,但并非每个程序都需要使用点击式界面运行。如果您的程序在纯文本终端中运行,请考虑使用 ncurses 来操作终端屏幕。

资源

Jim Hall 是一位开源软件倡导者和开发人员,可能最出名的是 FreeDOS 的创始人。Jim 还在 GNOME 等开源软件项目的可用性测试中非常活跃。在工作中,Jim 是 Hallmentum 的 CEO,这是一家 IT 执行咨询公司,帮助 CIO 和 IT 领导者进行战略规划和组织发展。

加载 Disqus 评论