ElyMark: find and replace, list continuation, indent, table menu, fence colors (C14.9)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 06:20:25 -04:00
1 parent dbd841c786
commit d4a7d3ebd7
13 files changed
+477 -10

No files matched your search

Generated
+1 -1
View File
@@ -1922,7 +1922,7 @@ dependencies = [
[[package]] [[package]]
name = "ely-gpui-component" name = "ely-gpui-component"
version = "0.1.1" 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 = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
+1 -1
View File
@@ -19,7 +19,7 @@ jiff = "0.2"
anyhow = "1" anyhow = "1"
directories = "6" directories = "6"
dunce = "1" 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" env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] } 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"] } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
+2 -2
View File
@@ -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: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(用户定:替换 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:`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 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated - 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 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
+10 -2
View File
@@ -53,6 +53,8 @@ pub struct Desk {
asked: Option<u64>, asked: Option<u64>,
/// The shown file's fingerprint when Git was last asked. /// The shown file's fingerprint when Git was last asked.
asked_print: Option<ely_files::Fingerprint>, asked_print: Option<ely_files::Fingerprint>,
/// The find box, while open.
pub(crate) finding: Option<crate::finding::Find>,
} }
impl Host for Desk { impl Host for Desk {
@@ -115,6 +117,9 @@ impl Host for Desk {
self.look(id, cx); self.look(id, cx);
} }
self.restore_shown(id, cx); self.restore_shown(id, cx);
if self.finding.is_some() {
self.count(cx);
}
// A save changes what Git sees. // A save changes what Git sees.
let print = self.active_tab().and_then(|tab| tab.session.fingerprint()); let print = self.active_tab().and_then(|tab| tab.session.fingerprint());
if print != self.asked_print { if print != self.asked_print {
@@ -196,6 +201,7 @@ pub(crate) fn open_in(
tracked: None, tracked: None,
asked: None, asked: None,
asked_print: None, asked_print: None,
finding: None,
} }
}); });
let menus = crate::keys::menus(&look, cx); 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_save_as))
.on_action(cx.listener(Self::on_rename)) .on_action(cx.listener(Self::on_rename))
.on_action(cx.listener(Self::on_close_tab)) .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, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(cx.listener(|desk, _: &VisualMode, window, cx| { .on_action(cx.listener(|desk, _: &VisualMode, window, cx| {
desk.switch(Mode::Visual, window, cx) desk.switch(Mode::Visual, window, cx)
@@ -426,8 +433,9 @@ impl Render for Desk {
.child(shell) .child(shell)
.children(self.core_dialog(cx)) .children(self.core_dialog(cx))
.children(self.naming_element(cx)) .children(self.naming_element(cx))
.children(self.compare_dialog(cx)); .children(self.compare_dialog(cx))
Self::look_actions(root, cx) .children(self.find_box(cx));
Self::table_actions(Self::look_actions(root, cx), cx)
} }
} }
+5 -1
View File
@@ -7,7 +7,10 @@ use super::{Desk, open_in};
use crate::view::Mode; use crate::view::Mode;
/// ElyMark over a fresh data folder, as `main` opens it. /// ElyMark over a fresh data folder, as `main` opens it.
pub(crate) fn start(cx: &mut TestAppContext, data: &Path) -> (Entity<Desk>, &'static mut VisualTestContext) { pub(crate) fn start(
cx: &mut TestAppContext,
data: &Path,
) -> (Entity<Desk>, &'static mut VisualTestContext) {
// The file watcher runs on a real thread. // The file watcher runs on a real thread.
cx.executor().allow_parking(); cx.executor().allow_parking();
cx.update(ely_gpui_component::init_for_tests); cx.update(ely_gpui_component::init_for_tests);
@@ -28,6 +31,7 @@ pub(crate) fn start(cx: &mut TestAppContext, data: &Path) -> (Entity<Desk>, &'st
.expect("a window") .expect("a window")
}); });
let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, _| window.activate_window());
cx.run_until_parked(); cx.run_until_parked();
let desk = held.take().expect("the desk"); let desk = held.take().expect("the desk");
(desk, cx) (desk, cx)
+247
View File
@@ -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<TextInput>,
replace: Option<Entity<TextInput>>,
options: FindOptions,
hits: Vec<Range<usize>>,
current: Option<usize>,
/// The pattern did not compile; why.
error: Option<SharedString>,
_query: Subscription,
}
impl Desk {
pub fn on_find(
&mut self,
_: &ely_office::FindReplace,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<MarkView>, &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<Self>) {
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<Self>) {
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<Self>) {
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<usize>, 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<Self>) {
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<Self>) -> Option<AnyElement> {
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<ely_gpui_component::documents::MarkdownDocument>,
hits: &[Range<usize>],
current: Option<usize>,
cx: &mut Context<Desk>,
) {
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;
+120
View File
@@ -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<Desk>,
&'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<Desk>, 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<Desk>, 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)
);
}
+17
View File
@@ -17,6 +17,7 @@ pub fn bind(cx: &mut App) {
KeyBinding::new("secondary-=", crate::look::Larger, at), KeyBinding::new("secondary-=", crate::look::Larger, at),
KeyBinding::new("secondary--", crate::look::Smaller, at), KeyBinding::new("secondary--", crate::look::Smaller, at),
KeyBinding::new("secondary-0", crate::look::ActualSize, 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<Menu> {
disabled: false, disabled: false,
}; };
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false); let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
use crate::tools::{
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
};
vec![ 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([ Menu::new(text(cx, "menu.view", &[])).items([
MenuItem::action(label(Mode::Visual, cx), VisualMode), MenuItem::action(label(Mode::Visual, cx), VisualMode),
MenuItem::action(label(Mode::Source, cx), SourceMode), MenuItem::action(label(Mode::Source, cx), SourceMode),
+2
View File
@@ -5,6 +5,7 @@ mod autosave;
mod compare; mod compare;
mod desk; mod desk;
mod engine; mod engine;
mod finding;
mod folder; mod folder;
mod keys; mod keys;
mod look; mod look;
@@ -14,6 +15,7 @@ mod rename;
mod restore; mod restore;
mod side; mod side;
mod text; mod text;
mod tools;
mod vcs; mod vcs;
mod view; mod view;
+67
View File
@@ -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<TableEdit>, cx: &mut Context<Self>) {
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<Self>) -> 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)
}))
}
}
+4
View File
@@ -131,6 +131,10 @@ impl MarkView {
} }
} }
pub(crate) fn document(&self) -> &Entity<MarkdownDocument> {
&self.document
}
/// Every change is in the text at once. /// Every change is in the text at once.
pub fn settled(&self) -> bool { pub fn settled(&self) -> bool {
true true
-2
View File
@@ -21,7 +21,6 @@ pub(super) const EN: &[(&str, &str)] = &[
("mark.table.delete_row", "Delete Row"), ("mark.table.delete_row", "Delete Row"),
("mark.table.delete_column", "Delete Column"), ("mark.table.delete_column", "Delete Column"),
("mark.table.none", "Put the caret in a table first"), ("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.untitled", "Save the document first: images go beside it"),
("mark.attach.preview", "Switch to an editing mode to add images"), ("mark.attach.preview", "Switch to an editing mode to add images"),
("mark.attach.images", "Only images can be added"), ("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_row", "删除行"),
("mark.table.delete_column", "删除列"), ("mark.table.delete_column", "删除列"),
("mark.table.none", "请先把插入点放进表格"), ("mark.table.none", "请先把插入点放进表格"),
("mark.table.visual", "表格在可视模式下修改"),
("mark.attach.untitled", "请先保存文档:图片放在它旁边"), ("mark.attach.untitled", "请先保存文档:图片放在它旁边"),
("mark.attach.preview", "请切到编辑模式再添加图片"), ("mark.attach.preview", "请切到编辑模式再添加图片"),
("mark.attach.images", "只能添加图片"), ("mark.attach.images", "只能添加图片"),
+1 -1
View File
@@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...]
echo "fetch $name $rev" 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 zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b
fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \ fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \
include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \ include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \