Ncurses not write out the specified number of wide characters(关于列需要一个宽字符)

Ncurses not writing out the specified number of wide characters (about column needs of a wide character)

在下面的程序中,我尝试使用 ncurses 输出十行,每行十个 Unicode 字符。循环的每次迭代都从三个 Unicode 字符的数组中选择一个随机字符。然而,我遇到的问题是 ncurses 并不总是每行写十个字符......这有点难以解释,但如果你 运行 程序也许你会看到这里有空格并且那里。有些行将包含十个字符,有些只有九个,有些只有八个。在这一点上,我不知道我做错了什么。

我运行在 Ubuntu 20.04.1 机器上运行这个程序,我使用的是默认的 GUI 终端。

#define _XOPEN_SOURCE_EXTENDED 1
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <ncurses.h>

#include <locale.h>
#include <time.h>

#define ITERATIONS 3000
#define REFRESH_DELAY 720000L
#define MAXX 10
#define MAXY 10
#define RANDOM_KANA &katakana[(rand()%3)]
#define SAME_KANA &katakana[2]

void show();

cchar_t katakana[3];
cchar_t kana1;
cchar_t kana2;
cchar_t kana3;

int main() {
  setlocale(LC_ALL, "");
  srand(time(0));

  setcchar(&kana1, L"\u30d0", WA_NORMAL, 5, NULL);
  setcchar(&kana2, L"\u30a6", WA_NORMAL, 4, NULL);
  setcchar(&kana3, L"\u30b3", WA_NORMAL, 4, NULL);
  katakana[0] = kana1;
  katakana[1] = kana2;
  katakana[2] = kana3;
  
  initscr();
  for (int i=0; i < ITERATIONS; i++) {
    show();
    usleep(REFRESH_DELAY);
  }
}

void show() {
  for (int x=0; x < MAXX; x++) {
    for (int y = 0; y < MAXY; y++) {
      mvadd_wch(y, x, RANDOM_KANA);
    }
  }
  refresh();
  //getch();
}

TL;DR:基本问题是片假名(和许多其他 Unicode 字符)通常被称为“双角字符”,因为它们在 monospaced 终端字体中占据两列。

因此,如果您将 ba 放在显示的第 0 列,则需要将下一个字符放在第 2 列,而不是第 1 列。这不是您在做的;您试图将下一个字符放在第 1 列,部分与 ba 重叠,从 ncurses 库和用于显示的终端仿真器的角度来看,这是未定义的行为。

所以你应该换行

      mvadd_wch(y, x, RANDOM_KANA);

      mvadd_wch(y, 2*x, RANDOM_KANA);

考虑到片假名占据两列的事实。这将告诉 ncurses 将每个字符放在它应该在的列中,从而避免重叠问题。如果这样做,您的屏幕将显示为整齐的 10x10 矩阵。

注意这种“宽度”的用法(即显示字符的宽度)与“宽字符”(wchar_t)的C概念关系不大,就是数字存储字符所需的字节数。非英语拉丁字母字符和希腊语、西里尔字母、阿拉伯语、希伯来语和其他字母表中的字符显示在单个列中,但必须以 wchar_t 或多字节编码存储。

阅读下面较长的答案时,请记住这一区别。

另外,称这些字符为“双倍宽度”是欧洲中心主义的;就亚洲书写系统(和 Unicode 标准)而言,东亚字符(包括表情符号)被分类为“半角”或“全角”(或“正常宽度”),因为正常字符是(视觉上)宽的一个。


问题肯定和你描述的一样,具体要看终端。不幸的是,没有屏幕截图似乎无法说明问题,所以我包括了一个。这就是我碰巧玩过的两个终端仿真器的样子;控制台显示在第二个屏幕之后(因为,正如我们将看到的,第一个屏幕总是按预期显示)。左边是KDE的Konsole;在右边,gnome-terminal。大多数终端仿真器更类似于 gnome-terminal,但不是全部。

在这两种情况下,您都可以看到参差不齐的右边距,但有一点不同:左侧每行有十个字符,但其中一些似乎放错了位置。在某些行中,一个字符与前一个字符重叠,将行移动。右边没有显示重叠的字符,所以有些行少于十个字符。但是在这些行上显示的字符显示相同的半字符移位。

这里的问题是片假名都是“双角”字符;也就是说,它们占据了两个相邻的终端单元格。我在屏幕截图中留下了我的提示(我很少这样做)所以你可以看到片假名如何占据与两个拉丁字符相同的 space。

现在,您正在使用 mvadd_wch 在您提供的屏幕坐标处显示每个字符。但是您提供的大多数屏幕坐标是不可能的,因为它们会强制双倍宽度字符重叠。例如,您将第一个字符放在第 0 列的每一行;它占据第 0 列和第 1 列(因为它是双倍宽度的)。然后将下一个字符放在同一行的第 1 列,与第一个字符重叠。

这是未定义的行为。在大多数应用程序中,第一个屏幕上实际发生的情况可能没问题:因为 ncurses 不会尝试向后输出半个双角字符,所以每个字符最终都会在同一行的前一个字符之后立即输出,所以在第一个屏幕片假名完美排列,每个人占据两个位置。所以视觉效果很好,但存在一个潜在问题:ncurses 将片假名记录为第 0、1、2、3 列......,但字符实际上在第 0、2、4、6 列......

当您开始用下一个 10x10 块覆盖第一个屏幕时,这个问题就变得明显了。由于 ncurses 记录了每一行和每一列的字符,这使得它可以通过不显示未更改的字符来优化 mvadd_wch,这在您的随机块中偶尔会发生,并且在大多数 ncurses 应用程序中经常发生。但是当然,虽然它不必显示已经显示的字符,但它确实必须将下一个字符放在它应该占据的列中。所以需要输出光标移动代码。但是由于字符实际上并没有显示在 ncurses 认为它​​们所在的列中,因此它没有计算出正确的移动代码。

以第二行为例:ncurses已经确定不需要改变第0列的字符,因为它没有改变。但是,您要求它在第 1 列显示的字符已更改。因此 ncurses 输出一个“向右移动一个字符”控制台代码,以便在第 1 列写入第二个字符,重叠之前在第 0 列的字符和之前在第 2 列的字符。如屏幕截图所示,Konsole 尝试显示重叠,gnome-terminal 会擦除重叠的字符。 (重叠字符是未定义的行为,所以这两个都是合理的。)然后它们都在第 1 列显示第二个字符。

好的,这就是冗长且可能令人困惑的解释。

而直接的解决方案是在这个答案的开头。但这可能不是一个完整的解决方案,因为这可能是您最终程序的高度简化版本。您的实际程序很可能需要以不太简单的方式计算列号。您需要了解输出的每个字符的实际列宽,并使用该信息计算正确的位置。

您可能只知道每个字符的宽度。 (例如,如果所有字符都是片假名,或者所有字符都是拉丁文,这很容易。)但通常情况下您并不确定,因此您可能会发现让 C 库告诉您有多少是有用的每个字符占用的列。您可以使用 wcwidth function 来做到这一点。 (有关详细信息,请参阅 link,或在您的控制台上尝试 man wcwidth。)

但这里有一个很大的警告:wcwidth 会告诉您存储在当前语言环境中的字符宽度。在 Unicode 区域设置中,对于区域设置中包含的字符,结果将始终为 0、1 或 2,对于不对应于区域设置信息的字符的字符代码,结果将始终为 -1。 0 用于大多数组合重音符号以及不移动光标的控制字符,2 用于东亚全角字符。

没关系,但 C 库不咨询终端仿真器。 (没有办法做到这一点,因为终端仿真器是一个不同的程序;事实上,它甚至可能不在同一台计算机上。)因此库必须假设您已经使用与您使用的相同的信息配置了终端仿真器配置语言环境。 (我知道这有点不公平。“你”可能只是安装了一个 Linux 发行版,所有配置都是由各种黑客完成的,他们将软件收集到发行版中。他们也没有t相互协调。)

大多数情况下这是有效的。但是总有一些字符的宽度配置不正确。通常,这是因为该字符位于终端仿真器所使用的字体中,但不被区域设置视为有效字符; wcwidth then returns -1 调用者需要猜测使用哪个宽度。不正确的猜测会产生类似于此答案中讨论的问题。所以你可能 运行 遇到偶尔的故障。

如果您这样做(或者即使您只是想稍微探索一下您的语言环境),您可以使用 this earlier SO answer.

中的工具和技术

最后,从 Unicode 9 开始,除了可以更改字符呈现的其他上下文规则之外,还有一个控制字符可以强制后面的字符为全角字符。因此,如果不查看上下文并了解比您想了解的更多关于 Unicode 东亚宽度规则的知识,就不再可能确定字符的列宽。这使得 wcwidth 比以前更不通用。