#editor #终端文本 #UTF-8 #文本编辑 #文本搜索

bin+lib kiro-editor

kiro 是一个简单的终端文本编辑器

15 个版本

0.4.3 2021 年 3 月 1 日
0.4.2 2021 年 1 月 30 日
0.4.0 2020 年 7 月 24 日
0.3.6 2020 年 7 月 24 日
0.1.1 2019 年 8 月 25 日

#94 in 文本编辑器


用于 notoriety-cli

MIT 许可证

160KB
4.5K SLoC

Kiro

crates.io Build Status

Kiro 是一个基于终端的 Rust 编写的微小型 UTF-8 文本编辑器。Kiro 是从将优秀的最小化文本编辑器 kilo 移植到 Rust 开始的,并随着各种扩展和改进而成长。

main screenshot

它提供基本功能作为最小化文本编辑器

  • 打开/保存文本文件
  • 创建新的文本文件和在内存中的空文本缓冲区
  • 编辑文本(插入/删除字符、插入/删除行等)
  • 简单的语法高亮
  • 简单的增量文本搜索

并且 Kiro 扩展了 kilo 以改进编辑(请参阅下面的“扩展功能”和“实现”部分以获取更多详细信息)

  • 支持编辑类似“🐶”的 UTF-8 字符(kilo 只支持 ASCII 字符)
  • 撤销/重做
  • 更多有用的快捷键(支持 Alt 修饰符)
  • 24 位颜色(真颜色)和 256 位颜色支持,使用 gruvbox 旧式颜色调色板,16 种颜色回退
  • 更高效的屏幕渲染和突出显示(kilo 每次都会渲染整个屏幕)
  • 打开多个文件(通过 Ctrl-X/Alt-X 切换缓冲区)
  • 支持调整终端窗口大小。屏幕大小负责
  • 突出显示更多语言(Rust、Go、JavaScript、C++)和项目(语句、类型、数字文字等)
  • 自动关闭行底部的消息栏
  • 每个逻辑(如解析按键输入、渲染屏幕、计算突出显示、修改文本缓冲区)都是模块化实现(kilo 在一个 kilo.c 中实现了一切,有几个全局变量)
  • 增量文本搜索已修复并改进(kiro 只突出显示当前匹配项,并且每行只击中一次)。

Kiro 旨在支持 Unix-like 系统上的各种 xterm 终端。例如 Terminal.app、iTerm2.app、Gnome-Terminal、(希望如此)WSL 上的 Windows Terminal。

我在遵循 '自己动手编写文本编辑器' 指南 制作这个项目的过程中学到了许多东西。请阅读下面的“实现”部分以找到一些有趣的主题。

安装

请使用 cargo 从源代码构建 kiro-editor 软件包。

$ cargo install kiro-editor

注意:请尽可能使用最新的 Rust 稳定工具链。

对于NetBSD,kiro-editor软件包可用。

https://pkgsrc.se/editors/kiro-editor

使用方法

命令行界面

安装kiro-editor软件包将在您的系统中引入kiro命令。

$ kiro                 # Start with an empty text buffer
$ kiro file1 file2...  # Open files to edit

请参阅kiro --help以了解命令使用方法。

编辑文本

Kiro是一个无模式的文本编辑器。像其他著名的无模式文本编辑器一样,例如Nano、Emacs、Gedit或NotePad.exe,您可以使用键盘在终端窗口中编辑文本。

并且有几个带有Ctrl或Alt修饰符的键被映射到各种功能。您不需要记住所有的映射。请键入Ctrl-?以了解编辑器中的所有映射。

  • 操作
映射 描述
Ctrl-? 在编辑器屏幕上显示所有键映射。
Ctrl-Q 退出Kiro。如果当前文本尚未保存,您需要输入Ctrl-Q两次。
Ctrl-S 将当前缓冲区保存到文件。提示将出现,要求输入未命名缓冲区的文件名。
Ctrl-G 增量文本搜索。
Ctrl-O 打开文件或空缓冲区。
Ctrl-X 切换到下一个缓冲区。
Alt-X 切换到上一个缓冲区。
Ctrl-L 刷新屏幕。
  • 移动光标
映射 描述
Ctrl-P 向上移动光标。
Ctrl-N 向下移动光标。
Ctrl-F 向右移动光标。
Ctrl-B 向左移动光标。
Ctrl-[Ctrl-VPAGE DOWN 将光标移动到行首。
Ctrl-EAlt-END 将光标移动到行尾。
Ctrl-[Ctrl-VPAGE DOWN 下一页。
Ctrl-]Alt-VPAGE UP 上一页。
Alt-FCtrl- 移动光标到下一个单词。
Alt-BCtrl- 移动光标到上一个单词。
Alt-NCtrl- 移动光标到下一个段落。
Alt-PCtrl- 移动光标到上一个段落。
Alt-< 移动光标到文件顶部。
Alt-> 移动光标到文件底部。
  • 编辑文本
映射 描述
Ctrl-HBACKSPACE 删除字符
Ctrl-DDELETE 删除下一个字符
Ctrl-W 删除一个单词
Ctrl-J 删除到行首
Ctrl-K 删除到行尾
Ctrl-M 插入新行
Ctrl-U 撤销最后一个更改
Ctrl-R 重做最后一个撤销更改

以下是一些基本功能的截图。

  • 创建新文件
screenshot for creating a new file
  • 增量文本搜索
screenshot for incremental text search

扩展功能

支持编辑UTF-8文本

Kiro是一个UTF-8文本编辑器。因此,您可以打开/创建/插入/删除/搜索UTF-8文本,包括支持双宽字符。

UTF-8 supports

注意,目前尚不支持使用U+200D(零宽连接符)的emoji,例如'👪'。

请阅读“支持编辑UTF-8文本”部分,了解实现细节。

24位颜色(真彩色)和256位颜色支持

Kiro尽可能地使用颜色,如果您的终端支持,它会输出24位颜色,使用gruvbox颜色方案,回退到256位颜色或最终到16位颜色。

  • 24位颜色
24-bit colors screenshot
  • 256位颜色
256 colors screenshot
  • 16位颜色
16 colors screenshot

处理窗口大小调整

终端通过SIGWINCH信号通知窗口大小调整事件。Kiro捕获该信号并以新的窗口大小适当地重新绘制其屏幕。

resize window

撤销/重做

Kiro支持撤销/重做编辑(Ctrl-U用于撤销,Ctrl-R用于重做)。最大历史条目数为1000。超出后,在向文本添加新更改时,将删除最旧的条目。

undo/redo screencast

请阅读“文本编辑作为diff序列”部分。

实现

这个项目是为了了解一个文本编辑器如何与终端应用程序交互来实现。我了解了许多与终端与应用程序之间的交互以及终端转义序列的规范,例如VT100或xterm。

我从遵循'Built Your Own Text Editor'指南移植了一个出色的最小化文本编辑器kilo。然后,我在我的实现中添加了一些改进。

以下是我特别感兴趣的主题。

高效的渲染和突出显示

kilo在您每次按键时都会更新渲染和突出显示。这种实现使实现变得简单,并且效果良好。

然而,这还不够,我在编辑较大的(10000~行)C文件时遇到了一些性能问题。

因此,Kiro改进了实现,仅在必要时渲染屏幕并更新突出显示。

Kiroscreen.rs中的Screen结构体中有一个变量dirty_start。它管理从哪一行开始渲染。

例如,假设我们有以下C代码

int main() {
    printf("hello\n");
}

并将!printf("hello!\n");一样放入。

在这种情况下,第一行没有变化。因此,我们不需要更新该行。然而,Kiro即使该行没有变化,也会渲染}行。这是因为在修改文本时,可能会影响该行之后的行的突出显示。例如,在\n之后删除"后,字符串字面量未终止,因此下一行继续字符串字面量的突出显示。

高亮具有相似的特性。虽然kilo每次输入键时都会计算整个文本缓冲区的高亮,但实际上屏幕底部的行是不会被渲染的。对于当前的语法高亮,前一行的高亮可能会影响后续行的高亮(例如,代码块注释/* */),而后续行的高亮不会影响前一行。因此,Kiro停止在屏幕底部行计算高亮。

UTF-8 支持

kilo仅支持ASCII文本。ASCII字符的宽度被固定为1字节。这个假设大大降低了kilo的实现复杂性,因为

  • 每个字符都可以表示为char(几乎与Rust中的u8相同)
  • ASCII文本中的任何字符都可以通过字节索引在O(1)内访问
  • 文本长度与文本的字节数相同

因此,kilo可以将文本缓冲区作为简单的char *包含,并通过字节索引访问其中的字符。此外,所有可打印的ASCII字符的显示宽度都是固定的,除了0x09制表符。

但事实上,世界上定义的Unicode字符更多。由于我是日本人,我日常使用的字符,如汉字或平假名,不是ASCII。而最常用的文本编码是UTF-8。因此,我决定扩展Kiro编辑器以支持UTF-8。

在UTF-8中,字符的字节长度是可变的。任何字符都占用1到4个字节(特殊情况下更多)。这里的重要一点是,在UTF-8文本中访问字符不是O(1)。要访问N个字符或知道文本长度,需要从文本的开头检查字符。

在更新文本缓冲区和高亮时,经常需要访问文本中的字符和获取文本长度。所以每次检查它们的时间复杂度为O(N)是不高效的。为了解决这个问题,Kiro将每一行文本中每个字符的字节索引存储为Vec<usize>。这些索引仅存在于行文本中至少有一个非ASCII字符的情况下。

UTF-8 support diagram

在表示一行文本的Row结构中,indices字段(Vec<usize>)专门用于存储每个字符的字节索引。

在第一个例子"Rust is nice"中,所有字符都是ASCII,所以可以使用字节索引访问文本中的字符。在这种情况下,indices字段是空的(并且容量设置为零)。一个零容量的Vec实例保证不会分配堆内存。因此,这里的内存开销仅是24字节的Vec<usize>实例本身(指针、容量为usize和长度为usize)。

在第二种情况下 "Rust🦀良い",存在一些非ASCII字符,因此 self.indices 缓存了每个字符的字节索引。多亏了这个缓存,每个字符都可以以O(1)的速度访问,并且可以以O(1)的速度获得其文本长度,如 self.indices.len()Row 还包含一个渲染的文本,并在内部文本缓冲区由 TextBuffer 更新时更新它。因此,self.indices 缓存也会在同一时间高效地更新。

尽管将字节索引保存在 Vec<usize> 中相当内存效率低下,但这些索引仅在行文本包含非ASCII字符时才需要。就编程代码编辑器而言,这相对较少见,我相信。

文本编辑作为差异序列

在Kiro编辑器中,每次文本编辑都表示为文本的差异。因此,文本编辑意味着将差异应用于当前文本缓冲区。撤销表示为“取消应用”差异。重做表示为再次应用差异。

一次撤销表示为多个差异,而不是一个差异。这是因为用户通常不想逐个字符撤销。因此,每个插入一个字符的差异被组合成一个撤销单元。

UTF-8 support diagram

首先用户输入 "abc" 到文本中。输入表示为每个字符的3个差异,它们组成一个撤销单元。因此,尽管表示为多个差异,插入 "abc" 在撤销时可以一次性撤销。然后用户将光标向后移动一个字符,删除字符 "ab" 直至行首。这表示为一个差异。最后,用户按回车键添加新行。插入行表示为两个差异。首先,编辑器截断光标后的文本("c"),然后将新行 "c" 插入到光标下一行。这两个差异组成一个撤销单元。

通过管理文本编辑的历史记录与撤销单元,每个文本编辑都可以表示为差异序列。重做将一个撤销单元中的差异应用于当前文本缓冲区。而撤销则取消应用一个撤销单元中的差异到当前文本缓冲区。

内部也将正常输入视为重做,这样编辑器就不需要用单独的实现来处理正常输入。

将C编辑器移植到Rust

将C源代码拆分为几个Rust模块

为了简化和最小化实现,kilo 使用了一些全局变量和局部 static 变量。编辑器的状态存储在一个全局变量 E 中,并在任何地方引用。

在将代码移植到Rust时,我将 kilo.c 拆分为几个Rust模块,每个模块对应一种逻辑。我将全局变量和局部静态变量移动到每个逻辑的结构体中,以移除它们。

  • editor.rs:导出 Editor 结构体,它管理编辑器的生命周期;运行循环以获取按键输入,更新文本缓冲区和高亮,然后渲染屏幕。
  • text_buffer.rs:导出 TextBuffer 结构体,该结构体用于管理编辑文本缓冲区,作为 Vec<Row>。它还包含缓冲区的元数据,例如文件名和文件类型。
  • edit_diff.rs:将文本编辑定义为应用一系列差异到文本上。此模块导出枚举 EditDiff,它表示差异及其应用逻辑。
  • row.rs:导出 Row 结构体,代表文本缓冲区中的一行,包含实际文本和渲染文本。由于 Kiro 专注于 UTF-8 文本编辑,内部文本缓冲区也保持为 UTF-8 字符串。当内部文本缓冲区通过 Editor 更新时,它会自动更新渲染文本。它还可能包含 UTF-8 非ASCII字符的字符索引(请参阅以下“UTF-8 支持”部分)。
  • history.rs:导出结构体 History,该结构体用于管理编辑历史。历史以一系列编辑差异表示。它管理撤销/重做的状态以及在一个撤销/重做操作中应该发生多少更改。
  • input.rs:导出结构体 StdinRawMode 和迭代器 InputSequencesStdinRawMode 将 STDIN 设置为原始模式(禁用各种终端功能,如回显)。 InputSequences 以字节序列读取用户的按键输入,带有超时,并将其解析为键序列流。VT100 和 xterm 转义序列(例如,\x1b[D 对应于 键)在这里解析。
  • highlight.rs:导出结构体 Highlighting,它包含文本缓冲区中每个字符的高亮信息。它还管理编辑器生命周期中的高亮。它计算渲染字符的高亮,并更新其信息。
  • screen.rs:导出结构体 Screen,它代表屏幕渲染。它通过输出字符和转义序列到 STDOUT 来渲染每个 Row,并使用高亮颜色。如前所述,它管理高效的渲染。它还管理并渲染屏幕底部的状态栏和信息栏。
  • status_bar.rs:导出结构体 StatusBar,它管理状态栏中显示的字段。它有一个标志 redraw,用于确定是否应该重新渲染。
  • prompt.rs:导出与使用信息栏的用户提示相关的结构体。此模块具有运行用户提示和文本搜索的逻辑。提示时的回调表示为 PromptAction 特性。
  • term_color.rs:导出小的 TermColor 枚举和 Color 枚举,它们表示终端颜色。此模块还具有检测终端对 24 位颜色和 256 色支持的逻辑。
  • language.rs:导出小的 Language 枚举,表示文件类型,如 C、Rust、Go、JavaScript、C++。它包含从文件名检测文件类型的逻辑。
  • signal.rs:导出 SigwinchWatcher 结构体,接收 SIGWINCH 信号并将其通知给 Screen。当终端窗口大小改变时发送该信号。Screen 需要该通知以调整屏幕大小。
  • error.rs:导出 Error 枚举和 Result<T> 类型,以处理 Kiro 编辑器可能发生的所有错误。

错误处理和资源清理

kilo 在错误情况下通过 perror() 输出消息并立即退出。它还使用 atexit 钩子清理 STDIN 配置。

Kiro 是用 Rust 实现的,所以它利用 Rust 语法来处理错误,使用 io::Result? 操作符。这减少了错误处理的代码量,使我能够专注于实现编辑器逻辑。

对于资源清理,Rust 的 Drop 库在 input.rs 中工作得非常好。

struct StdinRawMode {
    stdin: io::Stdin,
    // ...
}

impl StdinRawMode {
    fn new() -> io::Result<StdinRawMode> {
        // Setup terminal raw mode of stdin here
        // ...
    }
}

impl Drop for StdinRawMode {
    fn drop(&mut self) {
        // Restore original terminal mode of stdin here
    }
}

impl Deref for StdinRawMode {
    type Target = io::Stdin;
    fn deref(&self) -> &Self::Target {
        &self.stdin
    }
}

impl DerefMut for StdinRawMode {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.stdin
    }
}

drop() 方法在 StdinRawMode 实例死亡时被调用。因此,用户不需要记住清理工作。并且 StdinRawMode 还实现了 DerefDerefMut,因此它的行为几乎与 Stdin 相同。通过这种方式包装 io::Stdin,我可以向 io::Stdin 添加进入/退出终端原始模式的能力。

编辑器的抽象输入和输出

pub struct Editor<I, W>
where
    I: Iterator<Item = io::Result<InputSeq>>,
    W: Write,
{
    // ...
}

impl<I, W> Editor<I, W>
where
    I: Iterator<Item = io::Result<InputSeq>>,
    W: Write,
{
    // Initialize Editor struct with given input and output
    pub fn new(input: I, output: W) -> io::Result<Editor<I, W>> {
        // ...
    }
}

终端文本编辑器的输入是从终端的输入序列流,包括用户的按键输入和控制序列。输入使用输入序列的 Iterator 特性表示。在这里,InputSeq 代表一个按键输入或一个控制序列。

终端文本编辑器的输出也是向终端的序列流,包括输出字符串和控制序列。这通过简单地写入 stdout 实现,因此它使用 Write 特性表示。

这些抽象的好处是每个模块的可测试性。通过创建一个实现 Iterator<Item = io::Result<InputSeq>> 的虚拟结构体,可以轻松地替换输入为虚拟输入。由于 kilo 没有测试,这些抽象对于它来说不是必要的。

struct DummyInput(Vec<InputSeq>);

impl Iterator for DummyInput {
    type Item = io::Result<InputSeq>;

    fn next(&mut self) -> Option<Self::Item> {
        if self.0.is_empty() {
            None
        } else {
            Some(Ok(self.0.remove(0)))
        }
    }
}

// Dummy Ctrl-Q input to editor
let dummy_input = DummyInput(vec![ InputSeq::ctrl(b'q') ]);

通过实现一个简单地丢弃输出的虚拟结构体,我们可以忽略输出。它不需要在终端窗口中绘制屏幕。它不依赖于全局状态(终端原始模式),因此测试可以并行运行。结果是,测试可以运行得更快,终端窗口不会混乱。

struct Discard;

impl Write for Discard {
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        Ok(buf.len())
    }

    fn flush(&mut self) -> io::Result<()> {
        Ok(())
    }
}

使用这些模拟,可以轻松地测试编辑器的输入和输出,如下所示

#[test]
fn test_editor() {
    let mut editor = Editor::new(dummy_input, Discard).unwrap();
    editor.edit().unwrap();
    for line in editor.lines() {
        // Check lines of the current text buffer
    }
}

依赖库

此项目依赖于一些小库。我仔细选择了它们,以免阻碍学习终端文本编辑器的工作原理。

  • termios:对操作系统提供的 termios 接口的安全绑定。
  • term_size:使用 ioctl(2) 获取终端窗口大小的安全绑定。
  • unicode-width:用于计算 Unicode 字符显示宽度的小型库。
  • term:用于 terminfo 和终端颜色的库。此项目仅使用此库解析 terminfo 以支持 256 种颜色。
  • signal-hook:用于捕获 SIGWINCH 以支持缩放的小型信号处理包装器。
  • getopts:用于解析命令行参数的小型库。Kiro 只有相当简单的 CLI 选项,因此 clap 太重了。

待办事项

  • 单元测试不足。应添加更多测试
  • 提高滚动性能(终端滚动是否可用?)
  • 最小化文档
  • 文本选择和从系统剪贴板复制或粘贴
  • 保留所有高亮(Vec<Highlight>)不是内存效率高的。仅保留当前屏幕(rowoff..rowoff+num_rows)的位
  • 使用解析库 combinenom 来计算高亮。需要进行一些调查,因为高亮解析器必须在当前行超过屏幕底部时停止计算。另外,syntect 也很有趣。

未来工作

  • 使用增量解析进行准确的语法高亮
  • 支持更多系统和终端
  • 查看编辑器配置文件,例如 EditorConfig.vscode VS Code 工作区设置
  • 支持使用 U+200D 的表情符号
  • WebAssembly 支持
  • 鼠标支持
  • 使用语言服务器完成、转到定义和查找

开发

基准测试由 [cargo bench][cargo-bench] 完成,模糊测试由 cargo fuzzlibFuzzer 完成。

# Create release build
cargo build --release

# Run tests
cargo test

# Run benchmarks
cargo +nightly bench -- --logfile out.txt && cat out.txt

# Run fuzzing
cargo +nightly fuzz run input_text

GitHub Action 上运行的基准测试结果持续收集到这个页面

https://rhysd.github.io/kiro-editor/dev/bench/

许可证

本项目遵循 MIT 许可证 分发。

依赖项

~3.5MB
~70K SLoC