Ncurses 没有写出指定数量的宽字符关于宽字符的列需求

问题描述

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

我在 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",4,NULL);
  setcchar(&kana3,L"\u30b3",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 字符)通常被称为“双角字符”,因为它们在等宽终端字体中占据两列。

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

所以你应该换行

      mvadd_wch(y,x,RANDOM_KANA);

      mvadd_wch(y,2*x,RANDOM_KANA);

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

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

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

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


问题确实如您所描述,但细节取决于终端。不幸的是,如果没有屏幕截图似乎不可能说明问题,所以我包括了一个。这就是我碰巧玩过的两个终端模拟器中的样子;控制台显示在第二个屏幕之后(因为我们将看到,第一个屏幕总是按预期显示)。左边是KDE的Konsole;在右侧,gnome 终端。大多数终端模拟器更类似于 gnome-terminal,但不是全部。

Two terminal emulators showing misplaced characters

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

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

现在,您使用 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 做到这一点。 (有关详细信息,请参阅链接,或在您的控制台上尝试 man wcwidth。)

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

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

大多数时候这是有效的。但总有一些字符的宽度配置不正确。通常,这是因为字符在终端模拟器使用的字体中,但不被区域设置视为有效字符; wcwidth 然后返回 -1,调用者需要猜测要使用的宽度。不正确的猜测会产生与本答案中讨论的问题类似的问题。因此,您可能会偶尔遇到故障。

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

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