diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-07-21 22:27:50 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-07-21 22:27:50 +0800 |
| commit | f4732678f621aba71f635f9a1b3bafd3ce14a96a (patch) | |
| tree | 2625544cb1ebc941f5dcb42fa64404af331dd74b | |
docs: add ANSI-DRAW specification and WTFPL license
| -rw-r--r-- | ANSI-DRAW.md | 721 | ||||
| -rw-r--r-- | LICENSE | 13 |
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,而是在命令执行逻辑内部处理 + +### 终端尺寸变化 + +- 每次渲染时重新获取终端尺寸。如果终端变大了,画布区域增加,用户可以看到更多画面;如果终端变小了,画布区域减少,但画布和相机数据不变 + +### 滚动检查时机 + +- 每次光标移动后(无论通过什么命令移动)都要执行滚动检查 + +--- @@ -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. |
