文本编辑也讨厌你

Robert Lord 原作,授权 New Frontend 翻译。

Alexis Beingessner 上个月写的《文本渲染讨厌你》真是深得我心。

2017 年的时候,我在做一个浏览器中的富文本编辑器。因为对现存的使用 ContentEditable 的库不满意,我对自己说:「嘿,只要自己重新实现一下文本选取就行!这又能有多难呢?」我那时真是太年轻、太幼稚。我预计花两周可以完成。现实是,如果真要解决这个问题,我需要投入几年的时间。最后我甚至找了一份全职工作,花了一年时间为一个新操作系统实现文本编辑功能。

我很幸运,在工作中从我的前辈那里学到了这一领域大量的经验(我一直很感激 Raph Levien 和 Yohei Yukawa,我所有关于文本输入的知识都是他们教我的)。我听说了许许多多恐怖故事。其中包括有一个人在维护使用定制的文本框实现的 Windows 应用,他想要从老旧的 Windows 文本输入 API 更新到新版本。让我们看下新版本的接口列表

Text Services Framework Interfaces

没错,Windows 文本输入 API 包含 128 个接口。我可以很有把握地说,还有八种(8!)不同的锁以解决并发问题,不过老实说我并没有读过文档,所以别引用我的话。不管怎么说,我听说的那个工程师花了一年半的时间(全职!)尝试升级,最终,他失败了,还是继续使用老旧的 API。

文本输入很难。

Alexis 的文章已经在一些地方提到了文本选取,不过正如她在文中所说,她的经验主要在于文本渲染。作为一个从事文本编辑方面工作的人,我有一些东西要补充下。

光标垂直移动

我在之前的一篇文章中已经讲过这个,这里简短复述一下。

包含三行文本的文本框,光标在第一行中间

在上面的例子中,如果用户按 Up 键,光标会移动到行首,「hello」之前。目前为止看起来相当合理。然而,如果用户先按 Up 键再按 Down 键,那么光标会先跳到「hello」前,接着跳到「some」之后。

这可能看起来违反直觉。为什么光标会往右跳?好吧,光标会记住用于垂直移动的 x 轴位置(以像素为单位),只有用户按下 Left 或 Right 键时这个位置才会更新,按下 Up 或 Down 键时不会更新。这一行为同时也会防止垂直移动的光标在经过短行后发生左移。

译者注:作者这里说的是 textarea 在大多数浏览器下的行为。在 Firefox 下,在首行按 Up 键,光标不会移动。

Affinity

我们知道文本选取需要使用两个状态,字符串内部的字节偏移量和上文提到的以像素为单位的 x 轴位置。问题解决了?好吧,并没有。

看下非常长的一行内的两个光标位置:

软换行上行行尾和下行行首

因为「loooooooooong」是一个单词,上图中的两个光标位置具有完全相同的字符串内部字节偏移量。两者之间没有换行符,因为这是一个软换行。光标需要一个额外的状态记录在哪一行。大多数系统把这个状态称为「affinity」。混合双向文本也用到了这个状态。我们会在之后的小节讨论双向文本。

表情符号修饰符

比方说我要给朋友发条消息。为了强调我的激动之情,我想要加个表情符号。我输入了一个大拇指表情,又输入了一个字母 a,然后加上了一个表情符号肤色修饰符,就像这样:

带表情符号修饰符的文本消息

糟糕,我打错了,多打了一个 a。我把光标放在 a 后面,然后按退格键。会发生什么?取决于编辑器,我见过下面几种结果:

删除 a 后的三种结果

  • Bad #1 可能看起来很正确。但这是在对表情符号渲染的支持比较旧的文本编辑器中发生的,比如 Sublime Text。在字节层面,浅肤色大拇指表情符号编码为黄色大拇指表情符号紧跟着浅肤色修饰符。渲染时应该显示为单个表情符号。所以即便我从另一个应用中复制粘贴过来,在这些文本编辑器中也仍然会错误渲染成这样。
  • Bad #2 是 Chrome 77 地址栏的行为。这只是地址栏的行为,不是网页上的行为。由于复制粘贴带肤色的表情符号可以工作,所以这不是一个渲染问题。相反,Chrome 删除 a 后注意到 a 被肤色修饰符所修饰,所以接着删除了肤色修饰符。哎呀!
  • Bad #3 算是「正确」的行为,至少按照 Unicode 标准是这样,两个表情符号合并为一。但这对用户来说相当困惑,从字节层面来说,光标需要依序移动以免卡在单个表情符号当中。

这些选项都不好,所以你大概会希望能有第四选项。确实有!许多编辑器,比如 TextEdit,不允许我们把光标放在 a 后面,因为肤色修饰符和前一字符被视作一个单元。在表情符号的场景下看这很合理,对于这个例子来说甚至效果很好。然而,如果表情符号修饰符是一行的第一个字符会怎么样?

拇指朝下表情和肤色修饰符各占一行

现在表情符号修饰符修饰的是换行字符。TextEdit 不允许我们把光标放到第二行行首!我个人认为这个方案「也不好」。

你也许注意到拇指向上表情换成了拇指向下表情。这是我为了表达对现状的不满而特意换的。

顺带说下,TextEdit 的行首光标处理得十分糟糕。比如,猜一猜我如果在行首按下 4 会怎么样?

在拇指表情行首按下 4,4 会出现在行末

是的。你可能觉得这些数字之间有空格。事实上并没有。

双向文本

Alexis 提到了混合双向文本时不连续的选取范围,就像下面 TextEdit 中的例子:

混合英文和阿拉伯文后不连续的选取范围

实际上这是有道理的,因为阿拉伯文字符串是从右往左编码的,所以选取范围看起来不连续,但在字节层面的字符串范围却是连续的。

接着,让我们小小惊讶一下,可以这样选择文本:

混合英文和阿拉伯文表面连续的选取范围

是的,视觉上是连续的,但在字节层面却是不连续的。是的,这很糟糕。如果你使用方向键而不是鼠标选取字符,有些文本编辑引擎会出现这种行为。另一种方案是在右行文字区域内交换 Left、Right 键,这也不好。这里没有很好的方案。

作为一项额外的挑战,你可以试试搞清楚下面是什么情况:

混合英文和阿拉伯文,左边断裂、右边连续的文本选择范围

我可不想讨论这是怎么出现的。

输入法这件事

将按键转换为输入的软件称为「输入法」或「输入法编辑器」。对使用拉丁字母的英语用户来说,单个按键直接映射到插入一个字符,所以这些软件没什么意思。但是相当多的语言的字符非常多,在键盘上放不下,所以需要一点创意。例如,某些汉语输入法的使用者会输入读音,然后得到语音匹配的候选词列表:

汉语拼音输入法

有时候这称为输入区域,通常显示为下划线。有时候输入法需要在这个区域中添加样式。比如下面这个 Android 上的日文输入法使用背景色创建出切分建议区域:

日文输入法

(感谢 Shae 提供截图!)

各种高亮和输入区域能处理双向文本吗?让我们不要考虑这个问题。

输入法需要处处可用,甚至能在终端下使用:

终端 vim 中使用汉语拼音输入法

在选定汉字字符前,并没有向 Vim 实际发送字符。你大概会想「但是这个在 Vim 的命令模式下效果如何?」效果不太好。这正是 web 上文本输入和按键是不同事件的原因所在。终端混淆了两者,导致问题。

这只是人们输入文本的许许多多方式中的一个例子。别忘了还有非键盘输入法,比如语音输入和手写输入!对实现文本框的人来说,好消息是操作系统为你提供了所有这些输入方式,坏消息是文本框需要支持所有这些输入方式使用的常见文本输入协议。在 Windows 上,这意味着支持文章开头列出的 128 种接口。其他操作系统的接口要简单些,但通常仍然需要很多技巧才能正确实现。

你可能也注意到了输入法是独立于文本框的进程,由于输入法和应用都可能改变文本框的状态,这类协议是并发编辑协议。Windows 通过八种(8!)锁解决这一问题。尽管你可能对保持跨越进程边界的锁有所疑虑,大多数其他平台试图使用不完美的启发式算法来解决并发问题,或者直接寄希望于竞争条件不会出现。就我的经验而言,希望竞争条件不出现可不是什么有效的并发原语。

为什么这么复杂??

Jonathan Blow 在一场关于软件是如何越变越糟糕的演讲中举了 Ken Thompson 的文本编辑器 为例,Ken 花了一周就做出了一个文本编辑器。本文中的许多地方都是意外的复杂性。Windows 需要 128 个接口和 8 种锁来提供文本输入功能?绝对不需要。我们在 TextEdit 中碰到的 bug 让人失望,来自复杂的编辑模型?是的。现代程序中的大量 bug 是我们应该担忧的事情吗?至少我本人为此担忧。

然而,话说回来,Ken Thompson 的编辑器比我们今天期望的文本编辑器要简单太多太多。Unicode 几乎支持世界上现存的所有七千多种语言,以及大量死亡语言。这些语言使用各种文字、方向、输入法,纷纷给我们想要制作的编辑器带来需要复杂技巧才能解决的问题,甚至有些情况下会带来无法解决的问题。我们的编辑器也要能被使用屏幕阅读器的视障人士使用。

这里包含海量的必要复杂性,本文仅仅涉及了一些皮毛。如果有什么事情可以称为奇迹的话,那么我们直接在网页上加个 <textarea> 元素就能给全球各地的互联网用户提供文本输入功能,肯定算一个现代编程简单性的奇迹。

题图:Thought Catalog

评论

Loading comments ...