aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-07-21 22:27:50 +0800
committer魏曹先生 <1992414357@qq.com>2026-07-21 22:27:50 +0800
commitf4732678f621aba71f635f9a1b3bafd3ce14a96a (patch)
tree2625544cb1ebc941f5dcb42fa64404af331dd74b
docs: add ANSI-DRAW specification and WTFPL license
-rw-r--r--ANSI-DRAW.md721
-rw-r--r--LICENSE13
2 files changed, 734 insertions, 0 deletions
diff --git a/ANSI-DRAW.md b/ANSI-DRAW.md
new file mode 100644
index 0000000..70a3246
--- /dev/null
+++ b/ANSI-DRAW.md
@@ -0,0 +1,721 @@
+# ANSI-DRAW.md
+
+一个基于终端的 ANSI 字符画编辑器。
+
+它:
+
+- 无限画布
+- 丰富的色彩操作
+- ~~具有挑战性、随机性的安装过程~~。
+
+---
+
+## 安装方式
+
+0. 准备 `rust` 工具链(`cargo` + `rustc`)
+1. 将该仓库拉取到您的计算机
+2. 启动任意 AIAgent,让其完成
+
+---
+
+## For LLMs
+
+你现在要实现一个名为 `ANSI-DRAW` 的 `rust` TUI 程序。用户 **全程不会** 参与代码的 Review 环节,只参与验收。请独自完成全部。
+
+### 核心抽象
+
+- **命令**:程序管道的核心部分。所有用户操作都转化为命令,命令被顺序执行。命令的执行和画面的渲染在同一个主循环中交替进行
+- **画布**:记录所有字符及其颜色。无限大,只有被写入过的坐标才占用内存
+- **相机**:记录用户当前看见的画面区域在画布上的位置(左上角坐标)
+- **光标**:记录用户在画布上的焦点位置(坐标)
+- **模式**:全局状态枚举,决定按键绑定如何解析。可通过命令切换
+- **绑定**:将键盘事件映射为一个或多个命令。同一按键在不同模式下映射到不同命令
+
+### 开发流程(严格按顺序执行)
+
+请严格遵守以下顺序,一步一步完成。不允许合并多个步骤为一步,不允许跳过步骤。
+
+1. 按照本规范的全部要求编写代码
+2. 为每个模块编写测试(测试核心逻辑:光标移动、滚动检查、命令解析、ANSI 解析和序列化的对称性、各个编辑模式的行为)
+3. 执行 `cargo build`、`cargo test`,保证能构建且测试全部通过
+4. 执行 `cargo clippy -- -D warnings` 检查代码质量,修复所有警告直到输出为空。不允许添加任何 clippy 抑制属性
+5. 如果你有子 Agent 能力,生成两个子 Agent,分别对代码做 Review。Review 的重点:是否违反本规范中的硬性约束,是否存在遗漏的边界情况
+6. 将已完成的部分以简洁的要点形式写入根目录的 `DONE.md` 文件(写入即可,不需要读取)
+7. 读取 `DONE.md` 和本规范对比,检查是否有遗漏的功能点或偏差。不要写入任何文件
+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`:可选的字符串,用于在状态栏右侧显示临时消息。显示一次后应被消费并重置为无值
+
+---
+
+## 主循环
+
+程序主循环按以下顺序反复执行:
+
+1. **渲染**:根据当前 App 状态绘制一帧画面到终端
+2. **等待输入**:等待一个键盘事件,设置一个合适的超时时间(建议 100 毫秒),使画面能以合理帧率刷新
+3. **解析绑定**:将键盘事件与当前模式一起传入绑定解析函数,得到一组待执行的命令
+4. **执行命令**:按顺序逐一执行这组命令,每条命令可能会修改 App 的任意字段
+5. 回到第 1 步
+
+退出条件:`Quit` 命令执行成功后,退出循环并恢复终端。
+
+---
+
+## 终端布局
+
+终端画面从上到下分为三个区域:
+
+### 画布区域
+
+占据终端高度减去最底部一行的所有行。从左到右填满终端宽度。
+
+对于画布区域中的每一个终端单元格,计算其对应的画布坐标:
+
+- 画布 x = 相机 x + 终端列号
+- 画布 y = 相机 y + 终端行号
+
+然后读取画布上该坐标的格子。如果该坐标在画布中不存在,使用默认格子(空格、白字黑底)。用该格子的字符和颜色绘制到终端。
+
+对于选择模式:如果 `visual_anchor` 有值,计算以锚点和当前光标为对角的矩形范围。在该范围内的格子,渲染时应用视觉反转效果(前景色与背景色交换)。
+
+如果光标位于当前视口内(即光标坐标在相机坐标到相机坐标 + 终端尺寸范围内),在光标位置绘制一个可见的光标符号。光标的视觉样式为一个半透明或高亮方块(在字符背景上叠加一个半透明色块,或在字符下方绘制下划线——选择一个在你的 TUI 库中可行的方案)。注意:光标渲染不修改画布数据。
+
+### 状态栏
+
+状态栏固定在终端最后一行。从左到右依次显示:
+
+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 开始)
+
+### 滚动行为
+
+边距常量:8。
+
+当光标移动后,检查光标是否位于视口的「边缘区域」。边缘区域定义为视口内距离边界 8 格以内的范围。
+
+如果光标移动到了边缘区域之外(即离某一侧边界的距离小于 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)` 位于距离视口右上角 8 格的位置)。
+
+---
+
+## Unicode 宽度处理
+
+所有涉及光标水平移动、字符插入/删除、渲染对齐的场景,必须使用 `unicode-width` 库的宽度函数计算字符实际占用的终端列数,而不是使用 `char` 的默认长度方法。
+
+具体规则如下:
+
+- 光标水平移动(MoveLeft / MoveRight):移动的步长 = 当前字符的显示宽度。例如,在宽度为 2 的汉字上按右箭头,光标跳 2 列而非 1 列
+- 在输入模式/插入模式下输入字符:输入完成后光标右移的距离 = 该字符的显示宽度
+- Backspace(替换和输入模式):将光标处的字符替换为空格后,光标左移的距离 = 被删除字符的显示宽度
+- BackspaceSquash(插入模式):删除光标左侧的字符后,光标左移的距离 = 被删除字符的显示宽度
+- 渲染画布时:如果一个字符的宽度为 2,它在终端中占据 2 列。渲染完该字符后,渲染位置跳过 1 列(即同一行的下一个画布坐标,而非终端下一列),以保证后续字符对齐
+- 在画布坐标计算中,所有坐标使用「格子索引」而非「终端列数」。即两个相邻格子坐标的差值是 1(不论格子中的字符宽度是多少)。Unicode 宽度只在渲染和光标移动时用于计算终端列数偏移量
+- 选区计算:矩形选区的边界基于格子索引,不受字符宽度影响
+
+---
+
+## 命令输入语法
+
+命令输入模式下的输入字符串解析规则如下:
+
+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 | 无简写 | 将光标所在位置的格子替换为默认格子(空格、白字黑底),然后将光标左移一格(以被替换字符的宽度为准,但坐标减 1) | 无区别 | 执行 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. 将光标右侧本行的所有格子向右移动一个位置(从最右侧开始逐一右移,或者用更高效的插入方式,只要能保证字符正确插入且后续字符不丢失)
+3. 光标右移一格(以输入字符的显示宽度为准)
+
+例如(`[ ]` 表示光标):
+
+```
+Hello,[ ] World 输入 A →
+Hello,A[ ]World 输入 B →
+Hello,AB[W]orld 输入 C →
+Hello,ABC[o]rld
+```
+
+粘贴(P 键):将剪贴板内容逐个在光标处插入,每插入一个字符光标右移一格。完成后执行 MarkModify。
+
+退格(Backspace 键):将光标所在位置替换为默认格子,然后执行 MoveLeft。行为同替换输入模式。
+
+### 插入模式
+
+按任意字符键,在光标前方插入一个字符:
+
+1. 将该字符写入光标所在格子
+2. 将光标及光标右侧本行的所有格子向右移动一个位置
+3. 光标右移一格(以输入字符的显示宽度为准)
+
+与输入模式的区别:输入模式下光标位于已插入字符之后,后续字符向右被推;插入模式下光标位于新字符之前,光标本身也被右移。效果对比如下:
+
+输入模式:`Hello,[ ] World` → 输入 `A` → `Hello,A[ ]World`
+插入模式:`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] |
+| 可打印字符(含空格) | 替换模式 | [InputChar(该字符)]——InputChar 命令的执行逻辑见替换模式定义 |
+| 可打印字符(含空格) | 输入模式 | [InputChar(该字符)]——执行逻辑见输入模式定义 |
+| 可打印字符(含空格) | 插入模式 | [InputChar(该字符)]——执行逻辑见插入模式定义 |
+| 可打印字母(a-Z、A-Z) | 前景绘画模式 | [SetColor(对应颜色)] |
+| 可打印字母(a-Z、A-Z) | 背景绘画模式 | [SetColor(对应颜色)] |
+| 回车键 Enter | 命令输入模式 | 解析 cmd_buffer 得到命令列表,顺序执行,然后 [ModeNavigate],清空 cmd_buffer |
+| ESC | 命令输入模式 | 清空 cmd_buffer,[ModeNavigate] |
+| Backspace | 命令输入模式 | 删除 cmd_buffer 最后一个字符 |
+| 可打印字符(含空格) | 命令输入模式 | 追加到 cmd_buffer |
+
+注意:「命令输入模式」下的可打印字符不产生 InputChar 命令,而是直接修改 cmd_buffer 字符串。这不是通过命令系统完成的,而是对 App 状态的直接操作。
+
+---
+
+## 加载时的初始状态
+
+程序启动时:
+
+1. 解析命令行参数。如果提供了一个参数,将其作为文件路径存入 `file_path`,读取文件内容并调用 ANSI 解析函数加载到画布。如果文件不存在或读取失败,设置 `msg` 为错误描述,画布保持为空
+2. 如果没有提供参数,画布为空,`file_path` 为无值
+3. 相机初始位置为 (0, 0)。如果加载了文件,执行一次滚动检查(使 (0, 0) 位于距右上角 8 格的位置)
+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,而是在命令执行逻辑内部处理
+
+### 终端尺寸变化
+
+- 每次渲染时重新获取终端尺寸。如果终端变大了,画布区域增加,用户可以看到更多画面;如果终端变小了,画布区域减少,但画布和相机数据不变
+
+### 滚动检查时机
+
+- 每次光标移动后(无论通过什么命令移动)都要执行滚动检查
+
+---
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b3e27d9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,13 @@
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+ Version 2, December 2004
+
+ Copyright (C) 2026 Weicao-CatilGrass
+
+ Everyone is permitted to copy and distribute verbatim or modified
+ copies of this license document, and changing it is allowed as long
+ as the name is changed.
+
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. You just DO WHAT THE FUCK YOU WANT TO.