说出来你可能不信,我最近大半年在终端里写文本的默认工具,是一款只有不到700行代码、名叫Caveman的小编辑器。它没有插件系统、没有鼠标支持、没有多标签页,甚至连语法高亮都是我自己实现的最小子集。但正是这个“原始人”级别的工具,让我在SSH远程调试、改服务器配置、深夜写临时备注这几件事上彻底摆脱了nano和vim之间的选择困难症。这篇文章不是来吹某个新框架的,而是想认真聊聊:当你真的需要一个足够简单的文本编辑器时,从零手写一个到底要经历哪些过程——从终端raw模式到ANSI绘制,从光标移动到文件原子写入。
如果你是个经常在命令行里干活的人,或者想理解现代编辑器底层到底在做什么,这篇东西应该能帮你省掉不少试错的时间。
1. 为什么固执地要写一个“原始人”编辑器
先说清楚,我不是闲得没事干才手搓编辑器。真正让我动手的,是一连串非常具体的挫败感。
1.1 被花哨功能淹没的日常使用痛点
我日常有大量场景需要在终端里快速改文件:登录服务器改Nginx配置、在docker容器里调整启动脚本、在树莓派上改crontab,甚至只是想在命令行里记一条临时待办。这些场景有一个共同点:文件很小,改动很少,但环境往往很“原始”——没有图形界面,不能开IDE,甚至没有鼠标可用。
用Vim吧,我需要记住模式切换、操作符、文本对象这一整套东西。人是会忘的,三个月不用Vim,再进去连怎么复制一行都要查笔记。用nano吧,底部那一排快捷键提示永远占着屏幕,而且想关掉提示反而要多配个参数。用sed或awk改文件?那种玩法只适合一次性的脚本任务,不适合认真编辑。
另外还有一个很现实的痛点:现有终端编辑器在响应速度上,“感知性能”被做得越来越重。Vim启动要读vimrc、加载插件,VSCode Remote更是要起一个node进程。在一个只有512MB内存的VPS上,每次打开编辑器等两三秒,真的非常烦人。
1.2 极简不是简陋:功能取舍背后的三层原则
所以我想做一个属于自己的工具:Caveman。名字起得很直白——一个“穴居人”该有的样子,能打开文件、能移动光标、能增删字符、能找到文本、能保存退出,够了。
但这里有个容易走偏的问题:极简很容易做成简陋。功能少和功能精简是两回事。功能少是“没有能力”,功能精简是“刻意保留每一个必要的,砍掉每一个多余的”。我在设计Caveman时给自己定了三条取舍原则:
第一条,高频操作为王。统计一下自己平时编辑文件时真正用到的操作,无非就是插入字符、删除字符、上下左右移动、翻页、跳到行首行尾、查找、保存。这些高频动作必须有,而且要做得顺手。
第二条,数学上复杂的功能不碰。正则搜索、宏录制、多级撤销树、代码折叠,这些功能每一个都值得单独写一篇文章。它们的共同问题是:实现起来复杂度指数级上升,但对我这种“临时改配置文件”的使用强度来说收益太低。所以第一版里一概不做,只用Python自带的字符串查找做最简单的增量搜索。
第三条,可预测性优先。这一点很关键。我受够了一些编辑器在某些情况下“自作聪明”的行为——比如自动补全弹窗,比如自动格式化把我原本的换行风格改掉。Caveman只做你明确要求它做的事,不做任何隐式的、隐形的改写。按下什么键,就是什么操作,永远可预期。
1.3 第一版功能清单:比想象中还要少
最终第一版的Caveman,功能清单是这样的:
| 功能 | 说明 |
|---|---|
| 打开文件 | 启动时接收文件名,加载到内存 |
| 光标移动 | 上下左右、翻页、行首、行尾 |
| 文本编辑 | 插入字符、退格删除、回车换行 |
| 查找 | Ctrl+F 输入关键字,从当前光标向后查找并高亮 |
| 滚动 | 当前光标超出可视区域时,滚动显示 |
| 保存 | Ctrl+S 原子写入原始文件 |
| 退出 | Ctrl+Q,超简单 |
没有全局撤销(只有单步撤销,后来才加的),没有行号,没有自动缩进,没有多种主题,没有语法高亮(后来用最小正则加了一点)。说实话,这个功能列表糊在墙上的话,连一个现代编辑器的最低配都不如。但正是这个“最低配”让我能在一千行代码以内搞定核心,并且连续用了半年没换回去。
提示:如果你也想动手写一个,第一版的功能一定不要超过6个核心项。每多一个特性,代码量、测试量、边界处理量都会非线性增长。先把最顺手的路径跑通,后面再慢慢加。
2. 终端raw模式:让键盘替你打工的前提
写编辑器,第一个要处理的问题不是“怎么显示文本”,而是“怎么读取按键”。很多人第一次踩进终端编程的坑,都是在两步之间迷路的。
2.1 你平时输入的终端其实“不太听话”
平时我们在终端里敲命令,比如运行python、输入用户名,这些输入都经过两层的处理:
第一层是行缓冲。终端不会把每个字符立刻交给程序,而是先放进缓冲区,等你按下回车,才连同换行符一次性交给标准输入。这个“回车提交”的模式叫canonical mode(标准模式)。问题是编辑器需要的是“按下A键就立刻收到A”,而不是“先攒着,按回车再一起给”。
第二层是回显。终端默认会把你敲进去的字符回打到屏幕上,让你看着自己输入。但在编辑器里,字符要不要显示、显示在哪里,完全应该由编辑器自己控制。如果让终端自动回显,光标的位置就会乱套。
所以写编辑器第一件事,就是把终端从“标准模式”切换到所谓的raw mode(原始模式)。在这个模式下,输入不经过行缓冲、不自动回显、信号处理也由程序接管。
2.2 raw模式切换的十几行代码
在Python里操作raw模式,比想象中简单。核心是termios模块和tty模块:
import sys import tty import termios class RawTerminal: def __enter__(self): fd = sys.stdin.fileno() self.old = termios.tcgetattr(fd) tty.setraw(fd) return self def __exit__(self, exc_type, exc_val, exc_tb): fd = sys.stdin.fileno() termios.tcsetattr(fd, termios.TCSADRAIN, self.old)tty.setraw()背后做的事情,就是把终端的ICANON(行缓冲)和ECHO(回显)两个标志位关掉,同时把一些信号相关的特殊字符(比如Ctrl+C、Ctrl+Z)改为普通输入。这样程序就可以一个字节一个字节地读键盘事件。
但这里有一个一定要记牢的坑:程序退出前必须恢复终端设置。如果编辑器崩溃或者被强杀时没来得及恢复,用户就会发现终端变得非常诡异——输什么字符都不显示、输入字符不回显、甚至连回车都无法提交。这种状况俗称“终端裸奔”。所以上面我用__exit__来自动恢复,同时还会注册signal handler(信号处理器),在收到SIGTERM、SIGINT、SIGTSTP时也恢复终端设置。
2.3 ANSI转义:画一个光标,等一个按键
raw模式搞定之后,另一个基础能力就是“控制屏幕输出”。终端不是画布,你想把文字放到第几行第几列,不是print一个大字符串就能精确控制的。真正的手段是向stdout写入ANSI转义码(escape sequence)。
最常用的几个转义码:
\x1b[2J 清屏 \x1b[H 把光标移动到屏幕左上角 (0, 0) \x1b[{row};{col}H 把光标移动到指定位置 \x1b[7m 开启反白显示(状态栏常用) \x1b[0m 重置所有样式属性 \x1b[1;1H 定位到第1行第1列Caveman的渲染逻辑本质上就是:清屏、然后逐行输出已经处理好的可见区域内容、最后在最底部输出状态栏。我每次拿到一个按键,就更新内部状态,然后调用一次render()函数。整个编辑器的主循环就三句话:
while True: key = read_key() # 读一个按键事件 if key == "ctrl+q": break update_state(key) # 根据按键更新文件和光标 render() # 全量重绘当前可见区域你别看这个流程简单,它实际上是所有“从零手写编辑器”教程的最完整骨架。很多人写编辑器写着写着就复杂到无法收拾,往往是跳过了这个朴素的循环结构,过早引入异步、线程或者复杂的状态机。
3. 读取、移动、编辑、查找、保存——五大核心模块的编码思路
骨架搭好之后,真正的血肉是那五大核心模块。下面逐个聊一聊我在Caveman里是怎么设计和实现的,以及为什么要这么设计。
3.1 文本结构:用list存行,远比想象的稳
Caveman内部保存文本的数据结构,选择了最简单的方案:一个Python列表,每一项是一行字符串。
self.lines = [line.rstrip("\n") for line in open(filename)]这个选择在一开始被朋友吐槽过:“大文件怎么办?每次插入一行都要O(n)啊。”但实际跑下来,对一个日常配置文件编辑器来说,这个担心根本不成立。
原因是:Python列表的插入操作虽然是O(n),但底层的memmove是C实现的,移动几十万字节的开销是微秒级的。我们不是在做数据库,一秒钟插几万次的场景在人类手速下根本不存在。人的击键速度最快也就每秒10次,所以语言层面的复杂度完全够用。
真正要考虑的是边界情况:文件末尾没有换行符、空行、以及Windows的\r\n换行。我在加载文件时统一把所有行尾的\n去掉、把Windows的\r处理掉,在保存时再根据当前平台补回换行符。这一步看起来简单,但处理不好会产生非常恶心的数据错乱。
3.2 光标与滚动:屏幕坐标和文件坐标必须分开
新手写编辑器最容易踩的坑,就是把“光标所在的行号”当成“屏幕上的行号”。这两个完全不是一回事。当文件超过屏幕高度时,你需要滚动;当光标移动到屏幕外时,还要让滚动跟随光标。
Caveman的状态设计很简单,只维护这几个变量:
self.row:光标在文件中的行号(从0开始)self.col:光标在文件中的列号(从0开始)self.offset_row:当前屏幕顶部对应文件第几行self.offset_col:当前屏幕左端对应文件第几列(用于处理超长行)
渲染可见区域的时候,只显示self.offset_row到self.offset_row + term_height之间的内容。光标移动时,如果self.row < self.offset_row就把offset_row往上挪,如果self.row >= self.offset_row + term_height就把offset_row往下挪。这个“偏移量模式”是Terminal UI编程里最经典的做法,比任何花哨的虚拟滚动都要稳妥。
3.3 编辑操作:insert/delete的边界处理
文本编辑的核心动作有两个:往当前行插入字符、删除当前光标前的字符。
插入很简单,在Python里就是字符串拼接:
self.lines[self.row] = self.lines[self.row][:self.col] + ch + self.lines[self.row][self.col:] self.col += 1但删除就有陷阱了。如果光标在当前行的第0列,你按退格键,理论上应该把当前行和前一行合并;如果光标不在第0列,就只是把当前行的一个字符删掉;如果当前行是空行而且光标在行首,退格也应该把上一行和这个空行合并。
我一开始只写了“光标不在行首就删左侧字符”,结果连续编辑十分钟就发现:在空行进退格没有任何反应,光标永远停在原地,会直接影响后续操作的可预期性。后来补上了两行合并逻辑:
if self.col == 0 and self.row > 0: prev_len = len(self.lines[self.row - 1]) self.lines[self.row - 1] += self.lines[self.row] del self.lines[self.row] self.row -= 1 self.col = prev_len回车键的边界处理同样多:如果在行中间按回车,要把当前行拆成两行;如果在行尾按回车,就插入一个空行。这些边界比看起来的多一倍,每个都必须测。
3.4 搜索跳转与高亮
查找功能,Caveman第一版用的是最“原始人”的方式:直接调用Python字符串的find()方法。用户输入关键字时,维护一个查找方向(向下查找),从当前光标位置往后面搜,找到第一个匹配位置后跳到那里,并把匹配文本高亮。
高亮的实现不算复杂,核心思路是在渲染每一行时做一次局部匹配,把命中片段包进\x1b[7m和\x1b[0m之间即可。性能方面因为文件通常不大,完全没问题。
这里有个值得分享的小细节:搜索高亮必须区分“当前命中”和“其他命中”。我一开始给所有匹配项统一高亮,导致用户根本分不清哪个匹配项是当前光标停留的,在长文件里非常容易迷路。后来把当前命中项用另一种反白样式标出,状态栏也显示当前第几个匹配,这个问题才解决。
3.5 原子写入:保存时不坑用户
保存文件这件事看起来毫无技术含量,就是open(filename, "w").write(...)对不对?但如果你真这么干,一旦写入过程中程序崩溃、磁盘满或者中途断电,原文件就处于“半写”状态,轻则丢失编辑内容,重则整个配置文件报废。
Caveman的保存逻辑采用了“原子写入”策略:
import os, tempfile def save(self): fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(self.path)) with os.fdopen(fd, "w", encoding="utf-8") as f: f.writelines(line + "\n" for line in self.lines) os.replace(tmp_path, self.path)先写一个临时文件,写完之后用os.replace()原子替换原文件。这个操作在同一个文件系统内是原子性的,要么旧的完整文件,要么新的完整文件,绝不会出现“写了一半”的状态。这个思想跟数据库事务里的redo log其实是同一个道理。这个细节很多小工具都不在意,但一旦出过一次问题,你就知道这是保命的。
4. 每个按键都在2毫秒内完成的秘密
功能做出来了,接下来就得面对真实世界的拷问:文件一大,编辑器还快不快?
4.1 性能陷阱:10万行文本的前端卡顿
第一版Caveman写完后,我专门开了个测试:生成一个10万行的文本文件打开。结果一进去就卡得不行,每按一次方向键,肉眼可见的延迟,至少在500毫秒以上。
问题出在渲染策略上。第一版的render()是“全量重绘”:
def render(self): sys.stdout.write("\x1b[2J") # 清屏 sys.stdout.write("\x1b[H") # 光标回左上角 for row in range(self.rows): # 把整个文件全画一遍 ...10万行,每一行都走一遍字符串拼接和ANSI输出,哪怕Python再快,也是几百毫秒级别的开销。更何况终端本身还有输出缓冲,一次写10万行,ssh到服务器时数据要经过带宽传输,卡顿是完全可预见的。
4.2 局部刷新:只绘可见区域
优化策略其实很简单,就是“只渲染你看到的部分”。我拿到了终端的高度——比如40行,那渲染循环就只从offset_row跑到offset_row+40。
def render(self): term_height, term_width = shutil.get_terminal_size() out = [] out.append("\x1b[H") for i in range(term_height - 1): file_row = self.offset_row + i if file_row >= len(self.lines): break line = self.lines[file_row] # 处理横向偏移、长行截断、高亮 out.append(render_line(line, file_row)) out.append(render_status_bar()) sys.stdout.write("".join(out)) sys.stdout.flush()输出也从逐行多次write改成了一次性拼接所有可见行的字符串,然后一次写出、一次flush。这样做的效果非常明显:渲染的数据量从“整个文件”降到“一个屏幕”,而且终端输出的系统调用次数也大幅减少。
4.3 实测数据:从500ms到3ms
优化后我在同样10万行的文件上测试,每次按键响应时间稳定在2-3毫秒左右。这个数据已经跟Vim在本地文件上的感知速度基本一致了。
不过这里有个不得不提的“现实物理学”:当你在SSH上跑Caveman时,瓶颈根本不在编辑器本身,而在网络链路。如果你用4G网络连服务器,哪怕编辑器只输出40行,一帧画面的网络延迟可能也有几十毫秒。所以我做了一个小优化:只有当光标移动、文本变化时才触发render,纯粹的按键漂移不会导致重绘。这有点类似“脏矩形”思想,从源头减少终端输出量。
另外,状态栏的更新时间也很讲究。如果每次按键都重绘状态栏,在慢速网络下依然会觉得卡。我后来改成:状态栏内容变化时才刷新,比如光标行列号变了才刷新,纯横向移动时可以复用上一次的绘制。这个优化的收益在远程场景下非常直观。
5. 三件在真实终端里翻车的事
功能跑通了、性能也达标了,接下来是真正“用起来”之后遇到的几个翻车现场。这些细节教科书上很少写,但每个都在真实工作中坑过我。
5.1 中文宽度与截断
中文、日文、韩文这类“全角字符”在终端里的显示宽度是2,而英文和数字是1。Python的len()返回的是字符数不是显示宽度,如果你直接拿它去截断长行、计算行尾位置或者对齐列,就全乱套了。
我第一版根本没考虑到这一点,结果打开一个中文配置注释文件时,行尾截断对不齐,光标移动到第20列时永远指着错误的地方。更严重的是,光标在包含中文的行上移动时,看起来像是“跳着走”的,非常诡异。
解决方案是引入终端显示宽度的概念,遍历字符串时,逐字符累计显示宽度,遇到全角字符就按2计算,宽度超过term_width就截断。在移动光标时也按宽度来推进,而不是按字符数推进。这个改动又小又关键——如果你写的是跨语言工具,这个不能省。
5.2 退格键和删除键的“方言”差异
在xterm终端里按退格键,收到的是\x7f(DEL),但在某些终端上可能是\x08(BS)。Delete键同样是多样:有的是\x1b[3~,有的是\x1b[3;2~,还有一些终端发的是\x1b[A这种类似方向键的东西。
一开始我只在Caveman里映射了\x7f当退格,结果有用户在Windows Terminal里用这东西,发现按方向键会随机让光标跳转,折腾了半天才定位到是Delete键的转义序列识别问题。
最终的做法是:把所有可能出现的“退格语义键”统一映射到一个内部键值key_backspace,把所有Delete变体统一映射到key_delete,然后在编辑逻辑里操作。**永远不要在业务逻辑里直接写死某个ASCII码,而要经过一层语义键映射。**这是终端编程里非常实用的一条经验。
5.3 信号处理:别把用户终端留在“裸奔”状态
前面提过raw模式下终端会“裸奔”。我踩过最重的坑是:按Ctrl+Z把Caveman挂起,回到shell里之后,终端完全没法用了——输入不回显、回车没反应、乱七八糟的字符乱跳。
为什么?因为SIGTSTP(Ctrl+Z触发的暂停信号)默认行为是暂停进程,但进程没有机会执行清理代码,终端拓扑结构还停留在raw模式。进程暂停后,Shell重新获得了输入控制权,可终端本身仍然处于不回显模式,于是一切都乱了。
解决办法:为SIGTSTP注册处理器,在挂起前恢复终端到常规模式;等用户用fg恢复时,__enter__再次进入raw模式。同理,SIGTERM、SIGHUP、SIGINT也都要注册同样的恢复逻辑。这个坑一旦踩中,新手往往会以为是自己把系统搞坏了,其实只是一个信号处理漏了。
提示:凡是进入raw模式的自制终端工具,不管是编辑器、top类监控还是交互式CLI,信号恢复逻辑不能省。用现成库比如curses、click时它们会帮你处理,但自己手写时一定要把
恢复当成第一优先级。
6. 用了半年之后,Caveman变成了什么
工具写出来是为了用的。Caveman从第一版到现在,我连续使用了半年多,它也经历了三轮小幅迭代,逐步从一个“技术玩具”变成了真正贴合我工作流的工具。
6.1 真正愿意用它而不是去开VSCode的场景
半年下来,我用Caveman最多的场景可以归纳为三类:
一是SSH远程改配置。进服务器改Nginx、改systemd unit、改cron,一条命令ssh xxx -t caveman /etc/nginx/nginx.conf,然后纯键盘搞定所有操作,退出干净利落。二是在Docker容器里编辑脚本,容器里通常没有Vim或nano的组合依赖,Caveman因为是纯Python写的、没有任何第三方依赖,直接把脚本丢进去就能跑。三是本地临时备忘,在终端里快速记几条待办,比打开笔记软件快得多。
这三类场景有一个共同点:文件不大、改动不多、追求极致的短平快。在这类场景里,Caveman的价值完全不是功能丰富,而是“要什么立刻有什么”。
6.2 半年后新增的三个“高性价比”功能
迭代过程中我没急着堆功能,而是认真记录了“每次在Caveman里觉得不方便”的时刻,从中挑出三个真正高频的痛点来补:
第一个是单步撤销。谁都有误删一段内容的瞬间,没有撤销的编辑器会让人很崩溃。我实现了一个简单的撤销栈,每次编辑操作前把当前行快照入栈,Ctrl+Z撤销,足以覆盖99%的误操作情形。
第二个是自动缩进继承。按回车时,新行自动继承上一行的缩进。这个功能对写Python和YAML文件的人类来说简直是救命级别的便利,实现起来也就几行逻辑。
第三个是文件未保存提醒。退出前检查是否有修改,有则提示。这个功能看起来基础,但我在独立开发初期没有做,有好几次改了半天的配置文件不小心Ctrl+Q全丢。加上这个提醒之后,数据安全性才算达标。
我不加多标签页、不加鼠标点击定位、不加主题系统,因为它们对我的使用场景来说都是低频功能。每一次迭代的信念都很简单:只有在你真实需要的地方,极简主义才有意义;为了极简而极简,那是自欺欺人。
6.3 从Caveman带出来的几点通用经验
如果你看到这里,打算也做一个自己的终端工具,那除了代码之外,我还有几点经验想直接告诉你:
把“语义”和“显示”分开。光标坐标、屏幕坐标、字符宽度、转义序列,每一层都要独立封装,不要揉进一个函数里。这样做的好处是调试极其高效。
把“原始字节”和“内部按键”分开。键盘读回来的字节流先经过一个decoder映射成统一语义键,再进入编辑逻辑。以后支持更多终端时就不用改一层代码。
最后是,别小看状态栏。状态栏是用户了解编辑器当前处于什么状态的唯一窗口:是否修改过、光标在哪、正在做什么、文件路径是什么。没有状态栏的编辑器,就像没有仪表盘的汽车,开起来总觉得少了点什么。
最后讲一个夜里的片段
有一次我在凌晨登上一台客户的异常服务器排查问题,包里没有鼠标、没有图形界面,只有一台笔记本和一个ssh终端。我打开Caveman,键入几行临时脚本,改了两处配置,保存退出。整个过程没有任何卡顿,没有弹窗,没有让我背某个高级命令。那一刻我突然觉得,做这个“原始人编辑器”最大的收获可能不是代码,而是它让我重新理解了“够用”这个词的重量。如果你也被复杂工具折腾得有点烦,不妨试试从几行代码开始,写一个只属于你自己的微小工具——它不一定能改变世界,但一定能改变你跟自己工具之间的那点关系。