# 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,而是在命令执行逻辑内部处理 ### 终端尺寸变化 - 每次渲染时重新获取终端尺寸。如果终端变大了,画布区域增加,用户可以看到更多画面;如果终端变小了,画布区域减少,但画布和相机数据不变 ### 滚动检查时机 - 每次光标移动后(无论通过什么命令移动)都要执行滚动检查 ---