diff --git a/Cargo.lock b/Cargo.lock index 946a435..2e83722 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1922,7 +1922,7 @@ dependencies = [ [[package]] name = "ely-gpui-component" version = "0.1.1" -source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=2e196a10a7c3265eac6d5c52ebcec465ad0374c8#2e196a10a7c3265eac6d5c52ebcec465ad0374c8" +source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=ce4a1e9cb5fc712e9d03f5bb703d13685f1cb67a#ce4a1e9cb5fc712e9d03f5bb703d13685f1cb67a" dependencies = [ "alacritty_terminal", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 7736114..5952aeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ jiff = "0.2" anyhow = "1" directories = "6" dunce = "1" -ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "2e196a10a7c3265eac6d5c52ebcec465ad0374c8" } +ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "ce4a1e9cb5fc712e9d03f5bb703d13685f1cb67a" } env_logger = "0.11" gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] } diff --git a/agents.md b/agents.md index 49f1ee8..9ea6ece 100644 --- a/agents.md +++ b/agents.md @@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 ## 组件映射 -依赖 `ely-gpui-component`(pin 2e196a1,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 +依赖 `ely-gpui-component`(pin ce4a1e9,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 | 需要 | 用 | | --- | --- | @@ -283,4 +283,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。 - 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。 - 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。 -- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印删去,在 C14.9、C14.10、C14.12、C14.14 原生重做。C14.5 删去 CEF:`crates/ely-web-content`、`apps/ely-mark/web`、`scripts/fetch-cef.sh`、`scripts/build-mark-content.sh`、macOS 包里的辅助应用与其授权、Linux AppImage 的 `libcef` 安排、CEF 与网页依赖的许可、存储设置里的网页缓存、`webview.sh`、`hardened-macos.sh`、`linux-mark-idle.sh`、参考源码 cef、cef-rs、milkdown、codemirror-dev;`apps::open` 不再复原信号(只为 Chromium 屏蔽的 SIGTERM 而设)。旧 C14.1 至 C14.7、R2.13 与 ElyMark AppImage 的记录(CEF 实现)删去,在 git 历史;仍在的应用行为(外部变更与比较、会话、改名、Git 状态、最近与文件夹、`look.json`)在 C14.8 至 C14.16 逐条核对后重记。用户以前的 ElyMark 数据目录里可能留着 `web` 缓存文件夹,Ely 不再读写它。组件库 6f0401e:焦点进入空的可视文档即打开末尾的输入框,新文件可直接键入(原先键入无处落下);362a0fc:换回可视时同样打开(预览与可视共用焦点,无焦点事件),全文撤销后若文档成空,读完新文字再打开末尾的输入框;639521e:只有一份撤销历史,块的输入框自己的撤销与重做从不运行:在打开的块里撤销或重做,先把块写回为一步,再走全文的历史(块内逐键的撤销没有了,一次块编辑是一步),重做跨模式、跨多次撤销都在。ElyMark(C14.6):每个标签各记其模式,切换不改一个字节(编码写出与原文相同),撤销跨模式(全文一份撤销历史);视图在模式首次可键入时记一次 `mark: shown as "…"`(源码即刻,可视与预览在块读完后),PERF-07 计到这条。Windows 上实测(Ryzen 9 9950X3D)1 MB 文档 P50 341 毫秒、P95 560 毫秒(`docs/perf/2026-10-12-Ryzen9-9950X3D.md`);macOS、Linux 与树莓派未测。C14.7:语法由 markdown-rs 读(CommonMark 加 GFM 的表格、任务、删除线、自动链接,脚注,公式),front matter 不进可视,以原文块显示在最上(组件库 parse 测试核对各种块画成内容而非原文);链接照 M-05:预览单击、可视 Cmd(他处 Ctrl)按下交应用,可视普通按下编辑该块(组件库 bbb6071 的链接测试);应用只把 http、https、mailto 交系统打开,其余以提示拒绝(应用测试);链接地址与图片读不出的原因不进日志(地址与路径属文档内容)。文档旁图片按文件所在文件夹读(`MarkView` 在打开与改名时交给组件);组件库 4801555:表格单元格也是块,图片在格内照常画出(原只画替代文字),图片取自身宽度、以所在容器为上限(单元格可收缩,窄格里的图片按比例缩小,组件库 2e196a1)。gpui 的图片加载(`vendor/gpui-pre` 的 `img.rs`):GIF 与 WebP 的坏帧即结束加载并报错(`img.rs` 的加载器与 `platform.rs` 的 `Image::to_image_data`,后者给 `data:` 图片用)(原样跳过坏帧,解码器到文件尾仍不停地报错,加载永不结束、占满一个核),错误与日志不带图片路径(原样把路径写进错误,`AssetLogger` 照写进日志);应用测试以坏 GIF 核对两处加载都结束、错误里没有所在文件夹名。C14.8:左栏三页照旧(文件夹树、大纲、搜索);大纲在纵向滚动区里(标题再多也选得到,侧栏测试以 100 个标题滚到最后),取组件按 markdown-rs 读出的标题与行,选一项即 `reveal`(该行在视图里、插入点在其上;可视模式里键入仍须先按下块);多标签与最近列表经外壳与 `recent.json` 照旧。`desk::open_in` 收数据文件夹(`open` 照旧取本机的),应用的 gpui 测试以临时文件夹起整个窗口:打开文件即有标签、大纲行号、跳转落点与最近列表,键入的标题进大纲,两个文件两个标签各有大纲,打开文件夹只列 Markdown;文件监视在真线程上,测试以 `allow_parking` 允许。`ely_ui::install_no_launch_files`(`test-support`)给测试一个空的启动文件队列。随删去的网页页面一起的 `web.*` 文案(`i18n/web.rs`)删去。 +- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印删去,在 C14.9、C14.10、C14.12、C14.14 原生重做。C14.5 删去 CEF:`crates/ely-web-content`、`apps/ely-mark/web`、`scripts/fetch-cef.sh`、`scripts/build-mark-content.sh`、macOS 包里的辅助应用与其授权、Linux AppImage 的 `libcef` 安排、CEF 与网页依赖的许可、存储设置里的网页缓存、`webview.sh`、`hardened-macos.sh`、`linux-mark-idle.sh`、参考源码 cef、cef-rs、milkdown、codemirror-dev;`apps::open` 不再复原信号(只为 Chromium 屏蔽的 SIGTERM 而设)。旧 C14.1 至 C14.7、R2.13 与 ElyMark AppImage 的记录(CEF 实现)删去,在 git 历史;仍在的应用行为(外部变更与比较、会话、改名、Git 状态、最近与文件夹、`look.json`)在 C14.8 至 C14.16 逐条核对后重记。用户以前的 ElyMark 数据目录里可能留着 `web` 缓存文件夹,Ely 不再读写它。组件库 6f0401e:焦点进入空的可视文档即打开末尾的输入框,新文件可直接键入(原先键入无处落下);362a0fc:换回可视时同样打开(预览与可视共用焦点,无焦点事件),全文撤销后若文档成空,读完新文字再打开末尾的输入框;639521e:只有一份撤销历史,块的输入框自己的撤销与重做从不运行:在打开的块里撤销或重做,先把块写回为一步,再走全文的历史(块内逐键的撤销没有了,一次块编辑是一步),重做跨模式、跨多次撤销都在。ElyMark(C14.6):每个标签各记其模式,切换不改一个字节(编码写出与原文相同),撤销跨模式(全文一份撤销历史);视图在模式首次可键入时记一次 `mark: shown as "…"`(源码即刻,可视与预览在块读完后),PERF-07 计到这条。Windows 上实测(Ryzen 9 9950X3D)1 MB 文档 P50 341 毫秒、P95 560 毫秒(`docs/perf/2026-10-12-Ryzen9-9950X3D.md`);macOS、Linux 与树莓派未测。C14.7:语法由 markdown-rs 读(CommonMark 加 GFM 的表格、任务、删除线、自动链接,脚注,公式),front matter 不进可视,以原文块显示在最上(组件库 parse 测试核对各种块画成内容而非原文);链接照 M-05:预览单击、可视 Cmd(他处 Ctrl)按下交应用,可视普通按下编辑该块(组件库 bbb6071 的链接测试);应用只把 http、https、mailto 交系统打开,其余以提示拒绝(应用测试);链接地址与图片读不出的原因不进日志(地址与路径属文档内容)。文档旁图片按文件所在文件夹读(`MarkView` 在打开与改名时交给组件);组件库 4801555:表格单元格也是块,图片在格内照常画出(原只画替代文字),图片取自身宽度、以所在容器为上限(单元格可收缩,窄格里的图片按比例缩小,组件库 2e196a1)。gpui 的图片加载(`vendor/gpui-pre` 的 `img.rs`):GIF 与 WebP 的坏帧即结束加载并报错(`img.rs` 的加载器与 `platform.rs` 的 `Image::to_image_data`,后者给 `data:` 图片用)(原样跳过坏帧,解码器到文件尾仍不停地报错,加载永不结束、占满一个核),错误与日志不带图片路径(原样把路径写进错误,`AssetLogger` 照写进日志);应用测试以坏 GIF 核对两处加载都结束、错误里没有所在文件夹名。C14.8:左栏三页照旧(文件夹树、大纲、搜索);大纲在纵向滚动区里(标题再多也选得到,侧栏测试以 100 个标题滚到最后),取组件按 markdown-rs 读出的标题与行,选一项即 `reveal`(该行在视图里、插入点在其上;可视模式里键入仍须先按下块);多标签与最近列表经外壳与 `recent.json` 照旧。`desk::open_in` 收数据文件夹(`open` 照旧取本机的),应用的 gpui 测试以临时文件夹起整个窗口:打开文件即有标签、大纲行号、跳转落点与最近列表,键入的标题进大纲,两个文件两个标签各有大纲,打开文件夹只列 Markdown;文件监视在真线程上,测试以 `allow_parking` 允许。`ely_ui::install_no_launch_files`(`test-support`)给测试一个空的启动文件队列。随删去的网页页面一起的 `web.*` 文案(`i18n/web.rs`)删去。C14.9(组件库 ce4a1e9):回车照列表续写(无序、有序加一、任务为未勾),空项回车即结束列表,源码与打开的块都如此;打开的块里 Tab、Shift+Tab 把所触的行缩进或退回四格(`MarkdownDocument > ElyInput` 键位,块外 Tab 照旧在块间移动),源码由编辑器自己缩进;`MarkdownDocument::edit_table` 按插入点所在表格增删行列,`insert_table` 在插入点所在行后放一个三列表格,都为一步撤销,表格按规范写法重写;`show_match` 在源码选中、可视打开其所在块并选中、预览转到其行,`replace` 一步替换;围栏代码按其语言着色(`forms::syntax_fenced`,散文名不着色,无名照通用着色)。ElyMark:Cmd/Ctrl+F 与“编辑›查找和替换”打开查找框(组件库 `FindWidget`,`find_all` 的大小写、全词、正则;正则写错即框内显示原因),源码里标出全部匹配与当前一处;替换一处后转到下一处,全部替换为一步撤销;“表格”菜单插入表格与增删行列,插入点不在表格里即提示。应用测试以整窗核对计数、替换与撤销、正则错误、跳到下一处、表格菜单。 diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs index e38fcb6..9ec7e93 100644 --- a/apps/ely-mark/src/desk.rs +++ b/apps/ely-mark/src/desk.rs @@ -53,6 +53,8 @@ pub struct Desk { asked: Option, /// The shown file's fingerprint when Git was last asked. asked_print: Option, + /// The find box, while open. + pub(crate) finding: Option, } impl Host for Desk { @@ -115,6 +117,9 @@ impl Host for Desk { self.look(id, cx); } self.restore_shown(id, cx); + if self.finding.is_some() { + self.count(cx); + } // A save changes what Git sees. let print = self.active_tab().and_then(|tab| tab.session.fingerprint()); if print != self.asked_print { @@ -196,6 +201,7 @@ pub(crate) fn open_in( tracked: None, asked: None, asked_print: None, + finding: None, } }); let menus = crate::keys::menus(&look, cx); @@ -413,6 +419,7 @@ impl Render for Desk { .on_action(cx.listener(Self::on_save_as)) .on_action(cx.listener(Self::on_rename)) .on_action(cx.listener(Self::on_close_tab)) + .on_action(cx.listener(Self::on_find)) .on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx))) .on_action(cx.listener(|desk, _: &VisualMode, window, cx| { desk.switch(Mode::Visual, window, cx) @@ -426,8 +433,9 @@ impl Render for Desk { .child(shell) .children(self.core_dialog(cx)) .children(self.naming_element(cx)) - .children(self.compare_dialog(cx)); - Self::look_actions(root, cx) + .children(self.compare_dialog(cx)) + .children(self.find_box(cx)); + Self::table_actions(Self::look_actions(root, cx), cx) } } diff --git a/apps/ely-mark/src/desk/tests.rs b/apps/ely-mark/src/desk/tests.rs index 8c9d220..d19c6da 100644 --- a/apps/ely-mark/src/desk/tests.rs +++ b/apps/ely-mark/src/desk/tests.rs @@ -7,7 +7,10 @@ use super::{Desk, open_in}; use crate::view::Mode; /// ElyMark over a fresh data folder, as `main` opens it. -pub(crate) fn start(cx: &mut TestAppContext, data: &Path) -> (Entity, &'static mut VisualTestContext) { +pub(crate) fn start( + cx: &mut TestAppContext, + data: &Path, +) -> (Entity, &'static mut VisualTestContext) { // The file watcher runs on a real thread. cx.executor().allow_parking(); cx.update(ely_gpui_component::init_for_tests); @@ -28,6 +31,7 @@ pub(crate) fn start(cx: &mut TestAppContext, data: &Path) -> (Entity, &'st .expect("a window") }); let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + cx.update(|window, _| window.activate_window()); cx.run_until_parked(); let desk = held.take().expect("the desk"); (desk, cx) diff --git a/apps/ely-mark/src/finding.rs b/apps/ely-mark/src/finding.rs new file mode 100644 index 0000000..b27ccdc --- /dev/null +++ b/apps/ely-mark/src/finding.rs @@ -0,0 +1,247 @@ +//! Find and replace in the shown file (MARK-06). + +use std::ops::Range; + +use ely_gpui_component::{ + editor::{FindOptions, FindWidget, find_all}, + forms::{InputEvent, TextInput}, + primitives::{Severity, raise}, + theme::ActiveTheme as _, +}; +use ely_office::prelude::*; +use ely_ui::i18n::text; +use gpui::{ + AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _, + IntoElement, ParentElement as _, SharedString, Styled as _, Subscription, Window, div, +}; + +use crate::desk::Desk; +use crate::view::MarkView; + +/// The find box over one tab, and what it found. +pub struct Find { + tab: u64, + query: Entity, + replace: Option>, + options: FindOptions, + hits: Vec>, + current: Option, + /// The pattern did not compile; why. + error: Option, + _query: Subscription, +} + +impl Desk { + pub fn on_find( + &mut self, + _: &ely_office::FindReplace, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.core.active else { + let said = text(cx, "command.blocked.no_document", &[]); + return self.toast(said, Severity::Warning, cx); + }; + if let Some(find) = self.finding.as_ref().filter(|find| find.tab == tab) { + return window.focus(&find.query.focus_handle(cx), cx); + } + self.close_find(window, cx); + let query = cx.new(|cx| TextInput::new(window, cx)); + let subscription = + cx.subscribe_in(&query, window, |desk, _, event, window, cx| match event { + InputEvent::Changed => desk.count(cx), + InputEvent::Submit => desk.step(true, window, cx), + _ => {} + }); + window.focus(&query.focus_handle(cx), cx); + log::info!("mark: find opens on tab {tab}"); + self.finding = Some(Find { + tab, + query, + replace: None, + options: FindOptions::default(), + hits: Vec::new(), + current: None, + error: None, + _query: subscription, + }); + cx.notify(); + } + + fn found_view(&self) -> Option<(Entity, &Find)> { + let find = self.finding.as_ref()?; + Some((self.tab(find.tab)?.view.clone(), find)) + } + + /// Matches again in the tab's text, marked in source. + pub fn count(&mut self, cx: &mut Context) { + let Some((view, find)) = self.found_view() else { + return; + }; + let query = find.query.read(cx).text().to_string(); + let options = find.options; + let document = view.read(cx).document().clone(); + let found = find_all(&document.read(cx).text(cx), &query, options); + let Some(find) = self.finding.as_mut() else { + return; + }; + (find.hits, find.error) = match found { + Ok(hits) => (hits, None), + Err(why) => (Vec::new(), Some(why.into())), + }; + find.current = find.current.filter(|current| *current < find.hits.len()); + let (hits, current) = (find.hits.clone(), find.current); + mark(&document, &hits, current, cx); + cx.notify(); + } + + fn step(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { + let Some((view, find)) = self.found_view() else { + return; + }; + let count = find.hits.len(); + if count == 0 { + return; + } + let current = match (find.current, forward) { + (None, true) => 0, + (None, false) => count - 1, + (Some(at), true) => (at + 1) % count, + (Some(at), false) => (at + count - 1) % count, + }; + let range = find.hits[current].clone(); + let hits = find.hits.clone(); + if let Some(find) = self.finding.as_mut() { + find.current = Some(current); + } + let document = view.read(cx).document().clone(); + mark(&document, &hits, Some(current), cx); + document.update(cx, |document, cx| document.show_match(range, window, cx)); + cx.notify(); + } + + fn replace(&mut self, every: bool, window: &mut Window, cx: &mut Context) { + let Some((view, find)) = self.found_view() else { + return; + }; + let with = find + .replace + .as_ref() + .map_or(String::new(), |field| field.read(cx).text().to_string()); + let edits: Vec<(Range, String)> = match (every, find.current) { + (true, _) => find + .hits + .iter() + .map(|hit| (hit.clone(), with.clone())) + .collect(), + (false, Some(at)) => vec![(find.hits[at].clone(), with)], + (false, None) => return self.step(true, window, cx), + }; + if edits.is_empty() { + return; + } + let document = view.read(cx).document().clone(); + document.update(cx, |document, cx| document.replace(edits, cx)); + self.count(cx); + // The next match takes the replaced one's place. + let next = self + .finding + .as_ref() + .and_then(|find| find.current.and_then(|at| find.hits.get(at)).cloned()); + if let Some(range) = next.filter(|_| !every) { + document.update(cx, |document, cx| document.show_match(range, window, cx)); + } + } + + fn close_find(&mut self, window: &mut Window, cx: &mut Context) { + let Some(find) = self.finding.take() else { + return; + }; + if let Some(tab) = self.tab(find.tab) { + let document = tab.view.read(cx).document().clone(); + mark(&document, &[], None, cx); + window.focus(&tab.view.read(cx).focus.clone(), cx); + } + cx.notify(); + } + + /// The find box over the file, while open. + pub fn find_box(&self, cx: &mut Context) -> Option { + let find = self + .finding + .as_ref() + .filter(|find| Some(find.tab) == self.core.active)?; + let this = cx.entity(); + let mut widget = FindWidget::new("mark-find", &find.query, find.current, find.hits.len()) + .options(find.options) + .on_options({ + let this = this.clone(); + move |options, _, cx| { + this.update(cx, |desk, cx| { + if let Some(find) = desk.finding.as_mut() { + find.options = options; + } + desk.count(cx); + }) + } + }) + .on_step({ + let this = this.clone(); + move |forward, window, cx| { + this.update(cx, |desk, cx| desk.step(forward, window, cx)) + } + }) + .on_replace({ + let this = this.clone(); + move |every, window, cx| this.update(cx, |desk, cx| desk.replace(every, window, cx)) + }) + .on_toggle_replace({ + let this = this.clone(); + move |show, window, cx| { + this.update(cx, |desk, cx| { + if let Some(find) = desk.finding.as_mut() { + find.replace = show.then(|| cx.new(|cx| TextInput::new(window, cx))); + } + cx.notify(); + }) + } + }) + .on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx))); + if let Some(field) = &find.replace { + widget = widget.replace(field); + } + if let Some(error) = &find.error { + widget = widget.error(error.clone()); + } + let place = div() + .absolute() + .top_2() + .right_4() + .block_mouse_except_scroll() + .child(widget); + Some(raise("mark-find-layer", place).into_any_element()) + } +} + +/// The matches marked in the source; the current one stronger. +fn mark( + document: &Entity, + hits: &[Range], + current: Option, + cx: &mut Context, +) { + let tone = cx.theme().colors.warning; + let marks = hits + .iter() + .enumerate() + .map(|(at, hit)| { + let strength = if Some(at) == current { 0.45 } else { 0.2 }; + (hit.clone(), tone.opacity(strength)) + }) + .collect(); + let editor = document.read(cx).editor().clone(); + editor.update(cx, |editor, cx| editor.set_backgrounds(marks, cx)); +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-mark/src/finding/tests.rs b/apps/ely-mark/src/finding/tests.rs new file mode 100644 index 0000000..0a0b841 --- /dev/null +++ b/apps/ely-mark/src/finding/tests.rs @@ -0,0 +1,120 @@ +use ely_gpui_component::forms::TextInput; +use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext}; + +use crate::desk::{ + Desk, + tests::{open, start}, +}; + +fn opened( + text: &str, + cx: &mut TestAppContext, +) -> ( + Entity, + &'static mut VisualTestContext, + [tempfile::TempDir; 2], +) { + let (data, files) = ( + tempfile::tempdir().expect("data"), + tempfile::tempdir().expect("files"), + ); + let path = files.path().join("a.md"); + std::fs::write(&path, text).expect("a file"); + let (desk, cx) = start(cx, data.path()); + open(&desk, &path, cx); + (desk, cx, [data, files]) +} + +fn query(desk: &Entity, words: &str, cx: &mut VisualTestContext) { + desk.update_in(cx, |desk, window, cx| { + desk.on_find(&ely_office::FindReplace, window, cx) + }); + cx.simulate_input(words); + cx.run_until_parked(); +} + +fn current_text(desk: &Entity, cx: &mut VisualTestContext) -> String { + desk.read_with(cx, |desk, cx| { + let view = desk.active_view().expect("a tab"); + view.read(cx).document().read(cx).text(cx) + }) +} + +#[gpui::test] +fn every_match_is_counted_and_replaced_in_one_step(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("a b a\n", cx); + query(&desk, "a", cx); + assert_eq!( + desk.read_with(cx, |desk, _| desk + .finding + .as_ref() + .map(|find| find.hits.len())), + Some(2) + ); + desk.update_in(cx, |desk, window, cx| { + let field = cx.new(|cx| TextInput::new(window, cx)); + field.update(cx, |field, cx| field.set_text("x", cx)); + desk.finding.as_mut().expect("open").replace = Some(field); + desk.replace(true, window, cx); + }); + cx.run_until_parked(); + assert_eq!(current_text(&desk, cx), "x b x\n"); + let focus = desk.read_with(cx, |desk, cx| { + desk.active_view().expect("a tab").read(cx).focus.clone() + }); + cx.update(|window, cx| window.focus(&focus, cx)); + cx.run_until_parked(); + cx.simulate_keystrokes("secondary-z"); + assert_eq!( + current_text(&desk, cx), + "a b a\n", + "one undo takes all back" + ); +} + +#[gpui::test] +fn a_bad_pattern_says_why(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("a\n", cx); + desk.update_in(cx, |desk, window, cx| { + desk.on_find(&ely_office::FindReplace, window, cx) + }); + desk.update(cx, |desk, cx| { + desk.finding.as_mut().expect("open").options.regex = true; + desk.count(cx); + }); + cx.simulate_input("("); + cx.run_until_parked(); + let error = desk.read_with(cx, |desk, _| { + desk.finding.as_ref().and_then(|find| find.error.clone()) + }); + assert!(error.is_some()); +} + +#[gpui::test] +fn a_step_shows_the_next_match(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("one\n\ntwo one\n", cx); + query(&desk, "one", cx); + desk.update_in(cx, |desk, window, cx| { + desk.step(true, window, cx); + desk.step(true, window, cx); + }); + let current = desk.read_with(cx, |desk, _| { + desk.finding.as_ref().and_then(|find| find.current) + }); + assert_eq!(current, Some(1)); +} + +#[gpui::test] +fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) { + let (desk, cx, _files) = opened("Intro\n", cx); + let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone()); + cx.update(|window, cx| window.focus(&focus, cx)); + cx.dispatch_action(crate::tools::InsertTable); + assert!(current_text(&desk, cx).contains("| --- | --- | --- |")); + cx.dispatch_action(crate::tools::DeleteColumn); + assert!( + current_text(&desk, cx).contains("| --- | --- |\n"), + "{}", + current_text(&desk, cx) + ); +} diff --git a/apps/ely-mark/src/keys.rs b/apps/ely-mark/src/keys.rs index 9fd8218..43329f6 100644 --- a/apps/ely-mark/src/keys.rs +++ b/apps/ely-mark/src/keys.rs @@ -17,6 +17,7 @@ pub fn bind(cx: &mut App) { KeyBinding::new("secondary-=", crate::look::Larger, at), KeyBinding::new("secondary--", crate::look::Smaller, at), KeyBinding::new("secondary-0", crate::look::ActualSize, at), + KeyBinding::new("secondary-f", ely_office::FindReplace, at), ]); } @@ -35,7 +36,23 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec { disabled: false, }; let item = |key: &str, action: Box| checked(key, action, false); + use crate::tools::{ + ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow, + }; vec![ + Menu::new(text(cx, "menu.edit", &[])) + .items([item("office.find.open", Box::new(ely_office::FindReplace))]), + Menu::new(text(cx, "mark.menu.table", &[])).items([ + item("mark.table.insert", Box::new(InsertTable)), + MenuItem::separator(), + item("mark.table.row_above", Box::new(RowAbove)), + item("mark.table.row_below", Box::new(RowBelow)), + item("mark.table.column_left", Box::new(ColumnLeft)), + item("mark.table.column_right", Box::new(ColumnRight)), + MenuItem::separator(), + item("mark.table.delete_row", Box::new(DeleteRow)), + item("mark.table.delete_column", Box::new(DeleteColumn)), + ]), Menu::new(text(cx, "menu.view", &[])).items([ MenuItem::action(label(Mode::Visual, cx), VisualMode), MenuItem::action(label(Mode::Source, cx), SourceMode), diff --git a/apps/ely-mark/src/main.rs b/apps/ely-mark/src/main.rs index f2c4b01..0fc66cd 100644 --- a/apps/ely-mark/src/main.rs +++ b/apps/ely-mark/src/main.rs @@ -5,6 +5,7 @@ mod autosave; mod compare; mod desk; mod engine; +mod finding; mod folder; mod keys; mod look; @@ -14,6 +15,7 @@ mod rename; mod restore; mod side; mod text; +mod tools; mod vcs; mod view; diff --git a/apps/ely-mark/src/tools.rs b/apps/ely-mark/src/tools.rs new file mode 100644 index 0000000..7612ce9 --- /dev/null +++ b/apps/ely-mark/src/tools.rs @@ -0,0 +1,67 @@ +//! The table menu's work on the shown file (MARK-06). + +use ely_gpui_component::{documents::TableEdit, primitives::Severity}; +use ely_office::prelude::*; +use ely_ui::i18n::text; +use gpui::{Context, InteractiveElement as _, actions}; + +use crate::desk::Desk; + +actions!( + mark, + [ + InsertTable, + RowAbove, + RowBelow, + ColumnLeft, + ColumnRight, + DeleteRow, + DeleteColumn + ] +); + +impl Desk { + fn table(&mut self, edit: Option, cx: &mut Context) { + let Some(view) = self.active_view() else { + let said = text(cx, "command.blocked.no_document", &[]); + return self.toast(said, Severity::Warning, cx); + }; + let document = view.read(cx).document().clone(); + let done = document.update(cx, |document, cx| match edit { + Some(edit) => document.edit_table(edit, cx), + None => { + document.insert_table(cx); + true + } + }); + if !done { + let said = text(cx, "mark.table.none", &[]); + self.toast(said, Severity::Info, cx); + } + } + + pub fn table_actions(element: gpui::Div, cx: &mut Context) -> gpui::Div { + element + .on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table(None, cx))) + .on_action( + cx.listener(|desk, _: &RowAbove, _, cx| desk.table(Some(TableEdit::RowAbove), cx)), + ) + .on_action( + cx.listener(|desk, _: &RowBelow, _, cx| desk.table(Some(TableEdit::RowBelow), cx)), + ) + .on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| { + desk.table(Some(TableEdit::ColumnLeft), cx) + })) + .on_action(cx.listener(|desk, _: &ColumnRight, _, cx| { + desk.table(Some(TableEdit::ColumnRight), cx) + })) + .on_action( + cx.listener(|desk, _: &DeleteRow, _, cx| { + desk.table(Some(TableEdit::DeleteRow), cx) + }), + ) + .on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| { + desk.table(Some(TableEdit::DeleteColumn), cx) + })) + } +} diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs index bb5c1be..190d1b8 100644 --- a/apps/ely-mark/src/view.rs +++ b/apps/ely-mark/src/view.rs @@ -131,6 +131,10 @@ impl MarkView { } } + pub(crate) fn document(&self) -> &Entity { + &self.document + } + /// Every change is in the text at once. pub fn settled(&self) -> bool { true diff --git a/crates/ely-ui/src/i18n/mark.rs b/crates/ely-ui/src/i18n/mark.rs index b4d9aec..8b7f36c 100644 --- a/crates/ely-ui/src/i18n/mark.rs +++ b/crates/ely-ui/src/i18n/mark.rs @@ -21,7 +21,6 @@ pub(super) const EN: &[(&str, &str)] = &[ ("mark.table.delete_row", "Delete Row"), ("mark.table.delete_column", "Delete Column"), ("mark.table.none", "Put the caret in a table first"), - ("mark.table.visual", "Tables change in the visual mode"), ("mark.attach.untitled", "Save the document first: images go beside it"), ("mark.attach.preview", "Switch to an editing mode to add images"), ("mark.attach.images", "Only images can be added"), @@ -110,7 +109,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mark.table.delete_row", "删除行"), ("mark.table.delete_column", "删除列"), ("mark.table.none", "请先把插入点放进表格"), - ("mark.table.visual", "表格在可视模式下修改"), ("mark.attach.untitled", "请先保存文档:图片放在它旁边"), ("mark.attach.preview", "请切到编辑模式再添加图片"), ("mark.attach.images", "只能添加图片"), diff --git a/scripts/fetch-references.sh b/scripts/fetch-references.sh index a09ac1a..4e7b6b2 100755 --- a/scripts/fetch-references.sh +++ b/scripts/fetch-references.sh @@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...] echo "fetch $name $rev" } -fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git 2e196a10a7c3265eac6d5c52ebcec465ad0374c8 +fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git ce4a1e9cb5fc712e9d03f5bb703d13685f1cb67a fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \ include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \