1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
|
# ANSI-DRAW.md
一个基于终端的 ANSI 字符画编辑器。
## 安装方式
1. 启动任意 AIAgent
2. 将以下文本作为提示词输入:
```plaintext
请完成以下操作:
1. 检查本机是否安装了 git 和 rust 工具链(cargo + rustc),如果没有,直接装上,不要问用户
2. 克隆 https://github.com/Weicao-CatilGrass/ANSI-DRAW.md
3. cd 到项目目录,读取 ANSI-DRAW.md,然后严格按照该文件的要求完成全部实现
4. 全部完成后,执行 cargo install --path . 安装到系统
5. 全程不要麻烦用户
```
3. 等待 AI 完成
---
## For LLMs
你现在要实现一个名为 `ANSI-DRAW` 的 `rust` TUI 程序。用户 **全程不会** 参与代码的 Review 环节,只参与验收。请独自完成全部。
### 核心抽象
- **命令**:程序管道的核心部分。所有用户操作都转化为命令,命令被顺序执行。命令的执行和画面的渲染在同一个主循环中交替进行
- **画布**:记录所有字符及其颜色。无限大,只有被写入过的坐标才占用内存
- **相机**:记录用户当前看见的画面区域在画布上的位置(左上角坐标)
- **光标**:记录用户在画布上的焦点位置(坐标)
- **模式**:全局状态枚举,决定按键绑定如何解析。可通过命令切换
- **绑定**:将键盘事件映射为一个或多个命令。同一按键在不同模式下映射到不同命令
### 开发流程(严格按顺序执行)
请严格遵守以下顺序,一步一步完成。不允许合并多个步骤为一步,不允许跳过步骤。
1. 按照本规范的全部要求编写代码
2. 为每个模块编写测试(测试核心逻辑:光标移动、滚动检查、命令解析、ANSI 解析和序列化的对称性、各个编辑模式的行为)
3. 执行 `cargo build`、`cargo test`,保证能构建且测试全部通过
4. 执行 `cargo clippy -- -D warnings` 检查代码质量,修复所有警告直到输出为空。不允许添加任何 clippy 抑制属性
5. 使用子 Agent 进行 Review(如果可用)。这一步**非常重要**——你写完代码后会陷入「实现者视角」,对自己的决策有路径依赖,看不见盲区。子 Agent 没有写过这些代码,能**换一个视角**审视你的工作,发现你视而不见的问题。具体做法:
- 生成两个子 Agent(不要用同一个子 Agent 做两次,两个独立的 Agent 才可能发现不同的问题)
- 将整个 `src/` 目录的代码发给它们,同时告诉它们本文档中的「硬性约束」和「边缘情况处理规则」
- 让它们 Review 的重点:是否违反硬性约束、是否有遗漏的边界情况、是否有逻辑矛盾(如加载和保存的格式不对称)
- 子 Agent 返回后,认真对待每一条意见,确认问题属实后立即修复。如果意见是误报,理解原因后忽略
6. 将已完成的部分以简洁的要点形式写入根目录的 `DONE.md` 文件(写入即可,不需要读取)
7. 读取 `DONE.md` 和本规范对比,检查是否有遗漏的功能点或偏差。不要写入任何文件
- 补充说明:如果可以用子 Agent,让它来检查完成度,这比自己检查更容易发现问题
8. 执行 `git status` 确认 `ANSI-DRAW.md` 没有任何变化(即你的工作没有修改本文件)
9. 如果 `ANSI-DRAW.md` 产生了变化,执行 `git restore ./ANSI-DRAW.md` 恢复它
10. 再次读取本文件,将完整内容记下。这一步必须执行
11. 重复步骤 1 到 10,直到在第 7 步对比时确认完全无误
当准备停止第 11 步、进入验收阶段时,依次执行:
```
cargo fmt
cargo build
cargo test
cargo clippy -- -D warnings
```
任何一步产生问题,回到第 1 步。
> 重要:在完成所有步骤之前,不许执行 `cargo run`。
### 硬性约束
1. TUI 库使用 `ratatui` 配合 `crossterm`。不使用其他终端库
2. 不添加本文档未指定的任何功能
3. 所有颜色内部存储为 24 位 RGB。仅在文件读写时做色域转换
4. 禁止出现 `unsafe` 关键字
5. 禁止添加 clippy 抑制属性(`#[allow(...)]` 等等)
6. 不使用第三方 CLI 参数解析库。程序至多接受一个可选参数(文件路径),手动解析 `std::env::args()` 即可
7. 所有测试使用 `#[test]`,不依赖外部文件
8. 剪贴板只在进程内存中存在(程序内剪贴板),不调用操作系统剪贴板 API
### 必须使用的第三方库
依赖列表如下,版本号使用最新兼容版本:
- `ratatui`(TUI 框架)
- `crossterm`(终端控制)
- `unicode-width`(计算字符显示宽度)
- `anyhow`(错误处理)
---
## 模块划分
程序分为 11 个源文件,每个文件的责任如下:
| 源文件 | 责任范围 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `main.rs` | 解析命令行参数,初始化终端(进入 raw mode、安装 panic 恢复钩子),创建 App 实例,进入主循环,退出时恢复终端 |
| `app.rs` | 定义全局状态结构体(见下方「全局状态」一节),包含 new 方法和初始化逻辑 |
| `canvas.rs` | 定义「格子」和「画布」类型,提供格子默认值、画布上读写格子、按行插入/删除/移动格子段、清空矩形区域等操作 |
| `camera.rs` | 定义「相机」类型(两个有符号整数:x 和 y),提供上下左右移动方法 |
| `cursor.rs` | 定义「光标」类型(两个有符号整数:x 和 y),提供上下左右移动方法 |
| `mode.rs` | 定义「模式」枚举(8 个变体),实现显示名称的方法(见下方「模式显示名映射表」) |
| `command.rs` | 定义「命令」枚举(所有变体见下方「命令表」),提供将字符串解析为命令列表的函数(解析规则见「命令输入语法」) |
| `binding.rs` | 提供将键盘事件和当前模式解析为一组命令的函数。逻辑固定为「绑定表」的内容 |
| `renderer.rs` | 提供将 App 当前状态绘制到终端的函数。布局固定为「终端布局」一节的内容 |
| `ansi.rs` | 提供「从字符串加载为画布」和「将画布保存为字符串」两个函数。解析和序列化规则见「ANSI 解析规则」和「ANSI 序列化规则」两节 |
| `clipboard.rs` | 定义「剪贴板」类型(见下方「剪贴板」一节) |
---
## 数据类型
以下用自然语言描述所有数据类型的精确结构。实现时必须保证字段名称、类型、语义与此严格一致。
### 格子 (Cell)
一个格子包含三个字段:
- `ch`:字符类型(`char`),表示该位置显示的字符
- `fg`:前景色,必须是 24 位 RGB 值。默认值为白色(255, 255, 255)
- `bg`:背景色,必须是 24 位 RGB 值。默认值为黑色(0, 0, 0)
新建空画布时,所有坐标的格子默认为空格字符、白字黑底。注意:默认格子中的字符为空格 `' '`,而非空。这是为了保证背景色能正确渲染。
内部存储时前景色和背景色不允许出现「重置」「黑色」「白色」等枚举变体,必须展开为 RGB 三元组。
### 画布 (Canvas)
画布是一个稀疏映射:键是坐标对 `(x, y)`,值是一个格子。x 是列号(向右增大),y 是行号(向下增大)。只有被显式写入过的坐标才存在于映射中。未写入的坐标在读取时返回默认格子。
画布在概念上是无限的。坐标范围在 `i64` 的有效范围内。
### 相机 (Camera)
相机包含两个有符号整数:
- `x`:视口左上角在画布上的列坐标
- `y`:视口左上角在画布上的行坐标
### 光标 (Cursor)
光标包含两个有符号整数:
- `x`:光标在画布上的列坐标
- `y`:光标在画布上的行坐标
### 模式 (Mode)
模式枚举包含以下 8 个变体,其中文名和显示名如下:
| 模式 | 中文名 | 显示名(状态栏显示) |
| ------------ | ------------ | -------------------- |
| 导览模式 | 导览模式 | `NORMAL` |
| 插入模式 | 插入模式 | `INSERT` |
| 输入模式 | 输入模式 | `INPUT` |
| 替换输入模式 | 替换输入模式 | `REPLACE` |
| 命令输入模式 | 命令输入模式 | `COMMAND` |
| 前景绘画模式 | 前景绘画模式 | `PAINT-FG` |
| 背景绘画模式 | 背景绘画模式 | `PAINT-BG` |
| 选择模式 | 选择模式 | `VISUAL` |
### 剪贴板 (Clipboard)
剪贴板包含两个可选字段:
- `cell`:一个可选的格子。Yank 命令(复制单个字符)时存入此处
- `cells`:一个可选的格子矩阵(二维数组)。VisualYank 命令(复制矩形选区)时存入此处
任何时候 `cell` 和 `cells` 不会同时有值。执行 Yank 时设置 `cell` 为有值、`cells` 为无值。执行 VisualYank 时相反。
执行 Paste 时:优先使用 `cells`(有值时按矩形区域粘贴),否则使用 `cell`(有值时粘贴单个字符并移动到下一格),两者均无值时 Paste 命令无操作。
### 全局状态 (App)
全局状态结构体包含以下字段:
- `canvas`:画布
- `camera`:相机
- `cursor`:光标
- `mode`:当前模式
- `clipboard`:剪贴板
- `is_modified`:布尔值,标记文件是否被修改过。初始为 `false`
- `file_path`:可选的字符串,当前打开的文件路径。初始为命令行参数传入的值(如果有),否则为无值
- `visual_anchor`:可选的坐标对。仅在选择模式下有值,为进入选择模式时光标所在的位置。退出选择模式时重置为无值
- `cmd_buffer`:字符串,命令输入模式下的输入缓冲区。初始为空字符串
- `msg`:可选的字符串,用于在状态栏右侧显示临时消息。显示一次后应被消费并重置为无值
- `ime_composing`:布尔值,标记 IME 是否正在组合中。初始为 false
- `last_key_event`:(仅 Windows)记录上一次按键事件和时间的元组,用于检测重复事件。初始为无值
---
## 主循环
### 事件驱动架构
主循环使用**阻塞式事件读取**(而非定时轮询),以保证连续按键输入不丢帧、不卡顿。
### 输入缓冲清理
进入主循环前,先执行一次输入缓冲清理:轮询所有已排队的事件并丢弃。这可以防止启动前(如命令执行期间)残留的按键进入编辑器。
### 初始渲染
清理完毕后,执行第一次渲染。
### 事件循环
然后进入循环,反复执行以下步骤:
1. **阻塞等待事件**:调用阻塞的 `event::read()` 等待下一个事件。不设超时
2. **忽略非按键事件**:`Resize` 事件只更新终端尺寸(重新获取即可),`KeyEventKind::Release`(键盘释放事件)直接忽略
3. **IME 组合过滤**:如果事件来自 IME 组合过程(控制字符、组合进行中的中间字符),忽略
4. **重复事件去重**:(仅 Windows)如果同一个按键事件在 20 毫秒内重复到达,忽略第二次。这是 Windows 终端驱动在某些情况下会重复发送同一个事件
5. **解析绑定**:将键盘事件与当前模式一起传入绑定解析函数,得到一组待执行的命令
6. **执行命令**:按顺序逐一执行这组命令
7. **渲染**:渲染一帧
8. 回到第 1 步
退出条件:`Quit` 命令执行成功后,退出循环并恢复终端。
---
## 终端布局
终端画面从上到下分为三个区域:
### 画布区域
占据终端高度减去最底部一行的所有行。从左到右填满终端宽度。
渲染过程不是简单的「每个格子对应一个终端列」,而是使用 `skip_until` 机制处理宽字符的视觉消费:
1. 从行首开始,维护两个游标:`grid_x`(格子索引)和 `col`(终端列索引)
2. 同时维护 `skip_until` 值,初始与 `grid_x` 相同
3. 在每一轮:
- 如果 `grid_x < skip_until`,该格子被前一个宽字符视觉消费,跳过它:`grid_x += 1`,`col += 1`
- 但如果光标恰好落在这个被消费的格子上,则在当前位置绘制一个光标块(不读取格子数据)
4. 否则读取格子 `canvas[grid_x][row_y]`,获取其字符和宽度 W
5. 如果 W > 1,设置 `skip_until = grid_x + W`(后续 W-1 个格子将被跳过)
6. 将字符绘制到从 `col` 开始的 W 列中
7. `col += W`,`grid_x += 1`,回到步骤 3
#### 宽字符不需要清空后续格子
注意:当一个宽字符写入格子 N 时,格子 N+1 到 N+W-1 的数据保持不动。视觉遮挡由渲染器的 `skip_until` 机制处理。不要尝试清空这些格子,否则会:
- 在被消费位置产生可见的空格
- 破坏格子数据,光标离开后再回来时字符已丢失
#### 选择模式
如果 `visual_anchor` 有值,计算以锚点和当前光标为对角的矩形范围。在该范围内的格子,渲染时应用视觉反转效果(前景色与背景色交换)。跳过机制优先于选择高亮:被跳过的格子不参与选择高亮渲染。
#### 光标渲染
如果光标坐标在视口范围内,在光标位置绘制光标符号。光标样式为高亮方块(在背景色上叠加亮色)。
- 如果光标落在被宽字符消费的格子上(`grid_x < skip_until`),在该列绘制一个光标块,不读取格子数据
- 如果光标落在正常格子上,在字符上叠加光标样式
- 光标渲染不修改画布数据
### 状态栏
状态栏固定在终端最后一行。从左到右依次显示:
1. 当前模式的显示名(如 `NORMAL`),左对齐
2. 一个空格
3. 当前光标坐标,格式为 `(x, y)`,左对齐
4. 如果 `is_modified` 为 `true`,显示一个空格加 `[modified]`
5. 一个空格
6. 如果 `file_path` 有值,显示文件名(路径的最后一部分);否则显示 `[No Name]`
7. 如果 `msg` 有值,在最右侧显示 `msg` 的内容,右对齐;显示后应将 `msg` 重置为无值
### 命令输入行
仅在模式为命令输入模式时显示,且覆盖状态栏所在行(替换状态栏)。
显示格式:先显示一个冒号 `:`,紧接着显示 `cmd_buffer` 的内容,最后显示光标(终端光标)。
---
## 颜色系统
### 颜色内部表示
所有颜色在画布中存储为 24 位 RGB,即红、绿、蓝各 8 位(取值范围 0-255)。
### 绘画模式的按键到颜色映射
在前景绘画模式或背景绘画模式下,按下字母键将对应的颜色设置到光标位置的格子上。如果是前景绘画模式,设置格子的 `fg` 字段;如果是背景绘画模式,设置格子的 `bg` 字段。
大写字母对应高亮色(亮度较高),小写字母对应暗色(亮度较低):
| 按键 | 颜色 | RGB 值 |
| ---- | ------------ | --------------- |
| `K` | 亮黑(深灰) | (128, 128, 128) |
| `k` | 暗黑(纯黑) | (0, 0, 0) |
| `W` | 亮白 | (255, 255, 255) |
| `w` | 暗白(浅灰) | (192, 192, 192) |
| `R` | 亮红 | (255, 0, 0) |
| `r` | 暗红 | (128, 0, 0) |
| `G` | 亮绿 | (0, 255, 0) |
| `g` | 暗绿 | (0, 128, 0) |
| `B` | 亮蓝 | (0, 0, 255) |
| `b` | 暗蓝 | (0, 0, 128) |
| `Y` | 亮黄 | (255, 255, 0) |
| `y` | 暗黄(橄榄) | (128, 128, 0) |
| `M` | 亮品红 | (255, 0, 255) |
| `m` | 暗品红 | (128, 0, 128) |
| `C` | 亮青 | (0, 255, 255) |
| `c` | 暗青 | (0, 128, 128) |
---
## ANSI 解析规则(加载文件时)
当程序启动时传入了文件路径,或用户在命令模式下执行了打开文件的命令(本文档当前只定义从命令行参数加载文件,不定义运行时打开新文件的命令),调用加载函数将文件内容解析为画布。
加载函数的输入是一个 UTF-8 字符串,输出是画布。解析规则如下:
### 状态机
解析器维护两个状态:
- 当前前景色,初始为 RGB(255, 255, 255)
- 当前背景色,初始为 RGB(0, 0, 0)
### 处理过程
逐字节扫描输入字符串:
- 如果遇到 `\x1b`(ESC 字符,ASCII 27),进入「转义序列解析」状态
- 如果遇到 `\n`(换行符),将行号 y 加 1,列号 x 重置为 0。注意不写入画布
- 如果遇到 `\r`(回车符),忽略
- 如果遇到其他任何字符,在画布的 `(x, y)` 坐标处以「当前前景色」和「当前背景色」写入该字符,然后将 x 加 1
### SGR 序列解析
当遇到 `\x1b` 后,后续字符按 ANSI 转义序列的标准格式解析:
1. 读取 `[` 字符确认是 CSI 序列。如果不是 `[`,跳过 `\x1b` 并回退该字符重新作为普通字符处理
2. 读取参数部分:用 `;` 分隔的多个数字参数,直到遇到一个终字符(`m` 表示 SGR)
3. 如果终字符不是 `m`,忽略整个序列,状态机的颜色不变
4. 如果终字符是 `m`,按顺序处理每一个参数:
- 参数 `0`:重置前景色为 RGB(255,255,255),背景色为 RGB(0,0,0)
- 参数 `1`:设置「粗体标志」为 true。此标志不影响颜色值,但记录即可,无需用于渲染
- 参数 `22`:设置「粗体标志」为 false
- 参数 `30`-`37`:将前景色设置为 16 色标准色中对应的颜色(见下方映射表),亮度受粗体标志影响(粗体标志为 true 时使用亮色变体,否则使用暗色变体)
- 参数 `38`:扩展前景色。后面必须跟 `;5;n`(8 位索引)或 `;2;r;g;b`(24 位 RGB)。格式不正确时忽略整个 `38` 序列
- 参数 `39`:前景色重置为 RGB(255,255,255)
- 参数 `40`-`47`:将背景色设置为 16 色标准色中对应的颜色(暗色变体,不受粗体标志影响)
- 参数 `48`:扩展背景色。格式同 `38`,作用于背景
- 参数 `49`:背景色重置为 RGB(0,0,0)
- 参数 `90`-`97`:将前景色设置为 16 色标准色中对应的亮色变体
- 参数 `100`-`107`:将背景色设置为 16 色标准色中对应的亮色变体
其他任何参数忽略。
### 16 色标准色映射表
| ANSI 代码 | 暗色变体 RGB | 亮色变体 RGB |
| --------- | --------------- | --------------- |
| 0(黑) | (0, 0, 0) | (128, 128, 128) |
| 1(红) | (128, 0, 0) | (255, 0, 0) |
| 2(绿) | (0, 128, 0) | (0, 255, 0) |
| 3(黄) | (128, 128, 0) | (255, 255, 0) |
| 4(蓝) | (0, 0, 128) | (0, 0, 255) |
| 5(品红) | (128, 0, 128) | (255, 0, 255) |
| 6(青) | (0, 128, 128) | (0, 255, 255) |
| 7(白) | (192, 192, 192) | (255, 255, 255) |
对应关系:ANSI 代码 30-37 映射到 0-7 的暗色变体,40-47 映射到 0-7 的暗色变体,90-97 映射到 0-7 的亮色变体,100-107 映射到 0-7 的亮色变体。
### 8 位索引色(38;5;n / 48;5;n)映射规则
8 位索引色(0-255)按标准 xterm 颜色表映射为 RGB:
- 0-7:标准 16 色中的暗色变体(同上表)
- 8-15:标准 16 色中的亮色变体(同上表,8 对应亮黑,9 对应亮红,以此类推)
- 16-231:6×6×6 颜色立方体。编码方式为 `16 + 36*r + 6*g + b`,其中 r、g、b 的取值范围为 0-5,映射到 RGB 值为 `r*255/5`、`g*255/5`、`b*255/5`
- 232-255:灰度渐变。从 RGB(8,8,8) 到 RGB(238,238,238),步长 10
### 异常处理
- 遇到无法识别的转义序列(以 `\x1b` 开头但不是 CSI SGR 序列),跳过整个序列继续
- 遇到残缺的序列(如 `\x1b[38;5` 后没有终字符就结束),忽略该序列
- 遇到非 UTF-8 编码,立即报错终止加载
---
## ANSI 序列化规则(保存文件时)
保存函数的输入是画布、画布宽度和画布高度,输出是 UTF-8 字符串。
### 基本规则
1. 从左上角 `(camera.x, camera.y)` 开始,逐行逐列扫描一个宽度×高度的矩形区域
2. 每一行结束后输出一个换行符 `\n`
3. 只有当前格子的颜色与前一格子的颜色不同时,才输出 SGR 序列改变颜色
4. 每一行开始时强制输出一次 SGR 序列设置到该行第一个格子的颜色
### SGR 输出规则
将 RGB 值转换为 SGR 24 位序列:
```
\x1b[38;2;R;G;Bm 设置前景色
\x1b[48;2;R;G;Bm 设置背景色
```
如果某些 ANSI 阅读器不支持 24 位色,可以通过环境变量 `ANSI_DRAW_8BIT` 切换到 8 位索引输出模式。该模式下将 RGB 映射到最接近的 8 位索引色(在 6×6×6 颜色立方体中找欧几里得距离最近的色块),输出 `\x1b[38;5;Nm` 或 `\x1b[48;5;Nm`。
### 优化规则
- 如果当前格子的前景色和背景色都和上一个格子相同,不输出任何 SGR 序列
- 如果前景色变了但背景色没变,只输出前景色 SGR
- 如果背景色变了但前景色没变,只输出背景色 SGR
- 如果两者都变了,先输出前景色 SGR,再输出背景色 SGR
### 默认格子的处理
对于画布中不存在的坐标,视为默认格子(空格、白字黑底)。在序列化时正常输出空格字符和对应的颜色。
---
## 视口与滚动
### 视口计算
渲染时,终端画布区域每一格对应的画布坐标由以下公式计算:
- 画布 x = 相机 x + 该格在终端中的列号(从 0 开始)
- 画布 y = 相机 y + 该格在终端中的行号(从 0 开始)
### 滚动行为
边距常量:2。
当光标移动后,检查光标是否位于视口的「边缘区域」。边缘区域定义为视口内距离边界 2 格以内的范围。
如果光标移动到了边缘区域之外(即离某一侧边界的距离小于 8),则相机跟随光标移动,使光标回到边缘区域内。具体规则:
- 如果光标 x 小于 相机 x + 边距,将相机 x 设置为 光标 x - 边距
- 如果光标 x 大于等于 相机 x + 终端宽度 - 边距,将相机 x 设置为 光标 x - 终端宽度 + 边距 + 1
- 如果光标 y 小于 相机 y + 边距,将相机 y 设置为 光标 y - 边距
- 如果光标 y 大于等于 相机 y + 终端高度 - 边距(画布区域行数),将相机 y 设置为 光标 y - 终端高度 + 边距 + 1(画布区域行数)
注意:以上公式计算出的值不能小于 0(相机坐标不允许为负数)。
### 加载文件时的初始位置
加载文件后,将相机位置设置为:相机 x = 0,相机 y = 0。然后执行一次滚动检查(使第一个字符 `(0, 0)` 位于距离视口右上角 2 格的位置)。
---
## Unicode 宽度处理
所有涉及光标水平移动、字符插入/删除、渲染对齐的场景,必须使用 `unicode-width` 库的宽度函数计算字符实际占用的终端列数,而不是使用 `char` 的默认长度方法。
### 格子坐标与终端列的映射
画布使用**格子索引**坐标系统:相邻格子坐标差值为 1,不论格子中字符的显示宽度。Unicode 宽度只在以下三个场景中影响行为:
1. **渲染**:将格子映射到终端列时,宽度 W 的字符占据 W 列
2. **光标移动**:移动步长以格子索引为单位,但渲染时光标位置根据字符宽度映射到终端列
3. **选区计算**:选区边界基于格子索引,不受字符宽度影响
### 渲染器对宽字符的处理
这是整个程序中最容易踩坑的地方,必须精确理解:
- 当一个格子中的字符宽度 W > 1 时(例如中文字符宽度为 2),该字符在终端中占据 W 列
- 渲染器用 `skip_until` 机制追踪当前位置之后哪些格子被宽字符「视觉消费」了:
- 格子 N 的字符宽度为 W → 格子 N+1 到 N+W-1 在渲染时**跳过**(不读取、不绘制),只推进列计数
- 跳过的格子仍然存在于画布数据中,只是不在这一帧绘制
- 这就是视觉上「宽字符吃掉后面格子」的效果。数据不动,渲染层负责
#### 为什么不能清空被消费的格子数据
早期版本尝试在输入宽字符时手动清空后续 `width-1` 个格子的数据(设为默认空格)。这会产生两个问题:
1. 空格在终端上可见(尤其是前景色非黑时),造成 `你_[3]456` 中的多余空格
2. 清空数据是破坏性的:光标移走后再回来,被清空的格子丢失了原有的字符
正确的做法:**格子数据只记录字符本身,视觉重叠由渲染器处理**。
#### 光标落在被消费的格子上
如果用户移动光标到被宽字符消费的格子(例如宽字符在 N,光标在 N+1),渲染器应在该位置绘制一个光标块(半透明方块),因为该位置没有属于自己的字符可供高亮。光标块占据了被消费位置的终端列。
### 三种模式的 Backspace 与宽度
| 模式 | Backspace 操作 | 左移量 | 说明 |
| ---- | ---------------------------------- | ------ | ---------------------------------------------------------------------- |
| 替换 | 光标处格子 → 默认格子 | 1 格 | 当前格子变空格,光标左移 |
| 输入 | 光标**左邻**格子 → 默认格子 | 1 格 | 操作格子坐标,不关心字符宽度。宽字符在视觉上占据的中间格子逐个退格清除 |
| 插入 | 删除光标左邻格子,右方格子左移填补 | 1 格 | 内容左移,不产生空格 |
注意:输入模式的退格在格子坐标层面只处理 1 格(相邻格子),不按字符宽度批量处理。因为宽字符被渲染器跳过,格子数据层面的操作是统一的 1 格。
### 输入模式下的光标右移量
替换模式:光标不移动。
输入模式:光标右移 `W` 格(W = 输入字符的显示宽度)。例如输入中文(宽度 2)时光标跳 2 格。
插入模式:光标右移 1 格(字符插入在光标位置,后续内容已经右移)。
### 中日韩文字与 IME 输入
- 「可打印字符」的定义范围是:所有非控制字符(不是 ASCII 控制字符 0x00-0x1F 的 Unicode 字符)。包括但不限于 ASCII 字母数字、标点、空格、中日韩统一表意文字(CJK)、emoji 等
- 绑定表中「任何可打印 Unicode 字符」作用于替换/输入/插入模式时,对每个输入的字符产生一个独立的 `InputChar` 命令
- 输入法(IME)的选词弹出窗口由终端/操作系统管理,ANSI-DRAW 不做干预。IME 确认提交(commit)后,每个提交的字符作为一个独立的 `KeyCode::Char` 事件到达程序,逐一处理
- 终端键盘自动连发(按住一个键持续输入)由终端驱动层处理,每个重复的按键事件独立到达,逐一产生 `InputChar` 命令
- 宽度为 2 的字符(如大部分 CJK 文字)在光标移动和渲染时占 2 个终端列,但画布坐标仍以一个格子计数
---
## 命令输入语法
命令输入模式下的输入字符串解析规则如下:
1. 输入字符串可以以可选的 `!` 字符开头。如果存在 `!`,则该次解析出的所有命令都使用强制模式
2. 输入字符串中,用逗号 `,` 分隔多个命令
3. 每个命令可以是「全名」或「简写」
4. 如果命令简写连续出现(没有逗号隔开),从左到右依次解析,每个简写字符就是一个独立的命令
5. 全名和简写不能混写在同一段中(即 `rr,MoveRight` 合法,表示两个命令 `MoveRight` 和 `MoveRight`;`rrMoveRight` 不合法,应报错)
全名和简写的映射见下方的命令表。
解析示例:
| 输入 | 解析结果 |
| --------------- | ------------------------------------------------------------- |
| `rrrr` | MoveRight × 4 |
| `wq` | Write + Quit |
| `!wq` | Write(forced) + Quit(forced) |
| `w,MoveRight,q` | Write + MoveRight + Quit |
| `!w,MoveRight` | Write(forced) + MoveRight(强制对无强制行为差异的命令无影响) |
---
## 命令表
以下表格列出了所有命令的名称、简写、行为描述、强制模式下的行为差异、连带执行的后处理命令。
| 命令名 | 简写 | 行为 | 强制模式行为 | 后处理命令 |
| --------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------- |
| Write | w | 将画布序列化后写入 `file_path`。如果 `file_path` 为无值,写入当前目录下的 `my_ascii.txt`。如果目标文件已存在且未使用强制模式,用 `my_ascii.1.txt`、`my_ascii.2.txt` ……递增尝试,直到找到一个不存在的文件名 | 直接覆盖目标文件,不做递增尝试 | 执行 UnmarkModify 命令 |
| Quit | q | 检查 `is_modified`,如果为 false 则退出程序;如果为 true 则放弃本次操作(不退出) | 直接退出,忽略 `is_modified` 状态 | 无 |
| MoveRight | r | 将光标 x 加 1 | 无区别 | 无 |
| MoveLeft | l | 将光标 x 减 1,最小为 0 | 无区别 | 无 |
| MoveUp | u | 将光标 y 减 1,最小为 0 | 无区别 | 无 |
| MoveDown | d | 将光标 y 加 1 | 无区别 | 无 |
| Yank | y | 复制光标所在位置的格子(字符、前景色、背景色)到剪贴板的 `cell` 字段,同时清空剪贴板的 `cells` 字段 | 无区别 | 无 |
| Paste | p | 将剪贴板内容粘贴到当前光标所在位置。行为依当前模式而定(见各模式下的粘贴描述) | 无区别 | 见下方「Paste 后处理规则」 |
| BackspaceSquash | 无简写 | 删除光标左侧格子,将光标及右侧的本行所有格子左移一格填补空缺。自身不移动光标 | 无区别 | 执行 MarkModify,然后执行 MoveLeft 命令移动光标 |
| Backspace | 无简写 | 替换格子为默认格子,然后左移光标。替换的目标取决于当前模式:替换模式替换光标所在格子,输入模式替换光标左侧格子 | 无区别 | 执行 MoveLeft |
| ModeInsert | 无简写 | 切换到插入模式 | 无区别 | 无 |
| ModeInput | 无简写 | 切换到输入模式 | 无区别 | 无 |
| ModeReplace | 无简写 | 切换到替换输入模式 | 无区别 | 无 |
| ModeNavigate | 无简写 | 切换到导览模式 | 无区别 | 无 |
| ModeVisual | 无简写 | 切换到选择模式,并将当前光标位置记录到 `visual_anchor` | 无区别 | 无 |
| ModePaintFg | 无简写 | 切换到前景绘画模式 | 无区别 | 无 |
| ModePaintBg | 无简写 | 切换到背景绘画模式 | 无区别 | 无 |
| ModeCommand | 无简写 | 切换到命令输入模式,清空 `cmd_buffer` | 无区别 | 无 |
| MarkModify | 无简写 | 将 `is_modified` 设为 true | 无区别 | 无 |
| UnmarkModify | 无简写 | 将 `is_modified` 设为 false | 无区别 | 无 |
| CancelSelection | 无简写 | 将 `visual_anchor` 设为无值 | 无区别 | 无 |
| VisualYank | 无简写 | 将矩形选区内的所有格子(以 `visual_anchor` 和当前光标为对角的矩形范围)逐行复制到剪贴板的 `cells` 字段,同时清空 `cell` 字段。此操作不修改画布内容,不标记修改状态 | 无区别 | 执行 CancelSelection |
| VisualDelete | 无简写 | 将矩形选区内的所有格子(同上范围)替换为默认格子(空格、白字黑底),但不清除这些坐标的条目——或者也可以直接移除这些条目,读取时的默认值会返回默认格子。两种方式等价,任选一种 | 无区别 | 执行 MarkModify |
### Paste 后处理规则
Paste 命令的后处理依当前模式而定:
- 替换输入模式:先逐个替换光标处的格子(从剪贴板的 `cell` 或 `cells` 中读取),然后执行 MarkModify。不额外移动光标(因为 `cells` 粘贴完成后光标应在最后一格之后,但单字符粘贴时光标右移一格)
- 输入模式:在光标位置插入字符,将光标右移,然后执行 MarkModify
- 插入模式:在光标前方插入字符,后面字符右移,光标右移,然后执行 MarkModify
- 其他模式:Paste 不触发(没有绑定)
具体实现:Paste 命令自身在命令输入模式下被输入时,应该使用「替换输入模式」的语义(逐个替换光标处字符)。
---
## 模式
以下按模式分类说明行为。每种模式下列出该模式专有的行为。通用行为(箭头移动光标、En 滚动检查)在所有模式下均生效。
### 通用行为(所有模式)
- 箭头键(上、下、左、右)移动光标一个格子(移动量见 Unicode 宽度规则)。移动后执行滚动检查
- ESC 键回到导览模式(选择模式额外执行 CancelSelection)
### 导览模式
不能直接编辑画布。只能通过按键切换到其他模式:
| 按键 | 切换到 |
| ---- | ------------ |
| `R` | 替换输入模式 |
| `I` | 插入模式 |
| `A` | 输入模式 |
| `F` | 前景绘画模式 |
| `B` | 背景绘画模式 |
| `:` | 命令输入模式 |
| `V` | 选择模式 |
### 选择模式
进入选择模式时,将当前光标位置记录到 `visual_anchor`。移动光标时,`visual_anchor` 到当前光标之间的矩形区域在渲染时反色显示。
| 按键 | 命令 | 说明 |
| ----- | ------------------------------ | ---------------------------- |
| `Y` | VisualYank | 复制选区到剪贴板,清除选区 |
| `D` | VisualDelete | 清除选区内所有字符,标记修改 |
| `ESC` | ModeNavigate + CancelSelection | 退出选择模式,清除选区 |
### 替换输入模式
按任意字符键(0-9、a-Z、A-Z、标点符号等可以输入的字符),将该字符替换光标所在位置的格子内容,同时更新该格子的字符为新输入的字符。颜色不变。光标不移动。执行 MarkModify。
例如光标在 `Hello, Worl[d]` 的 `d` 上,按 `A` 后变成 `Hello, Worl[A]`(光标仍在 `A` 上)。
粘贴(P 键):将剪贴板中的格子逐个替换光标处的格子。如果是单字符,替换当前格后光标右移一格;如果是矩形矩阵,从当前光标位置开始逐行逐列替换。完成后执行 MarkModify。
退格(Backspace 键):将光标所在位置替换为默认格子,然后执行 MoveLeft。效果示例:
```
Hello, [W]orld → 按 Backspace
Hello,[ ] orld (W 变空格,光标左移到空格上)
再次按 Backspace:
Hello[ ] orld (上次光标在空格,替换空格并左移)
```
### 输入模式
按任意字符键,替换光标所在位置的字符,然后光标按字符宽度右移:
1. 将该字符写入光标所在格子(替换原来的字符。颜色保持原样不修改)
2. 光标向右移动 `W` 格(`W` = 该字符的显示宽度)
3. 不移动该行其他任何格子
例如,单宽字符(`[ ]` 表示光标):
```
Hello,[ ] World 输入 A →
Hello,A[W]orld 输入 B →
Hello,AB[o]rld 输入 C →
Hello,ABCo[r]ld
```
例如,双宽字符:
```
[1]23456 输入 '你'(宽度 2)→
你[3]456 1→你,2 被清空,光标向右跳 2 格指向 3
```
注意输入模式和替换模式的区别:替换模式光标不移动,输入模式光标右移。输入模式和插入模式的区别:输入模式不移动后续字符(仅清空被宽字符占据的格子),插入模式将后续字符全部右移。
粘贴(P 键):将剪贴板内容逐个在光标处替换,每替换一个字符光标右移一格(依字符宽度)。完成后执行 MarkModify。
退格(Backspace 键):将光标**左侧**的格子替换为默认格子,然后执行 MoveLeft 左移一格。不移动行内其他格子。
效果示例(`[_]` 表示光标):
```
12345[_]7890 按 Backspace →
1234[_]_7890 按 Backspace →
123[_]__7890
```
双宽字符退格:
```
你[3]456 按 Backspace →
[_]3456 你(宽度 2)被清掉 2 格,光标左移 2 格指向 3
```
### 插入模式
按任意字符键,在光标前方插入一个字符:
1. 将该字符写入光标所在格子
2. 将光标及光标右侧本行的所有格子向右移动一个位置
3. 光标右移一格(以输入字符的显示宽度为准)
与输入模式的区别:输入模式下不移动后续字符,仅替换光标处字符然后右移光标;插入模式下将光标及后续字符全部右移后再写入新字符,光标也右移。效果对比如下:
输入模式:`Hello,[ ] World` → 输入 `A` → `Hello,A[W]orld`(W 在原位,被光标指到)
插入模式:`Hello,[ ] World` → 输入 `A` → `Hello,A[ ] World`(全部右移,光标仍指向空格)
粘贴(P 键):将剪贴板内容逐个在光标前方插入,光标右移。完成后执行 MarkModify。
退格(Backspace 键):删除光标左侧的格子,将光标及之后的字符向左移动一格填补空缺。然后执行 MoveLeft。
例如:
```
Hello,ABC[ ] World 按 Backspace →
Hello,AB[C] World 按 Backspace →
Hello,A[B] World
```
### 命令输入模式
按键直接输入字符到 `cmd_buffer`。支持以下特殊按键:
- 回车键:解析 `cmd_buffer` 为命令列表,顺序执行。执行完后清空 `cmd_buffer`,切换到导览模式
- ESC:清空 `cmd_buffer`,切换到导览模式
- Backspace:删除 `cmd_buffer` 最后一个字符
解析规则见上方的「命令输入语法」一节。
### 前景绘画模式 / 背景绘画模式
按字母键将颜色设置到光标所在格子的对应颜色字段(前景绘画模式设置 `fg`,背景绘画模式设置 `bg`)。颜色按键映射表见「绘画模式的按键到颜色映射」一节。
此模式只修改颜色,不修改字符。如果光标所在位置在画布中不存在(默认格子),则先写入一个默认格子再修改颜色;也可直接将颜色写入不存在的坐标的画布条目——读取默认格子的颜色后修改即可。执行 MarkModify。
---
## 绑定表
以下表格定义了所有按键绑定。每个条目表示在某模式下按下某按键后应执行的命令序列。
| 按键 | 模式 | 命令序列 |
| ----------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------- |
| 左箭头 KeyLeft | 所有模式 | [MoveLeft](然后执行通用滚动检查) |
| 右箭头 KeyRight | 所有模式 | [MoveRight](然后执行通用滚动检查) |
| 上箭头 KeyUp | 所有模式 | [MoveUp](然后执行通用滚动检查) |
| 下箭头 KeyDown | 所有模式 | [MoveDown](然后执行通用滚动检查) |
| ESC | 插入/输入/替换/命令/前景绘画/背景绘画模式 | [ModeNavigate] |
| ESC | 选择模式 | [ModeNavigate, CancelSelection] |
| R | 导览模式 | [ModeReplace] |
| I | 导览模式 | [ModeInsert] |
| A | 导览模式 | [ModeInput] |
| F | 导览模式 | [ModePaintFg] |
| B | 导览模式 | [ModePaintBg] |
| : | 导览模式 | [ModeCommand] |
| V | 导览模式 | [ModeVisual] |
| Y | 插入/输入/替换模式 | [Yank] |
| Y | 选择模式 | [VisualYank, CancelSelection] |
| D | 选择模式 | [VisualDelete] |
| P | 插入/输入/替换模式 | [Paste](Paste 的行为依当前模式而定) |
| Backspace | 插入模式 | [BackspaceSquash] |
| Backspace | 输入/替换模式 | [Backspace] |
| 任何可打印 Unicode 字符(含空格、中文等) | 替换模式 | [InputChar(该字符)]——InputChar 命令的执行逻辑见替换模式定义 |
| 任何可打印 Unicode 字符(含空格、中文等) | 输入模式 | [InputChar(该字符)]——执行逻辑见输入模式定义 |
| 任何可打印 Unicode 字符(含空格、中文等) | 插入模式 | [InputChar(该字符)]——执行逻辑见插入模式定义 |
| 可打印字母(a-Z、A-Z) | 前景绘画模式 | [SetColor(对应颜色)] |
| 可打印字母(a-Z、A-Z) | 背景绘画模式 | [SetColor(对应颜色)] |
| 回车键 Enter | 命令输入模式 | 解析 cmd_buffer 得到命令列表,顺序执行,然后 [ModeNavigate],清空 cmd_buffer |
| ESC | 命令输入模式 | 清空 cmd_buffer,[ModeNavigate] |
| Backspace | 命令输入模式 | 删除 cmd_buffer 最后一个字符 |
| 任何可打印 Unicode 字符 | 命令输入模式 | 追加到 cmd_buffer |
注意:「命令输入模式」下的任何可打印 Unicode 字符不产生 InputChar 命令,而是直接追加到 cmd_buffer 字符串。这不是通过命令系统完成的,而是对 App 状态的直接操作。
---
## 加载时的初始状态
程序启动时:
1. 解析命令行参数。如果提供了一个参数,将其作为文件路径存入 `file_path`,读取文件内容并调用 ANSI 解析函数加载到画布。如果文件不存在或读取失败,设置 `msg` 为错误描述,画布保持为空
2. 如果没有提供参数,画布为空,`file_path` 为无值
3. 相机初始位置为 (0, 0)。如果加载了文件,执行一次滚动检查(使 (0, 0) 位于距右上角 2 格的位置)
4. 模式为导览模式
5. 所有其他字段为初始默认值
---
## 边缘情况处理
以下列出了所有边界条件和对应的处理方法:
### 光标越界
- 光标 x 最小值:0。MoveLeft 时如果 x 已为 0,不移动
- 光标 y 最小值:0。MoveUp 时如果 y 已为 0,不移动
- 光标 x 和 y 没有最大值上限(画布无限)
- 光标超出视口范围时正常运作,渲染时如果光标不在视口内则不绘制光标符号
### 相机越界
- 相机 x 和 y 最小值:0
- 滚动检查时如果计算出的相机坐标小于 0,取 0
### 文件保存失败
- 写入文件时如果目标目录不可写,设置 `msg` 为「写入失败:原因」
- 不改变 `is_modified` 状态(仍然保持为 true)
### 空画布保存
- 空画布保存为:不带任何颜色代码的空字符串。因为宽度×高度范围内的所有格子都是默认格子(空格、白字黑底),序列化后输出空内容。但是每行仍然应输出换行符
### 无内容文件加载
- 空文件加载后画布为空,无任何坐标有值
### 选择模式重叠
- 选择模式的矩形选区只计算格子坐标,不考虑字符宽度(选区边界基于 x/y 坐标,不涉及字符宽度计算)
- 如果选区宽度或高度为 0(即 `visual_anchor` 和当前光标在同一行同一列),选区无效,VisualYank 和 VisualDelete 无操作
### 剪贴板为空时粘贴
- 如果 `cell` 和 `cells` 均为无值,Paste 命令无操作
### 绘画模式在空位置涂色
- 如果光标所在坐标在画布中不存在,创建一个默认格子再设置颜色。等效于写入一个格子 `{ ch: ' ', fg: (255,255,255), bg: (0,0,0) }` 后将对应的颜色字段改为目标颜色
### 插入模式下在行尾插入
- 如果光标右侧没有字符(该行在 x 右侧无任何坐标有值),直接在当前坐标写入字符,光标右移。不需要移动任何现有字符
### InsertChar 与 SetColor 的 MarkModify
- `InputChar` 命令(替换/输入/插入模式)执行后应自动执行 MarkModify
- `SetColor` 命令(绘画模式)执行后应自动执行 MarkModify
- 这两者不依赖绑定表上的额外 MarkModify,而是在命令执行逻辑内部处理
### 终端尺寸变化
- 每次渲染时重新获取终端尺寸。如果终端变大了,画布区域增加,用户可以看到更多画面;如果终端变小了,画布区域减少,但画布和相机数据不变
### 滚动检查时机
- 每次光标移动后(无论通过什么命令移动)都要执行滚动检查
---
|