Compare commits
51
Commits
v0.1.4
...
b973c8d62a
No files matched your search
@@ -15,6 +15,9 @@ jobs:
|
||||
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
|
||||
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
|
||||
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
|
||||
# Repository variables; both live in the runner's keychain.
|
||||
ELY_SIGN_IDENTITY: ${{ vars.ELY_SIGN_IDENTITY }}
|
||||
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
|
||||
run: |
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
|
||||
@@ -18,3 +18,4 @@ node_modules/
|
||||
/assets/papirus_icons-master.zip
|
||||
/Gitea.md
|
||||
/.work/
|
||||
/TyporaRef
|
||||
Generated
+20
-16
@@ -1875,7 +1875,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "ely-agent"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -1884,7 +1884,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-doc"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"csv",
|
||||
@@ -1906,7 +1906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-files"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"dunce",
|
||||
@@ -1923,7 +1923,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=8fd3e431db3c57c67328869cffdcd43d5f7fd056#8fd3e431db3c57c67328869cffdcd43d5f7fd056"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=2b6bd1d36951713e61763bcd9572bbd42464dd7d#2b6bd1d36951713e61763bcd9572bbd42464dd7d"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -1960,6 +1960,7 @@ dependencies = [
|
||||
"similar",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"usvg",
|
||||
"vte",
|
||||
"web-time",
|
||||
@@ -1968,14 +1969,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ipc"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-lok"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"dunce",
|
||||
@@ -1996,7 +1997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mail"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -2005,7 +2006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mark"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
@@ -2030,7 +2031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-office"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2051,7 +2052,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ely-files",
|
||||
@@ -2069,7 +2070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf-engine"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"ely-ipc",
|
||||
@@ -2085,7 +2086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-platform"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2094,6 +2095,7 @@ dependencies = [
|
||||
"directories",
|
||||
"ely-ipc",
|
||||
"env_logger",
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -2119,7 +2121,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-show"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2139,7 +2141,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-table"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"ely-gpui-component",
|
||||
"ely-lok",
|
||||
@@ -2156,7 +2158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-tree"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ely-files",
|
||||
@@ -2176,7 +2178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ui"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2185,6 +2187,7 @@ dependencies = [
|
||||
"futures",
|
||||
"gpui-pre",
|
||||
"gpui-pre-platform",
|
||||
"gpui-pre-windows",
|
||||
"log",
|
||||
"notify",
|
||||
"semver",
|
||||
@@ -2979,6 +2982,7 @@ dependencies = [
|
||||
"waker-fn",
|
||||
"web-time",
|
||||
"windows 0.62.2",
|
||||
"zed-font-kit",
|
||||
"zed-scap",
|
||||
]
|
||||
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
|
||||
exclude = ["engines", "reference"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -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 = "8fd3e431db3c57c67328869cffdcd43d5f7fd056" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "2b6bd1d36951713e61763bcd9572bbd42464dd7d" }
|
||||
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"] }
|
||||
|
||||
@@ -478,6 +478,8 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
|
||||
|
||||
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则、Markion 的 GPUI 原生 Markdown 编辑,不复制其整个应用窗口。不用 Electron,不用 CEF 或其他网页内容宿主。[S17][S18]
|
||||
|
||||
解析、渲染、编辑命令与右键菜单以 Typora 1.14.10 的默认设置为对照,不输于它;可视模式仍是按下的块以原文编辑,不改为所见即所得的行内编辑(用户 2026-10-13 定)。Typora 是参照,不求一一相同:验收看每项功能可用,缺 Typora 的某项、次序或措辞不同不算缺陷(用户 2026-10-14 定)。[S25]
|
||||
|
||||
### 14.2 功能要求
|
||||
|
||||
| 编号 | 功能 | 完成要求 |
|
||||
@@ -485,7 +487,7 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
|
||||
| MARK-01 | 本地文件 | 新建、打开、保存、重命名和另存;支持直接打开文件或文件夹 |
|
||||
| MARK-02 | 编辑模式 | 可视化、源码和只读预览可切换;切换本身不修改文件内容 |
|
||||
| MARK-03 | Markdown | 支持 CommonMark 与 GFM 常用语法:标题、列表、任务、引用、链接、图片、表格和代码块 |
|
||||
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;资源随包提供 |
|
||||
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;照 Typora 默认显示 `:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块与 `[toc]` 目录;`==`、`^`、单个 `~` 照 GFM,不作高亮、上下标;Typora 与 GitHub 读法不同处随 GitHub(表情名与转义、标签与强调交叉);资源随包提供 |
|
||||
| MARK-05 | 导航 | 提供文件树、文档大纲、多标签、最近文件和按标题跳转 |
|
||||
| MARK-06 | 编辑工具 | 支持查找替换、源码正则搜索、缩进、列表续写、表格行列操作和代码高亮 |
|
||||
| MARK-07 | 附件 | 支持拖放和粘贴图片,默认复制到文档旁的资源目录并写入相对路径 |
|
||||
@@ -496,6 +498,8 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
|
||||
| MARK-12 | 会话恢复 | 恢复打开标签、未保存内容、光标、滚动和视图设置 |
|
||||
| MARK-13 | Git 配合 | 在仓库内显示文件是否有改动,并可调用 ElyTree 查看;不内置第二套 Git 客户端 |
|
||||
| MARK-14 | 大文件 | 大文件采用延迟渲染或源码视图,说明限制,不截断保存内容 |
|
||||
| MARK-15 | 格式命令 | 段落与格式菜单照 Typora 的项与快捷键:标题与段落、引用与列表、插入块、行内格式与清除格式;每条命令一步撤销,用不了的地方说明原因 |
|
||||
| MARK-16 | 右键菜单 | 右键处的块、链接、图片、表格或代码决定菜单内容,照 Typora 的布局(剪贴板与格式为图标行);预览只读时只留复制类 |
|
||||
|
||||
数学与 Mermaid 默认仅渲染文档中已保存的源代码,不执行任意本地命令。语法错误显示原文与错误位置,不清空代码块。
|
||||
|
||||
@@ -933,3 +937,6 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
|
||||
[S24] MailCore 2 官方 LICENSE 文件。
|
||||
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
|
||||
|
||||
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
|
||||
`https://support.typora.io/Shortcut-Keys/`
|
||||
@@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `ely-gpui-component`(pin 8fd3e43,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 2b6bd1d,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
|
||||
| 需要 | 用 |
|
||||
| --- | --- |
|
||||
@@ -55,7 +55,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
| Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` |
|
||||
| Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` |
|
||||
| Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` |
|
||||
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode}` `editor::{CodeEditor, FindWidget}` |
|
||||
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode, MarkdownReader, MarkdownBlock, alert, is_toc, BlockCommand, InlineFormat, TableEdit, EditRefusal, TableRefusal, DocumentTarget, TargetBlock}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法;段落、格式与表格菜单经文档的 `block`、`format`、`edit_table`;右键菜单听 `DocumentEvent::Pointed` |
|
||||
| Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` |
|
||||
| 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` |
|
||||
| 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 |
|
||||
@@ -84,7 +84,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
|
||||
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
|
||||
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
|
||||
| 组件内部文案未接 i18n(面板族之外:欢迎页标题、菜单项、`SortBuilder` 与 `FilterBuilder` 等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
|
||||
| 组件内部文案未接 i18n(Ely 用到的组件已接:提示、默认占位、补全空行、字号示例,6df9ab0;查找框、排序与筛选、密码开关,a4bf696;`i18n/tests/shipped.rs` 防回退;其余组件之外:欢迎页标题、菜单项等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
|
||||
| LOK 的 `.uno:StyleApply` 状态给界面名(`Default Paragraph Style`),样式列表给程序名(`Standard`),两者对不上 | ElyDoc 属性栏:列表外的当前样式显示为占位文字;更新样式用状态里的界面名。上游待办:状态带程序名 |
|
||||
|
||||
## gpui 规则
|
||||
@@ -123,7 +123,7 @@ scripts/fetch-references.sh 取上游源码到 reference/
|
||||
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
|
||||
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
|
||||
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
|
||||
scripts/review.sh ID 轮次 路径 审查一条任务
|
||||
scripts/review.sh ID 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6-astra,effort max,fast)
|
||||
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
|
||||
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
|
||||
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
|
||||
@@ -159,7 +159,7 @@ tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地
|
||||
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
|
||||
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
|
||||
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
|
||||
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
|
||||
tests/scripts/one-instance.sh 第二份 ElyDoc 把文件交给第一份后退出,第一份开出它、工作目录不动
|
||||
tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限)
|
||||
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
|
||||
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
|
||||
@@ -173,7 +173,7 @@ tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住
|
||||
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
|
||||
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
|
||||
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
|
||||
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
|
||||
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
|
||||
cargo check --workspace
|
||||
@@ -197,7 +197,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
|
||||
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
|
||||
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
|
||||
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案未接 i18n,中文界面下仍为英文(上游待办)。
|
||||
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案 a4bf696 起接 i18n(原为英文)。
|
||||
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
|
||||
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
|
||||
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
|
||||
@@ -265,7 +265,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
|
||||
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
|
||||
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
|
||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
|
||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后先发令牌,门答“开”或“拒”之后才发各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上、令牌被拒(旧的门文件)或答“开”之前断开时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。门为每个来者各开一个线程,一个来者从连上到答完共 5 秒(零星补字不续期),令牌前的帧至多 64 字节、路径帧至多 128 KiB(`ely_ipc::read_capped`,超出即在分配前拒绝)。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。每个应用的启动(`ely_ui::run`,`blank` 的 ElyMail、ElyAgent 同)在主窗口开出后装上交接的接收者(`opening::home`):先把主窗口带到前台(最小化的先复原),再把文件放进 `ely_ui::on_open` 的同一队列(各应用不改),然后才回执;主窗口已不在即不回执,第二份照报未收下。不带文件的再次启动只带到前台。主窗口关闭即退出应用(只剩设置窗口时进程原会一直持锁,却开不了文件)。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。
|
||||
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
|
||||
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
|
||||
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
|
||||
@@ -286,3 +286,14 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 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-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进),保存后交给会话与 `Saved` 的路径以 `dunce::simplified` 整体判定后为普通写法;ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。
|
||||
- 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。
|
||||
- 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。
|
||||
- 2026-10-14(用户定):Typora 是参照,不求一一相同;ElyMark 的验收与评审只看每项功能是否可用,缺 Typora 的某行、次序或措辞不同不算缺陷。
|
||||
- 2026-10-14(用户要求照 Word):ElyDoc 的右键与表格(R5)。右键菜单由 Ely 按目标自建(`apps/ely-doc/src/menu.rs`),开在所点之处(`LokView` 记下右键点,`take_menu` 一并交出):目标、各项可否与拼写建议都取自内核这次给的菜单(`ely_lok::menu`:有 `WrapMenu` 为图片,有 `TableInsertMenu` 为表格,否则正文;缺 `enabled` 即报错),命令只经 `COMMANDS`。剪切、复制、粘贴为图标行(组件库 `Menu::buttons`);正文为链接…、新建批注;表格另有插入、删除、合并、拆分、平均分布;图片为文字环绕(嵌入、四周、上下、浮于文字上方;内核无紧密、穿越、衬于文字下方的对应项,不列)、置于顶层、置于底层、插入题注…,新建批注灰显(内核的图片菜单无批注)。链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显(Word 未选中也可用;照做须改内核,未做)。拖动表格边线:worker 把 `LOK_CALLBACK_TABLE_SELECTED` 读成 `ely_lok::table::Table`(列与行边线、上下限),视图在边线上显示调整指针(命中范围不超过与邻线距离的四分之一,矮行的中间仍落在文档),按住时记下读到它的批次的修订与按住处的偏移,拖动时画参考线(出视图照跟),松开以 `.uno:TableChangeCurrentBorderPosition` 发一条命令(一步撤销),原地松开不发;其间文档变了即以过期拒绝(`view/borders.rs`)。内核在 LOK 下不处理边线的指针与拖动(依赖 LOK 没有的标尺)。属性栏在光标进表格时把表格组排在最前(组件库 9ff403d 起按标题记折叠)。Windows 真窗口逐项核对过(右键三种目标、插入行列、拖动列宽行高、撤销、保存后重开);拼写建议在 Windows 上内核不给(P3-01),未核对。
|
||||
- 2026-10-14(用户要求核对 ElyTable 与 ElyShow):ElyTable 的右键(R5.5)。菜单机制移入 `ely_office::menu`(取内核这次给的菜单、开在所点之处、剪贴板图标行、子菜单可否随其中各项),ElyDoc 与 ElyTable 共用;`ely_lok::menu` 的目标另有单元格(有 `.uno:InsertCell`)与 Writer 之外的绘图对象(有 `.uno:ArrangeMenu`)。网格照 Excel:插入 ▸、删除 ▸、清除内容(`.uno:ClearContents`,同 Delete 键,不弹内核的删除内容对话框)、合并、拆分、设置单元格格式(打开格式面板);对象为置于顶层、置于底层;编辑单元格时只有剪贴板行。行列表头由 Ely 自画,内核不给菜单:右键先选中该行或列(已选的整行整列含它即不动,同 Excel)。选中对象或正编辑图形里的文字时,绘图外壳取代了单元格外壳,`.uno:SelectRow`/`.uno:SelectColumn` 原无处执行而被丢弃;补丁 `0046-lok-calc-headers-leave-drawings.patch` 让 LOK 下视图外壳接下这两条:先结束图形编辑(键入的文字留下)并取消选中,再选行列,同 Calc 自己的表头(`DrawDeselectAll`),菜单为插入、删除、清除内容、行高或列宽、隐藏、显示。ElyTable 每一项的可否都取自内核对它所发的那条命令此刻的状态(内核的菜单只定目标):补丁 `0045-lok-command-states.patch` 加 `getCommandValues(".uno:CommandStates?commands=…")`,先冲出调度器、再逐条问调度器(同菜单栏),未知的命令即报错(`ely_lok::menu::runs`);LOK 推送的状态只有固定的一组,且在空闲时才更新。因此受保护而许插入行列的工作表照样可插;行高、列宽只在文档只读时禁用(内核的规则),受保护时由内核拒绝并报出其错误。ElyTable 无批注界面,不列批注。
|
||||
- 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。
|
||||
- 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。
|
||||
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0;R4.11 真窗口查出 Windows 的中文输入法把 Ctrl+Shift+0 留作热键(注册表 `HKCU\Control Panel\Input Method\Hot Keys\00000104`),应用收不到,Windows 另绑 Ctrl+Shift+9(菜单显示它)。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点与数字一律写所出的字符(`secondary-{`、`secondary-+`、`alt-%`):三端的 gpui 都把 Shift 加该键报成字符并去掉 Shift(Windows 见 `gpui-pre-windows` 的 `get_keystroke_key`);R4.9 原在 Windows 写键名,真键盘按不出(R4.11 真窗口查出)。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
|
||||
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。第 2 轮后:转换完成时剪贴板已不是开始时的内容(此后有任何复制,含编辑器自己的 Ctrl+C)即丢弃这次结果,同内容的再次写入分不出;打开的块里只认最后一次右键(组件 2b8bd9f),所点字节对回所画字节的读法逐段只对齐一次(原逐字重读,8000 字的段落要近 2 秒)。第 3 轮后:剪贴板的比对另含其 HTML(gpui 读剪贴板不含 HTML,纯文字相同而富文本不同的后来复制原被盖掉;`ely_platform::clipboard::html`);组件的计时测试改用 `web_time::Instant`(组件 e07fd34)。
|
||||
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
|
||||
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。
|
||||
- 2026-10-15(用户报:ElyMark 点表格整个表格变成源码,应在格内编辑;用户定:格内显示该格原文):表格单元格编辑(R6.1)。组件库 2594b3f:可视模式按下表格只打开所按的那一格,在格内原位以单行输入框显示该格的 Markdown 原文(`**粗体**` 带记号),表格其余照常画出;按在格内文字上即落在所按的字,按在格内空白落在格末;以表格边缘、键盘或上下键从邻块进入的表格块打开其首格或末格;Tab、Shift+Tab 到下一格、上一格(跨行),上下键到上下一格,越出表格即到邻块;回车与 Esc 写回,写回为一步撤销;格内键入的 `|` 写回时转义为 `\|`(表格编辑读到的也是转义后的文字);格开着时做表格编辑(插删行列、整理等)先写回该格再执行,格随之关闭。列表、引用里的表格同此。表格绘制移入组件 `render/table.rs`,按格的空白处也带出所按的格(`Place::Cell`)。第 1 轮后(组件库 6a3f348):开着的格里的按下只给其输入框,插入点落在所按处、仍是一步撤销;格末单数的反斜杠写回时补成一对,不吞下一格的竖线;查找在可视模式里打开匹配所在的格并选中,跨格的匹配转到其行;格开着时右键所按的字节随格内键入的竖线换算;在未开的格的空白处右键,目标为该格末尾。第 2 轮后(组件库 2b6bd1d):格末的反斜杠只在紧接竖线时补成一对(`| a\ |` 的反斜杠后是空格,原样写回,只打开不键入不改一个字节);改首格后仍认得所在表格(无外侧竖线的表格首格始于块首,原被当作写掉了块),Tab 与按邻格照常;开着的格的空白处左键把插入点移到格末、右键以格末为目标,都不写回,仍是一步撤销。
|
||||
@@ -48,7 +48,7 @@ impl Docs {
|
||||
.size_full()
|
||||
.child(tab.view.clone())
|
||||
.children(self.find_box(cx))
|
||||
.children(self.spelling_menu(cx))
|
||||
.children(self.context_menu(cx))
|
||||
.into_any_element();
|
||||
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
|
||||
let fonts = tab.view.read(cx).missing_fonts().join(", ");
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct Docs {
|
||||
pub review: Entity<Review>,
|
||||
/// What the right column shows.
|
||||
pub side: Side,
|
||||
pub offer: Option<crate::spell::Offer>,
|
||||
pub offer: Option<ely_office::menu::Offer<ely_lok::menu::Offered>>,
|
||||
pub merge: Entity<crate::merge::Merge>,
|
||||
pub navigator: Entity<crate::navigate::Navigator>,
|
||||
/// The left column shows the navigator.
|
||||
@@ -88,7 +88,7 @@ impl Host for Docs {
|
||||
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
self.count(id, view, cx);
|
||||
self.found(id, view, cx);
|
||||
self.offer_spelling(id, view, cx);
|
||||
self.offer_menu(id, view, cx);
|
||||
}
|
||||
|
||||
/// A dialog about a closed tab goes with it.
|
||||
|
||||
@@ -7,13 +7,13 @@ mod compare;
|
||||
mod docs;
|
||||
mod files;
|
||||
mod insert;
|
||||
mod menu;
|
||||
mod merge;
|
||||
mod navigate;
|
||||
mod output;
|
||||
mod props;
|
||||
mod refs;
|
||||
mod review;
|
||||
mod spell;
|
||||
|
||||
use ely_platform::AppId;
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! The right-click menu, laid out as Word's (DOC-02, DOC-07, DOC-12).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::{
|
||||
LokView,
|
||||
menu::{Offered, Target},
|
||||
};
|
||||
use ely_office::menu::{self, Entry, Offer};
|
||||
use gpui::{App, Context, Entity, Window};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use crate::refs::{InsertCaption, InsertComment, InsertLink};
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// One thing the menu does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Pick {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
Link,
|
||||
Comment,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
RowAbove,
|
||||
RowBelow,
|
||||
DeleteRows,
|
||||
DeleteColumns,
|
||||
DeleteTable,
|
||||
Merge,
|
||||
SplitColumns,
|
||||
SplitRows,
|
||||
EvenRows,
|
||||
EvenColumns,
|
||||
Inline,
|
||||
Square,
|
||||
TopBottom,
|
||||
InFront,
|
||||
Front,
|
||||
Forward,
|
||||
Back,
|
||||
Backward,
|
||||
Caption,
|
||||
}
|
||||
|
||||
const TABLE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub(
|
||||
"doc.menu.insert",
|
||||
&[Pick::ColumnLeft, Pick::ColumnRight, Pick::RowAbove, Pick::RowBelow],
|
||||
),
|
||||
Entry::Sub(
|
||||
"doc.menu.delete",
|
||||
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable],
|
||||
),
|
||||
Entry::Row(Pick::Merge),
|
||||
Entry::Sub("doc.menu.split", &[Pick::SplitColumns, Pick::SplitRows]),
|
||||
Entry::Row(Pick::EvenRows),
|
||||
Entry::Row(Pick::EvenColumns),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Link),
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const FRAME: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub(
|
||||
"doc.menu.wrap",
|
||||
&[Pick::Inline, Pick::Square, Pick::TopBottom, Pick::InFront],
|
||||
),
|
||||
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
|
||||
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Caption),
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const TEXT: &[Entry<Pick>] = &[Entry::Line, Entry::Row(Pick::Link), Entry::Row(Pick::Comment)];
|
||||
|
||||
impl menu::Pick for Pick {
|
||||
fn row(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
|
||||
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
|
||||
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
|
||||
Pick::Link => ("doc.menu.link", ".uno:InsertHyperlink"),
|
||||
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
|
||||
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
|
||||
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
|
||||
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
|
||||
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
|
||||
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
|
||||
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
|
||||
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
|
||||
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
|
||||
Pick::SplitColumns => ("doc.menu.split_columns", ".uno:SplitCell"),
|
||||
Pick::SplitRows => ("doc.menu.split_rows", ".uno:SplitCell"),
|
||||
Pick::EvenRows => ("doc.menu.even_rows", ".uno:DistributeRows"),
|
||||
Pick::EvenColumns => ("doc.menu.even_columns", ".uno:DistributeColumns"),
|
||||
Pick::Inline => ("doc.menu.inline", ".uno:SetAnchorToChar"),
|
||||
Pick::Square => ("doc.menu.square", ".uno:WrapOn"),
|
||||
Pick::TopBottom => ("doc.menu.top_bottom", ".uno:WrapOff"),
|
||||
Pick::InFront => ("doc.menu.in_front", ".uno:WrapThrough"),
|
||||
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
|
||||
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
|
||||
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
|
||||
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
|
||||
Pick::Caption => ("doc.menu.caption", ".uno:InsertCaptionDialog"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pick {
|
||||
/// The kernel offers it and it runs now.
|
||||
pub(crate) fn on(self, offered: &Offered) -> bool {
|
||||
offered.allows(menu::Pick::row(self).1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Turns the kernel's context menu into Ely's.
|
||||
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
let (offered, at) = match menu::take(view, cx) {
|
||||
None => return,
|
||||
Some(Ok(taken)) => taken,
|
||||
Some(Err(error)) => {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
if matches!(offered.target, Target::Cell | Target::Object | Target::Picture | Target::Slide) {
|
||||
let error = format!("a {:?} menu in a text document", offered.target);
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error)]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
let suggestions = offered.spelling.as_ref().map_or(0, |spelling| spelling.suggestions.len());
|
||||
log::info!("doc: {suggestions} suggestions");
|
||||
self.offer = Some(Offer::new(self.offer.as_ref(), id, offered, at));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The menu's host, for the active tab.
|
||||
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
|
||||
let offer = self
|
||||
.offer
|
||||
.as_ref()
|
||||
.filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
Some(offer.host("doc-menu", menu_of(&cx.entity(), &offer.what, cx)))
|
||||
}
|
||||
|
||||
/// Runs one menu row.
|
||||
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("doc: the menu picks {pick:?}");
|
||||
let (id, values): (&str, &[&str]) = match pick {
|
||||
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
|
||||
Pick::Link => return self.on_link(&InsertLink, window, cx),
|
||||
Pick::Comment => return self.on_comment(&InsertComment, window, cx),
|
||||
Pick::Caption => return self.on_caption(&InsertCaption, window, cx),
|
||||
Pick::ColumnLeft => ("table.column_left", &[]),
|
||||
Pick::ColumnRight => ("table.column_right", &[]),
|
||||
Pick::RowAbove => ("table.row_above", &[]),
|
||||
Pick::RowBelow => ("table.row_below", &[]),
|
||||
Pick::DeleteRows => ("table.delete_rows", &[]),
|
||||
Pick::DeleteColumns => ("table.delete_columns", &[]),
|
||||
Pick::DeleteTable => ("table.delete", &[]),
|
||||
Pick::Merge => ("table.merge", &[]),
|
||||
Pick::SplitColumns => ("table.split", &["2", "false"]),
|
||||
Pick::SplitRows => ("table.split", &["2", "true"]),
|
||||
Pick::EvenRows => ("table.even_rows", &[]),
|
||||
Pick::EvenColumns => ("table.even_columns", &[]),
|
||||
Pick::Inline => ("image.anchor_as_char", &[]),
|
||||
Pick::Square => ("image.wrap_on", &[]),
|
||||
Pick::TopBottom => ("image.wrap_off", &[]),
|
||||
Pick::InFront => ("image.wrap_through", &[]),
|
||||
Pick::Front => ("image.front", &[]),
|
||||
Pick::Forward => ("image.forward", &[]),
|
||||
Pick::Back => ("image.back", &[]),
|
||||
Pick::Backward => ("image.backward", &[]),
|
||||
};
|
||||
self.run_active(id, values, cx);
|
||||
}
|
||||
|
||||
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
|
||||
log::warn!("doc: {pick:?} with no document");
|
||||
return;
|
||||
};
|
||||
view.update(cx, |view, _| match pick {
|
||||
Pick::Paste => view.paste(),
|
||||
_ => view.copy(pick == Pick::Cut),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Spelling first, then the clipboard row, then the target's rows.
|
||||
pub(crate) fn menu_of(this: &Entity<Docs>, offered: &Offered, cx: &App) -> Menu {
|
||||
let item = |pick: Pick| {
|
||||
let this = this.clone();
|
||||
MenuItem::new(Docs::text(cx, menu::Pick::row(pick).0, &[]))
|
||||
.disabled(!pick.on(offered))
|
||||
.on_click(move |window, cx| this.update(cx, |docs, cx| docs.choose(pick, window, cx)))
|
||||
};
|
||||
let mut menu = Menu::new();
|
||||
if let Some(spelling) = &offered.spelling {
|
||||
for (label, rule) in &spelling.suggestions {
|
||||
let (this, rule) = (this.clone(), rule.clone());
|
||||
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
|
||||
}));
|
||||
}
|
||||
if spelling.suggestions.is_empty() {
|
||||
menu = menu.item(MenuItem::new(Docs::text(cx, "doc.spell.none", &[])).disabled(true));
|
||||
}
|
||||
let this = this.clone();
|
||||
let ignore = MenuItem::new(Docs::text(cx, "doc.spell.ignore_all", &[])).on_click(
|
||||
move |_, cx| {
|
||||
this.update(cx, |docs, cx| {
|
||||
docs.run_active("spell.ignore_all", &["Spelling"], cx)
|
||||
})
|
||||
},
|
||||
);
|
||||
menu = menu.item(ignore).separator();
|
||||
}
|
||||
let menu = menu::clipboard(menu, [Pick::Cut, Pick::Copy, Pick::Paste], item);
|
||||
let rows = match offered.target {
|
||||
Target::Table => TABLE,
|
||||
Target::Frame => FRAME,
|
||||
Target::Text => TEXT,
|
||||
Target::Cell | Target::Object | Target::Picture | Target::Slide => unreachable!("offer_menu refuses them"),
|
||||
};
|
||||
menu::entries(menu, rows, cx, |pick| pick.on(offered), item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,117 @@
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use ely_lok::{LokView, menu::Target};
|
||||
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::Pick;
|
||||
use crate::docs::Docs;
|
||||
use crate::tests::{blank, docs, settled, text_of, type_into, with_window, written};
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// The view with the menu's host over it.
|
||||
struct Host(Entity<Docs>, Entity<LokView>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, _: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
let menu = self.0.update(cx, |docs, cx| docs.context_menu(cx));
|
||||
gpui::div().relative().size_full().child(self.1.clone()).children(menu)
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
/// The caret's middle, in window pixels.
|
||||
fn caret(cx: &mut VisualTestContext, view: &Entity<LokView>) -> Point<Pixels> {
|
||||
frames(cx);
|
||||
cx.update(|_, app| view.read(app).cursor_bounds().expect("a caret").center())
|
||||
}
|
||||
|
||||
/// Right-clicks through the view and waits for the target's menu.
|
||||
fn right_click(cx: &mut VisualTestContext, docs: &Entity<Docs>, at: Point<Pixels>, target: Target) {
|
||||
cx.update(|_, app| docs.update(app, |docs, _| docs.offer = None));
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||
let shown = |app: &gpui::App| {
|
||||
docs.read(app)
|
||||
.offer
|
||||
.as_ref()
|
||||
.is_some_and(|offer| offer.what.target == target)
|
||||
};
|
||||
for _ in 0..500 {
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| shown(app)) {
|
||||
return frames(cx);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("no {target:?} menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_right_click_opens_word_rows_where_it_lands() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| {
|
||||
ely_gpui_component::init_for_tests(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
let id = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Text");
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
let window = cx.add_window(|_, _| Host(docs.clone(), view.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
let text = caret(&mut shown, &view) - gpui::point(gpui::px(8.0), gpui::px(0.0));
|
||||
right_click(&mut shown, &docs, text, Target::Text);
|
||||
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
|
||||
let off = cut.origin - text;
|
||||
assert!(
|
||||
off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0),
|
||||
"the menu opens at the click, not the caret: {off:?}"
|
||||
);
|
||||
let on = |shown: &mut VisualTestContext, pick: Pick| {
|
||||
shown.update(|_, app| pick.on(&docs.read(app).offer.as_ref().expect("an offer").what))
|
||||
};
|
||||
assert!(on(&mut shown, Pick::Comment));
|
||||
assert!(!on(&mut shown, Pick::Link), "the kernel links a selection");
|
||||
assert!(!on(&mut shown, Pick::Cut), "nothing is selected");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| docs.insert_table(2.0, 2.0, cx));
|
||||
settled(&cx, &docs);
|
||||
let cell = caret(&mut shown, &view);
|
||||
right_click(&mut shown, &docs, cell, Target::Table);
|
||||
assert!(on(&mut shown, Pick::RowBelow) && on(&mut shown, Pick::SplitRows));
|
||||
assert!(!on(&mut shown, Pick::Merge), "one cell does not merge");
|
||||
for pick in [Pick::RowBelow, Pick::ColumnRight] {
|
||||
with_window(&mut cx, &docs, |docs, window, cx| docs.choose(pick, window, cx));
|
||||
settled(&cx, &docs);
|
||||
}
|
||||
let path = dir.path().join("grown.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let flat = text_of(&mut cx, &docs, &path);
|
||||
assert_eq!(flat.matches("<table:table-row").count(), 3, "a row below");
|
||||
assert_eq!(flat.matches("<table:table-cell").count(), 9, "and a column right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_follows_the_kernel_flag() {
|
||||
let menu = |enabled: bool| {
|
||||
let json = format!(
|
||||
r#"{{"menu":[{{"text":"x","type":"command","command":".uno:InsertHyperlink","enabled":"{enabled}"}}]}}"#
|
||||
);
|
||||
ely_lok::menu::read(&json).expect("the menu parses")
|
||||
};
|
||||
assert!(Pick::Link.on(&menu(true)));
|
||||
assert!(!Pick::Link.on(&menu(false)), "protected text gets no link");
|
||||
}
|
||||
@@ -250,15 +250,15 @@ impl Render for Props {
|
||||
let grid = if graphic {
|
||||
grid.group(self.object_group(cx))
|
||||
} else {
|
||||
let grid = grid
|
||||
.group(self.text_group(window, cx))
|
||||
.group(self.paragraph_group(cx))
|
||||
.group(self.style_group(cx))
|
||||
.group(self.page_group(cx));
|
||||
match self.state(".uno:DeleteTable", cx).as_deref() {
|
||||
// In a table, its group leads, as in Word.
|
||||
let grid = match self.state(".uno:DeleteTable", cx).as_deref() {
|
||||
Some("enabled") => grid.group(self.table_group(cx)),
|
||||
_ => grid,
|
||||
}
|
||||
};
|
||||
grid.group(self.text_group(window, cx))
|
||||
.group(self.paragraph_group(cx))
|
||||
.group(self.style_group(cx))
|
||||
.group(self.page_group(cx))
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use ely_gpui_component::{data_display::PropertyGroup, primitives::IconName};
|
||||
use ely_office::panel::{Panel, color_state, font_box, palette, row, size_menu, toggle};
|
||||
use gpui::{App, Context, SharedString, Window};
|
||||
use gpui::{App, Context, InteractiveElement, ParentElement, SharedString, Window, div};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
@@ -77,7 +77,12 @@ impl Props {
|
||||
PropertyGroup::new(tip("doc.props.text"))
|
||||
.row(tip("doc.props.font"), font)
|
||||
.row(tip("doc.props.size"), size_menu(self, "doc-props-size", cx))
|
||||
.row(tip("doc.props.effects"), effects)
|
||||
.row(
|
||||
tip("doc.props.effects"),
|
||||
div()
|
||||
.debug_selector(|| "doc-props-text".into())
|
||||
.child(effects),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.color"),
|
||||
palette(&this, "format.color", (color, AUTOMATIC), None, cx),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use ely_gpui_component::{buttons::Button, data_display::PropertyGroup, theme::ControlSize};
|
||||
use ely_office::panel::{button, color_state, palette, row, send, toggle};
|
||||
use gpui::{AnyElement, Context, IntoElement, SharedString};
|
||||
use gpui::{AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, div};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
@@ -14,11 +14,13 @@ impl Props {
|
||||
PropertyGroup::new(tip("doc.props.table"))
|
||||
.row(
|
||||
tip("doc.props.rows"),
|
||||
row([
|
||||
button(self, "table.row_above", "doc.props.above_row", why, cx),
|
||||
button(self, "table.row_below", "doc.props.below_row", why, cx),
|
||||
button(self, "table.delete_rows", "doc.props.delete", why, cx),
|
||||
]),
|
||||
div()
|
||||
.debug_selector(|| "doc-props-table".into())
|
||||
.child(row([
|
||||
button(self, "table.row_above", "doc.props.above_row", why, cx),
|
||||
button(self, "table.row_below", "doc.props.below_row", why, cx),
|
||||
button(self, "table.delete_rows", "doc.props.delete", why, cx),
|
||||
])),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.columns"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use ely_lok::LokError;
|
||||
use ely_office::panel::Panel as _;
|
||||
use gpui::{Entity, TestAppContext};
|
||||
@@ -296,3 +297,82 @@ fn quick_spacing_edits_keep_each_other() {
|
||||
"both quick edits land"
|
||||
);
|
||||
}
|
||||
|
||||
/// A window root that shows the panel.
|
||||
struct Shown(Entity<Props>);
|
||||
|
||||
impl gpui::Render for Shown {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
gpui::div().size_full().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// The top of the element marked `selector`.
|
||||
fn top(cx: &mut gpui::VisualTestContext, selector: &'static str) -> Option<gpui::Pixels> {
|
||||
// Past reduced motion's 1ms folds.
|
||||
for _ in 0..3 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
cx.debug_bounds(selector).map(|bounds| bounds.origin.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_a_table_the_table_group_comes_first_and_folds_stay_put() {
|
||||
const DOWN: i32 = 1024;
|
||||
const UP: i32 = 1025;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| {
|
||||
ely_gpui_component::init_for_tests(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Table");
|
||||
let props = panel(&mut cx, &docs);
|
||||
let window = cx.add_window(|_, _| Shown(props.clone()));
|
||||
let mut shown = gpui::VisualTestContext::from_window(window.into(), &cx);
|
||||
assert_eq!(
|
||||
top(&mut shown, "doc-props-table"),
|
||||
None,
|
||||
"no table, no group"
|
||||
);
|
||||
let header = gpui::point(gpui::px(10.0), gpui::px(12.0));
|
||||
shown.simulate_click(header, gpui::Modifiers::none());
|
||||
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds");
|
||||
shown.simulate_click(header, gpui::Modifiers::none());
|
||||
assert!(top(&mut shown, "doc-props-text").is_some(), "Text opens");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.insert_table(3.0, 2.0, cx)
|
||||
});
|
||||
let in_table = |app: &gpui::App| props.read(app).state(".uno:DeleteTable", app);
|
||||
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
|
||||
let table = top(&mut shown, "doc-props-table").expect("the table group");
|
||||
let text = top(&mut shown, "doc-props-text").expect("the text group");
|
||||
assert!(table < text, "the table group leads: {table:?} vs {text:?}");
|
||||
keys(&mut cx, &docs, &[UP]);
|
||||
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
|
||||
shown.simulate_click(header, gpui::Modifiers::none());
|
||||
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds again");
|
||||
keys(&mut cx, &docs, &[DOWN]);
|
||||
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
|
||||
assert!(top(&mut shown, "doc-props-table").is_some(), "Table opens");
|
||||
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text stays folded");
|
||||
keys(&mut cx, &docs, &[UP]);
|
||||
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
|
||||
assert_eq!(
|
||||
top(&mut shown, "doc-props-text"),
|
||||
None,
|
||||
"and after the table"
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
//! Spelling suggestions from the kernel's context menu (DOC-12).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::LokView;
|
||||
use gpui::{Context, Entity, IntoElement, Pixels, Point};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// A misspelled word's menu, asked for at a point.
|
||||
pub struct Offer {
|
||||
tab: u64,
|
||||
pub(crate) suggestions: Vec<(String, String)>,
|
||||
/// Bumps for each menu, so each opens once.
|
||||
request: (u64, Point<Pixels>),
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Turns the kernel's context menu into a spelling offer.
|
||||
pub(crate) fn offer_spelling(
|
||||
&mut self,
|
||||
id: u64,
|
||||
view: &Entity<LokView>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
|
||||
return;
|
||||
};
|
||||
let spelling = match ely_lok::review::spelling(&json) {
|
||||
Ok(spelling) => spelling,
|
||||
Err(error) => {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
let Some(spelling) = spelling else {
|
||||
log::info!("doc: the kernel menu has no spelling");
|
||||
return;
|
||||
};
|
||||
let Some(caret) = view.read(cx).cursor_bounds() else {
|
||||
log::warn!("doc: a spelling menu without a caret");
|
||||
return;
|
||||
};
|
||||
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
|
||||
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
|
||||
self.offer = Some(Offer {
|
||||
tab: id,
|
||||
suggestions: spelling.suggestions,
|
||||
request: (number, caret.bottom_left()),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The spelling menu's host, for the active tab.
|
||||
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
let offer = self
|
||||
.offer
|
||||
.as_ref()
|
||||
.filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
let this = cx.entity();
|
||||
let mut menu = Menu::new();
|
||||
for (label, rule) in &offer.suggestions {
|
||||
let (this, rule) = (this.clone(), rule.clone());
|
||||
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
|
||||
}));
|
||||
}
|
||||
if offer.suggestions.is_empty() {
|
||||
let none = Docs::text(cx, "doc.spell.none", &[]);
|
||||
menu = menu.item(MenuItem::new(none).disabled(true));
|
||||
}
|
||||
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
|
||||
let menu = menu
|
||||
.separator()
|
||||
.item(MenuItem::new(ignore).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| {
|
||||
docs.run_active("spell.ignore_all", &["Spelling"], cx)
|
||||
})
|
||||
}));
|
||||
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,8 @@ fn a_misspelled_word_offers_suggestions() {
|
||||
settle(&cx, |app| docs.read(app).offer.is_some());
|
||||
let rule = cx.update(|app| {
|
||||
let offer = docs.read(app).offer.as_ref().expect("an offer");
|
||||
let (_, rule) = offer
|
||||
let spelling = offer.what.spelling.as_ref().expect("a spelling menu");
|
||||
let (_, rule) = spelling
|
||||
.suggestions
|
||||
.iter()
|
||||
.find(|(label, _)| label == "The")
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Typora's Paragraph and Format menus at the caret (MARK-15).
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{BlockCommand, EditRefusal, InlineFormat, MarkdownDocument, TableRefusal},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{
|
||||
Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, SharedString,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::{desk::Desk, keys::item};
|
||||
|
||||
actions!(
|
||||
mark,
|
||||
[
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
Paragraph,
|
||||
Promote,
|
||||
Demote,
|
||||
MathBlock,
|
||||
CodeFences,
|
||||
Quote,
|
||||
OrderedList,
|
||||
BulletList,
|
||||
TaskList,
|
||||
ParagraphBefore,
|
||||
ParagraphAfter,
|
||||
LinkReference,
|
||||
Footnote,
|
||||
Rule,
|
||||
Toc,
|
||||
FrontMatter,
|
||||
Strong,
|
||||
Emphasis,
|
||||
Underline,
|
||||
InlineCode,
|
||||
Strike,
|
||||
Hyperlink,
|
||||
InsertImage,
|
||||
ClearFormat,
|
||||
DeleteBlock
|
||||
]
|
||||
);
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-1", Heading1, at),
|
||||
KeyBinding::new("secondary-2", Heading2, at),
|
||||
KeyBinding::new("secondary-3", Heading3, at),
|
||||
KeyBinding::new("secondary-4", Heading4, at),
|
||||
KeyBinding::new("secondary-5", Heading5, at),
|
||||
KeyBinding::new("secondary-6", Heading6, at),
|
||||
KeyBinding::new("secondary-0", Paragraph, at),
|
||||
KeyBinding::new("secondary-=", Promote, at),
|
||||
KeyBinding::new("secondary--", Demote, at),
|
||||
KeyBinding::new("secondary-shift-m", MathBlock, at),
|
||||
KeyBinding::new("secondary-shift-k", CodeFences, at),
|
||||
KeyBinding::new("secondary-shift-q", Quote, at),
|
||||
KeyBinding::new("secondary-{", OrderedList, at),
|
||||
KeyBinding::new("secondary-}", BulletList, at),
|
||||
KeyBinding::new("secondary-shift-x", TaskList, at),
|
||||
KeyBinding::new("secondary-b", Strong, at),
|
||||
KeyBinding::new("secondary-i", Emphasis, at),
|
||||
KeyBinding::new("secondary-u", Underline, at),
|
||||
KeyBinding::new("secondary-~", InlineCode, at),
|
||||
KeyBinding::new("alt-%", Strike, at),
|
||||
KeyBinding::new("secondary-k", Hyperlink, at),
|
||||
KeyBinding::new("secondary-shift-i", InsertImage, at),
|
||||
KeyBinding::new("secondary-\\", ClearFormat, at),
|
||||
]);
|
||||
}
|
||||
|
||||
pub fn paragraph_menu(cx: &App) -> Menu {
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
Menu::new(text(cx, "mark.menu.paragraph", &[])).items([
|
||||
item("mark.block.h1", Box::new(Heading1)),
|
||||
item("mark.block.h2", Box::new(Heading2)),
|
||||
item("mark.block.h3", Box::new(Heading3)),
|
||||
item("mark.block.h4", Box::new(Heading4)),
|
||||
item("mark.block.h5", Box::new(Heading5)),
|
||||
item("mark.block.h6", Box::new(Heading6)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.paragraph", Box::new(Paragraph)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.promote", Box::new(Promote)),
|
||||
item("mark.block.demote", Box::new(Demote)),
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu(crate::tools::table_menu(cx)),
|
||||
item("mark.block.math", Box::new(MathBlock)),
|
||||
item("mark.block.code", Box::new(CodeFences)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.quote", Box::new(Quote)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.ordered", Box::new(OrderedList)),
|
||||
item("mark.block.bullet", Box::new(BulletList)),
|
||||
item("mark.block.task", Box::new(TaskList)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.before", Box::new(ParagraphBefore)),
|
||||
item("mark.block.after", Box::new(ParagraphAfter)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.link_reference", Box::new(LinkReference)),
|
||||
item("mark.block.footnote", Box::new(Footnote)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.rule", Box::new(Rule)),
|
||||
item("mark.block.toc", Box::new(Toc)),
|
||||
item("mark.block.front_matter", Box::new(FrontMatter)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn format_menu(cx: &App) -> Menu {
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
Menu::new(text(cx, "mark.menu.format", &[])).items([
|
||||
item("mark.format.strong", Box::new(Strong)),
|
||||
item("mark.format.emphasis", Box::new(Emphasis)),
|
||||
item("mark.format.underline", Box::new(Underline)),
|
||||
item("mark.format.code", Box::new(InlineCode)),
|
||||
MenuItem::separator(),
|
||||
item("mark.format.strike", Box::new(Strike)),
|
||||
MenuItem::separator(),
|
||||
item("mark.format.link", Box::new(Hyperlink)),
|
||||
item("mark.format.image", Box::new(InsertImage)),
|
||||
MenuItem::separator(),
|
||||
item("mark.format.clear", Box::new(ClearFormat)),
|
||||
])
|
||||
}
|
||||
|
||||
/// What to tell the user for `why`.
|
||||
pub(crate) fn refusal(why: EditRefusal, cx: &App) -> SharedString {
|
||||
let key = match why {
|
||||
EditRefusal::Preview => "mark.preview.read_only",
|
||||
EditRefusal::Code => "mark.refused.code",
|
||||
EditRefusal::Math => "mark.refused.math",
|
||||
EditRefusal::Html => "mark.refused.html",
|
||||
EditRefusal::FrontMatter => "mark.refused.front_matter",
|
||||
EditRefusal::Outside => "mark.refused.outside",
|
||||
EditRefusal::Table => "mark.refused.table",
|
||||
EditRefusal::NoImage => "mark.refused.no_image",
|
||||
EditRefusal::Present => "mark.refused.present",
|
||||
EditRefusal::Across => "mark.refused.across",
|
||||
EditRefusal::TooLong => "mark.refused.too_long",
|
||||
EditRefusal::Busy => "mark.refused.busy",
|
||||
EditRefusal::Changed => "mark.refused.changed",
|
||||
EditRefusal::Misread => "mark.refused.misread",
|
||||
EditRefusal::Cells(TableRefusal::Outside) => "mark.table.none",
|
||||
EditRefusal::Cells(TableRefusal::Kept) => "mark.table.kept",
|
||||
EditRefusal::Cells(TableRefusal::Beyond) => "mark.table.beyond",
|
||||
EditRefusal::Cells(TableRefusal::Mixed) => "mark.table.mixed",
|
||||
EditRefusal::Cells(TableRefusal::Edge) => "mark.table.edge",
|
||||
};
|
||||
text(cx, key, &[])
|
||||
}
|
||||
|
||||
/// Each action to its document edit.
|
||||
macro_rules! edits {
|
||||
($root:expr, $cx:expr, $($action:ident => $edit:expr),* $(,)?) => {
|
||||
$root$(.on_action($cx.listener(|desk, _: &$action, _, cx| desk.edit($edit, cx))))*
|
||||
};
|
||||
}
|
||||
pub(crate) use edits;
|
||||
|
||||
impl Desk {
|
||||
/// `edit` on the shown document; a refusal is said.
|
||||
pub(crate) fn edit(
|
||||
&mut self,
|
||||
edit: impl FnOnce(
|
||||
&mut MarkdownDocument,
|
||||
&mut Context<MarkdownDocument>,
|
||||
) -> Result<(), EditRefusal>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
if let Err(why) = document.update(cx, edit) {
|
||||
self.toast(refusal(why, cx), Severity::Info, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn block_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
use BlockCommand as B;
|
||||
use InlineFormat as F;
|
||||
edits!(root, cx,
|
||||
Heading1 => |d, cx| d.block(B::Heading(1), cx),
|
||||
Heading2 => |d, cx| d.block(B::Heading(2), cx),
|
||||
Heading3 => |d, cx| d.block(B::Heading(3), cx),
|
||||
Heading4 => |d, cx| d.block(B::Heading(4), cx),
|
||||
Heading5 => |d, cx| d.block(B::Heading(5), cx),
|
||||
Heading6 => |d, cx| d.block(B::Heading(6), cx),
|
||||
Paragraph => |d, cx| d.block(B::Paragraph, cx),
|
||||
Promote => |d, cx| d.block(B::Promote, cx),
|
||||
Demote => |d, cx| d.block(B::Demote, cx),
|
||||
MathBlock => |d, cx| d.block(B::MathBlock, cx),
|
||||
CodeFences => |d, cx| d.block(B::CodeBlock, cx),
|
||||
Quote => |d, cx| d.block(B::Quote, cx),
|
||||
OrderedList => |d, cx| d.block(B::OrderedList, cx),
|
||||
BulletList => |d, cx| d.block(B::BulletList, cx),
|
||||
TaskList => |d, cx| d.block(B::TaskList, cx),
|
||||
ParagraphBefore => |d, cx| d.block(B::ParagraphBefore, cx),
|
||||
ParagraphAfter => |d, cx| d.block(B::ParagraphAfter, cx),
|
||||
LinkReference => |d, cx| d.block(B::LinkDefinition, cx),
|
||||
Footnote => |d, cx| d.block(B::Footnote, cx),
|
||||
Rule => |d, cx| d.block(B::Rule, cx),
|
||||
Toc => |d, cx| d.block(B::Toc, cx),
|
||||
FrontMatter => |d, cx| d.block(B::FrontMatter, cx),
|
||||
Strong => |d, cx| d.format(F::Strong, cx),
|
||||
Emphasis => |d, cx| d.format(F::Emphasis, cx),
|
||||
Underline => |d, cx| d.format(F::Underline, cx),
|
||||
InlineCode => |d, cx| d.format(F::Code, cx),
|
||||
Strike => |d, cx| d.format(F::Strike, cx),
|
||||
Hyperlink => |d, cx| d.format(F::Link, cx),
|
||||
InsertImage => |d, cx| d.format(F::Image, cx),
|
||||
ClearFormat => |d, cx| d.format(F::Clear, cx),
|
||||
DeleteBlock => |d, cx| d.block(B::Delete, cx),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// macOS menus are the system's, not drawn.
|
||||
#[cfg(test)]
|
||||
mod context;
|
||||
#[cfg(all(test, not(target_os = "macos")))]
|
||||
mod menus;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,251 @@
|
||||
//! The right-click menu, driven as a user would (R4.10).
|
||||
|
||||
use ely_gpui_component::theme::{ActiveTheme as _, TextSize};
|
||||
use gpui::{
|
||||
ClipboardItem, Entity, Modifiers, MouseButton, Point, TestAppContext, VisualTestContext, point,
|
||||
px,
|
||||
};
|
||||
|
||||
use super::tests::{current, document, opened, toasted};
|
||||
use crate::{desk::Desk, view::Mode};
|
||||
|
||||
/// A right press on block `ix`'s first line, character `k`.
|
||||
fn right_on(ix: usize, k: usize, cx: &mut VisualTestContext) {
|
||||
let block = cx
|
||||
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
|
||||
.expect("the block is drawn");
|
||||
let size = cx.update(|window, cx| {
|
||||
cx.theme()
|
||||
.text_size(TextSize::Base)
|
||||
.to_pixels(window.rem_size())
|
||||
});
|
||||
// The test text system sets glyphs 0.6 em apart.
|
||||
let x = block.left() + px(13.0) + size * 0.6 * (k as f32 + 0.25);
|
||||
right_at(point(x, block.top() + px(10.0)), cx);
|
||||
}
|
||||
|
||||
fn right_at(at: Point<gpui::Pixels>, cx: &mut VisualTestContext) {
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Arrow `keys`, then Enter; menus choose on its release.
|
||||
fn choose(keys: &str, cx: &mut VisualTestContext) {
|
||||
if !keys.is_empty() {
|
||||
cx.simulate_keystrokes(keys);
|
||||
}
|
||||
cx.simulate_keystrokes("enter");
|
||||
let enter = gpui::Keystroke::parse("enter").expect("a key");
|
||||
cx.simulate_event(gpui::KeyUpEvent { keystroke: enter });
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
|
||||
cx.read_from_clipboard().and_then(|item| item.text())
|
||||
}
|
||||
|
||||
fn switch(desk: &Entity<Desk>, mode: Mode, cx: &mut VisualTestContext) {
|
||||
desk.update_in(cx, |desk, window, cx| desk.switch(mode, window, cx));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn paste_from_the_menu_lands_at_the_pressed_caret(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Intro\n", cx);
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("New ".into()));
|
||||
right_on(0, 0, cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(current(&desk, cx), "New Intro\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_link_opens_and_copies_its_address(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened("[docs](https://x.test)\n", cx);
|
||||
right_on(0, 1, cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(cx.opened_url().as_deref(), Some("https://x.test"));
|
||||
right_on(0, 1, cx);
|
||||
choose("down down down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("https://x.test"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_code_block_copies_its_code_and_deletes_by_name(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("```rust\nlet x = 1;\n```\n\nNext\n", cx);
|
||||
right_on(0, 0, cx);
|
||||
choose("down down down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("let x = 1;"));
|
||||
right_on(0, 0, cx);
|
||||
choose("up", cx);
|
||||
assert!(
|
||||
!current(&desk, cx).contains("let x"),
|
||||
"{}",
|
||||
current(&desk, cx)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_quote_is_deleted_from_the_last_row(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("> quoted\n\nNext\n", cx);
|
||||
right_on(0, 1, cx);
|
||||
choose("up", cx);
|
||||
let text = current(&desk, cx);
|
||||
assert!(!text.contains("quoted") && text.contains("Next"), "{text}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_picture_shows_its_file_or_goes(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Lead.\n\n\n", cx);
|
||||
right_on(1, 1, cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(toasted(&desk, "no file here", cx), 1);
|
||||
right_on(1, 1, cx);
|
||||
choose("down down", cx);
|
||||
assert!(!current(&desk, cx).contains("!["), "{}", current(&desk, cx));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn source_cuts_the_selection_a_right_press_keeps(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
switch(&desk, Mode::Source, cx);
|
||||
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
|
||||
let line = cx.debug_bounds("editor-line-0").expect("drawn");
|
||||
right_at(point(line.left() + px(20.0), line.top() + px(6.0)), cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(current(&desk, cx), " beta\n");
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_block_copies_its_selection_and_stays(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Intro words\n", cx);
|
||||
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
cx.simulate_mouse_down(block.center(), MouseButton::Left, Modifiers::none());
|
||||
cx.simulate_mouse_up(block.center(), MouseButton::Left, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("home shift-right shift-right shift-right shift-right shift-right");
|
||||
let field = cx.debug_bounds("markdown-open").expect("open");
|
||||
right_at(point(field.left() + px(20.0), field.top() + px(14.0)), cx);
|
||||
choose("down right", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("Intro"));
|
||||
assert!(
|
||||
cx.debug_bounds("markdown-open").is_some(),
|
||||
"the block stays open"
|
||||
);
|
||||
assert_eq!(current(&desk, cx), "Intro words\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_offers_copies_only(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha **beta**\n", cx);
|
||||
switch(&desk, Mode::Preview, cx);
|
||||
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
right_at(block.center(), cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha beta\n"));
|
||||
right_at(block.center(), cx);
|
||||
choose("down down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha **beta**\n"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn html_code_keeps_picture_addresses(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened(" and *it*\n", cx);
|
||||
cx.dispatch_action(crate::export::CopyHtml);
|
||||
cx.run_until_parked();
|
||||
let html = clipboard(cx).expect("copied");
|
||||
assert!(
|
||||
html.contains("src=\"missing.png\"") && html.contains("<em>it</em>"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn copies_convert_off_the_window_thread(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into()));
|
||||
// The context's dispatch parks; this one does not.
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
assert_eq!(
|
||||
clipboard(cx).as_deref(),
|
||||
Some("before"),
|
||||
"not on the window thread"
|
||||
);
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyPlain), cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha it\n"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_later_markdown_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::clip::CopyMarkdown), cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha *it*\n"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_later_editor_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
|
||||
use gpui::Focusable as _;
|
||||
let (desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
switch(&desk, Mode::Source, cx);
|
||||
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
|
||||
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
let copy = gpui::Keystroke::parse("secondary-c").expect("key");
|
||||
cx.update(|window, cx| window.dispatch_keystroke(copy, cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_later_rich_copy_with_the_same_text_outlives_a_pending_conversion(cx: &mut TestAppContext) {
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
|
||||
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
|
||||
return;
|
||||
}
|
||||
let rich = |html: &str| Clip::Html {
|
||||
html: html.into(),
|
||||
text: "x".into(),
|
||||
};
|
||||
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
clipboard::write(&rich("<b>x</b>")).expect("writes");
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("x".into()));
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
clipboard::write(&rich("<i>x</i>")).expect("writes");
|
||||
cx.run_until_parked();
|
||||
let html = clipboard::html().expect("reads");
|
||||
assert_eq!(
|
||||
clipboard(cx).as_deref(),
|
||||
Some("x"),
|
||||
"the conversion is dropped"
|
||||
);
|
||||
assert!(html.is_some_and(|html| html.contains("<i>x</i>")));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_shut_menu_stays_shut_when_its_tab_returns(cx: &mut TestAppContext) {
|
||||
let (desk, cx, files) = opened("alpha\n", cx);
|
||||
right_on(0, 1, cx);
|
||||
assert!(cx.debug_bounds("menu-button-0-0").is_some(), "opened");
|
||||
cx.simulate_keystrokes("escape");
|
||||
cx.run_until_parked();
|
||||
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "shut");
|
||||
let other = files[1].path().join("b.md");
|
||||
std::fs::write(&other, "beta\n").expect("b");
|
||||
crate::desk::tests::open(&desk, &other, cx);
|
||||
let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id);
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.core.active = Some(first);
|
||||
cx.notify();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "not replayed");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use ely_gpui_component::theme::{ActiveTheme as _, ControlSize};
|
||||
use gpui::{
|
||||
App, Entity, KeyUpEvent, Keystroke, Menu, Modifiers, TestAppContext, VisualTestContext, point,
|
||||
px,
|
||||
};
|
||||
|
||||
use super::tests::{current, document, opened, press};
|
||||
use crate::{desk::Desk, view::Mode};
|
||||
|
||||
/// Selects `alpha` in the first block, opened.
|
||||
fn select_alpha(desk: &Entity<Desk>, cx: &mut VisualTestContext) {
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("a block");
|
||||
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
press(
|
||||
"home shift-right shift-right shift-right shift-right shift-right",
|
||||
cx,
|
||||
);
|
||||
let selected = document(desk, cx).read_with(cx, |document, cx| document.selected(cx));
|
||||
assert_eq!(selected, "alpha");
|
||||
}
|
||||
|
||||
/// Runs `menu`'s row `row` from the title bar.
|
||||
fn choose(menu: fn(&App) -> Menu, row: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, cx| {
|
||||
cx.set_menus(vec![menu(cx)]);
|
||||
window.refresh();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let at = cx.update(|window, cx| {
|
||||
let rem = window.rem_size();
|
||||
let bar = cx.theme().titlebar_height().to_pixels(rem);
|
||||
let title = cx.theme().control_height(ControlSize::Sm).to_pixels(rem);
|
||||
point(px(12.), bar + title / 2.)
|
||||
});
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
press(&format!("{} enter", vec!["down"; row].join(" ")), cx);
|
||||
let enter = Keystroke::parse("enter").expect("a key");
|
||||
cx.simulate_event(KeyUpEvent { keystroke: enter });
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn edit_menu(cx: &App) -> Menu {
|
||||
let look = crate::look::Look::default();
|
||||
crate::keys::menus(&look, cx).remove(0)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_menu_formats_the_open_blocks_selection(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
select_alpha(&desk, cx);
|
||||
choose(super::format_menu, 1, cx);
|
||||
assert_eq!(current(&desk, cx), "**alpha** beta\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_menu_copies_the_open_blocks_selection(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
select_alpha(&desk, cx);
|
||||
// Copy as Markdown, after Copy as Plain Text.
|
||||
choose(edit_menu, 2, cx);
|
||||
let copied = cx.read_from_clipboard().and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
const TABLED: &str = "before\n\n| A |\n| - |\n| 1 |\n\nafter\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_table_from_the_menu_is_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened(TABLED, cx);
|
||||
desk.update_in(cx, |desk, window, cx| desk.switch(Mode::Source, window, cx));
|
||||
let document = document(&desk, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
|
||||
choose(crate::tools::table_menu, 13, cx);
|
||||
assert_eq!(current(&desk, cx), "before\n\nafter\n");
|
||||
press("secondary-z", cx);
|
||||
assert_eq!(current(&desk, cx), TABLED);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_table_in_an_open_block_keeps_keyboard_undo(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened(TABLED, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-1").expect("the table");
|
||||
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
choose(crate::tools::table_menu, 13, cx);
|
||||
assert_eq!(current(&desk, cx), "before\n\nafter\n");
|
||||
press("secondary-z", cx);
|
||||
assert_eq!(current(&desk, cx), TABLED);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use ely_gpui_component::documents::MarkdownDocument;
|
||||
#[cfg(target_os = "windows")]
|
||||
use gpui::Action;
|
||||
use gpui::{
|
||||
ClipboardItem, Entity, Focusable as _, Menu, MenuItem, TestAppContext, VisualTestContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
desk::{
|
||||
Desk,
|
||||
tests::{open, start},
|
||||
},
|
||||
view::Mode,
|
||||
};
|
||||
|
||||
pub(super) 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);
|
||||
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
(desk, cx, [data, files])
|
||||
}
|
||||
|
||||
pub(super) fn document(
|
||||
desk: &Entity<Desk>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Entity<MarkdownDocument> {
|
||||
desk.read_with(cx, |desk, cx| {
|
||||
let view = desk.active_view().expect("a tab");
|
||||
view.read(cx).document().clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn current(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
|
||||
let document = document(desk, cx);
|
||||
document.read_with(cx, |document, cx| document.text(cx))
|
||||
}
|
||||
|
||||
fn mode(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Mode {
|
||||
desk.read_with(cx, |desk, cx| {
|
||||
desk.active_view().expect("a tab").read(cx).mode
|
||||
})
|
||||
}
|
||||
|
||||
/// `keys`, then the background reads.
|
||||
pub(super) fn press(keys: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(keys);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// The window's toasts that hold `words`.
|
||||
pub(super) fn toasted(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) -> usize {
|
||||
let root = desk.read_with(cx, |desk, _| desk.core.root.upgrade().expect("the window"));
|
||||
let toasts = root.read_with(cx, |root, cx| root.toasts(cx));
|
||||
toasts
|
||||
.iter()
|
||||
.filter(|(said, _)| said.contains(words))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn paragraph_keys_retype_the_caret_block(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Intro\n", cx);
|
||||
press("secondary-1", cx);
|
||||
assert_eq!(current(&desk, cx), "# Intro\n");
|
||||
press("secondary--", cx);
|
||||
assert_eq!(current(&desk, cx), "## Intro\n");
|
||||
press("secondary-=", cx);
|
||||
assert_eq!(current(&desk, cx), "# Intro\n");
|
||||
press("secondary-0", cx);
|
||||
assert_eq!(current(&desk, cx), "Intro\n");
|
||||
press("secondary-{", cx);
|
||||
assert_eq!(current(&desk, cx), "1. Intro\n");
|
||||
press("secondary-}", cx);
|
||||
assert_eq!(current(&desk, cx), "- Intro\n");
|
||||
press("secondary-shift-q", cx);
|
||||
assert_eq!(
|
||||
current(&desk, cx),
|
||||
"- > Intro\n",
|
||||
"the item's text is quoted"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn format_keys_wrap_the_caret_word(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("word\n", cx);
|
||||
press("secondary-b", cx);
|
||||
assert_eq!(current(&desk, cx), "**word**\n");
|
||||
press("secondary-\\", cx);
|
||||
assert_eq!(current(&desk, cx), "word\n");
|
||||
press("alt-%", cx);
|
||||
assert_eq!(current(&desk, cx), "~~word~~\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn ctrl_slash_toggles_source_inside_the_code_editor(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a\n", cx);
|
||||
press("secondary-/", cx);
|
||||
assert_eq!(mode(&desk, cx), Mode::Source);
|
||||
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
press("secondary-/", cx);
|
||||
assert_eq!(mode(&desk, cx), Mode::Visual);
|
||||
assert_eq!(current(&desk, cx), "a\n", "no comment marks");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_refused_command_says_why(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("```\ncode\n```\n", cx);
|
||||
press("secondary-1", cx);
|
||||
assert_eq!(current(&desk, cx), "```\ncode\n```\n");
|
||||
assert_eq!(toasted(&desk, "inside code", cx), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn alt_arrows_move_a_table_row(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("| A | B |\n| - | - |\n| 1 | 2 |\n", cx);
|
||||
press("secondary-/", cx);
|
||||
document(&desk, cx).update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||
press("alt-up", cx);
|
||||
assert_eq!(
|
||||
current(&desk, cx),
|
||||
"| 1 | 2 |\n| ---- | ---- |\n| A | B |\n"
|
||||
);
|
||||
press("alt-up", cx);
|
||||
assert_eq!(toasted(&desk, "Nothing lies past", cx), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn view_keys_follow_typora(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a\n", cx);
|
||||
press("f8 f9 secondary-+", cx);
|
||||
let look = desk.read_with(cx, |desk, _| desk.look.clone());
|
||||
assert!(look.focus && look.typewriter && look.size > 100, "{look:?}");
|
||||
press("secondary-)", cx);
|
||||
assert_eq!(desk.read_with(cx, |desk, _| desk.look.size), 100);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn copy_as_markdown_and_paste_as_plain_text(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a **b**\n", cx);
|
||||
press("secondary-shift-c", cx);
|
||||
let copied = cx.read_from_clipboard().and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("a **b**\n"));
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("*x*".into()));
|
||||
press("secondary-/", cx);
|
||||
press("secondary-shift-v", cx);
|
||||
assert_eq!(current(&desk, cx), "*x*a **b**\n");
|
||||
}
|
||||
|
||||
/// Row names; `-` a line, `▸` a submenu.
|
||||
fn rows(menu: &Menu) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for item in &menu.items {
|
||||
match item {
|
||||
MenuItem::Separator => out.push("-".into()),
|
||||
MenuItem::Submenu(sub) => {
|
||||
out.push(format!("▸ {}", sub.name));
|
||||
out.extend(rows(sub).into_iter().map(|row| format!(" {row}")));
|
||||
}
|
||||
MenuItem::Action { name, .. } => out.push(name.to_string()),
|
||||
MenuItem::SystemMenu(os) => out.push(os.name.to_string()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_paragraph_and_format_menus_follow_typora(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a\n", cx);
|
||||
let menus = desk.read_with(cx, |desk, cx| crate::keys::menus(&desk.look, cx));
|
||||
let named: Vec<String> = menus.iter().map(|menu| menu.name.to_string()).collect();
|
||||
assert_eq!(named, ["Edit", "Paragraph", "Format", "View"]);
|
||||
let table = [
|
||||
"Insert Table",
|
||||
"-",
|
||||
"Add Row Above",
|
||||
"Add Row Below",
|
||||
"Add Column Before",
|
||||
"Add Column After",
|
||||
"-",
|
||||
"Move Row Up",
|
||||
"Move Row Down",
|
||||
"Move Column Left",
|
||||
"Move Column Right",
|
||||
"-",
|
||||
"Delete Row",
|
||||
"Delete Column",
|
||||
"-",
|
||||
"Prettify Source Code",
|
||||
"-",
|
||||
"Delete Table",
|
||||
];
|
||||
let mut paragraph: Vec<String> = [
|
||||
"Heading 1",
|
||||
"Heading 2",
|
||||
"Heading 3",
|
||||
"Heading 4",
|
||||
"Heading 5",
|
||||
"Heading 6",
|
||||
"-",
|
||||
"Paragraph",
|
||||
"-",
|
||||
"Increase Heading Level",
|
||||
"Decrease Heading Level",
|
||||
"-",
|
||||
"▸ Table",
|
||||
]
|
||||
.map(String::from)
|
||||
.into();
|
||||
paragraph.extend(table.map(|row| format!(" {row}")));
|
||||
paragraph.extend(
|
||||
[
|
||||
"Math Block",
|
||||
"Code Fences",
|
||||
"-",
|
||||
"Quote",
|
||||
"-",
|
||||
"Ordered List",
|
||||
"Unordered List",
|
||||
"Task List",
|
||||
"-",
|
||||
"Insert Paragraph Before",
|
||||
"Insert Paragraph After",
|
||||
"-",
|
||||
"Link Reference",
|
||||
"Footnotes",
|
||||
"-",
|
||||
"Horizontal Line",
|
||||
"Table of Contents",
|
||||
"YAML Front Matter",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
assert_eq!(rows(&menus[1]), paragraph);
|
||||
assert_eq!(
|
||||
rows(&menus[2]).join("|"),
|
||||
"Strong|Emphasis|Underline|Code|-|Strike|-|Hyperlink|Insert Image|-|Clear Format"
|
||||
);
|
||||
}
|
||||
|
||||
/// The keys a menu row shows for `action`.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn keys(action: &dyn Action, cx: &mut VisualTestContext) -> String {
|
||||
cx.update(|window, _| {
|
||||
let binding = window
|
||||
.highest_precedence_binding_for_action(action)
|
||||
.expect("a binding");
|
||||
let strokes: Vec<String> = binding
|
||||
.keystrokes()
|
||||
.iter()
|
||||
.map(|key| key.unparse())
|
||||
.collect();
|
||||
strokes.join(" ")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[gpui::test]
|
||||
fn every_command_takes_typoras_windows_key(cx: &mut TestAppContext) {
|
||||
use crate::{blocks::*, clip::*, keys::ToggleSource, look, tools::*};
|
||||
let (_desk, cx, _files) = opened("a\n", cx);
|
||||
let typora: [(&dyn Action, &str); 34] = [
|
||||
(&Heading1, "ctrl-1"),
|
||||
(&Heading6, "ctrl-6"),
|
||||
(&Paragraph, "ctrl-0"),
|
||||
(&Promote, "ctrl-="),
|
||||
(&Demote, "ctrl--"),
|
||||
(&MathBlock, "ctrl-shift-m"),
|
||||
(&CodeFences, "ctrl-shift-k"),
|
||||
(&Quote, "ctrl-shift-q"),
|
||||
(&OrderedList, "ctrl-{"),
|
||||
(&BulletList, "ctrl-}"),
|
||||
(&TaskList, "ctrl-shift-x"),
|
||||
(&Strong, "ctrl-b"),
|
||||
(&Emphasis, "ctrl-i"),
|
||||
(&Underline, "ctrl-u"),
|
||||
(&InlineCode, "ctrl-~"),
|
||||
(&Strike, "alt-%"),
|
||||
(&Hyperlink, "ctrl-k"),
|
||||
(&InsertImage, "ctrl-shift-i"),
|
||||
(&ClearFormat, "ctrl-\\"),
|
||||
(&InsertTable, "ctrl-t"),
|
||||
(&RowBelow, "ctrl-enter"),
|
||||
(&DeleteRow, "ctrl-shift-backspace"),
|
||||
(&MoveRowUp, "alt-up"),
|
||||
(&MoveRowDown, "alt-down"),
|
||||
(&MoveColumnLeft, "alt-left"),
|
||||
(&MoveColumnRight, "alt-right"),
|
||||
(&ToggleSource, "ctrl-/"),
|
||||
(&look::FocusMode, "f8"),
|
||||
(&look::Typewriter, "f9"),
|
||||
(&look::Larger, "ctrl-+"),
|
||||
(&look::Smaller, "ctrl-_"),
|
||||
(&look::ActualSize, "ctrl-("),
|
||||
(&CopyMarkdown, "ctrl-shift-c"),
|
||||
(&PastePlain, "ctrl-shift-v"),
|
||||
];
|
||||
for (action, want) in typora {
|
||||
assert_eq!(keys(action, cx), want, "{}", action.name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Typora's Copy as Markdown and Paste as Plain Text.
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{
|
||||
App, ClipboardItem, Context, Focusable as _, InteractiveElement as _, KeyBinding, Window,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
|
||||
actions!(mark, [CopyMarkdown, PastePlain]);
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-shift-c", CopyMarkdown, at),
|
||||
KeyBinding::new("secondary-shift-v", PastePlain, at),
|
||||
]);
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Whether the document or nothing else holds focus.
|
||||
fn on_document(&self, window: &Window, cx: &App) -> bool {
|
||||
let Some(focused) = window.focused(cx) else {
|
||||
return true;
|
||||
};
|
||||
let held = (self.active_view()).is_some_and(|view| {
|
||||
let document = view.read(cx).document().read(cx);
|
||||
document.focus_handle(cx).contains_focused(window, cx)
|
||||
});
|
||||
held || focused == self.core.focus
|
||||
}
|
||||
|
||||
/// Hands a field outside the document its own `name` action.
|
||||
fn to_field(name: &str, window: &mut Window, cx: &mut App) {
|
||||
match cx.build_action(name, None) {
|
||||
Ok(action) => window.dispatch_action(action, cx),
|
||||
Err(error) => log::error!("mark: no action {name}: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected Markdown, or the whole text, as written.
|
||||
fn copy_markdown(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.on_document(window, cx) {
|
||||
return Self::to_field("ely_input::Copy", window, cx);
|
||||
}
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().read(cx);
|
||||
let selected = document.selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.text(cx),
|
||||
false => selected,
|
||||
};
|
||||
log::info!("mark: {} bytes copied as Markdown", markdown.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(markdown));
|
||||
}
|
||||
|
||||
/// The clipboard's text at the caret, pictures left out.
|
||||
fn paste_plain(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.on_document(window, cx) {
|
||||
return Self::to_field("ely_input::Paste", window, cx);
|
||||
}
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let Some(pasted) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
let said = text(cx, "mark.paste.none", &[]);
|
||||
return self.toast(said, Severity::Info, cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
if !document.update(cx, |document, cx| document.insert(&pasted, cx)) {
|
||||
let said = text(cx, "mark.preview.read_only", &[]);
|
||||
self.toast(said, Severity::Info, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clip_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
root.on_action(
|
||||
cx.listener(|desk, _: &CopyMarkdown, window, cx| desk.copy_markdown(window, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|desk, _: &PastePlain, window, cx| desk.paste_plain(window, cx)))
|
||||
}
|
||||
}
|
||||
+24
-31
@@ -23,7 +23,6 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::engine::Mark;
|
||||
use crate::keys::{CheckDisk, PreviewMode, SourceMode, VisualMode};
|
||||
use crate::view::{MarkView, Mode};
|
||||
|
||||
pub struct Desk {
|
||||
@@ -57,6 +56,8 @@ pub struct Desk {
|
||||
asked_print: Option<ely_files::Fingerprint>,
|
||||
/// The find box, while open.
|
||||
pub(crate) finding: Option<crate::finding::Find>,
|
||||
/// The copy being converted; a newer one drops it.
|
||||
pub(crate) copying: Option<gpui::Task<()>>,
|
||||
}
|
||||
|
||||
impl Host for Desk {
|
||||
@@ -206,6 +207,7 @@ pub(crate) fn open_in(
|
||||
asked: None,
|
||||
asked_print: None,
|
||||
finding: None,
|
||||
copying: None,
|
||||
}
|
||||
});
|
||||
let menus = crate::keys::menus(&look, cx);
|
||||
@@ -334,7 +336,7 @@ impl Desk {
|
||||
self.new_from_text("untitled.md", "", cx);
|
||||
}
|
||||
|
||||
fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub(crate) fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(view) = self.active_view() {
|
||||
view.update(cx, |view, cx| view.switch(mode, window, cx));
|
||||
}
|
||||
@@ -417,35 +419,26 @@ impl Render for Desk {
|
||||
.status(self.status(cx)),
|
||||
}
|
||||
.child(body);
|
||||
let root =
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(commands::CONTEXT)
|
||||
.track_focus(&self.core.focus)
|
||||
.on_action(cx.listener(Self::open_picked))
|
||||
.on_action(cx.listener(Self::on_new))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.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)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &SourceMode, window, cx| {
|
||||
desk.switch(Mode::Source, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &PreviewMode, window, cx| {
|
||||
desk.switch(Mode::Preview, window, cx)
|
||||
}))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.naming_element(cx))
|
||||
.children(self.compare_dialog(cx))
|
||||
.children(self.find_box(cx))
|
||||
.children(self.export_dialog(cx));
|
||||
Self::output_actions(Self::table_actions(Self::look_actions(root, cx), cx), cx)
|
||||
let root = div()
|
||||
.size_full()
|
||||
.key_context(commands::CONTEXT)
|
||||
.track_focus(&self.core.focus)
|
||||
.on_action(cx.listener(Self::open_picked))
|
||||
.on_action(cx.listener(Self::on_new))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.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))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.naming_element(cx))
|
||||
.children(self.compare_dialog(cx))
|
||||
.children(self.find_box(cx))
|
||||
.children(self.export_dialog(cx));
|
||||
let root = Self::mode_actions(Self::look_actions(root, cx), cx);
|
||||
let root = Self::block_actions(Self::table_actions(root, cx), cx);
|
||||
Self::output_actions(Self::clip_actions(root, cx), cx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,26 @@ fn a_file_opens_with_its_outline_and_joins_the_recent_list(cx: &mut TestAppConte
|
||||
assert!(!kept.contains(r"\\\\?\\"), "plain paths only: {kept}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_recent_list_clears_from_its_button(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("a.md");
|
||||
std::fs::write(&path, "Text.\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
assert!(desk.read_with(cx, |desk, _| !desk.recent.is_empty()));
|
||||
let button = cx.debug_bounds("mark-clear-recent").expect("the button");
|
||||
cx.simulate_click(button.center(), gpui::Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
assert!(desk.read_with(cx, |desk, _| desk.recent.is_empty()));
|
||||
let kept = std::fs::read_to_string(data.path().join("recent.json")).expect("the list");
|
||||
assert_eq!(kept, "[]");
|
||||
assert!(cx.debug_bounds("mark-clear-recent").is_none(), "nothing left to clear");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_outline_follows_typing(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
|
||||
@@ -8,11 +8,11 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_office::{Engine, Host, Load};
|
||||
use ely_office::{Engine, Host, Load, Tabs as _};
|
||||
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
|
||||
|
||||
use crate::text::Source;
|
||||
use crate::view::{Failed, MarkView, Reported};
|
||||
use crate::view::{Failed, MarkView, Refused, Reported};
|
||||
|
||||
/// A file as read, and its text now.
|
||||
pub struct Text {
|
||||
@@ -137,6 +137,11 @@ impl Engine for Mark {
|
||||
failed(host, &error.0, cx)
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(view, |host, _, refused: &Refused, cx| {
|
||||
let said = crate::blocks::refusal(refused.0, cx);
|
||||
host.toast(said, ely_gpui_component::primitives::Severity::Info, cx)
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(view, move |host, view, _: &Reported, cx| {
|
||||
let modified = view.read(cx).modified();
|
||||
reported(host, &view, modified, cx)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Rich, HTML code and plain text copies (R4.10).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{ClipboardItem, Context, Div, InteractiveElement as _, actions};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::output::{self, Page, Pictures};
|
||||
|
||||
actions!(mark, [CopyRich, CopyHtml, CopyPlain]);
|
||||
|
||||
/// What a copy turns the Markdown into.
|
||||
#[derive(Clone, Copy)]
|
||||
enum As {
|
||||
Rich,
|
||||
Html,
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// A converted copy, ready for the clipboard.
|
||||
enum Copied {
|
||||
Rich(Clip, Page),
|
||||
Html(String, Page),
|
||||
Plain(String),
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub(super) fn copy_actions(root: Div, cx: &mut Context<Self>) -> Div {
|
||||
root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy(As::Rich, cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy(As::Html, cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy(As::Plain, cx)))
|
||||
}
|
||||
|
||||
/// The selection or whole text, and the file's folder.
|
||||
fn copied_text(&mut self, cx: &mut Context<Self>) -> Option<(String, Option<PathBuf>)> {
|
||||
let Some(view) = self.active_view() else {
|
||||
self.no_document(cx);
|
||||
return None;
|
||||
};
|
||||
let document = view.read(cx).document().read(cx);
|
||||
let selected = document.selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.text(cx),
|
||||
false => selected,
|
||||
};
|
||||
let beside = (self.active_tab())
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| path.parent())
|
||||
.map(PathBuf::from);
|
||||
Some((markdown, beside))
|
||||
}
|
||||
|
||||
fn copy_failed(&mut self, error: &std::io::Error, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// Converts in the background, then writes the clipboard.
|
||||
fn copy(&mut self, kind: As, cx: &mut Context<Self>) {
|
||||
let Some((markdown, beside)) = self.copied_text(cx) else {
|
||||
return;
|
||||
};
|
||||
let before = witness(cx);
|
||||
self.copying = Some(cx.spawn(async move |desk, cx| {
|
||||
let converted = cx
|
||||
.background_executor()
|
||||
.spawn(async move { convert(kind, markdown, beside) })
|
||||
.await;
|
||||
let landed = desk.update(cx, |desk, cx| {
|
||||
desk.copying = None;
|
||||
// A later copy, here or elsewhere, wins.
|
||||
if witness(cx) != before {
|
||||
return log::info!("mark: the clipboard changed first; a copy is dropped");
|
||||
}
|
||||
desk.copied(converted, cx);
|
||||
});
|
||||
if landed.is_err() {
|
||||
log::warn!("mark: a copy finished after its window closed");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn copied(&mut self, converted: std::io::Result<Copied>, cx: &mut Context<Self>) {
|
||||
let written = converted.and_then(|copied| match copied {
|
||||
Copied::Rich(clip, page) => clipboard::write(&clip)
|
||||
.map(|()| Some(page))
|
||||
.map_err(|error| std::io::Error::other(format!("{error:#}"))),
|
||||
Copied::Html(html, page) => {
|
||||
log::info!("mark: {} bytes copied as HTML code", html.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(html));
|
||||
Ok(Some(page))
|
||||
}
|
||||
Copied::Plain(plain) => {
|
||||
log::info!("mark: {} bytes copied as plain text", plain.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(plain));
|
||||
Ok(None)
|
||||
}
|
||||
});
|
||||
match written {
|
||||
Ok(Some(page)) => self.left_out(&page, cx),
|
||||
Ok(None) => {}
|
||||
Err(error) => self.copy_failed(&error, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the clipboard holds, its HTML included.
|
||||
fn witness(cx: &mut Context<Desk>) -> (Option<ClipboardItem>, Result<Option<String>, String>) {
|
||||
let html = clipboard::html().map_err(|error| {
|
||||
log::warn!("mark: the clipboard's HTML did not read: {error:#}");
|
||||
format!("{error:#}")
|
||||
});
|
||||
(cx.read_from_clipboard(), html)
|
||||
}
|
||||
|
||||
/// The copy as `kind`, converted off the window thread.
|
||||
fn convert(kind: As, markdown: String, beside: Option<PathBuf>) -> std::io::Result<Copied> {
|
||||
let beside = beside.as_deref();
|
||||
match kind {
|
||||
As::Rich => output::body(&markdown, beside, &Pictures::Inline).map(|mut page| {
|
||||
let html = std::mem::take(&mut page.html);
|
||||
let clip = Clip::Html {
|
||||
html,
|
||||
text: markdown,
|
||||
};
|
||||
Copied::Rich(clip, page)
|
||||
}),
|
||||
As::Html => output::body(&markdown, beside, &Pictures::Written)
|
||||
.map(|mut page| Copied::Html(std::mem::take(&mut page.html), page)),
|
||||
As::Plain => output::plain(&markdown).map(Copied::Plain),
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,20 @@ use ely_gpui_component::{
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
use ely_ui::{
|
||||
commands::{Export, Print},
|
||||
i18n::text,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::output::{self, Format, Page, Pictures, Target};
|
||||
use crate::output::{self, Format, Page, Target};
|
||||
|
||||
actions!(mark, [CopyRich]);
|
||||
mod copy;
|
||||
|
||||
pub use copy::{CopyHtml, CopyPlain, CopyRich};
|
||||
|
||||
const FORMATS: [(Format, &str, &str); 4] = [
|
||||
(Format::Page, "page", "html"),
|
||||
@@ -41,12 +41,13 @@ fn key(format: Format) -> &'static str {
|
||||
|
||||
impl Desk {
|
||||
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
|
||||
root.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
|
||||
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx)))
|
||||
let root = root
|
||||
.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
|
||||
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)));
|
||||
Self::copy_actions(root, cx)
|
||||
}
|
||||
|
||||
fn no_document(&self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn no_document(&self, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "command.blocked.no_document", &[]);
|
||||
self.toast(said, Severity::Warning, cx);
|
||||
}
|
||||
@@ -275,39 +276,6 @@ impl Desk {
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// The selection, or the whole text, as HTML.
|
||||
fn copy_rich(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
let selected = document.read(cx).selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.read(cx).text(cx),
|
||||
false => selected,
|
||||
};
|
||||
let beside = self
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| path.parent());
|
||||
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|mut page| {
|
||||
let clip = Clip::Html {
|
||||
html: std::mem::take(&mut page.html),
|
||||
text: markdown,
|
||||
};
|
||||
clipboard::write(&clip)
|
||||
.map(|()| page)
|
||||
.map_err(|error| std::io::Error::other(format!("{error:#}")))
|
||||
});
|
||||
match copied {
|
||||
Ok(page) => self.left_out(&page, cx),
|
||||
Err(error) => {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file of its own for each print job.
|
||||
|
||||
@@ -25,7 +25,7 @@ fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
let html = std::fs::read_to_string(&out).expect("written");
|
||||
assert!(
|
||||
html.contains("<h1>Note</h1>") && html.contains("<svg"),
|
||||
html.contains("<h1 id=\"h-0\">Note</h1>") && html.contains("<svg"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
|
||||
});
|
||||
cx.dispatch_action(crate::export::CopyRich);
|
||||
cx.run_until_parked();
|
||||
let clip = ely_platform::clipboard::read()
|
||||
.expect("reads")
|
||||
.expect("a clip");
|
||||
|
||||
@@ -110,10 +110,11 @@ fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) {
|
||||
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("| --- | --- | --- |"));
|
||||
assert!(current_text(&desk, cx).contains("| ---- | ---- | ---- |"));
|
||||
cx.dispatch_action(crate::tools::DeleteColumn);
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
current_text(&desk, cx).contains("| --- | --- |\n"),
|
||||
current_text(&desk, cx).contains("| ---- | ---- |\n"),
|
||||
"{}",
|
||||
current_text(&desk, cx)
|
||||
);
|
||||
@@ -175,3 +176,25 @@ fn preview_keeps_its_text_from_tables_and_replacement(cx: &mut TestAppContext) {
|
||||
replace_one(&desk, "x", cx);
|
||||
assert_eq!(current_text(&desk, cx), "a\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn clipboard_keys_in_find_reach_the_field(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
query(&desk, "beta", cx);
|
||||
let field = desk.read_with(cx, |desk, _| {
|
||||
desk.finding.as_ref().expect("open").query.clone()
|
||||
});
|
||||
field.update(cx, |field, cx| field.select(0..4, cx));
|
||||
cx.simulate_keystrokes("secondary-shift-c");
|
||||
cx.run_until_parked();
|
||||
let copied = cx.read_from_clipboard().and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("beta"));
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("alpha".into()));
|
||||
cx.simulate_keystrokes("secondary-shift-v");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
field.read_with(cx, |field, _| field.text().to_string()),
|
||||
"alpha"
|
||||
);
|
||||
assert_eq!(current_text(&desk, cx), "alpha beta\n");
|
||||
}
|
||||
+78
-31
@@ -1,24 +1,34 @@
|
||||
//! Modes, menus and keys (MARK-01, MARK-02).
|
||||
//! Modes, menus and keys, as Typora's (MARK-01, MARK-02, MARK-15).
|
||||
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{App, KeyBinding, Menu, MenuItem, actions};
|
||||
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
|
||||
|
||||
use crate::view::Mode;
|
||||
use crate::{desk::Desk, view::Mode};
|
||||
|
||||
actions!(mark, [VisualMode, SourceMode, PreviewMode, CheckDisk]);
|
||||
actions!(
|
||||
mark,
|
||||
[VisualMode, SourceMode, PreviewMode, ToggleSource, CheckDisk]
|
||||
);
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-1", VisualMode, at),
|
||||
KeyBinding::new("secondary-2", SourceMode, at),
|
||||
KeyBinding::new("secondary-3", PreviewMode, at),
|
||||
KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at),
|
||||
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-/", ToggleSource, at),
|
||||
// Over the code editor's `//` toggle.
|
||||
KeyBinding::new("secondary-/", ToggleSource, Some("ElyEditor")),
|
||||
KeyBinding::new("f8", crate::look::FocusMode, at),
|
||||
KeyBinding::new("f9", crate::look::Typewriter, at),
|
||||
KeyBinding::new("secondary-+", crate::look::Larger, at),
|
||||
KeyBinding::new("secondary-_", crate::look::Smaller, at),
|
||||
KeyBinding::new("secondary-)", crate::look::ActualSize, at),
|
||||
KeyBinding::new("secondary-f", ely_office::FindReplace, at),
|
||||
]);
|
||||
// Windows' Chinese input keeps Ctrl+Shift+0 for itself.
|
||||
#[cfg(target_os = "windows")]
|
||||
cx.bind_keys([KeyBinding::new("ctrl-(", crate::look::ActualSize, at)]);
|
||||
crate::blocks::bind(cx);
|
||||
crate::tools::bind(cx);
|
||||
crate::clip::bind(cx);
|
||||
}
|
||||
|
||||
/// The label of `mode`.
|
||||
@@ -26,36 +36,39 @@ pub fn label(mode: Mode, cx: &App) -> gpui::SharedString {
|
||||
text(cx, &format!("mark.mode.{}", mode.name()), &[])
|
||||
}
|
||||
|
||||
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
use crate::look::{self, Font, Width};
|
||||
let checked = |key: &str, action: Box<dyn gpui::Action>, checked: bool| MenuItem::Action {
|
||||
/// A menu row named by `key`, ticked when `checked`.
|
||||
pub(crate) fn checked(key: &str, action: Box<dyn Action>, checked: bool, cx: &App) -> MenuItem {
|
||||
MenuItem::Action {
|
||||
name: text(cx, key, &[]),
|
||||
action,
|
||||
os_action: None,
|
||||
checked,
|
||||
disabled: false,
|
||||
};
|
||||
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
|
||||
use crate::tools::{
|
||||
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn item(key: &str, action: Box<dyn Action>, cx: &App) -> MenuItem {
|
||||
checked(key, action, false, cx)
|
||||
}
|
||||
|
||||
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
use crate::look::{self, Font, Width};
|
||||
let checked = |key: &str, action: Box<dyn Action>, on: bool| checked(key, action, on, cx);
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
vec![
|
||||
Menu::new(text(cx, "menu.edit", &[])).items([
|
||||
item("office.find.open", Box::new(ely_office::FindReplace)),
|
||||
item("mark.copy.plain", Box::new(crate::export::CopyPlain)),
|
||||
item("mark.copy.markdown", Box::new(crate::clip::CopyMarkdown)),
|
||||
item("mark.copy.html", Box::new(crate::export::CopyHtml)),
|
||||
item("mark.copy.rich", Box::new(crate::export::CopyRich)),
|
||||
]),
|
||||
Menu::new(text(cx, "mark.menu.table", &[])).items([
|
||||
item("mark.table.insert", Box::new(InsertTable)),
|
||||
item("mark.paste.plain", Box::new(crate::clip::PastePlain)),
|
||||
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)),
|
||||
item("office.find.open", Box::new(ely_office::FindReplace)),
|
||||
]),
|
||||
crate::blocks::paragraph_menu(cx),
|
||||
crate::blocks::format_menu(cx),
|
||||
Menu::new(text(cx, "menu.view", &[])).items([
|
||||
item("mark.view.source", Box::new(ToggleSource)),
|
||||
MenuItem::action(label(Mode::Visual, cx), VisualMode),
|
||||
MenuItem::action(label(Mode::Source, cx), SourceMode),
|
||||
MenuItem::action(label(Mode::Preview, cx), PreviewMode),
|
||||
@@ -99,11 +112,45 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
look.font == Font::Mono,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
item("mark.look.actual", Box::new(look::ActualSize)),
|
||||
item("mark.look.larger", Box::new(look::Larger)),
|
||||
item("mark.look.smaller", Box::new(look::Smaller)),
|
||||
item("mark.look.actual", Box::new(look::ActualSize)),
|
||||
MenuItem::separator(),
|
||||
item("mark.outside.check", Box::new(CheckDisk)),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Source and visual, as Typora's Source Code Mode.
|
||||
fn toggle_source(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let mode = match view.read(cx).mode {
|
||||
Mode::Source => Mode::Visual,
|
||||
Mode::Visual | Mode::Preview => Mode::Source,
|
||||
};
|
||||
self.switch(mode, window, cx);
|
||||
}
|
||||
|
||||
pub fn mode_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
root.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &ToggleSource, window, cx| desk.toggle_source(window, cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &VisualMode, window, cx| {
|
||||
desk.switch(Mode::Visual, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &SourceMode, window, cx| {
|
||||
desk.switch(Mode::Source, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_action(cx.listener(|desk, _: &PreviewMode, window, cx| {
|
||||
desk.switch(Mode::Preview, window, cx)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -196,8 +196,8 @@ impl Desk {
|
||||
mod tests {
|
||||
use ely_gpui_component::documents::DocumentLook;
|
||||
use gpui::{
|
||||
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext,
|
||||
point, rems,
|
||||
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point,
|
||||
rems,
|
||||
};
|
||||
|
||||
use crate::desk::{
|
||||
@@ -286,8 +286,11 @@ mod tests {
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("a.md");
|
||||
std::fs::write(&path, "a
|
||||
").expect("a file");
|
||||
std::fs::write(
|
||||
&path, "a
|
||||
",
|
||||
)
|
||||
.expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
|
||||
@@ -300,12 +303,22 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let other = match cfg!(target_os = "macos") {
|
||||
true => Modifiers { control: true, ..Default::default() },
|
||||
false => Modifiers { platform: true, ..Default::default() },
|
||||
true => Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
},
|
||||
false => Modifiers {
|
||||
platform: true,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
cx.simulate_event(wheel(other, 3.0));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(document_look(&desk, cx).scale, 1.0, "only Ctrl, or Cmd on macOS");
|
||||
assert_eq!(
|
||||
document_look(&desk, cx).scale,
|
||||
1.0,
|
||||
"only Ctrl, or Cmd on macOS"
|
||||
);
|
||||
cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(document_look(&desk, cx).scale, 1.1, "a notch is one step");
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
mod attach;
|
||||
mod autosave;
|
||||
mod blocks;
|
||||
mod clip;
|
||||
mod compare;
|
||||
mod desk;
|
||||
mod engine;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Addresses, read as browsers read them.
|
||||
|
||||
/// A plain, web or mail address, read as browsers do.
|
||||
pub(super) fn safe(url: &str) -> bool {
|
||||
// Browsers trim spaces and controls, then drop tabs and newlines.
|
||||
let read: String = url
|
||||
.trim_matches(|ch: char| ch <= ' ')
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
|
||||
.collect();
|
||||
match scheme(&read) {
|
||||
None => true,
|
||||
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An address's scheme, when it has one.
|
||||
pub(super) fn scheme(url: &str) -> Option<&str> {
|
||||
let end = url.find(':')?;
|
||||
let name = &url[..end];
|
||||
let plain = name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
|
||||
// A drive letter is a path, not a scheme.
|
||||
(plain && name.len() > 1).then_some(name)
|
||||
}
|
||||
+358
-156
@@ -1,84 +1,100 @@
|
||||
//! A syntax tree written as HTML, node by node.
|
||||
//! Markdown as HTML, as the drawing reads it.
|
||||
|
||||
use std::{collections::HashMap, io};
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{HashSet, VecDeque},
|
||||
io,
|
||||
ops::Range,
|
||||
};
|
||||
|
||||
use ely_gpui_component::{documents::diagram_svg, typography::formula_svg};
|
||||
use ely_gpui_component::{
|
||||
documents::{
|
||||
AlertKind, Inline, MarkdownBlock, MarkdownReader, Style, alert, diagram_svg, is_toc,
|
||||
},
|
||||
typography::formula_svg,
|
||||
};
|
||||
use markdown::mdast::{AlignKind, Node};
|
||||
|
||||
use super::{Page, attribute, escape, pictures::Placed};
|
||||
use super::{Page, address::safe, attribute, escape, pictures::Placed};
|
||||
|
||||
/// Writes `tree` into `page`; pictures go through `placed`.
|
||||
pub(super) fn write(tree: &Node, placed: &mut Placed, page: &mut Page) -> io::Result<()> {
|
||||
/// Writes `tree`, read from `source`, into `page`.
|
||||
pub(super) fn write(
|
||||
tree: &Node,
|
||||
source: &str,
|
||||
placed: &mut Placed,
|
||||
page: &mut Page,
|
||||
) -> io::Result<()> {
|
||||
let mut writer = Writer {
|
||||
placed,
|
||||
page,
|
||||
links: HashMap::new(),
|
||||
notes: HashMap::new(),
|
||||
called: Vec::new(),
|
||||
source,
|
||||
reader: MarkdownReader::new(source, tree),
|
||||
anchored: HashSet::new(),
|
||||
};
|
||||
writer.gather(tree);
|
||||
writer.children(tree, false)?;
|
||||
writer.footnotes()
|
||||
writer.root(tree)
|
||||
}
|
||||
|
||||
struct Writer<'a, 'b, 'c> {
|
||||
placed: &'b mut Placed<'a>,
|
||||
page: &'c mut Page,
|
||||
/// Link definitions by identifier: address and title.
|
||||
links: HashMap<String, (String, Option<String>)>,
|
||||
notes: HashMap<String, Node>,
|
||||
/// Footnotes in the order first called.
|
||||
called: Vec<String>,
|
||||
source: &'c str,
|
||||
reader: MarkdownReader<'c>,
|
||||
/// Notes whose definition has its anchor.
|
||||
anchored: HashSet<usize>,
|
||||
}
|
||||
|
||||
/// What a read area leaves out, in reading order.
|
||||
#[derive(Default)]
|
||||
struct Atoms {
|
||||
/// Each formula's line.
|
||||
formulas: VecDeque<usize>,
|
||||
/// Each footnote reference's number.
|
||||
notes: VecDeque<usize>,
|
||||
}
|
||||
|
||||
/// A stretch of read text's mark.
|
||||
enum Mark<'a> {
|
||||
Style(Style),
|
||||
Link(&'a str),
|
||||
}
|
||||
|
||||
impl Writer<'_, '_, '_> {
|
||||
fn gather(&mut self, node: &Node) {
|
||||
match node {
|
||||
Node::Definition(definition) => {
|
||||
let target = (definition.url.clone(), definition.title.clone());
|
||||
self.links
|
||||
.entry(definition.identifier.clone())
|
||||
.or_insert(target);
|
||||
}
|
||||
Node::FootnoteDefinition(note) => {
|
||||
self.notes
|
||||
.entry(note.identifier.clone())
|
||||
.or_insert_with(|| node.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.gather(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, text: &str) {
|
||||
self.page.html.push_str(text);
|
||||
}
|
||||
|
||||
fn children(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.node(child, tight)?;
|
||||
/// Root blocks: alerts and `[toc]` live only here.
|
||||
fn root(&mut self, tree: &Node) -> io::Result<()> {
|
||||
for child in tree.children().into_iter().flatten() {
|
||||
match child {
|
||||
Node::Blockquote(quote) => match alert(quote) {
|
||||
Some((kind, body)) => self.alert(kind, &body)?,
|
||||
None => self.block(child, false)?,
|
||||
},
|
||||
_ if is_toc(child, self.source) => self.toc(),
|
||||
_ => self.block(child, false)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> {
|
||||
self.push(&format!("<{tag}>"));
|
||||
self.children(node, false)?;
|
||||
self.push(&format!("</{tag}>"));
|
||||
fn blocks(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.block(child, tight)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
fn block(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
match node {
|
||||
Node::Paragraph(_) if tight => self.children(node, false)?,
|
||||
Node::Paragraph(_) => self.wrapped("p", node)?,
|
||||
Node::Heading(heading) => self.wrapped(&format!("h{}", heading.depth), node)?,
|
||||
Node::Blockquote(_) => self.wrapped("blockquote", node)?,
|
||||
Node::Emphasis(_) => self.wrapped("em", node)?,
|
||||
Node::Strong(_) => self.wrapped("strong", node)?,
|
||||
Node::Delete(_) => self.wrapped("del", node)?,
|
||||
Node::Paragraph(_) | Node::Heading(_) | Node::Table(_) | Node::Html(_) => {
|
||||
self.area(node, tight)?
|
||||
}
|
||||
Node::Blockquote(_) => {
|
||||
self.push("<blockquote>");
|
||||
self.blocks(node, false)?;
|
||||
self.push("</blockquote>");
|
||||
}
|
||||
Node::List(list) => {
|
||||
let tag = if list.ordered { "ol" } else { "ul" };
|
||||
match list.start.filter(|start| list.ordered && *start != 1) {
|
||||
@@ -92,73 +108,145 @@ impl Writer<'_, '_, '_> {
|
||||
let mark = if checked { " checked" } else { "" };
|
||||
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
|
||||
}
|
||||
self.children(item, !list.spread && !each.spread)?;
|
||||
self.blocks(item, !list.spread && !each.spread)?;
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
Node::Table(table) => self.table(node, &table.align)?,
|
||||
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
|
||||
let line = line_of(node);
|
||||
match diagram_svg(&code.value) {
|
||||
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
|
||||
Err(_) => {
|
||||
self.page.diagrams.push(line);
|
||||
self.page.diagrams.push(line_of(node));
|
||||
self.code(Some("mermaid"), &code.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
|
||||
Node::Math(math) => self.math(node, &math.value, true),
|
||||
Node::InlineMath(math) => self.math(node, &math.value, false),
|
||||
Node::InlineCode(code) => self.push(&format!("<code>{}</code>", escape(&code.value))),
|
||||
Node::Text(text) => self.push(&escape(&text.value)),
|
||||
// HTML in the document shows as text, never runs.
|
||||
Node::Html(html) => self.push(&escape(&html.value)),
|
||||
Node::Break(_) => self.push("<br>"),
|
||||
Node::Math(math) => self.math(line_of(node), &math.value, true),
|
||||
Node::ThematicBreak(_) => self.push("<hr>"),
|
||||
Node::Link(link) => self.link(node, &link.url, link.title.as_deref())?,
|
||||
Node::LinkReference(link) => match self.links.get(&link.identifier).cloned() {
|
||||
Some((url, title)) => self.link(node, &url, title.as_deref())?,
|
||||
None => self.children(node, false)?,
|
||||
},
|
||||
Node::Image(image) => self.picture(&image.url, &image.alt, image.title.as_deref())?,
|
||||
Node::ImageReference(image) => match self.links.get(&image.identifier).cloned() {
|
||||
Some((url, title)) => self.picture(&url, &image.alt, title.as_deref())?,
|
||||
None => self.push(&escape(&image.alt)),
|
||||
},
|
||||
Node::FootnoteReference(call) => {
|
||||
let number = match self.called.iter().position(|id| *id == call.identifier) {
|
||||
Some(at) => at + 1,
|
||||
None => {
|
||||
self.called.push(call.identifier.clone());
|
||||
self.called.len()
|
||||
}
|
||||
};
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"));
|
||||
Node::FootnoteDefinition(note) => {
|
||||
// A reference reaches its note's first definition.
|
||||
let id = (self.reader.note(¬e.identifier))
|
||||
.filter(|number| self.anchored.insert(*number))
|
||||
.map_or_else(String::new, |number| format!(" id=\"fn-{number}\""));
|
||||
self.push(&format!(
|
||||
"<div{id} style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[{}]</span><div>",
|
||||
escape(&self.reader.label(note))
|
||||
));
|
||||
self.blocks(node, false)?;
|
||||
self.push("</div></div>");
|
||||
}
|
||||
// Written elsewhere, or never.
|
||||
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {}
|
||||
_ => self.children(node, false)?,
|
||||
// Never written.
|
||||
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) => {}
|
||||
other => unreachable!("{other:?} is no block outside MDX"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> {
|
||||
/// A paragraph, heading, table or HTML block, read as drawn.
|
||||
fn area(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
let mut read = Vec::new();
|
||||
self.reader.block(node, &mut read);
|
||||
if let Node::Table(_) = node {
|
||||
let [MarkdownBlock::Table { aligns, head, rows }] = read.as_slice() else {
|
||||
unreachable!("a table reads as one table");
|
||||
};
|
||||
return self.table(node, aligns, head, rows);
|
||||
}
|
||||
// A heading's pictures follow it whole.
|
||||
let split = !matches!(node, Node::Heading(_));
|
||||
let stretches = self.stretches(node, split);
|
||||
self.run(&read, node, tight, split, stretches)
|
||||
}
|
||||
|
||||
/// Pieces in order; with `split`, each picture ends a stretch.
|
||||
fn run(
|
||||
&mut self,
|
||||
pieces: &[MarkdownBlock],
|
||||
node: &Node,
|
||||
tight: bool,
|
||||
split: bool,
|
||||
mut stretches: VecDeque<Atoms>,
|
||||
) -> io::Result<()> {
|
||||
let mut written = false;
|
||||
for piece in pieces {
|
||||
match piece {
|
||||
MarkdownBlock::Image { alt, url, link, .. } => {
|
||||
self.picture(url, alt, link.as_deref())?;
|
||||
if split {
|
||||
retire(stretches.pop_front(), written);
|
||||
written = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let atoms = stretches.front_mut().expect("a stretch per text");
|
||||
self.piece(piece, node, tight, atoms);
|
||||
written = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
retire(stretches.pop_front(), written);
|
||||
assert!(stretches.is_empty(), "a stretch per picture");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Formulas and notes by stretch; pictures part them.
|
||||
fn stretches(&self, node: &Node, split: bool) -> VecDeque<Atoms> {
|
||||
let mut out = VecDeque::from([Atoms::default()]);
|
||||
self.gather(node, split, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn gather(&self, node: &Node, split: bool, out: &mut VecDeque<Atoms>) {
|
||||
match node {
|
||||
Node::InlineMath(_) => {
|
||||
(out.back_mut().expect("a stretch").formulas).push_back(line_of(node))
|
||||
}
|
||||
Node::FootnoteReference(call) => {
|
||||
let number = self
|
||||
.reader
|
||||
.note(&call.identifier)
|
||||
.expect("a reference names a note");
|
||||
out.back_mut().expect("a stretch").notes.push_back(number);
|
||||
}
|
||||
Node::Image(_) | Node::ImageReference(_) if split => out.push_back(Atoms::default()),
|
||||
_ => {}
|
||||
}
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.gather(child, split, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn table(
|
||||
&mut self,
|
||||
node: &Node,
|
||||
aligns: &[AlignKind],
|
||||
head: &[Vec<MarkdownBlock>],
|
||||
rows: &[Vec<Vec<MarkdownBlock>>],
|
||||
) -> io::Result<()> {
|
||||
let written = node.children().expect("a table holds rows");
|
||||
let read: Vec<&[Vec<MarkdownBlock>]> = std::iter::once(head)
|
||||
.chain(rows.iter().map(Vec::as_slice))
|
||||
.collect();
|
||||
assert_eq!(read.len(), written.len(), "a read row per row");
|
||||
self.push("<table>");
|
||||
for (at, row) in node.children().into_iter().flatten().enumerate() {
|
||||
let cell = if at == 0 { "th" } else { "td" };
|
||||
for (at, (row, cells)) in read.into_iter().zip(written).enumerate() {
|
||||
let tag = if at == 0 { "th" } else { "td" };
|
||||
let cells = cells.children().expect("a row holds cells");
|
||||
assert_eq!(row.len(), cells.len(), "a read cell per cell");
|
||||
self.push("<tr>");
|
||||
for (column, each) in row.children().into_iter().flatten().enumerate() {
|
||||
let side = match align.get(column) {
|
||||
for (column, (pieces, cell)) in row.iter().zip(cells).enumerate() {
|
||||
let side = match aligns.get(column) {
|
||||
Some(AlignKind::Left) => " style=\"text-align:left\"",
|
||||
Some(AlignKind::Right) => " style=\"text-align:right\"",
|
||||
Some(AlignKind::Center) => " style=\"text-align:center\"",
|
||||
_ => "",
|
||||
};
|
||||
self.push(&format!("<{cell}{side}>"));
|
||||
self.children(each, false)?;
|
||||
self.push(&format!("</{cell}>"));
|
||||
self.push(&format!("<{tag}{side}>"));
|
||||
let stretches = self.stretches(cell, true);
|
||||
self.run(pieces, cell, true, true, stretches)?;
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
self.push("</tr>");
|
||||
}
|
||||
@@ -166,6 +254,113 @@ impl Writer<'_, '_, '_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn piece(&mut self, piece: &MarkdownBlock, node: &Node, tight: bool, atoms: &mut Atoms) {
|
||||
match piece {
|
||||
MarkdownBlock::Paragraph(text) if tight => self.inline(text, atoms),
|
||||
MarkdownBlock::Paragraph(text) => {
|
||||
self.push("<p>");
|
||||
self.inline(text, atoms);
|
||||
self.push("</p>");
|
||||
}
|
||||
MarkdownBlock::Heading(depth, text) => {
|
||||
let at = node.position().expect("a parsed node has its place");
|
||||
self.push(&format!("<h{depth} id=\"h-{}\">", at.start.offset));
|
||||
self.inline(text, atoms);
|
||||
self.push(&format!("</h{depth}>"));
|
||||
}
|
||||
MarkdownBlock::Math(tex) => {
|
||||
let line = atoms.formulas.pop_front().expect("a formula's line");
|
||||
self.math(line, tex, true);
|
||||
}
|
||||
other => unreachable!("an area's text reads as text or math, not {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read text; its marks open and close as a stack.
|
||||
fn inline(&mut self, read: &Inline, atoms: &mut Atoms) {
|
||||
let mut marks: Vec<(&Range<usize>, Mark)> = (read.styles.iter())
|
||||
.filter(|(_, style)| *style != Style::Link)
|
||||
.map(|(range, style)| (range, Mark::Style(*style)))
|
||||
.chain(
|
||||
read.links
|
||||
.iter()
|
||||
.map(|(range, url)| (range, Mark::Link(url))),
|
||||
)
|
||||
.collect();
|
||||
// Outer first; a formula or note is its own text.
|
||||
marks.sort_by_key(|(range, mark)| {
|
||||
let whole = matches!(mark, Mark::Style(Style::Math | Style::Footnote));
|
||||
(range.start, Reverse(range.end), whole)
|
||||
});
|
||||
let (mut open, mut at): (Vec<(usize, &str)>, usize) = (Vec::new(), 0);
|
||||
let mut links = 0;
|
||||
for (range, mark) in marks {
|
||||
links -= self.close_to(read, range.start, &mut open, &mut at);
|
||||
assert!(
|
||||
range.start >= at && open.last().is_none_or(|(end, _)| range.end <= *end),
|
||||
"read marks nest"
|
||||
);
|
||||
self.text(&read.text[at..range.start]);
|
||||
at = range.start;
|
||||
match mark {
|
||||
Mark::Style(Style::Math) => {
|
||||
let line = atoms.formulas.pop_front().expect("a formula's line");
|
||||
self.math(line, &read.text[range.clone()], false);
|
||||
at = range.end;
|
||||
}
|
||||
Mark::Style(Style::Footnote) => {
|
||||
let number = atoms.notes.pop_front().expect("a note's number");
|
||||
// Links never nest.
|
||||
match links > 0 {
|
||||
true => self.push(&format!("<sup>{number}</sup>")),
|
||||
false => {
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"))
|
||||
}
|
||||
}
|
||||
at = range.end;
|
||||
}
|
||||
Mark::Style(style) => {
|
||||
let name = tag(style);
|
||||
self.push(&format!("<{name}>"));
|
||||
open.push((range.end, name));
|
||||
}
|
||||
Mark::Link(url) => {
|
||||
match safe(url) {
|
||||
true => self.push(&format!("<a href=\"{}\">", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
}
|
||||
open.push((range.end, "a"));
|
||||
links += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.close_to(read, read.text.len(), &mut open, &mut at);
|
||||
self.text(&read.text[at..]);
|
||||
}
|
||||
|
||||
/// Closes each mark ending by `to`; counts closed links.
|
||||
fn close_to(
|
||||
&mut self,
|
||||
read: &Inline,
|
||||
to: usize,
|
||||
open: &mut Vec<(usize, &str)>,
|
||||
at: &mut usize,
|
||||
) -> usize {
|
||||
let mut links = 0;
|
||||
while let Some(&(end, name)) = open.last().filter(|(end, _)| *end <= to) {
|
||||
self.text(&read.text[*at..end]);
|
||||
self.push(&format!("</{name}>"));
|
||||
*at = end;
|
||||
open.pop();
|
||||
links += usize::from(name == "a");
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
fn text(&mut self, text: &str) {
|
||||
self.push(&escape(text).replace('\n', "<br>"));
|
||||
}
|
||||
|
||||
fn code(&mut self, lang: Option<&str>, value: &str) {
|
||||
let class = lang.map_or_else(String::new, |lang| {
|
||||
format!(" class=\"language-{}\"", attribute(lang))
|
||||
@@ -173,12 +368,12 @@ impl Writer<'_, '_, '_> {
|
||||
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
|
||||
}
|
||||
|
||||
fn math(&mut self, node: &Node, value: &str, display: bool) {
|
||||
fn math(&mut self, line: usize, value: &str, display: bool) {
|
||||
match (formula_svg(value, display), display) {
|
||||
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
|
||||
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
|
||||
(Err(error), _) => {
|
||||
self.page.formulas.push((line_of(node), error.to_string()));
|
||||
self.page.formulas.push((line, error.to_string()));
|
||||
match display {
|
||||
true => self.code(Some("math"), value),
|
||||
false => self.push(&format!("<code>{}</code>", escape(value))),
|
||||
@@ -187,21 +382,37 @@ impl Writer<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn link(&mut self, node: &Node, url: &str, title: Option<&str>) -> io::Result<()> {
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
// Only web, mail and in-page addresses follow.
|
||||
match safe(url) {
|
||||
true => self.push(&format!("<a href=\"{}\"{title}>", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
/// A GitHub alert: its kind's title, then its blocks.
|
||||
fn alert(&mut self, kind: AlertKind, body: &[Node]) -> io::Result<()> {
|
||||
let (title, hue) = (kind.title(), hue(kind));
|
||||
self.push(&format!(
|
||||
"<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid {hue}\"><p style=\"font-weight:600;color:{hue}\">{title}</p>"
|
||||
));
|
||||
for node in body {
|
||||
self.block(node, false)?;
|
||||
}
|
||||
self.children(node, false)?;
|
||||
self.push("</a>");
|
||||
self.push("</div>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> {
|
||||
/// `[toc]`: each heading, linked to its place.
|
||||
fn toc(&mut self) {
|
||||
let items: String = (self.reader.headings.iter())
|
||||
.map(|heading| {
|
||||
format!(
|
||||
"<li style=\"margin-left:{}em\"><a href=\"#h-{}\">{}</a></li>",
|
||||
heading.depth - 1,
|
||||
heading.at,
|
||||
escape(&heading.title)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.push(&format!(
|
||||
"<ul style=\"list-style:none;padding-left:0\">{items}</ul>"
|
||||
));
|
||||
}
|
||||
|
||||
fn picture(&mut self, url: &str, alt: &str, link: Option<&str>) -> io::Result<()> {
|
||||
let src = self.placed.place(url)?;
|
||||
if src.is_none() {
|
||||
self.page.missing.push(if url.is_empty() {
|
||||
@@ -215,36 +426,52 @@ impl Writer<'_, '_, '_> {
|
||||
self.push(&format!("[{}]", escape(alt)));
|
||||
return Ok(());
|
||||
};
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
self.push(&format!(
|
||||
"<img src=\"{}\" alt=\"{}\"{title}>",
|
||||
let image = format!(
|
||||
"<img src=\"{}\" alt=\"{}\">",
|
||||
attribute(&src),
|
||||
attribute(alt)
|
||||
));
|
||||
);
|
||||
match link {
|
||||
Some(link) if safe(link) => {
|
||||
self.push(&format!("<a href=\"{}\">{image}</a>", attribute(link)))
|
||||
}
|
||||
Some(_) => self.push(&format!("<a>{image}</a>")),
|
||||
None => self.push(&image),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn footnotes(&mut self) -> io::Result<()> {
|
||||
if self.called.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.push("<section class=\"footnotes\"><hr><ol>");
|
||||
// A note may call another note.
|
||||
let mut at = 0;
|
||||
while at < self.called.len() {
|
||||
let note = self.notes.get(&self.called[at]).cloned();
|
||||
at += 1;
|
||||
self.push(&format!("<li id=\"fn-{at}\">"));
|
||||
match note {
|
||||
Some(note) => self.children(¬e, false)?,
|
||||
None => log::error!("mark: footnote {at} has no definition"),
|
||||
}
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push("</ol></section>");
|
||||
Ok(())
|
||||
/// A stretch done: all read, or blank and dropped.
|
||||
fn retire(atoms: Option<Atoms>, written: bool) {
|
||||
let atoms = atoms.expect("a stretch per picture");
|
||||
assert!(
|
||||
atoms.notes.is_empty() && (!written || atoms.formulas.is_empty()),
|
||||
"every formula and note is read"
|
||||
);
|
||||
}
|
||||
|
||||
/// GitHub's color for an alert kind.
|
||||
fn hue(kind: AlertKind) -> &'static str {
|
||||
match kind {
|
||||
AlertKind::Note => "#0969da",
|
||||
AlertKind::Tip => "#1a7f37",
|
||||
AlertKind::Important => "#8250df",
|
||||
AlertKind::Warning => "#9a6700",
|
||||
AlertKind::Caution => "#d1242f",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tag a read style writes.
|
||||
fn tag(style: Style) -> &'static str {
|
||||
match style {
|
||||
Style::Strong => "strong",
|
||||
Style::Emphasis => "em",
|
||||
Style::Strike => "del",
|
||||
Style::Code => "code",
|
||||
Style::Underline => "u",
|
||||
Style::Mark => "mark",
|
||||
Style::Link | Style::Math | Style::Footnote => unreachable!("{style:?} writes no tag"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,28 +484,3 @@ fn line_of(node: &Node) -> usize {
|
||||
fn bare(svg: &str) -> &str {
|
||||
svg.find("<svg").map_or(svg, |at| &svg[at..])
|
||||
}
|
||||
|
||||
/// A plain, web or mail address, read as browsers do.
|
||||
fn safe(url: &str) -> bool {
|
||||
// Browsers trim spaces and controls, then drop tabs and newlines.
|
||||
let read: String = url
|
||||
.trim_matches(|ch: char| ch <= ' ')
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
|
||||
.collect();
|
||||
match scheme(&read) {
|
||||
None => true,
|
||||
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An address's scheme, when it has one.
|
||||
pub(super) fn scheme(url: &str) -> Option<&str> {
|
||||
let end = url.find(':')?;
|
||||
let name = &url[..end];
|
||||
let plain = name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
|
||||
// A drive letter is a path, not a scheme.
|
||||
(plain && name.len() > 1).then_some(name)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Markdown as an HTML page or plain text (MARK-11).
|
||||
|
||||
mod address;
|
||||
mod files;
|
||||
mod html;
|
||||
mod pictures;
|
||||
@@ -12,7 +13,7 @@ use std::{io, path::Path};
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
|
||||
pub use files::{Format, Target, write};
|
||||
pub use pictures::Pictures;
|
||||
pub use pictures::{Pictures, picture_file};
|
||||
pub use plain::plain;
|
||||
|
||||
/// A page and what it could not carry.
|
||||
@@ -45,7 +46,7 @@ fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result<Page> {
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let mut page = Page::default();
|
||||
html::write(&tree, placed, &mut page)?;
|
||||
html::write(&tree, text, placed, &mut page)?;
|
||||
log::info!(
|
||||
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
|
||||
page.html.len(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::html::scheme;
|
||||
use super::address::scheme;
|
||||
|
||||
/// Where pictures go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -19,6 +19,8 @@ pub enum Pictures {
|
||||
Folder(PathBuf),
|
||||
/// Copied into this folder, in formats MuPDF reads.
|
||||
Pdf(PathBuf),
|
||||
/// Addresses as the text writes them.
|
||||
Written,
|
||||
}
|
||||
|
||||
/// Picture formats MuPDF reads from files.
|
||||
@@ -40,6 +42,22 @@ pub(super) struct Placed<'a> {
|
||||
pub made: bool,
|
||||
}
|
||||
|
||||
/// A picture address's file; none for other schemes.
|
||||
pub fn picture_file(url: &str, beside: Option<&Path>) -> Option<PathBuf> {
|
||||
if url.to_lowercase().starts_with("file://") {
|
||||
return Some(file_path(&url["file://".len()..]));
|
||||
}
|
||||
if scheme(url).is_some() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(decoded(url));
|
||||
match (path.is_absolute(), beside) {
|
||||
(true, _) => Some(path),
|
||||
(false, Some(beside)) => Some(beside.join(path)),
|
||||
(false, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Placed<'a> {
|
||||
pub fn new(beside: Option<&'a Path>, pictures: &'a Pictures) -> Self {
|
||||
Self {
|
||||
@@ -56,6 +74,9 @@ impl<'a> Placed<'a> {
|
||||
|
||||
/// The `src` for `url`; none when it is not carried.
|
||||
pub fn place(&mut self, url: &str) -> io::Result<Option<String>> {
|
||||
if matches!(self.pictures, Pictures::Written) {
|
||||
return Ok(Some(url.to_owned()));
|
||||
}
|
||||
let lower = url.to_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
return Ok((!self.pdf()).then(|| url.to_owned()));
|
||||
@@ -67,20 +88,10 @@ impl<'a> Placed<'a> {
|
||||
};
|
||||
return Ok(readable.then(|| url.to_owned()));
|
||||
}
|
||||
let path = match (lower.starts_with("file://"), scheme(url)) {
|
||||
(true, _) => file_path(&url["file://".len()..]),
|
||||
// Other schemes are no picture of the document's.
|
||||
(false, Some(_)) => return Ok(None),
|
||||
(false, None) => {
|
||||
let path = PathBuf::from(decoded(url));
|
||||
match (path.is_absolute(), self.beside) {
|
||||
(true, _) => path,
|
||||
(false, Some(beside)) => beside.join(path),
|
||||
(false, None) => return Ok(None),
|
||||
}
|
||||
}
|
||||
};
|
||||
self.carried(&path)
|
||||
match picture_file(url, self.beside) {
|
||||
Some(path) => self.carried(&path),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
|
||||
@@ -98,6 +109,7 @@ impl<'a> Placed<'a> {
|
||||
}
|
||||
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return Ok(None),
|
||||
Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(),
|
||||
Pictures::Written => unreachable!("written addresses are not carried"),
|
||||
};
|
||||
let named = match self.copied.iter().find(|(from, _)| from == path) {
|
||||
Some((_, name)) => name.clone(),
|
||||
|
||||
@@ -2,21 +2,44 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
use ely_gpui_component::documents::{
|
||||
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_options,
|
||||
};
|
||||
use markdown::mdast::Node;
|
||||
|
||||
/// `text` as plain text, a blank line between blocks.
|
||||
pub fn plain(text: &str) -> io::Result<String> {
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let reader = MarkdownReader::new(text, &tree);
|
||||
let mut blocks = Vec::new();
|
||||
// Alerts and `[toc]` live only at the root.
|
||||
for child in tree.children().into_iter().flatten() {
|
||||
block(child, &mut blocks);
|
||||
match child {
|
||||
Node::Blockquote(quote) => match alert(quote) {
|
||||
Some((kind, body)) => {
|
||||
blocks.push(kind.title().to_string());
|
||||
for node in &body {
|
||||
block(&reader, node, &mut blocks);
|
||||
}
|
||||
}
|
||||
None => block(&reader, child, &mut blocks),
|
||||
},
|
||||
_ if is_toc(child, text) => {
|
||||
let titles: Vec<&str> = (reader.headings.iter())
|
||||
.map(|heading| heading.title.as_str())
|
||||
.collect();
|
||||
if !titles.is_empty() {
|
||||
blocks.push(titles.join("\n"));
|
||||
}
|
||||
}
|
||||
_ => block(&reader, child, &mut blocks),
|
||||
}
|
||||
}
|
||||
Ok(blocks.join("\n\n") + "\n")
|
||||
}
|
||||
|
||||
fn block(node: &Node, blocks: &mut Vec<String>) {
|
||||
fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
|
||||
match node {
|
||||
Node::Code(code) => blocks.push(code.value.clone()),
|
||||
Node::Math(math) => blocks.push(math.value.clone()),
|
||||
@@ -24,7 +47,7 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
|
||||
let items = list.children.iter().map(|item| {
|
||||
let mut inner = Vec::new();
|
||||
for child in item.children().into_iter().flatten() {
|
||||
block(child, &mut inner);
|
||||
block(reader, child, &mut inner);
|
||||
}
|
||||
inner.join("\n")
|
||||
});
|
||||
@@ -32,32 +55,45 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
|
||||
}
|
||||
Node::Table(table) => {
|
||||
let rows = table.children.iter().map(|row| {
|
||||
let cells = row.children().into_iter().flatten().map(words);
|
||||
let cells = row.children().into_iter().flatten();
|
||||
let cells = cells.map(|cell| words(reader, cell));
|
||||
cells.collect::<Vec<_>>().join("\t")
|
||||
});
|
||||
blocks.push(rows.collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
Node::Blockquote(_) | Node::FootnoteDefinition(_) => {
|
||||
Node::FootnoteDefinition(note) => {
|
||||
let mut body = Vec::new();
|
||||
for child in ¬e.children {
|
||||
block(reader, child, &mut body);
|
||||
}
|
||||
// The label the drawing shows beside it.
|
||||
let label = format!("[{}]", reader.label(note));
|
||||
blocks.push([label, body.join("\n\n")].join(" ").trim_end().to_string());
|
||||
}
|
||||
Node::Blockquote(_) => {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
block(child, blocks);
|
||||
block(reader, child, blocks);
|
||||
}
|
||||
}
|
||||
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)),
|
||||
// Front matter, HTML, rules and definitions are not words.
|
||||
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)),
|
||||
Node::Html(_) => {
|
||||
let mut read = Vec::new();
|
||||
reader.block(node, &mut read);
|
||||
for piece in read {
|
||||
match piece {
|
||||
MarkdownBlock::Paragraph(text) => blocks.push(text.text),
|
||||
other => unreachable!("an HTML block reads as text, not {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Front matter, rules and definitions are not words.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The words of an inline run.
|
||||
fn words(node: &Node) -> String {
|
||||
match node {
|
||||
Node::Text(text) => text.value.clone(),
|
||||
Node::InlineCode(code) => code.value.clone(),
|
||||
Node::InlineMath(math) => math.value.clone(),
|
||||
Node::Break(_) => "\n".into(),
|
||||
Node::Image(image) => image.alt.clone(),
|
||||
Node::ImageReference(image) => image.alt.clone(),
|
||||
Node::Html(_) => String::new(),
|
||||
_ => node.children().into_iter().flatten().map(words).collect(),
|
||||
}
|
||||
/// An inline run's words, as the drawing reads them.
|
||||
fn words(reader: &MarkdownReader, node: &Node) -> String {
|
||||
reader
|
||||
.inline(node.children().expect("an inline run holds inlines"))
|
||||
.text
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use std::path::Path;
|
||||
|
||||
use super::{Pictures, body, document, plain};
|
||||
|
||||
mod visual;
|
||||
|
||||
fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
|
||||
body(text, beside, pictures).expect("a page")
|
||||
}
|
||||
@@ -51,17 +53,17 @@ fn markdown_writes_as_html() {
|
||||
let text = "# T\n\n- [x] done\n- b\n\n3. c\n\n| l | r |\n|:--|--:|\n| 1 | 2 |\n\n~~gone~~ [a](https://x.org \"t\") [r][ref] [bad](javascript:alert(1))\n\nNote[^1].\n\n[^1]: The note.\n\n[ref]: /docs\n";
|
||||
let html = page(text, None, &Pictures::Inline).html;
|
||||
for part in [
|
||||
"<h1>T</h1>",
|
||||
"<h1 id=\"h-0\">T</h1>",
|
||||
"<li><input type=\"checkbox\" disabled checked> done</li>",
|
||||
"<ol start=\"3\">",
|
||||
"<th style=\"text-align:left\">l</th>",
|
||||
"<td style=\"text-align:right\">2</td>",
|
||||
"<del>gone</del>",
|
||||
"<a href=\"https://x.org\" title=\"t\">a</a>",
|
||||
"<a href=\"https://x.org\">a</a>",
|
||||
"<a href=\"/docs\">r</a>",
|
||||
"<a>bad</a>",
|
||||
"<sup><a href=\"#fn-1\">1</a></sup>",
|
||||
"<li id=\"fn-1\"><p>The note.</p></li>",
|
||||
"<div id=\"fn-1\" style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[1]</span><div><p>The note.</p></div></div>",
|
||||
] {
|
||||
assert!(html.contains(part), "{part} in {html}");
|
||||
}
|
||||
@@ -458,9 +460,15 @@ fn a_formula_letter_no_font_has_stays_as_tex() {
|
||||
fn a_large_document_exports_whole() {
|
||||
// Long paragraphs: markdown-rs slows with the block count.
|
||||
let words = "word ".repeat(200);
|
||||
let mut text: String = (0..2_200).map(|n| format!("Paragraph {n}: {words}\n\n")).collect();
|
||||
let mut text: String = (0..2_200)
|
||||
.map(|n| format!("Paragraph {n}: {words}\n\n"))
|
||||
.collect();
|
||||
text.push_str("The last line.\n");
|
||||
assert!(text.len() > ely_gpui_component::documents::LARGEST_READ);
|
||||
assert!(plain(&text).expect("text").ends_with("The last line.\n"));
|
||||
assert!(page(&text, None, &Pictures::Inline).html.ends_with("<p>The last line.</p>"));
|
||||
assert!(
|
||||
page(&text, None, &Pictures::Inline)
|
||||
.html
|
||||
.ends_with("<p>The last line.</p>")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Exports read the file as the visual mode does (R4.4).
|
||||
|
||||
use super::{Pictures, page, plain};
|
||||
use crate::output::{Page, html::write, markdown_options, pictures::Placed};
|
||||
|
||||
fn html(text: &str) -> String {
|
||||
page(text, None, &Pictures::Inline).html
|
||||
}
|
||||
|
||||
fn text(text: &str) -> String {
|
||||
plain(text).expect("plain text")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_turn_as_github_turns_them() {
|
||||
let page = html("Go :smile: x:smile:\n\nx:smile: :smile:\n\na <code>:smile:</code> :smile:\n");
|
||||
for part in [
|
||||
"<p>Go 😄 x😄</p>",
|
||||
"<p>x:smile: :smile:</p>",
|
||||
"<p>a <code>:smile:</code> 😄</p>",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert_eq!(
|
||||
text("Go :smile:\n\nx:smile: :smile:\n"),
|
||||
"Go 😄\n\nx:smile: :smile:\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_tags_keep_what_they_mean_and_nothing_runs() {
|
||||
let source = "a <u>u</u> <mark>m</mark> <kbd>k</kbd> <span>s</span> <script>x</script> \
|
||||
<a href=\"javascript:x\">j</a> <a href=\"https://e.test\">e</a>\n";
|
||||
let page = html(source);
|
||||
for part in [
|
||||
"<u>u</u>",
|
||||
"<mark>m</mark>",
|
||||
"<code>k</code>",
|
||||
" s ",
|
||||
"<script>x</script>",
|
||||
"<a>j</a>",
|
||||
"<a href=\"https://e.test\">e</a>",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert!(
|
||||
!page.contains("<span") && !page.contains("<script"),
|
||||
"{page}"
|
||||
);
|
||||
assert_eq!(
|
||||
text("a <b>b</b> <script>x</script>\n"),
|
||||
"a b <script>x</script>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_html_block_shows_its_text() {
|
||||
let source = "<div align=\"center\">\n <b>Bold</b> and :smile:\n</div>\n";
|
||||
let page = html(source);
|
||||
assert!(
|
||||
page.contains("<p><strong>Bold</strong> and 😄</p>"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(!page.contains("<div"), "{page}");
|
||||
assert_eq!(text(source), "Bold and 😄\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_writes_its_kind_and_title() {
|
||||
let page = html("> [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n");
|
||||
let alert = "<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid #0969da\"><p style=\"font-weight:600;color:#0969da\">Note</p><p>Read this.</p></div>";
|
||||
assert!(page.contains(alert), "{page}");
|
||||
assert!(!page.contains("#1a7f37"), "only root quotes alert: {page}");
|
||||
assert_eq!(text("> [!WARNING]\n> Hot.\n"), "Warning\n\nHot.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_toc_lists_the_headings_and_links_them() {
|
||||
let page = html("[toc]\n\n# A\n\n## B :smile:\n");
|
||||
let toc = "<ul style=\"list-style:none;padding-left:0\"><li style=\"margin-left:0em\"><a href=\"#h-7\">A</a></li><li style=\"margin-left:1em\"><a href=\"#h-12\">B 😄</a></li></ul>";
|
||||
assert!(page.contains(toc), "{page}");
|
||||
assert!(page.contains("<h1 id=\"h-7\">A</h1>") && page.contains("<h2 id=\"h-12\">B 😄</h2>"));
|
||||
assert_eq!(text("[toc]\n\n# A\n\n## B\n"), "A\nB\n\nA\n\nB\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_crossing_containers_read_as_drawn() {
|
||||
for (source, shown) in [
|
||||
("*a <code>b* :smile:</code> :smile:\n", "a b :smile: 😄"),
|
||||
("a <code>*b</code>* :smile:\n", "a b 😄"),
|
||||
] {
|
||||
assert_eq!(text(source), format!("{shown}\n"));
|
||||
let page = html(source);
|
||||
assert_eq!(page.matches("😄").count(), 1, "{page}");
|
||||
assert_eq!(
|
||||
page.matches(":smile:").count(),
|
||||
shown.matches(":smile:").count(),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_cell_and_block_reads_on_its_own() {
|
||||
let page = html("| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |\n");
|
||||
assert_eq!(page.matches("😄").count(), 3, "{page}");
|
||||
let page = html("# a <code>x\n\n:smile:\n");
|
||||
assert!(page.contains("<p>😄</p>"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_closes_the_tags_left_open_in_it() {
|
||||
let page = html("a <b>bold\n\nnormal\n");
|
||||
assert!(
|
||||
page.contains("<p>a <strong>bold</strong></p><p>normal</p>"),
|
||||
"{page}"
|
||||
);
|
||||
let page = html("a <a href=\"https://example.test\">link\n\nnormal\n");
|
||||
assert!(page.ends_with("<p>normal</p>"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_toc_entry_has_its_heading() {
|
||||
let page = html("[toc]\n\n# Main\n\n[^n]:\n ## Unused\n");
|
||||
let targets: Vec<&str> = page
|
||||
.split("href=\"#")
|
||||
.skip(1)
|
||||
.map(|rest| &rest[..rest.find('"').expect("a quote")])
|
||||
.collect();
|
||||
assert_eq!(targets.len(), 2, "{page}");
|
||||
for target in targets {
|
||||
assert!(
|
||||
page.contains(&format!("id=\"{target}\"")),
|
||||
"{target} in {page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_html_block_writes_in_linear_time() {
|
||||
let took = |count: usize| {
|
||||
let source = format!("<div>{}</div>\n", "<b>x</b> ".repeat(count));
|
||||
let start = std::time::Instant::now();
|
||||
html(&source);
|
||||
start.elapsed()
|
||||
};
|
||||
let (small, large) = (took(8_000), took(32_000));
|
||||
// Four times the input; squared would be sixteen.
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_under_deep_marks_write_in_linear_time() {
|
||||
let took = |count: usize| {
|
||||
let source = format!(
|
||||
"start {}{}
|
||||
|
||||
[^n]: note
|
||||
",
|
||||
"<b>".repeat(count),
|
||||
"x[^n] ".repeat(count)
|
||||
);
|
||||
let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed");
|
||||
let mut placed = Placed::new(None, &Pictures::Inline);
|
||||
let start = std::time::Instant::now();
|
||||
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
|
||||
start.elapsed()
|
||||
};
|
||||
// Parsing grows squared upstream; untimed.
|
||||
let (small, large) = (took(4_000), took(16_000));
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_formula_is_dropped_with_its_text() {
|
||||
for source in [
|
||||
"$ $\n",
|
||||
"$ $ \n",
|
||||
"| $ $ | a |\n| - | - |\n| b | c |\n",
|
||||
] {
|
||||
html(source);
|
||||
}
|
||||
let page = page("$ $ \nnext $\\sqrt{$\n", None, &Pictures::Inline);
|
||||
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
|
||||
assert_eq!(lines, [2], "{}", page.html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_in_a_link_keeps_the_link_whole() {
|
||||
let page = html("a <a href=\"https://example.test\">b[^n] c</a> after\n\n[^n]: note\n");
|
||||
assert!(
|
||||
page.contains("<a href=\"https://example.test\">b<sup>1</sup> c</a>"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_reaches_its_own_note() {
|
||||
let page = html("[^1]: unused\n\nCall[^used].\n\n[^used]: right target\n");
|
||||
assert_eq!(page.matches("id=\"fn-1\"").count(), 1, "{page}");
|
||||
let at = page.find("id=\"fn-1\"").expect("an anchor");
|
||||
assert!(
|
||||
page[at..].contains("right target") && !page[at..].contains("unused"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_notes_keep_the_labels_drawn_beside_them() {
|
||||
assert_eq!(
|
||||
text("A[^b] B[^a].\n\n[^a]: Alpha\n\n[^b]: Beta\n"),
|
||||
"A\u{b9} B\u{b2}.\n\n[2] Alpha\n\n[1] Beta\n"
|
||||
);
|
||||
assert_eq!(text("x\n\n[^n]: note\n"), "x\n\n[n] note\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_fragment_carries_its_look() {
|
||||
let page = html("> [!NOTE]\n> Read this.\n\n[toc]\n\n# Title\n\nCall[^n].\n\n[^n]: note\n");
|
||||
for part in [
|
||||
"border-left:.25em solid #0969da",
|
||||
"font-weight:600",
|
||||
"list-style:none",
|
||||
"display:flex",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert!(
|
||||
!page.contains("class="),
|
||||
"the look needs no page style: {page}"
|
||||
);
|
||||
}
|
||||
@@ -293,7 +293,13 @@ impl Desk {
|
||||
}
|
||||
|
||||
/// A new file with the saved text; edits stay.
|
||||
fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context<Self>) {
|
||||
fn keep_edits(
|
||||
&mut self,
|
||||
id: u64,
|
||||
disk: Source,
|
||||
fingerprint: Fingerprint,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
@@ -398,7 +404,10 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
|
||||
let (sender, mut changes) = mpsc::unbounded();
|
||||
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
// Reads, ours included, are no change.
|
||||
if event.as_ref().is_ok_and(|event| ely_ui::watching::only_read(&event.kind)) {
|
||||
if event
|
||||
.as_ref()
|
||||
.is_ok_and(|event| ely_ui::watching::only_read(&event.kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
|
||||
|
||||
@@ -108,6 +108,22 @@ impl Desk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties the recent list.
|
||||
pub fn clear_recent(&mut self, cx: &mut Context<Self>) {
|
||||
match ely_files::recent::clear(&self.recent_file) {
|
||||
Ok(()) => {
|
||||
log::info!("mark: the recent list is cleared");
|
||||
self.recent.clear();
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("mark: the recent list did not clear: {error}");
|
||||
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line and column `at` in a tab or file.
|
||||
pub fn open_at(
|
||||
&mut self,
|
||||
|
||||
@@ -141,8 +141,11 @@ impl Keeping {
|
||||
/// The untitled tabs to make: their place here and name.
|
||||
pub fn untitled(&self) -> Vec<(usize, String)> {
|
||||
let waiting = self.waiting.iter().enumerate();
|
||||
let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
|
||||
untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect()
|
||||
let untitled =
|
||||
waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
|
||||
untitled
|
||||
.map(|(at, waiting)| (at, waiting.kept.name.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Entry `at` belongs to untitled tab `tab`.
|
||||
@@ -152,11 +155,14 @@ impl Keeping {
|
||||
|
||||
/// What tab `id` takes: its file or its copy.
|
||||
fn take(&mut self, real: Option<&PathBuf>, id: u64) -> Option<Waiting> {
|
||||
let at = self.waiting.iter().position(|waiting| match (&waiting.real, real) {
|
||||
(Some(kept), Some(open)) => kept == open,
|
||||
(None, None) => waiting.tab == Some(id),
|
||||
_ => false,
|
||||
})?;
|
||||
let at = self
|
||||
.waiting
|
||||
.iter()
|
||||
.position(|waiting| match (&waiting.real, real) {
|
||||
(Some(kept), Some(open)) => kept == open,
|
||||
(None, None) => waiting.tab == Some(id),
|
||||
_ => false,
|
||||
})?;
|
||||
Some(self.waiting.remove(at))
|
||||
}
|
||||
|
||||
@@ -243,7 +249,9 @@ impl Desk {
|
||||
// Modes take focus: in the window, after this update.
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let shown = ely_office::in_window(&desk, cx, |_, window, cx| {
|
||||
view.update(cx, |view, cx| view.reopen(mode, kept.place, back, window, cx))
|
||||
view.update(cx, |view, cx| {
|
||||
view.reopen(mode, kept.place, back, window, cx)
|
||||
})
|
||||
});
|
||||
if let Err(error) = shown {
|
||||
log::warn!("mark: tab {id} did not restore: {error:#}");
|
||||
@@ -285,7 +293,8 @@ impl Desk {
|
||||
.spawn(async move {
|
||||
let now = SystemTime::now();
|
||||
let limits = ely_platform::storage::limits(ely_platform::AppId::Mark)?;
|
||||
let mut recovery = Recovery::new(folder).with_limits(limits.keep, limits.megabytes * 1024 * 1024);
|
||||
let mut recovery = Recovery::new(folder)
|
||||
.with_limits(limits.keep, limits.megabytes * 1024 * 1024);
|
||||
recovery.write_once(current.as_bytes(), None, now)
|
||||
})
|
||||
.await;
|
||||
@@ -350,7 +359,11 @@ impl Desk {
|
||||
None => text(cx, "office.stopped.never", &[]).to_string(),
|
||||
};
|
||||
let name = tab.name.to_string();
|
||||
let said = text(cx, "mark.recovery.failed", &[("name", &name), ("error", &error), ("at", &at)]);
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.recovery.failed",
|
||||
&[("name", &name), ("error", &error), ("at", &at)],
|
||||
);
|
||||
self.toast_unlogged(said, ely_gpui_component::primitives::Severity::Danger, cx);
|
||||
}
|
||||
|
||||
|
||||
+65
-25
@@ -15,8 +15,7 @@ use ely_office::prelude::*;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render,
|
||||
SharedString, Styled as _, WeakEntity, Window, div,
|
||||
ParentElement as _, Render, SharedString, Styled as _, WeakEntity, Window, div,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
@@ -201,7 +200,7 @@ impl Side {
|
||||
.action(open)
|
||||
.into_any_element();
|
||||
}
|
||||
let opener = desk.downgrade();
|
||||
let (opener, clearer) = (desk.downgrade(), desk.downgrade());
|
||||
let rows = read.recent.iter().map(|path| {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -215,15 +214,32 @@ impl Side {
|
||||
);
|
||||
(key, item)
|
||||
});
|
||||
rows.fold(
|
||||
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|
||||
|list, (key, item)| list.row(key, item),
|
||||
)
|
||||
.on_activate(move |key, _, cx| {
|
||||
let path = PathBuf::from(key.as_ref());
|
||||
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
||||
})
|
||||
.into_any_element()
|
||||
let list = rows
|
||||
.fold(
|
||||
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|
||||
|list, (key, item)| list.row(key, item),
|
||||
)
|
||||
.on_activate(move |key, _, cx| {
|
||||
let path = PathBuf::from(key.as_ref());
|
||||
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
||||
});
|
||||
let clear = Button::new("mark-clear-recent", text(cx, "mark.recent.clear", &[]))
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
clearer.update(cx, |desk, cx| desk.clear_recent(cx)).ok();
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.size_full()
|
||||
.child(div().flex_1().min_h_0().child(list))
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "mark-clear-recent".into())
|
||||
.child(clear),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn outline(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
||||
@@ -251,17 +267,21 @@ impl Side {
|
||||
},
|
||||
);
|
||||
let outline = outline.on_select(move |key, window, cx| {
|
||||
let Some(heading) = key.parse::<usize>().ok().and_then(|at| headings.get(at)) else {
|
||||
return log::error!("mark: the outline chose no heading");
|
||||
};
|
||||
// The heading in view, the caret on it.
|
||||
let line = heading.line;
|
||||
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
|
||||
});
|
||||
let Some(heading) = key.parse::<usize>().ok().and_then(|at| headings.get(at)) else {
|
||||
return log::error!("mark: the outline chose no heading");
|
||||
};
|
||||
// The heading in view, the caret on it.
|
||||
let line = heading.line;
|
||||
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
|
||||
});
|
||||
ScrollArea::new("mark-outline-scroll")
|
||||
.vertical()
|
||||
.size_full()
|
||||
.child(div().debug_selector(|| "mark-outline".into()).child(outline))
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "mark-outline".into())
|
||||
.child(outline),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -269,9 +289,17 @@ impl Side {
|
||||
let this = cx.entity();
|
||||
let scope = SegmentedControl::new("mark-scope", if self.wide { "folder" } else { "file" })
|
||||
.size(ControlSize::Sm)
|
||||
.segment("file", text(cx, "mark.search.file", &[]), Some(IconName::File));
|
||||
.segment(
|
||||
"file",
|
||||
text(cx, "mark.search.file", &[]),
|
||||
Some(IconName::File),
|
||||
);
|
||||
let scope = match desk.read(cx).folder {
|
||||
Some(_) => scope.segment("folder", text(cx, "mark.search.folder", &[]), Some(IconName::Folder)),
|
||||
Some(_) => scope.segment(
|
||||
"folder",
|
||||
text(cx, "mark.search.folder", &[]),
|
||||
Some(IconName::Folder),
|
||||
),
|
||||
None => scope,
|
||||
};
|
||||
let scope = scope.on_change(move |key, _, cx| {
|
||||
@@ -350,9 +378,21 @@ impl Render for Side {
|
||||
};
|
||||
let panes = SegmentedControl::new("mark-pane", key)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("files", text(cx, "mark.pane.files", &[]), Some(IconName::Folder))
|
||||
.segment("outline", text(cx, "mark.pane.outline", &[]), Some(IconName::List))
|
||||
.segment("search", text(cx, "mark.pane.search", &[]), Some(IconName::Search))
|
||||
.segment(
|
||||
"files",
|
||||
text(cx, "mark.pane.files", &[]),
|
||||
Some(IconName::Folder),
|
||||
)
|
||||
.segment(
|
||||
"outline",
|
||||
text(cx, "mark.pane.outline", &[]),
|
||||
Some(IconName::List),
|
||||
)
|
||||
.segment(
|
||||
"search",
|
||||
text(cx, "mark.pane.search", &[]),
|
||||
Some(IconName::Search),
|
||||
)
|
||||
.on_change(move |key, _, cx| {
|
||||
this.update(cx, |side, cx| {
|
||||
side.pane = match key.as_ref() {
|
||||
|
||||
+69
-56
@@ -1,14 +1,10 @@
|
||||
//! The table menu's work on the shown file (MARK-06).
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{TableEdit, TableRefusal},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{Context, InteractiveElement as _, actions};
|
||||
use ely_gpui_component::documents::TableEdit;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::{blocks::edits, desk::Desk, keys::item};
|
||||
|
||||
actions!(
|
||||
mark,
|
||||
@@ -18,58 +14,75 @@ actions!(
|
||||
RowBelow,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
MoveRowUp,
|
||||
MoveRowDown,
|
||||
MoveColumnLeft,
|
||||
MoveColumnRight,
|
||||
DeleteRow,
|
||||
DeleteColumn
|
||||
DeleteColumn,
|
||||
TidyTable,
|
||||
DeleteTable
|
||||
]
|
||||
);
|
||||
|
||||
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),
|
||||
});
|
||||
let Err(refusal) = done else {
|
||||
return;
|
||||
};
|
||||
let key = match refusal {
|
||||
TableRefusal::Outside => "mark.table.none",
|
||||
TableRefusal::Kept => "mark.table.kept",
|
||||
TableRefusal::Beyond => "mark.table.beyond",
|
||||
TableRefusal::Mixed => "mark.table.mixed",
|
||||
TableRefusal::Preview => "mark.preview.read_only",
|
||||
};
|
||||
let said = text(cx, key, &[]);
|
||||
self.toast(said, Severity::Info, cx);
|
||||
}
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
// An open block's input takes Ctrl+Enter as Submit.
|
||||
let open = Some("MarkdownDocument > ElyInput");
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-t", InsertTable, at),
|
||||
KeyBinding::new("secondary-enter", RowBelow, at),
|
||||
KeyBinding::new("secondary-enter", RowBelow, open),
|
||||
KeyBinding::new("alt-up", MoveRowUp, at),
|
||||
KeyBinding::new("alt-down", MoveRowDown, at),
|
||||
KeyBinding::new("alt-left", MoveColumnLeft, at),
|
||||
KeyBinding::new("alt-right", MoveColumnRight, at),
|
||||
KeyBinding::new("secondary-shift-backspace", DeleteRow, at),
|
||||
]);
|
||||
}
|
||||
|
||||
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)
|
||||
}))
|
||||
/// Typora's Paragraph ▸ Table.
|
||||
pub fn table_menu(cx: &App) -> Menu {
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
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.move_row_up", Box::new(MoveRowUp)),
|
||||
item("mark.table.move_row_down", Box::new(MoveRowDown)),
|
||||
item("mark.table.move_column_left", Box::new(MoveColumnLeft)),
|
||||
item("mark.table.move_column_right", Box::new(MoveColumnRight)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.delete_row", Box::new(DeleteRow)),
|
||||
item("mark.table.delete_column", Box::new(DeleteColumn)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.tidy", Box::new(TidyTable)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.delete", Box::new(DeleteTable)),
|
||||
])
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub fn table_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
use TableEdit as T;
|
||||
edits!(root, cx,
|
||||
InsertTable => |d, cx| d.insert_table(cx),
|
||||
RowAbove => |d, cx| d.edit_table(T::RowAbove, cx),
|
||||
RowBelow => |d, cx| d.edit_table(T::RowBelow, cx),
|
||||
ColumnLeft => |d, cx| d.edit_table(T::ColumnLeft, cx),
|
||||
ColumnRight => |d, cx| d.edit_table(T::ColumnRight, cx),
|
||||
MoveRowUp => |d, cx| d.edit_table(T::MoveRowUp, cx),
|
||||
MoveRowDown => |d, cx| d.edit_table(T::MoveRowDown, cx),
|
||||
MoveColumnLeft => |d, cx| d.edit_table(T::MoveColumnLeft, cx),
|
||||
MoveColumnRight => |d, cx| d.edit_table(T::MoveColumnRight, cx),
|
||||
DeleteRow => |d, cx| d.edit_table(T::DeleteRow, cx),
|
||||
DeleteColumn => |d, cx| d.edit_table(T::DeleteColumn, cx),
|
||||
TidyTable => |d, cx| d.edit_table(T::Tidy, cx),
|
||||
DeleteTable => |d, cx| d.edit_table(T::Delete, cx),
|
||||
)
|
||||
}
|
||||
}
|
||||
+69
-20
@@ -1,9 +1,6 @@
|
||||
//! Whether Git sees the file changed; ElyTree does the rest.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_gpui_component::{primitives::Severity, shell::StatusBarItem};
|
||||
use ely_office::prelude::*;
|
||||
@@ -145,7 +142,10 @@ impl Desk {
|
||||
}
|
||||
let (this, file) = (this.clone(), file.clone());
|
||||
cx.spawn(async move |cx| {
|
||||
let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await;
|
||||
let opened = cx
|
||||
.background_executor()
|
||||
.spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) })
|
||||
.await;
|
||||
if let Err(error) = opened {
|
||||
log::error!("mark: ElyTree did not open: {error}");
|
||||
cx.update(|cx| {
|
||||
@@ -176,7 +176,9 @@ mod tests {
|
||||
fn spaces_stay_and_broken_repositories_say_why() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
// Windows names end in no space.
|
||||
let repo = dir.path().join(if cfg!(windows) { "re po" } else { "repo " });
|
||||
let repo = dir
|
||||
.path()
|
||||
.join(if cfg!(windows) { "re po" } else { "repo " });
|
||||
std::fs::create_dir(&repo).expect("the folder");
|
||||
git(&repo, &["init", "-q"]);
|
||||
std::fs::write(repo.join("t.md"), "a").expect("a file");
|
||||
@@ -193,11 +195,28 @@ mod tests {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let repo = dir.path();
|
||||
git(repo, &["init", "-q"]);
|
||||
std::fs::write(repo.join(".gitignore"), "skip.md
|
||||
").expect("ignores");
|
||||
std::fs::write(
|
||||
repo.join(".gitignore"),
|
||||
"skip.md
|
||||
",
|
||||
)
|
||||
.expect("ignores");
|
||||
std::fs::write(repo.join("t.md"), "a").expect("a file");
|
||||
git(repo, &["add", "."]);
|
||||
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=T",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"one",
|
||||
],
|
||||
);
|
||||
assert_eq!(ask(&repo.join("t.md")), Tracked::Unchanged);
|
||||
std::fs::write(repo.join("t.md"), "b").expect("an edit");
|
||||
assert_eq!(ask(&repo.join("t.md")), Tracked::Changed);
|
||||
@@ -208,30 +227,60 @@ mod tests {
|
||||
#[gpui::test]
|
||||
fn the_status_item_follows_a_save(cx: &mut gpui::TestAppContext) {
|
||||
use ely_office::prelude::*;
|
||||
let (data, dir) = (tempfile::tempdir().expect("data"), tempfile::tempdir().expect("repo"));
|
||||
let (data, dir) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("repo"),
|
||||
);
|
||||
let repo = dir.path();
|
||||
git(repo, &["init", "-q"]);
|
||||
std::fs::write(repo.join("t.md"), "a
|
||||
").expect("a file");
|
||||
std::fs::write(
|
||||
repo.join("t.md"),
|
||||
"a
|
||||
",
|
||||
)
|
||||
.expect("a file");
|
||||
git(repo, &["add", "."]);
|
||||
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=T",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"one",
|
||||
],
|
||||
);
|
||||
let (desk, cx) = crate::desk::tests::start(cx, data.path());
|
||||
crate::desk::tests::open(&desk, &repo.join("t.md"), cx);
|
||||
let said = |cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
|
||||
let said =
|
||||
|cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
|
||||
assert_eq!(said(cx), Some(Tracked::Unchanged));
|
||||
let (id, view) = desk.read_with(cx, |desk, _| {
|
||||
let tab = desk.active_tab().expect("a tab");
|
||||
(tab.id, tab.view.clone())
|
||||
});
|
||||
view.update(cx, |view, cx| view.adopt("b
|
||||
".into(), cx));
|
||||
cx.executor().advance_clock(std::time::Duration::from_secs(1));
|
||||
view.update(cx, |view, cx| {
|
||||
view.adopt(
|
||||
"b
|
||||
"
|
||||
.into(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_secs(1));
|
||||
cx.run_until_parked();
|
||||
desk.update_in(cx, |desk, window, cx| desk.save(id, None, window, cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(std::fs::read_to_string(repo.join("t.md")).expect("saved"), "b
|
||||
");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(repo.join("t.md")).expect("saved"),
|
||||
"b
|
||||
"
|
||||
);
|
||||
assert_eq!(said(cx), Some(Tracked::Changed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::documents::{DocumentEvent, LARGEST_READ, MarkdownDocument, MarkdownMode};
|
||||
use ely_gpui_component::documents::{
|
||||
DocumentEvent, EditRefusal, LARGEST_READ, MarkdownDocument, MarkdownMode,
|
||||
};
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable as _,
|
||||
InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div,
|
||||
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Render, Styled as _,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::engine::{Doc, mark};
|
||||
@@ -16,6 +19,9 @@ pub struct Reported;
|
||||
/// What went wrong, for the shell to say.
|
||||
pub struct Failed(pub String);
|
||||
|
||||
/// An edit asked did not happen, and why.
|
||||
pub struct Refused(pub EditRefusal);
|
||||
|
||||
/// How a document shows (MARK-02).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -81,11 +87,14 @@ pub struct MarkView {
|
||||
pub(crate) shown: bool,
|
||||
/// Trackpad pixels toward the next size step.
|
||||
wheel: f32,
|
||||
/// The last right-click menu asked.
|
||||
offer: Option<ely_office::menu::Offer<menu::Asked>>,
|
||||
pub focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl EventEmitter<Reported> for MarkView {}
|
||||
impl EventEmitter<Failed> for MarkView {}
|
||||
impl EventEmitter<Refused> for MarkView {}
|
||||
|
||||
impl MarkView {
|
||||
pub fn new(doc: Doc, saved: String, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
@@ -126,6 +135,7 @@ impl MarkView {
|
||||
folder: None,
|
||||
shown: false,
|
||||
wheel: 0.0,
|
||||
offer: None,
|
||||
focus,
|
||||
}
|
||||
}
|
||||
@@ -269,6 +279,13 @@ impl MarkView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let DocumentEvent::EditRefused(why) = event {
|
||||
return cx.emit(Refused(*why));
|
||||
}
|
||||
if let DocumentEvent::Pointed(target) = event {
|
||||
let at = window.mouse_position();
|
||||
return self.ask_menu(menu::Asked::Text(target.clone()), at, cx);
|
||||
}
|
||||
if *event == DocumentEvent::Changed {
|
||||
let text = document.read(cx).text(cx);
|
||||
self.doc.lock().expect("the text lock").current = text;
|
||||
@@ -304,7 +321,7 @@ impl MarkView {
|
||||
}
|
||||
|
||||
impl Render for MarkView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
// Ctrl and the wheel size text, as Ctrl+= does.
|
||||
let zoom = ely_ui::wheel::zoom_layer(move |event, window, cx| {
|
||||
@@ -320,10 +337,16 @@ impl Render for MarkView {
|
||||
.relative()
|
||||
.size_full()
|
||||
.track_focus(&self.focus)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(|view, event, _, cx| view.pressed(event, cx)),
|
||||
)
|
||||
.child(self.document.clone())
|
||||
.child(zoom)
|
||||
.children(self.context_menu(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
mod menu;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,463 @@
|
||||
//! The right-click menu, as Typora's (R4.10).
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{DocumentTarget, TargetBlock},
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::IconName,
|
||||
};
|
||||
use ely_office::menu::Offer;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{
|
||||
Action, App, ClipboardItem, Context, Keystroke, MouseDownEvent, Pixels, Point, SharedString,
|
||||
Window,
|
||||
};
|
||||
|
||||
use super::{Failed, MarkView, Mode};
|
||||
use crate::{blocks as b, clip, export, tools};
|
||||
|
||||
/// What a right press asked a menu for.
|
||||
pub(crate) enum Asked {
|
||||
/// The visual mode's target.
|
||||
Text(DocumentTarget),
|
||||
Source,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl MarkView {
|
||||
pub(super) fn ask_menu(&mut self, asked: Asked, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
self.offer = Some(Offer::new(self.offer.as_ref(), 0, asked, at));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A right press the visual blocks left alone.
|
||||
pub(super) fn pressed(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
|
||||
let asked = match self.mode {
|
||||
Mode::Visual => return,
|
||||
Mode::Source => Asked::Source,
|
||||
Mode::Preview => Asked::Preview,
|
||||
};
|
||||
log::info!("mark: a right press in {}", self.mode.name());
|
||||
self.ask_menu(asked, event.position, cx);
|
||||
}
|
||||
|
||||
pub(super) fn context_menu(
|
||||
&self,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<ContextMenu> {
|
||||
let offer = self.offer.as_ref()?;
|
||||
let selected = !self.document.read(cx).selected(cx).is_empty();
|
||||
let menu = match &offer.what {
|
||||
Asked::Text(target) => self.text_menu(target, selected, window, cx),
|
||||
Asked::Source => Rows::default()
|
||||
.buttons(clipboard(selected, cx))
|
||||
.row(MenuItem::submenu(
|
||||
said("mark.menu.copy_as", cx),
|
||||
copy_as(window, cx),
|
||||
))
|
||||
.menu(),
|
||||
Asked::Preview => copies(window, cx).into_iter().fold(Menu::new(), Menu::item),
|
||||
};
|
||||
Some(offer.host("mark-menu", menu))
|
||||
}
|
||||
|
||||
/// Typora's groups, shown as the target allows.
|
||||
fn text_menu(
|
||||
&self,
|
||||
target: &DocumentTarget,
|
||||
selected: bool,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Menu {
|
||||
use TargetBlock as K;
|
||||
let view = cx.entity();
|
||||
let block = target.block;
|
||||
let is = |kinds: &[K]| block.is_some_and(|block| kinds.contains(&block));
|
||||
let inline = is(&[
|
||||
K::Paragraph,
|
||||
K::Quote,
|
||||
K::Alert,
|
||||
K::List,
|
||||
K::Table,
|
||||
K::Footnote,
|
||||
]) || matches!(block, Some(K::Heading(_)));
|
||||
let mut rows = Rows::default();
|
||||
if let Some(url) = &target.image {
|
||||
let url = url.clone();
|
||||
let key = match web(&url) {
|
||||
true => "mark.image.browser",
|
||||
false => "mark.image.location",
|
||||
};
|
||||
let (shown, dropped, at) = (view.clone(), view.clone(), target.at);
|
||||
rows =
|
||||
rows.row(MenuItem::new(said(key, cx)).on_click(move |_, cx| {
|
||||
shown.update(cx, |view, cx| view.show_picture(&url, cx))
|
||||
}))
|
||||
.row(MenuItem::new(said("mark.image.delete", cx)).on_click(move |_, cx| {
|
||||
dropped.update(cx, |view, cx| view.delete_image(at, cx))
|
||||
}))
|
||||
.line();
|
||||
}
|
||||
if let Some(url) = &target.link {
|
||||
let (url, view) = (url.clone(), view.clone());
|
||||
rows = rows
|
||||
.row(
|
||||
MenuItem::new(said("mark.link.open", cx))
|
||||
.on_click(move |_, cx| view.update(cx, |view, cx| view.follow(&url, cx))),
|
||||
)
|
||||
.line();
|
||||
}
|
||||
if is(&[K::Math, K::Html]) {
|
||||
let (at, view) = (target.at, view.clone());
|
||||
rows = rows.row(MenuItem::new(said("mark.block.edit", cx)).on_click(
|
||||
move |window, cx| {
|
||||
let document = view.read(cx).document.clone();
|
||||
document.update(cx, |document, cx| document.show_match(at..at, window, cx));
|
||||
},
|
||||
));
|
||||
}
|
||||
rows = rows.buttons(clipboard(selected, cx));
|
||||
if let Some(url) = &target.link {
|
||||
rows = rows.row(copy_text(said("mark.link.copy", cx), url));
|
||||
}
|
||||
rows = rows.row(MenuItem::submenu(
|
||||
said("mark.menu.copy_as", cx),
|
||||
copy_as(window, cx),
|
||||
));
|
||||
if let Some(code) = &target.code {
|
||||
rows = rows.row(copy_text(said("mark.code.copy", cx), code));
|
||||
}
|
||||
rows = rows.line();
|
||||
if inline {
|
||||
rows = rows.buttons(formats(window, cx));
|
||||
}
|
||||
if !is(&[K::Math, K::Html, K::FrontMatter]) {
|
||||
rows = rows.buttons(wraps(window, cx));
|
||||
}
|
||||
rows = rows.line();
|
||||
if target.in_table {
|
||||
rows = rows.row(MenuItem::submenu(
|
||||
said("mark.menu.table", cx),
|
||||
table(window, cx),
|
||||
));
|
||||
} else if block.is_some() && !is(&[K::Math, K::Html]) {
|
||||
let name = kind(block, cx);
|
||||
rows = rows.row(MenuItem::submenu(name, paragraph(window, cx)));
|
||||
}
|
||||
rows = rows.row(MenuItem::submenu(
|
||||
said("mark.menu.insert", cx),
|
||||
insert(inline, window, cx),
|
||||
));
|
||||
if block.is_some() && !is(&[K::Paragraph]) && !matches!(block, Some(K::Heading(_))) {
|
||||
let name = kind(block, cx);
|
||||
let label = text(cx, "mark.block.delete", &[("block", &name)]);
|
||||
rows = rows
|
||||
.line()
|
||||
.row(run(label, Box::new(b::DeleteBlock), window));
|
||||
}
|
||||
rows.menu()
|
||||
}
|
||||
|
||||
/// Deletes the picture at byte `at`, or says why not.
|
||||
fn delete_image(&mut self, at: usize, cx: &mut Context<Self>) {
|
||||
let deleted = (self.document).update(cx, |document, cx| document.delete_image(at, cx));
|
||||
if let Err(why) = deleted {
|
||||
cx.emit(super::Refused(why));
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a local picture's file, or opens a web one.
|
||||
fn show_picture(&mut self, url: &str, cx: &mut Context<Self>) {
|
||||
if web(url) {
|
||||
log::info!("mark: a picture opens in the browser");
|
||||
return cx.open_url(url);
|
||||
}
|
||||
match crate::output::picture_file(url, self.folder.as_deref()) {
|
||||
Some(path) if path.is_file() => cx.reveal_path(&path),
|
||||
_ => {
|
||||
log::info!("mark: a picture with no file here");
|
||||
cx.emit(Failed(said("mark.image.no_file", cx).to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn web(url: &str) -> bool {
|
||||
let lower = url.to_lowercase();
|
||||
lower.starts_with("http://") || lower.starts_with("https://")
|
||||
}
|
||||
|
||||
fn said(key: &str, cx: &App) -> SharedString {
|
||||
text(cx, key, &[])
|
||||
}
|
||||
|
||||
/// Rows with lines only between groups.
|
||||
#[derive(Default)]
|
||||
struct Rows {
|
||||
menu: Menu,
|
||||
filled: bool,
|
||||
line: bool,
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
fn row(self, item: MenuItem) -> Self {
|
||||
self.with(|menu| menu.item(item))
|
||||
}
|
||||
|
||||
fn buttons(self, items: Vec<MenuItem>) -> Self {
|
||||
self.with(|menu| menu.buttons(items))
|
||||
}
|
||||
|
||||
fn with(mut self, add: impl FnOnce(Menu) -> Menu) -> Self {
|
||||
if self.line && self.filled {
|
||||
self.menu = self.menu.separator();
|
||||
}
|
||||
self.menu = add(self.menu);
|
||||
(self.filled, self.line) = (true, false);
|
||||
self
|
||||
}
|
||||
|
||||
fn line(mut self) -> Self {
|
||||
self.line = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn menu(self) -> Menu {
|
||||
self.menu
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that runs `action`, its keys shown.
|
||||
fn run(label: SharedString, action: Box<dyn Action>, window: &Window) -> MenuItem {
|
||||
let keys = (window.highest_precedence_binding_for_action(action.as_ref())).map(|binding| {
|
||||
let strokes: Vec<String> = binding.keystrokes().iter().map(|k| k.unparse()).collect();
|
||||
strokes.join(" ")
|
||||
});
|
||||
let action: Rc<Box<dyn Action>> = Rc::new(action);
|
||||
let row = MenuItem::new(label)
|
||||
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx));
|
||||
match keys {
|
||||
Some(keys) => row.keys(keys),
|
||||
None => row,
|
||||
}
|
||||
}
|
||||
|
||||
/// A button that presses `stroke` where focus went back.
|
||||
fn press(label: SharedString, icon: IconName, stroke: &str, shown: bool) -> MenuItem {
|
||||
let keystroke = Keystroke::parse(stroke).expect("a fixed keystroke");
|
||||
let button = MenuItem::new(label).icon(icon);
|
||||
let button = match shown {
|
||||
true => button.keys(keystroke.unparse()),
|
||||
false => button,
|
||||
};
|
||||
button.on_click(move |window, cx| {
|
||||
let keystroke = keystroke.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
window.dispatch_keystroke(keystroke, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn clipboard(selected: bool, cx: &App) -> Vec<MenuItem> {
|
||||
vec![
|
||||
press(
|
||||
said("mark.edit.cut", cx),
|
||||
IconName::Scissors,
|
||||
"secondary-x",
|
||||
true,
|
||||
)
|
||||
.disabled(!selected),
|
||||
press(
|
||||
said("mark.edit.copy", cx),
|
||||
IconName::Copy,
|
||||
"secondary-c",
|
||||
true,
|
||||
)
|
||||
.disabled(!selected),
|
||||
press(
|
||||
said("mark.edit.paste", cx),
|
||||
IconName::Clipboard,
|
||||
"secondary-v",
|
||||
true,
|
||||
),
|
||||
press(
|
||||
said("mark.edit.delete", cx),
|
||||
IconName::Trash2,
|
||||
"backspace",
|
||||
false,
|
||||
)
|
||||
.disabled(!selected),
|
||||
]
|
||||
}
|
||||
|
||||
fn copy_text(label: SharedString, copied: &str) -> MenuItem {
|
||||
let copied = copied.to_string();
|
||||
MenuItem::new(label).on_click(move |_, cx| {
|
||||
log::info!("mark: {} bytes copied from the menu", copied.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(copied.clone()));
|
||||
})
|
||||
}
|
||||
|
||||
/// Typora's Copy / Paste As rows.
|
||||
fn copies(window: &Window, cx: &App) -> Vec<MenuItem> {
|
||||
vec![
|
||||
run(
|
||||
said("mark.copy.plain", cx),
|
||||
Box::new(export::CopyPlain),
|
||||
window,
|
||||
),
|
||||
run(
|
||||
said("mark.copy.markdown", cx),
|
||||
Box::new(clip::CopyMarkdown),
|
||||
window,
|
||||
),
|
||||
run(
|
||||
said("mark.copy.html", cx),
|
||||
Box::new(export::CopyHtml),
|
||||
window,
|
||||
),
|
||||
run(
|
||||
said("mark.copy.rich", cx),
|
||||
Box::new(export::CopyRich),
|
||||
window,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn copy_as(window: &Window, cx: &App) -> Menu {
|
||||
let paste = run(
|
||||
said("mark.paste.plain", cx),
|
||||
Box::new(clip::PastePlain),
|
||||
window,
|
||||
);
|
||||
copies(window, cx)
|
||||
.into_iter()
|
||||
.fold(Menu::new(), Menu::item)
|
||||
.separator()
|
||||
.item(paste)
|
||||
}
|
||||
|
||||
fn formats(window: &Window, cx: &App) -> Vec<MenuItem> {
|
||||
let row = |key, action: Box<dyn Action>, name| run(said(key, cx), action, window).icon(name);
|
||||
vec![
|
||||
row("mark.format.strong", Box::new(b::Strong), IconName::Bold),
|
||||
row(
|
||||
"mark.format.emphasis",
|
||||
Box::new(b::Emphasis),
|
||||
IconName::Italic,
|
||||
),
|
||||
row("mark.format.code", Box::new(b::InlineCode), IconName::Code),
|
||||
row("mark.format.link", Box::new(b::Hyperlink), IconName::Link),
|
||||
]
|
||||
}
|
||||
|
||||
fn wraps(window: &Window, cx: &App) -> Vec<MenuItem> {
|
||||
let row = |key, action: Box<dyn Action>, name| run(said(key, cx), action, window).icon(name);
|
||||
vec![
|
||||
row("mark.block.quote", Box::new(b::Quote), IconName::Quote),
|
||||
row(
|
||||
"mark.block.ordered",
|
||||
Box::new(b::OrderedList),
|
||||
IconName::ListOrdered,
|
||||
),
|
||||
row("mark.block.bullet", Box::new(b::BulletList), IconName::List),
|
||||
row(
|
||||
"mark.block.task",
|
||||
Box::new(b::TaskList),
|
||||
IconName::ListChecks,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn paragraph(window: &Window, cx: &App) -> Menu {
|
||||
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
|
||||
Menu::new()
|
||||
.item(row("mark.block.h1", Box::new(b::Heading1)))
|
||||
.item(row("mark.block.h2", Box::new(b::Heading2)))
|
||||
.item(row("mark.block.h3", Box::new(b::Heading3)))
|
||||
.item(row("mark.block.h4", Box::new(b::Heading4)))
|
||||
.item(row("mark.block.h5", Box::new(b::Heading5)))
|
||||
.item(row("mark.block.h6", Box::new(b::Heading6)))
|
||||
.separator()
|
||||
.item(row("mark.block.paragraph", Box::new(b::Paragraph)))
|
||||
}
|
||||
|
||||
fn table(window: &Window, cx: &App) -> Menu {
|
||||
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
|
||||
Menu::new()
|
||||
.item(row("mark.table.row_above", Box::new(tools::RowAbove)))
|
||||
.item(row("mark.table.row_below", Box::new(tools::RowBelow)))
|
||||
.item(row("mark.table.column_left", Box::new(tools::ColumnLeft)))
|
||||
.item(row("mark.table.column_right", Box::new(tools::ColumnRight)))
|
||||
.separator()
|
||||
.item(row("mark.table.move_row_up", Box::new(tools::MoveRowUp)))
|
||||
.item(row(
|
||||
"mark.table.move_row_down",
|
||||
Box::new(tools::MoveRowDown),
|
||||
))
|
||||
.item(row(
|
||||
"mark.table.move_column_left",
|
||||
Box::new(tools::MoveColumnLeft),
|
||||
))
|
||||
.item(row(
|
||||
"mark.table.move_column_right",
|
||||
Box::new(tools::MoveColumnRight),
|
||||
))
|
||||
.separator()
|
||||
.item(row("mark.table.delete_row", Box::new(tools::DeleteRow)))
|
||||
.item(row(
|
||||
"mark.table.delete_column",
|
||||
Box::new(tools::DeleteColumn),
|
||||
))
|
||||
.separator()
|
||||
.item(row("mark.table.tidy", Box::new(tools::TidyTable)))
|
||||
.separator()
|
||||
.item(row("mark.table.delete", Box::new(tools::DeleteTable)))
|
||||
}
|
||||
|
||||
fn insert(inline: bool, window: &Window, cx: &App) -> Menu {
|
||||
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
|
||||
let menu = match inline {
|
||||
true => Menu::new()
|
||||
.item(row("mark.insert.image", Box::new(b::InsertImage)))
|
||||
.separator(),
|
||||
false => Menu::new(),
|
||||
};
|
||||
menu.item(row("mark.block.footnote", Box::new(b::Footnote)))
|
||||
.item(row("mark.block.link_reference", Box::new(b::LinkReference)))
|
||||
.item(row("mark.block.rule", Box::new(b::Rule)))
|
||||
.item(row("mark.menu.table", Box::new(tools::InsertTable)))
|
||||
.item(row("mark.block.code", Box::new(b::CodeFences)))
|
||||
.item(row("mark.block.math", Box::new(b::MathBlock)))
|
||||
.item(row("mark.block.toc", Box::new(b::Toc)))
|
||||
.item(row("mark.block.front_matter", Box::new(b::FrontMatter)))
|
||||
.separator()
|
||||
.item(row("mark.insert.before", Box::new(b::ParagraphBefore)))
|
||||
.item(row("mark.insert.after", Box::new(b::ParagraphAfter)))
|
||||
}
|
||||
|
||||
/// A block kind's name, as Typora's menus say it.
|
||||
fn kind(block: Option<TargetBlock>, cx: &App) -> SharedString {
|
||||
use TargetBlock as K;
|
||||
let key = match block {
|
||||
None | Some(K::Paragraph) => "mark.block.paragraph",
|
||||
Some(K::Heading(level)) => {
|
||||
return said(&format!("mark.block.h{}", level.clamp(1, 6)), cx);
|
||||
}
|
||||
Some(K::Quote) => "mark.block.quote",
|
||||
Some(K::Alert) => "mark.kind.alert",
|
||||
Some(K::List) => "mark.kind.list",
|
||||
Some(K::Code) => "mark.block.code",
|
||||
Some(K::Math) => "mark.block.math",
|
||||
Some(K::Table) => "mark.menu.table",
|
||||
Some(K::Rule) => "mark.block.rule",
|
||||
Some(K::Html) => "mark.kind.html",
|
||||
Some(K::Footnote) => "mark.block.footnote",
|
||||
Some(K::Definition) => "mark.block.link_reference",
|
||||
Some(K::FrontMatter) => "mark.block.front_matter",
|
||||
Some(K::Toc) => "mark.block.toc",
|
||||
};
|
||||
said(key, cx)
|
||||
}
|
||||
@@ -57,6 +57,8 @@ pub struct Deck {
|
||||
pub remarks_pane: Entity<crate::remarks::Pane>,
|
||||
/// What the menus last checked: grid, guides, comments.
|
||||
pub snap_menus: Option<(bool, bool, bool)>,
|
||||
/// The canvas's right-click menu.
|
||||
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
|
||||
/// Pictures of closed tabs and ended previews, freed later.
|
||||
pub retired: ely_gpui_component::primitives::Retired,
|
||||
}
|
||||
@@ -103,6 +105,7 @@ impl Host for Deck {
|
||||
tab.extra.dark = dark;
|
||||
self.repaint_all(id, cx);
|
||||
}
|
||||
self.offer_menu(id, view, cx);
|
||||
self.reread(id, view, cx);
|
||||
}
|
||||
}
|
||||
@@ -172,6 +175,7 @@ impl Deck {
|
||||
reading_fonts: false,
|
||||
guide_drag: None,
|
||||
snap_menus: Some((false, false, false)),
|
||||
menu: None,
|
||||
notes: Default::default(),
|
||||
remarks_open: false,
|
||||
preview: None,
|
||||
@@ -379,7 +383,7 @@ impl Deck {
|
||||
self.paint_next(id, cx);
|
||||
}
|
||||
|
||||
fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let title = match self.core.loading.first() {
|
||||
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
|
||||
@@ -447,7 +451,8 @@ impl Render for Deck {
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.dialog_element(cx));
|
||||
.children(self.dialog_element(cx))
|
||||
.children(self.context_menu(cx));
|
||||
let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx);
|
||||
Self::bind_zoom(root, cx)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod guides;
|
||||
mod insert;
|
||||
mod list;
|
||||
mod media;
|
||||
mod menu;
|
||||
mod motion;
|
||||
mod new;
|
||||
mod notes;
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! The canvas's right-click menus, laid out as PowerPoint's (SHOW-03).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::{
|
||||
LokView,
|
||||
menu::{Runs, Target, runs},
|
||||
show::LAYOUTS,
|
||||
};
|
||||
use ely_office::menu::{self, Entry, Offer};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{App, Context, Entity, Window};
|
||||
|
||||
use crate::deck::Deck;
|
||||
|
||||
/// What was right-clicked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Kind {
|
||||
/// The slide, nothing selected.
|
||||
Slide,
|
||||
/// Text being edited.
|
||||
Text,
|
||||
/// A shape or a text box.
|
||||
Object,
|
||||
Picture,
|
||||
Table,
|
||||
}
|
||||
|
||||
/// A menu's kind and what the kernel lets run.
|
||||
pub struct What {
|
||||
pub kind: Kind,
|
||||
runs: Runs,
|
||||
}
|
||||
|
||||
/// One thing a menu does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Pick {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
/// An index into `LAYOUTS`.
|
||||
Layout(usize),
|
||||
Comment,
|
||||
Bullets,
|
||||
Numbering,
|
||||
Group,
|
||||
Ungroup,
|
||||
Front,
|
||||
Forward,
|
||||
Back,
|
||||
Backward,
|
||||
Replace,
|
||||
Crop,
|
||||
RowAbove,
|
||||
RowBelow,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
DeleteRows,
|
||||
DeleteColumns,
|
||||
DeleteTable,
|
||||
Merge,
|
||||
}
|
||||
|
||||
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
|
||||
|
||||
const LAYOUT_PICKS: [Pick; 12] = [
|
||||
Pick::Layout(0),
|
||||
Pick::Layout(1),
|
||||
Pick::Layout(2),
|
||||
Pick::Layout(3),
|
||||
Pick::Layout(4),
|
||||
Pick::Layout(5),
|
||||
Pick::Layout(6),
|
||||
Pick::Layout(7),
|
||||
Pick::Layout(8),
|
||||
Pick::Layout(9),
|
||||
Pick::Layout(10),
|
||||
Pick::Layout(11),
|
||||
];
|
||||
|
||||
const SLIDE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub("show.design.layout", &LAYOUT_PICKS),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const TEXT: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Bullets),
|
||||
Entry::Row(Pick::Numbering),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const OBJECT: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
|
||||
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
|
||||
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const PICTURE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Replace),
|
||||
Entry::Row(Pick::Crop),
|
||||
Entry::Line,
|
||||
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
|
||||
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
|
||||
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const TABLE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub(
|
||||
"doc.menu.insert",
|
||||
&[Pick::RowAbove, Pick::RowBelow, Pick::ColumnLeft, Pick::ColumnRight],
|
||||
),
|
||||
Entry::Sub("doc.menu.delete", &[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable]),
|
||||
Entry::Row(Pick::Merge),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
/// Each layout's label, in `LAYOUTS` order.
|
||||
const LAYOUT_KEYS: [&str; 12] = [
|
||||
"show.layout.20",
|
||||
"show.layout.0",
|
||||
"show.layout.1",
|
||||
"show.layout.3",
|
||||
"show.layout.19",
|
||||
"show.layout.32",
|
||||
"show.layout.12",
|
||||
"show.layout.15",
|
||||
"show.layout.14",
|
||||
"show.layout.16",
|
||||
"show.layout.18",
|
||||
"show.layout.34",
|
||||
];
|
||||
|
||||
impl menu::Pick for Pick {
|
||||
fn row(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
|
||||
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
|
||||
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
|
||||
Pick::Layout(index) => (LAYOUT_KEYS[index], ".uno:AssignLayout"),
|
||||
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
|
||||
Pick::Bullets => ("doc.props.bullets", ".uno:DefaultBullet"),
|
||||
Pick::Numbering => ("doc.props.numbering", ".uno:DefaultNumbering"),
|
||||
Pick::Group => ("show.object.group", ".uno:FormatGroup"),
|
||||
Pick::Ungroup => ("show.object.ungroup", ".uno:FormatUngroup"),
|
||||
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
|
||||
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
|
||||
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
|
||||
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
|
||||
Pick::Replace => ("show.object.replace", ".uno:ChangePicture"),
|
||||
Pick::Crop => ("show.object.crop", ".uno:Crop"),
|
||||
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
|
||||
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
|
||||
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
|
||||
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
|
||||
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
|
||||
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
|
||||
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
|
||||
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl What {
|
||||
/// The kernel lets the pick's own command run now.
|
||||
pub(crate) fn on(&self, pick: Pick) -> bool {
|
||||
self.runs.allows(menu::Pick::row(pick).1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn of(target: Target) -> Option<Kind> {
|
||||
match target {
|
||||
Target::Slide => Some(Kind::Slide),
|
||||
Target::Text => Some(Kind::Text),
|
||||
Target::Object => Some(Kind::Object),
|
||||
Target::Picture => Some(Kind::Picture),
|
||||
Target::Table => Some(Kind::Table),
|
||||
Target::Frame | Target::Cell => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Its rows under the clipboard row.
|
||||
fn rows(self) -> &'static [Entry<Pick>] {
|
||||
match self {
|
||||
Kind::Slide => SLIDE,
|
||||
Kind::Text => TEXT,
|
||||
Kind::Object => OBJECT,
|
||||
Kind::Picture => PICTURE,
|
||||
Kind::Table => TABLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command its rows run.
|
||||
fn commands(self) -> Vec<&'static str> {
|
||||
let rows = self.rows().iter().flat_map(|entry| match entry {
|
||||
Entry::Row(pick) => std::slice::from_ref(pick),
|
||||
Entry::Sub(_, picks) => picks,
|
||||
Entry::Line => &[],
|
||||
});
|
||||
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
|
||||
commands.sort_unstable();
|
||||
commands.dedup();
|
||||
commands
|
||||
}
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Turns the kernel's context menu into Ely's.
|
||||
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
let (offered, at) = match menu::take(view, cx) {
|
||||
None => return,
|
||||
Some(Ok(taken)) => taken,
|
||||
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
|
||||
};
|
||||
let Some(kind) = Kind::of(offered.target) else {
|
||||
let error = format!("a {:?} menu in a presentation", offered.target);
|
||||
return self.toast(Deck::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
|
||||
};
|
||||
let commands = kind.commands();
|
||||
self.ask_in(
|
||||
id,
|
||||
move |worker, doc| runs(worker, doc, &commands),
|
||||
move |deck, read, _, cx| match read {
|
||||
Ok(runs) => {
|
||||
deck.menu = Some(Offer::new(deck.menu.as_ref(), id, What { kind, runs }, at));
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => deck.engine_failed(&error.to_string(), cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// The menu's host, for the active deck.
|
||||
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
|
||||
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
Some(offer.host("show-menu", menu_of(&cx.entity(), &offer.what, cx)))
|
||||
}
|
||||
|
||||
/// Runs one menu row.
|
||||
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("show: the menu picks {pick:?}");
|
||||
let id = match pick {
|
||||
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
|
||||
Pick::Comment => return self.ask_remark(None, window, cx),
|
||||
Pick::Replace => return self.pick_picture(true, cx),
|
||||
Pick::Layout(index) => {
|
||||
return self.design_command("slide.layout", &[&LAYOUTS[index].to_string()], cx);
|
||||
}
|
||||
Pick::Bullets => "list.bullets",
|
||||
Pick::Numbering => "list.numbering",
|
||||
Pick::Group => "object.group",
|
||||
Pick::Ungroup => "object.ungroup",
|
||||
Pick::Front => "image.front",
|
||||
Pick::Forward => "image.forward",
|
||||
Pick::Back => "image.back",
|
||||
Pick::Backward => "image.backward",
|
||||
Pick::Crop => "image.crop",
|
||||
Pick::RowAbove => "table.row_above",
|
||||
Pick::RowBelow => "table.row_below",
|
||||
Pick::ColumnLeft => "table.column_left",
|
||||
Pick::ColumnRight => "table.column_right",
|
||||
Pick::DeleteRows => "table.delete_rows",
|
||||
Pick::DeleteColumns => "table.delete_columns",
|
||||
Pick::DeleteTable => "table.delete",
|
||||
Pick::Merge => "table.merge",
|
||||
};
|
||||
self.object_command(id, &[], cx);
|
||||
}
|
||||
|
||||
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
|
||||
log::warn!("show: {pick:?} with no deck");
|
||||
return;
|
||||
};
|
||||
view.update(cx, |view, _| match pick {
|
||||
Pick::Paste => view.paste(),
|
||||
_ => view.copy(pick == Pick::Cut),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard row, then the target's rows.
|
||||
pub(crate) fn menu_of(this: &Entity<Deck>, what: &What, cx: &App) -> Menu {
|
||||
let item = |pick: Pick| {
|
||||
let this = this.clone();
|
||||
MenuItem::new(Deck::text(cx, menu::Pick::row(pick).0, &[]))
|
||||
.disabled(!what.on(pick))
|
||||
.on_click(move |window, cx| this.update(cx, |deck, cx| deck.choose(pick, window, cx)))
|
||||
};
|
||||
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
|
||||
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
|
||||
}
|
||||
@@ -234,6 +234,7 @@ impl Render for Strip {
|
||||
let label = format!("{} {}", part + 1, slide.name);
|
||||
div()
|
||||
.id(("show-slide", part))
|
||||
.debug_selector(move || format!("show-slide-{part}"))
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.flex()
|
||||
@@ -301,6 +302,7 @@ impl Render for Strip {
|
||||
.children(slides);
|
||||
div()
|
||||
.size_full()
|
||||
.child(ContextMenu::new("show-strip-menu", popup(cx)).child(list))
|
||||
// A sized host lets the list overflow and scroll.
|
||||
.child(ContextMenu::new("show-strip-menu", popup(cx)).size_full().child(list))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
|
||||
|
||||
use crate::deck::Deck;
|
||||
|
||||
mod menu;
|
||||
mod motion;
|
||||
mod notes;
|
||||
mod objects;
|
||||
@@ -17,6 +18,7 @@ mod play_more;
|
||||
mod play_time;
|
||||
mod review;
|
||||
mod slides;
|
||||
mod strip;
|
||||
|
||||
/// An unrendered Deck over a fresh worker, if LOK exists.
|
||||
pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>> {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use ely_lok::show::Objects;
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::objects::{act, blank, objects};
|
||||
use super::settle;
|
||||
use crate::deck::Deck;
|
||||
use crate::menu::{Kind, Pick};
|
||||
|
||||
/// The canvas with the menu's host over it.
|
||||
struct Host(Entity<Deck>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
let body = self.0.update(cx, |deck, cx| deck.body(window, cx));
|
||||
let menu = self.0.update(cx, |deck, cx| deck.context_menu(cx));
|
||||
gpui::div().relative().size_full().flex().flex_col().child(body).children(menu)
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
/// A slide point, in 1/100 mm, in window pixels.
|
||||
fn spot(cx: &mut VisualTestContext, deck: &Entity<Deck>, (x, y): (i64, i64)) -> Point<Pixels> {
|
||||
frames(cx);
|
||||
cx.update(|_, app| {
|
||||
let read = deck.read(app).active_tab().expect("a tab").view.read(app);
|
||||
let twips = |mm100| crate::object::twips(mm100) as i32;
|
||||
read.origin() + read.place((twips(x), twips(y)))
|
||||
})
|
||||
}
|
||||
|
||||
/// A point `1/part` into the first object of `kind`.
|
||||
fn within(cx: &mut VisualTestContext, deck: &Entity<Deck>, kind: &str, part: i64) -> Point<Pixels> {
|
||||
let (x, y, w, h) = cx.update(|_, app| {
|
||||
let objects = objects(app, deck).expect("objects");
|
||||
objects.objects.iter().find(|object| object.kind == kind).expect("the object").rect
|
||||
});
|
||||
spot(cx, deck, (x + w / part, y + h / part))
|
||||
}
|
||||
|
||||
fn click(cx: &mut VisualTestContext, at: Point<Pixels>, button: MouseButton) {
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||
}
|
||||
|
||||
/// Right-clicks and waits for a menu of `kind`.
|
||||
fn right_click(cx: &mut VisualTestContext, deck: &Entity<Deck>, at: Point<Pixels>, kind: Kind) {
|
||||
cx.update(|_, app| deck.update(app, |deck, _| deck.menu = None));
|
||||
click(cx, at, MouseButton::Right);
|
||||
for _ in 0..500 {
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| deck.read(app).menu.as_ref().is_some_and(|offer| offer.what.kind == kind)) {
|
||||
return frames(cx);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
let got = cx.update(|_, app| deck.read(app).menu.as_ref().map(|offer| offer.what.kind));
|
||||
panic!("no {kind:?} menu, got {got:?}");
|
||||
}
|
||||
|
||||
fn on(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) -> bool {
|
||||
cx.update(|_, app| deck.read(app).menu.as_ref().expect("a menu").what.on(pick))
|
||||
}
|
||||
|
||||
fn choose(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) {
|
||||
cx.update(|window, app| deck.update(app, |deck, cx| deck.choose(pick, window, cx)));
|
||||
}
|
||||
|
||||
fn table_height(objects: &Objects) -> Option<i64> {
|
||||
objects.objects.iter().find(|object| object.kind == "table").map(|object| object.rect.3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_clicks_open_powerpoint_rows_on_each_target() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| Theme::update(cx, |theme| theme.reduced_motion = true));
|
||||
settle(&cx, |app| objects(app, &deck).is_some_and(|o| !o.objects.is_empty()));
|
||||
let window = cx.add_window(|_, _| Host(deck.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
let corner = spot(&mut shown, &deck, (27_000, 15_000));
|
||||
right_click(&mut shown, &deck, corner, Kind::Slide);
|
||||
let clipboard = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
|
||||
assert!((clipboard.origin - corner).x < gpui::px(40.0), "at the click");
|
||||
assert!(on(&mut shown, &deck, Pick::Layout(0)) && on(&mut shown, &deck, Pick::Comment));
|
||||
// Typing in the title: list rows.
|
||||
let title = within(&mut shown, &deck, "text", 2);
|
||||
click(&mut shown, title, MouseButton::Left);
|
||||
right_click(&mut shown, &deck, title, Kind::Text);
|
||||
assert!(on(&mut shown, &deck, Pick::Bullets) && on(&mut shown, &deck, Pick::Numbering));
|
||||
let rectangle = ("draw.shape", &["rectangle", "true"][..]);
|
||||
act(&mut cx, &deck, rectangle, |o| o.selection.as_ref().is_some_and(|s| s.kind == "shape"));
|
||||
let shape = within(&mut shown, &deck, "shape", 2);
|
||||
right_click(&mut shown, &deck, shape, Kind::Object);
|
||||
assert!(on(&mut shown, &deck, Pick::Back) && on(&mut shown, &deck, Pick::Forward));
|
||||
assert!(!on(&mut shown, &deck, Pick::Group), "one shape does not group");
|
||||
choose(&mut shown, &deck, Pick::Back);
|
||||
settle(&cx, |app| objects(app, &deck).is_some_and(|o| o.objects.first().is_some_and(|first| first.kind == "shape")));
|
||||
let path = dir.path().join("red.png");
|
||||
image::RgbaImage::from_pixel(300, 200, image::Rgba([255, 0, 0, 255])).save(&path).expect("the png writes");
|
||||
let image = ("image.insert", &[ely_lok::file_url(&path)][..]);
|
||||
let image: (&str, Vec<&str>) = (image.0, image.1.iter().map(String::as_str).collect());
|
||||
act(&mut cx, &deck, (image.0, &image.1), |o| o.selection.as_ref().is_some_and(|s| s.kind == "image"));
|
||||
let picture = within(&mut shown, &deck, "image", 2);
|
||||
right_click(&mut shown, &deck, picture, Kind::Picture);
|
||||
assert!(on(&mut shown, &deck, Pick::Replace) && on(&mut shown, &deck, Pick::Crop));
|
||||
act(&mut cx, &deck, ("table.insert", &["2", "2"]), |o| table_height(o).is_some());
|
||||
// A quarter in: the middle is where the borders cross.
|
||||
let cell = within(&mut shown, &deck, "table", 4);
|
||||
right_click(&mut shown, &deck, cell, Kind::Table);
|
||||
assert!(on(&mut shown, &deck, Pick::RowAbove), "inserting needs no cell");
|
||||
// Deleting rows needs the cursor in a cell.
|
||||
click(&mut shown, cell, MouseButton::Left);
|
||||
right_click(&mut shown, &deck, cell, Kind::Table);
|
||||
assert!(on(&mut shown, &deck, Pick::DeleteRows) && on(&mut shown, &deck, Pick::Merge));
|
||||
let before = cx.update(|app| objects(app, &deck).and_then(|o| table_height(&o))).expect("a table");
|
||||
choose(&mut shown, &deck, Pick::RowBelow);
|
||||
settle(&cx, |app| objects(app, &deck).and_then(|o| table_height(&o)).is_some_and(|h| h > before));
|
||||
// A right click that ends an edit gets no menu.
|
||||
click(&mut shown, corner, MouseButton::Left);
|
||||
right_click(&mut shown, &deck, corner, Kind::Slide);
|
||||
choose(&mut shown, &deck, Pick::Layout(0));
|
||||
let layout = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.design.as_ref().map(|d| d.layout));
|
||||
settle(&cx, |app| layout(app) == Some(20));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use gpui::{Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::objects::blank;
|
||||
use super::{counts, settle, update};
|
||||
use crate::slides::Strip;
|
||||
|
||||
/// The slide strip alone in a window.
|
||||
struct Host(Entity<Strip>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
gpui::div().size_full().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_scrolls_the_strip_to_its_last_slide() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
return eprintln!("SKIP: no LOK build");
|
||||
};
|
||||
for n in 2..=24 {
|
||||
update(&mut cx, &deck, |deck, cx| deck.object_command("slide.insert", &[], cx));
|
||||
settle(&cx, |app| counts(app, &deck).0 == n);
|
||||
}
|
||||
let strip = cx.update(|app| deck.read(app).strip.clone());
|
||||
let window = cx.add_window(|_, _| Host(strip));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
shown.run_until_parked();
|
||||
let height = shown.update(|window, _| window.viewport_size().height);
|
||||
let last = |shown: &mut VisualTestContext| shown.debug_bounds("show-slide-23").expect("the last slide is laid out");
|
||||
let before = last(&mut shown);
|
||||
assert!(before.bottom() > height, "the strip is longer than the window");
|
||||
let at = point(before.left() + px(20.0), height / 2.0);
|
||||
for _ in 0..20 {
|
||||
shown.simulate_event(ScrollWheelEvent {
|
||||
position: at,
|
||||
delta: ScrollDelta::Pixels(point(px(0.0), px(-1000.0))),
|
||||
..Default::default()
|
||||
});
|
||||
shown.run_until_parked();
|
||||
}
|
||||
let after = last(&mut shown);
|
||||
assert!(after.bottom() <= height, "the last slide is in view: {after:?}");
|
||||
}
|
||||
@@ -23,6 +23,7 @@ ely-ui.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely-ui = { workspace = true, features = ["test-support"] }
|
||||
ely-gpui-component = { workspace = true, features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ pub struct Book {
|
||||
pub drag: Option<Drag>,
|
||||
/// The sheet menu's request: number and place.
|
||||
pub sheet_menu: Option<(u64, gpui::Point<gpui::Pixels>)>,
|
||||
/// The grid or header menu.
|
||||
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
|
||||
pub panel: Entity<Panel>,
|
||||
pub panel_shown: bool,
|
||||
pub choices: Option<crate::rules::Choices>,
|
||||
@@ -95,6 +97,7 @@ impl Host for Book {
|
||||
self.freeze(id, view, cx);
|
||||
self.found(id, view, cx);
|
||||
self.requested(id, view, cx);
|
||||
self.offer_menu(id, view, cx);
|
||||
self.follow_rule(id, view, cx);
|
||||
self.read_links(id, view, cx);
|
||||
if self.core.active == Some(id) {
|
||||
@@ -190,6 +193,7 @@ impl Book {
|
||||
bars: Bars::new(window, cx),
|
||||
drag: None,
|
||||
sheet_menu: None,
|
||||
menu: None,
|
||||
panel,
|
||||
panel_shown: true,
|
||||
choices: None,
|
||||
@@ -388,7 +392,8 @@ impl Render for Book {
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.dialog_element(cx));
|
||||
.children(self.dialog_element(cx))
|
||||
.children(self.context_menu(cx));
|
||||
let root = Self::bind_zoom(crate::cells::bind(crate::sheets::bind(root, cx), cx), cx);
|
||||
let root = crate::data::bind(crate::formulas::bind(root, cx), cx);
|
||||
let root = crate::page::bind(crate::protect::bind(root, cx), cx);
|
||||
|
||||
@@ -144,7 +144,7 @@ impl Book {
|
||||
self.run_in(Some(tab), id, &[&size.to_string()], cx);
|
||||
}
|
||||
|
||||
fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub(crate) fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.core.active else {
|
||||
let text = Book::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
|
||||
|
||||
@@ -140,6 +140,12 @@ impl Book {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects a header's row or column.
|
||||
pub(crate) fn select_header(&mut self, column: bool, index: u32, modifier: &str, cx: &mut Context<Self>) {
|
||||
let id = if column { "select.column" } else { "select.row" };
|
||||
self.run_active(id, &[&index.to_string(), modifier], cx);
|
||||
}
|
||||
|
||||
/// One row or column header with its sizing edge.
|
||||
fn header(
|
||||
&self,
|
||||
@@ -201,13 +207,15 @@ impl Book {
|
||||
.border_color(transparent_black())
|
||||
.focus_ring(cx)
|
||||
.tab_index(0)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |book, event: &MouseDownEvent, _, cx| {
|
||||
cx.stop_propagation();
|
||||
book.header_menu(column, index, event.position, cx);
|
||||
}),
|
||||
)
|
||||
.on_click(cx.listener(move |book, event: &ClickEvent, _, cx| {
|
||||
let id = if column {
|
||||
"select.column"
|
||||
} else {
|
||||
"select.row"
|
||||
};
|
||||
book.run_active(id, &[&index.to_string(), &modifier(&event.modifiers())], cx);
|
||||
book.select_header(column, index, &modifier(&event.modifiers()), cx);
|
||||
}))
|
||||
.child(label);
|
||||
let cell = div()
|
||||
|
||||
@@ -11,6 +11,7 @@ mod figures;
|
||||
mod format;
|
||||
mod formulas;
|
||||
mod grid;
|
||||
mod menu;
|
||||
mod output;
|
||||
mod page;
|
||||
mod protect;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! The right-click menus, laid out as Excel's (TABLE-02).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::{
|
||||
LokView, Rect,
|
||||
menu::{Runs, Target, runs},
|
||||
sheet::Geometry,
|
||||
};
|
||||
use ely_office::menu::{self, Entry, Offer};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{App, Context, Entity, Pixels, Point, Window};
|
||||
|
||||
use crate::book::Book;
|
||||
|
||||
/// What was right-clicked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Kind {
|
||||
Cell,
|
||||
Object,
|
||||
/// A cell's text, being edited.
|
||||
Text,
|
||||
Rows,
|
||||
Columns,
|
||||
}
|
||||
|
||||
/// A menu's kind and what the kernel lets run.
|
||||
pub struct What {
|
||||
pub kind: Kind,
|
||||
runs: Runs,
|
||||
}
|
||||
|
||||
/// One thing a menu does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Pick {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
RowsAbove,
|
||||
RowsBelow,
|
||||
ColumnsBefore,
|
||||
ColumnsAfter,
|
||||
InsertDown,
|
||||
InsertRight,
|
||||
DeleteRows,
|
||||
DeleteColumns,
|
||||
DeleteUp,
|
||||
DeleteLeft,
|
||||
Clear,
|
||||
Merge,
|
||||
Split,
|
||||
Format,
|
||||
RowHeight,
|
||||
ColumnWidth,
|
||||
HideRows,
|
||||
ShowRows,
|
||||
HideColumns,
|
||||
ShowColumns,
|
||||
Front,
|
||||
Forward,
|
||||
Back,
|
||||
Backward,
|
||||
}
|
||||
|
||||
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
|
||||
|
||||
const CELL: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub(
|
||||
"table.menu.insert",
|
||||
&[
|
||||
Pick::RowsAbove,
|
||||
Pick::RowsBelow,
|
||||
Pick::ColumnsBefore,
|
||||
Pick::ColumnsAfter,
|
||||
Pick::InsertDown,
|
||||
Pick::InsertRight,
|
||||
],
|
||||
),
|
||||
Entry::Sub(
|
||||
"table.menu.delete",
|
||||
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteUp, Pick::DeleteLeft],
|
||||
),
|
||||
Entry::Row(Pick::Clear),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Merge),
|
||||
Entry::Row(Pick::Split),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Format),
|
||||
];
|
||||
|
||||
const OBJECT: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
|
||||
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
|
||||
];
|
||||
|
||||
const ROWS: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::RowsAbove),
|
||||
Entry::Row(Pick::DeleteRows),
|
||||
Entry::Row(Pick::Clear),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::RowHeight),
|
||||
Entry::Row(Pick::HideRows),
|
||||
Entry::Row(Pick::ShowRows),
|
||||
];
|
||||
|
||||
const COLUMNS: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::ColumnsBefore),
|
||||
Entry::Row(Pick::DeleteColumns),
|
||||
Entry::Row(Pick::Clear),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::ColumnWidth),
|
||||
Entry::Row(Pick::HideColumns),
|
||||
Entry::Row(Pick::ShowColumns),
|
||||
];
|
||||
|
||||
impl menu::Pick for Pick {
|
||||
fn row(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
|
||||
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
|
||||
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
|
||||
Pick::RowsAbove => ("table.cells.rows_above", ".uno:InsertRowsBefore"),
|
||||
Pick::RowsBelow => ("table.cells.rows_below", ".uno:InsertRowsAfter"),
|
||||
Pick::ColumnsBefore => ("table.cells.columns_before", ".uno:InsertColumnsBefore"),
|
||||
Pick::ColumnsAfter => ("table.cells.columns_after", ".uno:InsertColumnsAfter"),
|
||||
Pick::InsertDown => ("table.cells.insert_down", ".uno:InsertCell"),
|
||||
Pick::InsertRight => ("table.cells.insert_right", ".uno:InsertCell"),
|
||||
Pick::DeleteRows => ("table.cells.delete_rows", ".uno:DeleteRows"),
|
||||
Pick::DeleteColumns => ("table.cells.delete_columns", ".uno:DeleteColumns"),
|
||||
Pick::DeleteUp => ("table.cells.delete_up", ".uno:DeleteCell"),
|
||||
Pick::DeleteLeft => ("table.cells.delete_left", ".uno:DeleteCell"),
|
||||
Pick::Clear => ("table.menu.clear", ".uno:ClearContents"),
|
||||
Pick::Merge => ("table.cells.merge", ".uno:MergeCells"),
|
||||
Pick::Split => ("table.cells.split", ".uno:SplitCell"),
|
||||
Pick::Format => ("table.menu.format", ".uno:FormatCellDialog"),
|
||||
Pick::RowHeight => ("table.cells.row_height", ".uno:RowHeight"),
|
||||
Pick::ColumnWidth => ("table.cells.column_width", ".uno:ColumnWidth"),
|
||||
Pick::HideRows => ("table.cells.hide_rows", ".uno:HideRow"),
|
||||
Pick::ShowRows => ("table.cells.show_rows", ".uno:ShowRow"),
|
||||
Pick::HideColumns => ("table.cells.hide_columns", ".uno:HideColumn"),
|
||||
Pick::ShowColumns => ("table.cells.show_columns", ".uno:ShowColumn"),
|
||||
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
|
||||
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
|
||||
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
|
||||
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl What {
|
||||
/// The kernel lets the pick's own command run now.
|
||||
pub(crate) fn on(&self, pick: Pick) -> bool {
|
||||
self.runs.allows(menu::Pick::row(pick).1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// Its rows under the clipboard row.
|
||||
fn rows(self) -> &'static [Entry<Pick>] {
|
||||
match self {
|
||||
Kind::Cell => CELL,
|
||||
Kind::Object => OBJECT,
|
||||
Kind::Text => &[],
|
||||
Kind::Rows => ROWS,
|
||||
Kind::Columns => COLUMNS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command its rows run.
|
||||
fn commands(self) -> Vec<&'static str> {
|
||||
let rows = self.rows().iter().flat_map(|entry| match entry {
|
||||
Entry::Row(pick) => std::slice::from_ref(pick),
|
||||
Entry::Sub(_, picks) => picks,
|
||||
Entry::Line => &[],
|
||||
});
|
||||
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
|
||||
commands.sort_unstable();
|
||||
commands.dedup();
|
||||
commands
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole selected row or column covers the header.
|
||||
pub(crate) fn held(selection: &[Rect], geometry: &Geometry, column: bool, index: u32) -> bool {
|
||||
let (along, across) = if column {
|
||||
(&geometry.columns, &geometry.rows)
|
||||
} else {
|
||||
(&geometry.rows, &geometry.columns)
|
||||
};
|
||||
let (from, to) = (along.start(index), along.start(index + 1));
|
||||
let end = across.start(across.count());
|
||||
selection.iter().any(|&(x, y, w, h)| {
|
||||
let ((start, size), (side, span)) = if column { ((x, w), (y, h)) } else { ((y, h), (x, w)) };
|
||||
side == 0 && span + 1 >= end && start <= from && to <= start + size + 1
|
||||
})
|
||||
}
|
||||
|
||||
impl Book {
|
||||
/// Turns the kernel's context menu into Ely's.
|
||||
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
let (offered, at) = match menu::take(view, cx) {
|
||||
None => return,
|
||||
Some(Ok(taken)) => taken,
|
||||
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
|
||||
};
|
||||
let kind = match offered.target {
|
||||
Target::Cell => Kind::Cell,
|
||||
Target::Object | Target::Picture => Kind::Object,
|
||||
Target::Text => Kind::Text,
|
||||
Target::Table | Target::Frame | Target::Slide => {
|
||||
let error = format!("a {:?} menu in a spreadsheet", offered.target);
|
||||
return self.toast(Book::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
self.offer(id, kind, at, cx);
|
||||
}
|
||||
|
||||
/// Selects a header, then offers its menu.
|
||||
pub(crate) fn header_menu(&mut self, column: bool, index: u32, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let id = tab.id;
|
||||
// A selected object keeps nothing held.
|
||||
let read = tab.view.read(cx);
|
||||
if read.graphic().is_some() || !held(read.selection(), &tab.extra.geometry, column, index) {
|
||||
self.select_header(column, index, "0", cx);
|
||||
}
|
||||
log::info!("table: a header menu, column {column}");
|
||||
self.menu = None;
|
||||
let kind = if column { Kind::Columns } else { Kind::Rows };
|
||||
match self.settled(id, cx) {
|
||||
true => self.offer(id, kind, at, cx),
|
||||
false => self.when_settled(id, move |book, _, cx| book.offer(id, kind, at, cx)),
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Asks what may run now, then opens the menu.
|
||||
fn offer(&mut self, id: u64, kind: Kind, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let commands = kind.commands();
|
||||
self.ask_in(
|
||||
id,
|
||||
move |worker, doc| runs(worker, doc, &commands),
|
||||
move |book, read, _, cx| match read {
|
||||
Ok(runs) => {
|
||||
book.menu = Some(Offer::new(book.menu.as_ref(), id, What { kind, runs }, at));
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => book.engine_failed(&error.to_string(), cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// The menu's host, for the active workbook.
|
||||
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
|
||||
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
Some(offer.host("table-menu", menu_of(&cx.entity(), &offer.what, cx)))
|
||||
}
|
||||
|
||||
/// Runs one menu row.
|
||||
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("table: the menu picks {pick:?}");
|
||||
let (id, values): (&str, &[&str]) = match pick {
|
||||
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
|
||||
Pick::Merge => return self.merge(self.core.active, cx),
|
||||
Pick::RowHeight => return self.ask_size(false, window, cx),
|
||||
Pick::ColumnWidth => return self.ask_size(true, window, cx),
|
||||
Pick::Format => {
|
||||
self.panel_shown = true;
|
||||
return cx.notify();
|
||||
}
|
||||
Pick::RowsAbove => ("rows.insert_above", &[]),
|
||||
Pick::RowsBelow => ("rows.insert_below", &[]),
|
||||
Pick::ColumnsBefore => ("columns.insert_before", &[]),
|
||||
Pick::ColumnsAfter => ("columns.insert_after", &[]),
|
||||
Pick::InsertDown => ("cells.insert", &["V"]),
|
||||
Pick::InsertRight => ("cells.insert", &[">"]),
|
||||
Pick::DeleteRows => ("rows.delete", &[]),
|
||||
Pick::DeleteColumns => ("columns.delete", &[]),
|
||||
Pick::DeleteUp => ("cells.delete", &["U"]),
|
||||
Pick::DeleteLeft => ("cells.delete", &["L"]),
|
||||
Pick::Clear => ("cells.clear", &[]),
|
||||
Pick::Split => ("cells.split", &[]),
|
||||
Pick::HideRows => ("rows.hide", &[]),
|
||||
Pick::ShowRows => ("rows.show", &[]),
|
||||
Pick::HideColumns => ("columns.hide", &[]),
|
||||
Pick::ShowColumns => ("columns.show", &[]),
|
||||
Pick::Front => ("image.front", &[]),
|
||||
Pick::Forward => ("image.forward", &[]),
|
||||
Pick::Back => ("image.back", &[]),
|
||||
Pick::Backward => ("image.backward", &[]),
|
||||
};
|
||||
self.run_active(id, values, cx);
|
||||
}
|
||||
|
||||
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
|
||||
log::warn!("table: {pick:?} with no workbook");
|
||||
return;
|
||||
};
|
||||
view.update(cx, |view, _| match pick {
|
||||
Pick::Paste => view.paste(),
|
||||
_ => view.copy(pick == Pick::Cut),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard row, then the target's rows.
|
||||
pub(crate) fn menu_of(this: &Entity<Book>, what: &What, cx: &App) -> Menu {
|
||||
let item = |pick: Pick| {
|
||||
let this = this.clone();
|
||||
MenuItem::new(Book::text(cx, menu::Pick::row(pick).0, &[]))
|
||||
.disabled(!what.on(pick))
|
||||
.on_click(move |window, cx| this.update(cx, |book, cx| book.choose(pick, window, cx)))
|
||||
};
|
||||
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
|
||||
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
|
||||
}
|
||||
@@ -395,6 +395,7 @@ fn a_delete_stays_with_its_workbook() {
|
||||
|
||||
mod analysis;
|
||||
mod data;
|
||||
mod menu;
|
||||
mod names;
|
||||
mod numbers;
|
||||
mod output;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use ely_lok::{LokView, sheet};
|
||||
use gpui::{Modifiers, MouseButton, Pixels, Point, VisualTestContext};
|
||||
|
||||
use super::data::{cells, open, run};
|
||||
use super::*;
|
||||
use crate::menu::{Kind, Pick, What, held};
|
||||
|
||||
type Shows = fn(&What) -> bool;
|
||||
|
||||
/// The grid with the menu's host over it.
|
||||
struct Host(Entity<Book>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
let grid = self.0.update(cx, |book, cx| book.grid(window, cx));
|
||||
let menu = self.0.update(cx, |book, cx| book.context_menu(cx));
|
||||
gpui::div().relative().size_full().flex().flex_col().child(grid).children(menu)
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
fn view(cx: &mut VisualTestContext, book: &Entity<Book>) -> Entity<LokView> {
|
||||
cx.update(|_, app| book.read(app).active_tab().expect("a tab").view.clone())
|
||||
}
|
||||
|
||||
/// A cell's middle or its header's, in window pixels.
|
||||
fn spot(cx: &mut VisualTestContext, book: &Entity<Book>, column: u32, row: u32, header: Option<bool>) -> Point<Pixels> {
|
||||
frames(cx);
|
||||
let view = view(cx, book);
|
||||
cx.update(|_, app| {
|
||||
let geometry = &book.read(app).active_tab().expect("a tab").extra.geometry;
|
||||
let x = geometry.columns.start(column) + geometry.columns.size(column) / 2;
|
||||
let y = geometry.rows.start(row) + geometry.rows.size(row) / 2;
|
||||
let read = view.read(app);
|
||||
let at = read.origin() + read.place((x, y));
|
||||
match header {
|
||||
None => at,
|
||||
Some(false) => gpui::point(read.origin().x - gpui::px(24.0), at.y),
|
||||
Some(true) => gpui::point(at.x, read.origin().y - gpui::px(12.0)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Right-clicks and waits until `shown` holds for the menu.
|
||||
fn right_click(cx: &mut VisualTestContext, book: &Entity<Book>, at: Point<Pixels>, shown: Shows) {
|
||||
cx.update(|_, app| book.update(app, |book, _| book.menu = None));
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||
for _ in 0..500 {
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| book.read(app).menu.as_ref().is_some_and(|offer| shown(&offer.what))) {
|
||||
return frames(cx);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("no menu");
|
||||
}
|
||||
|
||||
fn on(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) -> bool {
|
||||
cx.update(|_, app| book.read(app).menu.as_ref().expect("a menu").what.on(pick))
|
||||
}
|
||||
|
||||
fn choose(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) {
|
||||
cx.update(|window, app| book.update(app, |book, cx| book.choose(pick, window, cx)));
|
||||
}
|
||||
|
||||
/// A column range's cells, empty ones kept.
|
||||
fn column(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -> Vec<String> {
|
||||
cells(cx, book, range).lines().map(|line| line.trim().to_string()).collect()
|
||||
}
|
||||
|
||||
fn cell(what: &What) -> bool {
|
||||
what.kind == Kind::Cell
|
||||
}
|
||||
|
||||
fn rows(what: &What) -> bool {
|
||||
what.kind == Kind::Rows
|
||||
}
|
||||
|
||||
fn columns(what: &What) -> bool {
|
||||
what.kind == Kind::Columns
|
||||
}
|
||||
|
||||
/// A new, selected rectangle over A1.
|
||||
fn corner_rectangle(cx: &mut TestAppContext, book: &Entity<Book>) {
|
||||
run(cx, book, "draw.shape", &["rectangle", "true"]);
|
||||
run(cx, book, "object.x", &["0"]);
|
||||
run(cx, book, "object.y", &["0"]);
|
||||
let selected = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
|
||||
assert!(selected.is_some_and(|(x, y, ..)| x < 100 && y < 100), "selected at A1: {selected:?}");
|
||||
}
|
||||
|
||||
/// Protects the sheet, allowing selection and `more`.
|
||||
fn protect(cx: &mut TestAppContext, book: &Entity<Book>, more: &[&str]) {
|
||||
let allow: Vec<String> = ["selectLocked", "selectUnlocked"].iter().chain(more).map(|key| key.to_string()).collect();
|
||||
run(cx, book, "sheet.protect", &[&sheet::protect_json("pw", &allow)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_clicks_open_excel_rows_on_cells_and_headers() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| {
|
||||
ely_gpui_component::init_for_tests(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
open(&mut cx, &book);
|
||||
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.geometry.rows.count() > 0));
|
||||
let window = cx.add_window(|_, _| Host(book.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
let a2 = spot(&mut shown, &book, 0, 1, None);
|
||||
right_click(&mut shown, &book, a2, cell);
|
||||
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
|
||||
let off = cut.origin - a2;
|
||||
assert!(off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0), "at the click: {off:?}");
|
||||
assert!(on(&mut shown, &book, Pick::RowsBelow) && on(&mut shown, &book, Pick::Clear));
|
||||
assert!(!on(&mut shown, &book, Pick::Merge), "one cell does not merge");
|
||||
choose(&mut shown, &book, Pick::RowsBelow);
|
||||
assert_eq!(column(&mut cx, &book, "A2:A4"), ["a", "", "b"], "a row below A2");
|
||||
// The read left A2:A4 selected; a click outside it moves.
|
||||
run(&mut cx, &book, "cell.goto", &["D9"]);
|
||||
let a2 = spot(&mut shown, &book, 0, 1, None);
|
||||
right_click(&mut shown, &book, a2, cell);
|
||||
choose(&mut shown, &book, Pick::Clear);
|
||||
assert_eq!(column(&mut cx, &book, "A1:A4"), ["Name", "", "", "b"], "cleared");
|
||||
assert_eq!(cells(&mut cx, &book, "B2"), "3", "one cell only");
|
||||
let row5 = spot(&mut shown, &book, 0, 4, Some(false));
|
||||
right_click(&mut shown, &book, row5, rows);
|
||||
assert!(on(&mut shown, &book, Pick::HideRows) && on(&mut shown, &book, Pick::DeleteRows));
|
||||
choose(&mut shown, &book, Pick::DeleteRows);
|
||||
assert_eq!(cells(&mut cx, &book, "A5"), "c", "row 5 went, not row 2");
|
||||
// A selected rectangle over A1 hides no header menu.
|
||||
corner_rectangle(&mut cx, &book);
|
||||
let row1 = spot(&mut shown, &book, 0, 0, Some(false));
|
||||
right_click(&mut shown, &book, row1, rows);
|
||||
assert!(on(&mut shown, &book, Pick::RowsAbove) && on(&mut shown, &book, Pick::HideRows));
|
||||
choose(&mut shown, &book, Pick::RowsAbove);
|
||||
assert_eq!(cells(&mut cx, &book, "A2"), "Name", "a row above row 1");
|
||||
corner_rectangle(&mut cx, &book);
|
||||
let a = spot(&mut shown, &book, 0, 0, Some(true));
|
||||
right_click(&mut shown, &book, a, columns);
|
||||
assert!(on(&mut shown, &book, Pick::ColumnsBefore) && on(&mut shown, &book, Pick::DeleteColumns));
|
||||
protect(&mut cx, &book, &[]);
|
||||
let headers: [(Point<Pixels>, Shows, [Pick; 4]); 2] = [
|
||||
(spot(&mut shown, &book, 0, 2, Some(false)), rows, [Pick::RowsAbove, Pick::DeleteRows, Pick::HideRows, Pick::ShowRows]),
|
||||
(spot(&mut shown, &book, 1, 0, Some(true)), columns, [Pick::ColumnsBefore, Pick::DeleteColumns, Pick::HideColumns, Pick::ShowColumns]),
|
||||
];
|
||||
for (at, shown_as, picks) in headers {
|
||||
right_click(&mut shown, &book, at, shown_as);
|
||||
assert!(on(&mut shown, &book, Pick::Copy), "copy stays on");
|
||||
for pick in picks.into_iter().chain([Pick::Cut, Pick::Clear]) {
|
||||
assert!(!on(&mut shown, &book, pick), "{pick:?} is off on a protected sheet");
|
||||
}
|
||||
}
|
||||
// Protection that lets rows and columns in.
|
||||
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
|
||||
protect(&mut cx, &book, &["insertRows", "insertColumns"]);
|
||||
let b = spot(&mut shown, &book, 1, 0, Some(true));
|
||||
right_click(&mut shown, &book, b, columns);
|
||||
assert!(on(&mut shown, &book, Pick::ColumnsBefore), "inserting columns is allowed");
|
||||
assert!(!on(&mut shown, &book, Pick::DeleteColumns), "deleting columns is not");
|
||||
let row2 = spot(&mut shown, &book, 0, 1, Some(false));
|
||||
right_click(&mut shown, &book, row2, rows);
|
||||
assert!(on(&mut shown, &book, Pick::RowsAbove), "inserting rows is allowed");
|
||||
assert!(!on(&mut shown, &book, Pick::DeleteRows), "deleting rows is not");
|
||||
choose(&mut shown, &book, Pick::RowsAbove);
|
||||
// Protected cells read as nothing.
|
||||
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
|
||||
assert_eq!(cells(&mut cx, &book, "A3"), "Name", "a row above row 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_keeps_a_whole_selection_that_holds_it() {
|
||||
let geometry = sheet::geometry(
|
||||
r#"{"rows":{"sizes":"256:1048575","hidden":"0:1048575"},"columns":{"sizes":"1280:16383","hidden":"0:16383"}}"#,
|
||||
)
|
||||
.expect("parses");
|
||||
// As Calc reports column B, then row 3.
|
||||
let column = [(1280, 0, 1279, 268435455)];
|
||||
assert!(held(&column, &geometry, true, 1));
|
||||
assert!(!held(&column, &geometry, true, 0));
|
||||
assert!(!held(&column, &geometry, false, 0), "a column is no row");
|
||||
let row = [(0, 512, 20971519, 255)];
|
||||
assert!(held(&row, &geometry, false, 2));
|
||||
assert!(!held(&row, &geometry, true, 0));
|
||||
let one = [(1280, 256, 1279, 255)];
|
||||
assert!(!held(&one, &geometry, true, 1), "one cell is no column");
|
||||
assert!(!held(&[], &geometry, false, 0));
|
||||
}
|
||||
|
||||
/// Starts typing into the selected shape's text.
|
||||
fn type_in_shape(cx: &mut TestAppContext, book: &Entity<Book>) {
|
||||
cx.update(|app| {
|
||||
let book = book.read(app);
|
||||
let tab = book.active_tab().expect("a tab");
|
||||
let mut worker = book.core.worker.lock().expect("the worker");
|
||||
worker.post_key(&tab.doc, 0, 769).expect("F2");
|
||||
worker.post_key(&tab.doc, 'z' as u32, 537).expect("a key");
|
||||
});
|
||||
fresh(cx, book);
|
||||
let graphic = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
|
||||
assert!(graphic.is_none(), "editing the shape's text: {graphic:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_ends_shape_text_editing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| {
|
||||
ely_gpui_component::init_for_tests(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
open(&mut cx, &book);
|
||||
fresh(&cx, &book);
|
||||
let window = cx.add_window(|_, _| Host(book.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
let headers: [(bool, u32, Shows, Pick); 2] =
|
||||
[(false, 2, rows, Pick::RowsAbove), (true, 1, columns, Pick::ColumnsBefore)];
|
||||
for (column, index, shown_as, insert) in headers {
|
||||
corner_rectangle(&mut cx, &book);
|
||||
type_in_shape(&mut cx, &book);
|
||||
let at = if column { spot(&mut shown, &book, index, 2, Some(true)) } else { spot(&mut shown, &book, 0, index, Some(false)) };
|
||||
right_click(&mut shown, &book, at, shown_as);
|
||||
let picked = cx.update(|app| {
|
||||
let tab = book.read(app).active_tab().expect("a tab");
|
||||
held(tab.view.read(app).selection(), &tab.extra.geometry, column, index)
|
||||
});
|
||||
assert!(picked, "the header's line is selected, column {column}");
|
||||
assert!(on(&mut shown, &book, insert), "{insert:?} is on");
|
||||
choose(&mut shown, &book, insert);
|
||||
}
|
||||
assert_eq!(cells(&mut cx, &book, "A4"), "b", "a row above row 3");
|
||||
assert_eq!(cells(&mut cx, &book, "C2"), "3", "a column before B");
|
||||
let path = dir.path().join("shapes.fods");
|
||||
cx.update(|app| {
|
||||
let book = book.read(app);
|
||||
let mut worker = book.core.worker.lock().expect("the worker");
|
||||
let doc = &book.active_tab().expect("a tab").doc;
|
||||
doc.save_as(&mut worker, &ely_lok::file_url(&path), "fods").expect("the save");
|
||||
});
|
||||
let xml = std::fs::read_to_string(&path).expect("the save");
|
||||
assert_eq!(xml.matches(">z</text:p>").count(), 2, "each shape keeps its typed text");
|
||||
}
|
||||
@@ -39,6 +39,14 @@ pub fn forget(file: &Path, root: &Path) -> io::Result<()> {
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// Empties the list.
|
||||
pub fn clear(file: &Path) -> io::Result<()> {
|
||||
if let Some(dir) = file.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
crate::save_atomic(file, b"[]", 0).map(drop)
|
||||
}
|
||||
|
||||
/// Puts `root` first in the list and writes it.
|
||||
pub fn remember(file: &Path, root: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
let mut list = recent(file)?;
|
||||
@@ -60,7 +68,7 @@ pub fn remember(file: &Path, root: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{forget, recent, remember};
|
||||
use super::{clear, forget, recent, remember};
|
||||
|
||||
#[test]
|
||||
fn the_list_puts_the_newest_first_and_keeps_twenty() {
|
||||
@@ -82,6 +90,8 @@ mod tests {
|
||||
let plain = [PathBuf::from(r"C:\r\old"), PathBuf::from(r"C:\r\new")];
|
||||
assert_eq!(recent(&file).expect("reads"), plain);
|
||||
}
|
||||
clear(&file).expect("clears");
|
||||
assert!(recent(&file).expect("reads").is_empty());
|
||||
std::fs::write(&file, "not json").expect("writes");
|
||||
assert!(
|
||||
remember(&file, Path::new("/r/1")).is_err(),
|
||||
|
||||
@@ -53,6 +53,11 @@ pub fn check_reply(asked: u64, got: u64) -> io::Result<()> {
|
||||
|
||||
/// Reads (tag, id, payload); None at a clean stream end.
|
||||
pub fn read_frame(input: &mut impl Read) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
|
||||
read_capped(input, MAX_FRAME)
|
||||
}
|
||||
|
||||
/// As `read_frame`, refusing payloads over `cap` before allocating.
|
||||
pub fn read_capped(input: &mut impl Read, cap: usize) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
|
||||
let mut len = [0u8; 4];
|
||||
// Only a stream ending between frames ends cleanly.
|
||||
let mut got = 0;
|
||||
@@ -66,10 +71,10 @@ pub fn read_frame(input: &mut impl Read) -> io::Result<Option<(u8, u64, Vec<u8>)
|
||||
}
|
||||
}
|
||||
let len = u32::from_le_bytes(len) as usize;
|
||||
if len > MAX_FRAME {
|
||||
if len > cap {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"frame over the cap",
|
||||
format!("a {len} byte frame is over the {cap} byte cap"),
|
||||
));
|
||||
}
|
||||
let mut tag = [0u8; 1];
|
||||
|
||||
@@ -32,6 +32,7 @@ windows-sys.workspace = true
|
||||
tempfile.workspace = true
|
||||
ely-ui.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
ely-gpui-component = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[[example]]
|
||||
name = "lokview"
|
||||
|
||||
@@ -48,6 +48,7 @@ fn unpack_pushed(body: &[u8]) -> io::Result<(u64, Events)> {
|
||||
selection: marked,
|
||||
};
|
||||
let cell = crate::events::Cell::unpack(&mut unpack)?;
|
||||
let table = crate::table::Table::unpack(&mut unpack)?;
|
||||
let events = Events {
|
||||
seq,
|
||||
revision,
|
||||
@@ -72,6 +73,7 @@ fn unpack_pushed(body: &[u8]) -> io::Result<(u64, Events)> {
|
||||
a11y,
|
||||
tabs,
|
||||
cell,
|
||||
table,
|
||||
};
|
||||
Ok((id, events))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::path::PathBuf;
|
||||
use super::*;
|
||||
|
||||
mod analysis;
|
||||
mod borders;
|
||||
mod charts;
|
||||
mod check;
|
||||
mod commands;
|
||||
@@ -15,6 +16,7 @@ pub(crate) mod input;
|
||||
mod latency;
|
||||
mod layout;
|
||||
mod media;
|
||||
mod menus;
|
||||
mod motion;
|
||||
mod output;
|
||||
mod paint;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use super::input::open;
|
||||
use super::menus::{DOC, find};
|
||||
use super::*;
|
||||
|
||||
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// The first inner column line, once it differs from `from`.
|
||||
fn column(worker: &mut Worker, doc: &Doc, seen: &Events, from: i32) -> Events {
|
||||
poll_until(worker, doc, seen, WAIT, |seen| {
|
||||
seen.table.as_ref().is_some_and(|table| table.columns.inner[0].position != from)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_border_reports_and_moves_in_one_step() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (_dir, mut worker, doc) = open(DOC);
|
||||
click(&mut worker, &doc, 150, 150);
|
||||
let mut seen = Events::default();
|
||||
let (x, y) = find(&mut worker, &doc, &mut seen, "left");
|
||||
click(&mut worker, &doc, x, y);
|
||||
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| seen.table.is_some());
|
||||
let table = seen.table.clone().expect("the cursor's table");
|
||||
let (columns, rows) = (&table.columns, &table.rows);
|
||||
assert_eq!(columns.inner.len(), 1, "two columns, one inner line");
|
||||
assert_eq!(rows.inner.len(), 1, "two rows, one inner line");
|
||||
let caret = seen.cursor.expect("a caret");
|
||||
let across = (columns.offset + columns.left, columns.offset + columns.inner[0].position);
|
||||
let down = (rows.offset + rows.left, rows.offset + rows.inner[0].position);
|
||||
assert!(across.0 < caret.0 && caret.0 < across.1, "the caret is in column one: {across:?} {caret:?}");
|
||||
assert!(down.0 <= caret.1 && caret.1 < down.1, "the caret is in row one: {down:?} {caret:?}");
|
||||
|
||||
let read = worker.now(&doc).expect("the revision");
|
||||
let line = columns.inner[0].position;
|
||||
worker.run(&read, "table.border", &["column-middle", "0", "500"]).expect("the line moves");
|
||||
seen = column(&mut worker, &doc, &seen, line);
|
||||
let moved = seen.table.clone().expect("still in the table");
|
||||
assert_eq!(moved.columns.inner[0].position, line + 500, "500 twips right");
|
||||
let stale = worker.run(&read, "table.border", &["column-middle", "0", "500"]);
|
||||
assert!(stale.is_err_and(|error| crate::is_stale(&error)), "an old read is refused");
|
||||
|
||||
worker.run(&doc, "edit.undo", &[]).expect("undo");
|
||||
seen = column(&mut worker, &doc, &seen, line + 500);
|
||||
let undone = seen.table.clone().expect("still in the table");
|
||||
assert_eq!(undone.columns.inner[0].position, line, "one undo restores it");
|
||||
|
||||
let bottom = undone.rows.right;
|
||||
let read = worker.now(&doc).expect("the revision");
|
||||
worker.run(&read, "table.border", &["row-right", "0", "400"]).expect("the bottom moves");
|
||||
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| {
|
||||
seen.table.as_ref().is_some_and(|table| table.rows.right != bottom)
|
||||
});
|
||||
let taller = seen.table.expect("still in the table");
|
||||
assert_eq!(taller.rows.right, bottom + 400, "the last row grows");
|
||||
}
|
||||
@@ -143,7 +143,7 @@ fn every_mapped_command_reaches_the_kernel_and_reports_state() {
|
||||
assert!(text.contains("fo:font-weight=\"bold\""), "bold landed");
|
||||
assert_eq!(
|
||||
commands::all().count(),
|
||||
246,
|
||||
248,
|
||||
"the rows of text, layout, refs, tools, sheet, calc, data, analysis, page, show and draw"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use super::input::open;
|
||||
use super::layout::png;
|
||||
use super::*;
|
||||
use crate::menu::{self, Target};
|
||||
|
||||
pub(super) const DOC: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
|
||||
<office:body><office:text><text:p>The cat ran.</text:p>
|
||||
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
|
||||
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell><text:p>down</text:p></table:table-cell><table:table-cell><text:p>more</text:p></table:table-cell></table:table-row>
|
||||
</table:table><text:p>after</text:p></office:text></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Right-clicks at a point and reads the kernel's menu.
|
||||
fn right(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
|
||||
click(worker, doc, x, y);
|
||||
press(worker, doc, seen, (x, y))
|
||||
}
|
||||
|
||||
/// Right-clicks without moving the selection first.
|
||||
fn press(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
|
||||
for kind in [0, 1] {
|
||||
let event = Mouse { kind, x, y, count: 1, buttons: 4, modifiers: 0 };
|
||||
worker.post_mouse(doc, event).expect("the click posts");
|
||||
}
|
||||
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.menu.is_some());
|
||||
menu::read(&seen.menu.take().expect("a menu")).expect("the menu parses")
|
||||
}
|
||||
|
||||
/// The middle of the first match of `word`.
|
||||
pub(super) fn find(worker: &mut Worker, doc: &Doc, seen: &mut Events, word: &str) -> (i32, i32) {
|
||||
let before = seen.searched;
|
||||
worker
|
||||
.run(doc, "find.run", &[word, "false", "0", "0", "0", "1"])
|
||||
.expect("the search runs");
|
||||
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.searched > before);
|
||||
let (x, y, _, h) = seen.found[0];
|
||||
(x + 60, y + h / 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_right_click_names_its_target() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (dir, mut worker, doc) = open(DOC);
|
||||
click(&mut worker, &doc, 150, 150);
|
||||
let mut seen = Events::default();
|
||||
let at = find(&mut worker, &doc, &mut seen, "cat");
|
||||
let text = right(&mut worker, &doc, &mut seen, at);
|
||||
assert_eq!(text.target, Target::Text);
|
||||
assert!(text.allows(".uno:InsertAnnotation") && text.allows(".uno:Paste"));
|
||||
assert!(!text.allows(".uno:Cut"), "nothing is selected");
|
||||
let at = find(&mut worker, &doc, &mut seen, "left");
|
||||
let table = right(&mut worker, &doc, &mut seen, at);
|
||||
assert_eq!(table.target, Target::Table);
|
||||
for uno in [
|
||||
".uno:InsertRowsBefore",
|
||||
".uno:InsertColumnsAfter",
|
||||
".uno:DeleteTable",
|
||||
".uno:SplitCell",
|
||||
] {
|
||||
assert!(table.allows(uno), "{uno} runs in a cell");
|
||||
}
|
||||
assert!(!table.allows(".uno:MergeCells"), "one cell does not merge");
|
||||
let at = find(&mut worker, &doc, &mut seen, "after");
|
||||
click(&mut worker, &doc, at.0, at.1);
|
||||
let blue = png(dir.path(), "blue.png", [0, 0, 255, 255]);
|
||||
worker.run(&doc, "image.insert", &[&blue]).expect("the image lands");
|
||||
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| seen.graphic.is_some());
|
||||
let (x, y, w, h) = seen.graphic.expect("the image is selected");
|
||||
let frame = right(&mut worker, &doc, &mut seen, (x + w / 2, y + h / 2));
|
||||
assert_eq!(frame.target, Target::Frame);
|
||||
for uno in [".uno:Copy", ".uno:WrapOn", ".uno:BringToFront", ".uno:InsertCaptionDialog"] {
|
||||
assert!(frame.allows(uno), "{uno} runs on an image");
|
||||
}
|
||||
assert!(!frame.allows(".uno:InsertAnnotation"), "no comment on an image");
|
||||
}
|
||||
|
||||
const LOCKED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
|
||||
<office:body><office:text><text:p>open words</text:p>
|
||||
<text:section text:name="S" text:protected="true"><text:p>sealed words</text:p></text:section>
|
||||
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
|
||||
<table:table-row><table:table-cell table:protected="true"><text:p>fixed</text:p></table:table-cell><table:table-cell><text:p>free</text:p></table:table-cell></table:table-row>
|
||||
</table:table><text:p>after</text:p></office:text></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
#[test]
|
||||
fn protected_text_offers_no_link() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (_dir, mut worker, doc) = open(LOCKED);
|
||||
click(&mut worker, &doc, 150, 150);
|
||||
let mut seen = Events::default();
|
||||
for (word, target, link) in [
|
||||
("open", Target::Text, true),
|
||||
("sealed", Target::Text, false),
|
||||
("free", Target::Table, true),
|
||||
("fixed", Target::Table, false),
|
||||
] {
|
||||
let at = find(&mut worker, &doc, &mut seen, word);
|
||||
let offered = press(&mut worker, &doc, &mut seen, at);
|
||||
assert_eq!(offered.target, target, "{word}");
|
||||
assert_eq!(offered.allows(".uno:InsertHyperlink"), link, "{word} selected");
|
||||
}
|
||||
let at = find(&mut worker, &doc, &mut seen, "open");
|
||||
let caret = right(&mut worker, &doc, &mut seen, at);
|
||||
assert!(!caret.allows(".uno:InsertHyperlink"), "the kernel links a selection");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::input::{open, saved};
|
||||
use super::*;
|
||||
use crate::review;
|
||||
use crate::{menu, review};
|
||||
|
||||
const TEXT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
|
||||
@@ -125,8 +125,9 @@ fn spelling_suggests_and_ignores() {
|
||||
);
|
||||
let (x, y, _, h) = seen.found[0];
|
||||
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
|
||||
let spelling = review::spelling(&json)
|
||||
let spelling = menu::read(&json)
|
||||
.expect("the menu parses")
|
||||
.spelling
|
||||
.expect("a spelling menu");
|
||||
let (_, rule) = spelling
|
||||
.suggestions
|
||||
@@ -145,13 +146,13 @@ fn spelling_suggests_and_ignores() {
|
||||
);
|
||||
let (x, y, _, h) = seen.found[0];
|
||||
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
|
||||
assert!(review::spelling(&json).expect("parses").is_some());
|
||||
assert!(menu::read(&json).expect("parses").spelling.is_some());
|
||||
worker
|
||||
.run(&doc, "spell.ignore_all", &["Spelling"])
|
||||
.expect("ignore all");
|
||||
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
|
||||
assert_eq!(
|
||||
review::spelling(&json).expect("parses"),
|
||||
menu::read(&json).expect("parses").spelling,
|
||||
None,
|
||||
"an ignored word is no longer wrong"
|
||||
);
|
||||
|
||||
@@ -153,6 +153,7 @@ pub const QUERIES: &[&str] = &[
|
||||
crate::sheet::NUMBER,
|
||||
crate::sheet::FILTER,
|
||||
crate::sheet::LIST,
|
||||
crate::menu::STATES,
|
||||
];
|
||||
|
||||
/// The kernel's font families, from `.uno:CharFontName`.
|
||||
|
||||
@@ -159,6 +159,16 @@ pub(super) const ROWS: &[Command] = &[
|
||||
&[],
|
||||
Check::Undoable,
|
||||
),
|
||||
command(
|
||||
"table.border",
|
||||
".uno:TableChangeCurrentBorderPosition",
|
||||
&[
|
||||
Arg::Text("BorderType"),
|
||||
Arg::Int("Index", "unsigned short", 0, 999),
|
||||
Arg::Int("Offset", "long", -600_000, 600_000),
|
||||
],
|
||||
Check::Undoable,
|
||||
),
|
||||
command(
|
||||
"table.borders_all",
|
||||
".uno:SetBorderStyle",
|
||||
|
||||
@@ -100,6 +100,7 @@ pub(super) const ROWS: &[Command] = &[
|
||||
Check::Undoable,
|
||||
),
|
||||
command("cells.split", ".uno:SplitCell", &[], Check::Undoable),
|
||||
command("cells.clear", ".uno:ClearContents", &[], Check::Undoable),
|
||||
command("view.freeze", ".uno:FreezePanes", &[], Check::Finished),
|
||||
command("rows.hide", ".uno:HideRow", &[], Check::Undoable),
|
||||
command("rows.show", ".uno:ShowRow", &[], Check::Undoable),
|
||||
|
||||
@@ -54,6 +54,7 @@ const CELL_ADDRESS: i32 = 34;
|
||||
const REFERENCE_MARKS: i32 = 45;
|
||||
const FUNCTION_LIST: i32 = 47;
|
||||
const TOOLTIP: i32 = 71;
|
||||
const TABLE: i32 = 44;
|
||||
|
||||
/// A mixed state; kernel values never hold NUL.
|
||||
pub const MIXED: &str = "\u{0}mixed";
|
||||
@@ -103,6 +104,7 @@ pub struct Events {
|
||||
/// The paragraph's own tab stops, in 1/100 mm.
|
||||
pub tabs: Vec<i32>,
|
||||
pub cell: Cell,
|
||||
pub table: Option<crate::table::Table>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -144,7 +146,8 @@ fn signal() -> &'static Condvar {
|
||||
SIGNAL.get_or_init(Condvar::new)
|
||||
}
|
||||
|
||||
const KINDS: [i32; 34] = [
|
||||
const KINDS: [i32; 35] = [
|
||||
TABLE,
|
||||
MOUSE_POINTER,
|
||||
REFERENCE_MARKS,
|
||||
FUNCTION_LIST,
|
||||
@@ -266,6 +269,7 @@ fn record(slot: &mut Slot, kind: i32, text: &str) -> Option<u32> {
|
||||
.push(format!("an unreadable search result: {text}")),
|
||||
},
|
||||
MENU => batch.menu = Some(text.to_string()),
|
||||
TABLE => crate::table::read(text).map_or_else(|fault| batch.faults.push(fault), |table| batch.table = table),
|
||||
CELL_CURSOR => batch.cell.cursor = rect_in(batch, "cell cursor", text),
|
||||
CELL_FORMULA => batch.cell.formula = text.to_string(),
|
||||
CELL_ADDRESS => batch.cell.address = text.to_string(),
|
||||
|
||||
@@ -7,9 +7,11 @@ mod ffi;
|
||||
mod handshake;
|
||||
pub mod history;
|
||||
mod ipc;
|
||||
pub mod menu;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod show;
|
||||
mod table;
|
||||
mod worker;
|
||||
|
||||
pub use client::{
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! The kernel's context menu: target and flags (DOC-02).
|
||||
|
||||
use std::{collections::HashMap, io};
|
||||
|
||||
use crate::{
|
||||
Doc, Worker,
|
||||
review::{entries, invalid, parse},
|
||||
};
|
||||
|
||||
/// Where a right click landed, per the kernel's menu.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Target {
|
||||
Text,
|
||||
Table,
|
||||
/// A Writer graphic, object or frame.
|
||||
Frame,
|
||||
/// A spreadsheet cell.
|
||||
Cell,
|
||||
/// A drawing object outside Writer.
|
||||
Object,
|
||||
/// A picture outside Writer.
|
||||
Picture,
|
||||
/// An Impress slide, nothing selected.
|
||||
Slide,
|
||||
}
|
||||
|
||||
/// A misspelled word's menu: suggestions as the kernel's rules.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Spelling {
|
||||
/// Label and `ApplyRule` value for each suggestion.
|
||||
pub suggestions: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// One menu the kernel offered.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Offered {
|
||||
pub target: Target,
|
||||
pub spelling: Option<Spelling>,
|
||||
/// Each command, at any depth, and whether it runs now.
|
||||
enabled: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
impl Offered {
|
||||
/// The kernel offers `uno` and it runs now.
|
||||
pub fn allows(&self, uno: &str) -> bool {
|
||||
self.enabled.get(uno) == Some(&true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which asked commands run now, per the kernel's dispatcher.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Runs(HashMap<String, bool>);
|
||||
|
||||
impl Runs {
|
||||
/// The kernel lets `uno` run now.
|
||||
pub fn allows(&self, uno: &str) -> bool {
|
||||
self.0.get(uno) == Some(&true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The read of which commands run now (patch 0045).
|
||||
pub const STATES: &str = ".uno:CommandStates";
|
||||
|
||||
/// Asks the kernel which of `commands` run now.
|
||||
pub fn runs(worker: &mut Worker, doc: &Doc, commands: &[&str]) -> io::Result<Runs> {
|
||||
let json = worker.values(doc, &format!("{STATES}?commands={}", commands.join(",")))?;
|
||||
read_runs(&json, commands)
|
||||
}
|
||||
|
||||
/// Reads the answer; every asked command must be in it.
|
||||
fn read_runs(json: &str, commands: &[&str]) -> io::Result<Runs> {
|
||||
let value = parse(json)?;
|
||||
let states = value["commandStates"]
|
||||
.as_object()
|
||||
.ok_or_else(|| invalid(format!("no command states: {json}")))?;
|
||||
let read = |command: &&str| match states.get(*command).and_then(serde_json::Value::as_bool) {
|
||||
Some(on) => Ok((command.to_string(), on)),
|
||||
None => Err(invalid(format!("no state for {command}: {json}"))),
|
||||
};
|
||||
Ok(Runs(commands.iter().map(read).collect::<io::Result<_>>()?))
|
||||
}
|
||||
|
||||
/// Reads a `LOK_CALLBACK_CONTEXT_MENU` payload.
|
||||
pub fn read(json: &str) -> io::Result<Offered> {
|
||||
let list = parse(json)?;
|
||||
let mut enabled = HashMap::new();
|
||||
let mut suggestions = Vec::new();
|
||||
walk(entries(&list, "menu")?, &mut enabled, &mut suggestions)?;
|
||||
let spelling = enabled
|
||||
.keys()
|
||||
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
|
||||
.then_some(Spelling { suggestions });
|
||||
let target = if enabled.contains_key(".uno:TableInsertMenu") {
|
||||
Target::Table
|
||||
} else if enabled.contains_key(".uno:WrapMenu") {
|
||||
Target::Frame
|
||||
} else if enabled.contains_key(".uno:InsertCell") {
|
||||
Target::Cell
|
||||
} else if enabled.contains_key(".uno:ChangePicture") {
|
||||
Target::Picture
|
||||
} else if enabled.contains_key(".uno:ArrangeMenu") {
|
||||
Target::Object
|
||||
} else if enabled.contains_key(".uno:SlideLayoutMenu") {
|
||||
Target::Slide
|
||||
} else {
|
||||
Target::Text
|
||||
};
|
||||
Ok(Offered {
|
||||
target,
|
||||
spelling,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
fn walk(
|
||||
items: &[serde_json::Value],
|
||||
enabled: &mut HashMap<String, bool>,
|
||||
suggestions: &mut Vec<(String, String)>,
|
||||
) -> io::Result<()> {
|
||||
for item in items {
|
||||
if item["type"] == "separator" {
|
||||
continue;
|
||||
}
|
||||
let on = match item["enabled"].as_str() {
|
||||
Some("true") => true,
|
||||
Some("false") => false,
|
||||
_ => return Err(invalid(format!("a menu item has no enabled flag: {item}"))),
|
||||
};
|
||||
// A submenu may have no command.
|
||||
match (item["command"].as_str(), item["type"].as_str()) {
|
||||
(Some(command), _) => {
|
||||
enabled.insert(command.to_string(), on);
|
||||
if let Some(rule) =
|
||||
command.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")
|
||||
{
|
||||
let label = item["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid(format!("a suggestion has no text: {item}")))?;
|
||||
suggestions.push((label.to_string(), rule.to_string()));
|
||||
}
|
||||
}
|
||||
(None, Some("menu")) => {}
|
||||
(None, _) => return Err(invalid(format!("a menu item has no command: {item}"))),
|
||||
}
|
||||
if let Some(sub) = item.get("menu") {
|
||||
let sub = sub
|
||||
.as_array()
|
||||
.ok_or_else(|| invalid(format!("a submenu is no list: {item}")))?;
|
||||
walk(sub, enabled, suggestions)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,64 @@
|
||||
use super::*;
|
||||
|
||||
fn item(command: &str, enabled: bool) -> String {
|
||||
format!(r#"{{"text":"x","type":"command","command":"{command}","enabled":"{enabled}"}}"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spelling_rows_come_first_class() {
|
||||
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The","enabled":"true"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling","enabled":"true"}]}"#;
|
||||
let offered = read(menu).expect("the menu parses");
|
||||
let spelling = offered.spelling.expect("a spelling menu");
|
||||
assert_eq!(spelling.suggestions, [("The".into(), "Spelling_The".into())]);
|
||||
assert_eq!(offered.target, Target::Text);
|
||||
let other = format!(r#"{{"menu":[{}]}}"#, item(".uno:Cut", true));
|
||||
assert_eq!(read(&other).expect("the menu parses").spelling, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_names_its_target_and_flags() {
|
||||
let table = format!(
|
||||
r#"{{"menu":[{},{{"text":"Insert","type":"menu","command":".uno:TableInsertMenu","menu":[{}],"enabled":"true"}}]}}"#,
|
||||
item(".uno:MergeCells", false),
|
||||
item(".uno:InsertRowsBefore", true),
|
||||
);
|
||||
let table = read(&table).expect("the table menu parses");
|
||||
assert_eq!(table.target, Target::Table);
|
||||
assert!(table.allows(".uno:InsertRowsBefore"), "submenu rows count");
|
||||
assert!(!table.allows(".uno:MergeCells"), "a disabled row");
|
||||
assert!(!table.allows(".uno:DeleteTable"), "an absent row");
|
||||
let frame = format!(
|
||||
r#"{{"menu":[{{"type":"menu","menu":[{}],"enabled":"true"}}]}}"#,
|
||||
item(".uno:WrapMenu", true)
|
||||
);
|
||||
assert_eq!(read(&frame).expect("a commandless submenu").target, Target::Frame);
|
||||
let cell = format!(r#"{{"menu":[{}]}}"#, item(".uno:InsertCell", false));
|
||||
assert_eq!(read(&cell).expect("parses").target, Target::Cell);
|
||||
let object = format!(r#"{{"menu":[{}]}}"#, item(".uno:ArrangeMenu", true));
|
||||
assert_eq!(read(&object).expect("parses").target, Target::Object);
|
||||
let text = format!(r#"{{"menu":[{}]}}"#, item(".uno:Paste", true));
|
||||
assert_eq!(read(&text).expect("parses").target, Target::Text);
|
||||
// Impress: a picture also arranges; a bare slide has layouts.
|
||||
let picture = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:ChangePicture", true), item(".uno:ArrangeMenu", true));
|
||||
assert_eq!(read(&picture).expect("parses").target, Target::Picture);
|
||||
let slide = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:Paste", true), item(".uno:SlideLayoutMenu", true));
|
||||
assert_eq!(read(&slide).expect("parses").target, Target::Slide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_menu_fails() {
|
||||
assert!(read(r#"{"menu":[{"type":"command","command":".uno:Cut"}]}"#).is_err());
|
||||
assert!(read(r#"{"menu":[{"type":"command","enabled":"true"}]}"#).is_err());
|
||||
assert!(read(r#"{"items":[]}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_states_answer_each_asked_command() {
|
||||
let json = r#"{"commandStates":{".uno:DeleteRows":false,".uno:InsertRowsBefore":true}}"#;
|
||||
let runs = read_runs(json, &[".uno:InsertRowsBefore", ".uno:DeleteRows"]).expect("parses");
|
||||
assert!(runs.allows(".uno:InsertRowsBefore"));
|
||||
assert!(!runs.allows(".uno:DeleteRows"));
|
||||
assert!(read_runs(json, &[".uno:HideRow"]).is_err(), "an unanswered command");
|
||||
let text = r#"{"commandStates":{".uno:Cut":"true"}}"#;
|
||||
assert!(read_runs(text, &[".uno:Cut"]).is_err(), "a state is a boolean");
|
||||
}
|
||||
@@ -38,13 +38,6 @@ pub struct Counts {
|
||||
pub selected_characters: u64,
|
||||
}
|
||||
|
||||
/// A misspelled word's menu: suggestions as the kernel's rules.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Spelling {
|
||||
/// Label and `ApplyRule` value for each suggestion.
|
||||
pub suggestions: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The counts from `.uno:WordCount`.
|
||||
pub fn counts(json: &str) -> io::Result<Counts> {
|
||||
let list = parse(json)?;
|
||||
@@ -57,32 +50,6 @@ pub fn counts(json: &str) -> io::Result<Counts> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A context menu's spelling part, if it has one.
|
||||
pub fn spelling(json: &str) -> io::Result<Option<Spelling>> {
|
||||
let list = parse(json)?;
|
||||
let items = entries(&list, "menu")?;
|
||||
let commands: Vec<&str> = items
|
||||
.iter()
|
||||
.filter_map(|item| item["command"].as_str())
|
||||
.collect();
|
||||
if !commands
|
||||
.iter()
|
||||
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let suggestions = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let rule = item["command"]
|
||||
.as_str()?
|
||||
.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")?;
|
||||
Some((item["text"].as_str()?.to_string(), rule.to_string()))
|
||||
})
|
||||
.collect();
|
||||
Ok(Some(Spelling { suggestions }))
|
||||
}
|
||||
|
||||
/// A heading from `.uno:Outline`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Heading {
|
||||
@@ -190,11 +157,11 @@ pub fn formula(json: &str) -> io::Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(json: &str) -> io::Result<serde_json::Value> {
|
||||
pub(crate) fn parse(json: &str) -> io::Result<serde_json::Value> {
|
||||
serde_json::from_str(json).map_err(|error| invalid(format!("a bad kernel list: {error}")))
|
||||
}
|
||||
|
||||
fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
|
||||
pub(crate) fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
|
||||
list[key]
|
||||
.as_array()
|
||||
.ok_or_else(|| invalid(format!("the list has no {key}")))
|
||||
@@ -257,7 +224,7 @@ fn plain(html: &str) -> String {
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
fn invalid(message: String) -> io::Error {
|
||||
pub(crate) fn invalid(message: String) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
}
|
||||
|
||||
@@ -288,12 +255,5 @@ mod tests {
|
||||
let counts = counts(r#"{"document":{"words":11,"characters":41,"charactersNoSpaces":31},"selection":{"words":2,"characters":7,"charactersNoSpaces":6}}"#)
|
||||
.expect("counts parse");
|
||||
assert_eq!((counts.words, counts.selected_characters), (11, 7));
|
||||
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling"}]}"#;
|
||||
let found = spelling(menu)
|
||||
.expect("the menu parses")
|
||||
.expect("a spelling menu");
|
||||
assert_eq!(found.suggestions, [("The".into(), "Spelling_The".into())]);
|
||||
let other = r#"{"menu":[{"text":"Cut","type":"command","command":".uno:Cut"}]}"#;
|
||||
assert_eq!(spelling(other).expect("the menu parses"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! A table's borders, as Writer and Impress report them (DOC-07).
|
||||
|
||||
use std::io;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ipc::{Pack, Unpack};
|
||||
|
||||
/// One axis in twips: columns run across, rows down.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Lines {
|
||||
/// The first line, from `offset`.
|
||||
pub left: i32,
|
||||
/// The last line, from `offset`.
|
||||
pub right: i32,
|
||||
pub offset: i32,
|
||||
pub inner: Vec<Line>,
|
||||
/// Bounds of the first and last lines, when sent.
|
||||
pub ends: Option<[(i32, i32); 2]>,
|
||||
}
|
||||
|
||||
/// An inner line and the kernel's bounds for it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Line {
|
||||
pub position: i32,
|
||||
pub min: i32,
|
||||
pub max: i32,
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// The table holding the cursor.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Table {
|
||||
pub columns: Lines,
|
||||
pub rows: Lines,
|
||||
}
|
||||
|
||||
/// Reads a `LOK_CALLBACK_TABLE_SELECTED` payload; `{}` is no table.
|
||||
pub fn read(text: &str) -> Result<Option<Table>, String> {
|
||||
let fault = || format!("an unreadable table: {text}");
|
||||
let json: Value = serde_json::from_str(text).map_err(|_| fault())?;
|
||||
let object = json.as_object().ok_or_else(fault)?;
|
||||
if object.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// Impress writes every value as text (boost's property tree).
|
||||
let int = |value: Option<&Value>| match value? {
|
||||
Value::Number(number) => i32::try_from(number.as_i64()?).ok(),
|
||||
Value::String(text) => text.parse().ok(),
|
||||
_ => None,
|
||||
};
|
||||
let flag = |value: Option<&Value>| match value? {
|
||||
Value::Bool(on) => Some(*on),
|
||||
Value::String(text) => text.parse().ok(),
|
||||
_ => None,
|
||||
};
|
||||
let lines = |key: &str| -> Option<Lines> {
|
||||
let axis = object.get(key)?;
|
||||
// Boost writes an empty list as an empty string.
|
||||
let entries = match axis.get("entries")? {
|
||||
Value::Array(entries) => entries.as_slice(),
|
||||
Value::String(text) if text.is_empty() => &[],
|
||||
_ => return None,
|
||||
};
|
||||
let inner = entries.iter().map(|entry| {
|
||||
Some(Line {
|
||||
position: int(entry.get("position"))?,
|
||||
min: int(entry.get("min"))?,
|
||||
max: int(entry.get("max"))?,
|
||||
hidden: flag(entry.get("hidden"))?,
|
||||
})
|
||||
});
|
||||
let bound = |key: &str| int(axis.get(key));
|
||||
let ends = match ["leftMin", "leftMax", "rightMin", "rightMax"].map(|key| axis.get(key).is_some()) {
|
||||
[false, false, false, false] => None,
|
||||
[true, true, true, true] => {
|
||||
Some([(bound("leftMin")?, bound("leftMax")?), (bound("rightMin")?, bound("rightMax")?)])
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(Lines {
|
||||
left: int(axis.get("left"))?,
|
||||
right: int(axis.get("right"))?,
|
||||
offset: int(axis.get("tableOffset"))?,
|
||||
inner: inner.collect::<Option<_>>()?,
|
||||
ends,
|
||||
})
|
||||
};
|
||||
let columns = lines("columns").ok_or_else(fault)?;
|
||||
let rows = lines("rows").ok_or_else(fault)?;
|
||||
Ok(Some(Table { columns, rows }))
|
||||
}
|
||||
|
||||
impl Table {
|
||||
pub(crate) fn pack(table: Option<&Table>, pack: Pack) -> Pack {
|
||||
let Some(table) = table else {
|
||||
return pack.u64(0);
|
||||
};
|
||||
[&table.columns, &table.rows].into_iter().fold(pack.u64(1), |pack, axis| {
|
||||
let pack = pack
|
||||
.i64(axis.left.into())
|
||||
.i64(axis.right.into())
|
||||
.i64(axis.offset.into())
|
||||
.u64(axis.inner.len() as u64);
|
||||
let pack = match axis.ends {
|
||||
None => pack.u64(0),
|
||||
Some(ends) => ends.iter().fold(pack.u64(1), |pack, &(low, high)| pack.i64(low.into()).i64(high.into())),
|
||||
};
|
||||
axis.inner.iter().fold(pack, |pack, line| {
|
||||
pack.i64(line.position.into())
|
||||
.i64(line.min.into())
|
||||
.i64(line.max.into())
|
||||
.u64(line.hidden as u64)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn unpack(unpack: &mut Unpack) -> io::Result<Option<Table>> {
|
||||
if unpack.u64()? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut lines = || -> io::Result<Lines> {
|
||||
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
|
||||
let (left, right, offset) = (int()?, int()?, int()?);
|
||||
let count = unpack.u64()?;
|
||||
let ends = match unpack.u64()? {
|
||||
0 => None,
|
||||
_ => {
|
||||
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
|
||||
Some([(int()?, int()?), (int()?, int()?)])
|
||||
}
|
||||
};
|
||||
let inner = (0..count)
|
||||
.map(|_| {
|
||||
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
|
||||
let (position, min, max) = (int()?, int()?, int()?);
|
||||
Ok(Line { position, min, max, hidden: unpack.u64()? != 0 })
|
||||
})
|
||||
.collect::<io::Result<_>>()?;
|
||||
Ok(Lines { left, right, offset, inner, ends })
|
||||
};
|
||||
let columns = lines()?;
|
||||
let rows = lines()?;
|
||||
Ok(Some(Table { columns, rows }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SEEN: &str = r#"{ "columns": { "left": 0, "right": 9638, "tableOffset": 1135, "entries": [ { "position": 4819, "min": 300, "max": 9338, "hidden": false } ] }, "rows": { "left": 0, "right": 552, "tableOffset": 1418, "entries": [ { "position": 276, "min": 100, "max": 552, "hidden": false } ] } }"#;
|
||||
|
||||
/// As Impress sent it for a new 2x2 table.
|
||||
const IMPRESS: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "entries": [ { "position": "3996", "min": "340", "max": "7653", "hidden": "false" } ] }, "rows": { "tableOffset": "4340", "left": "0", "right": "1091", "entries": [ { "position": "545", "min": "340", "max": "152183880", "hidden": "false" } ] }, "rectangle": { "x": "3940", "y": "4351", "width": "7993", "height": "1091" } }"#;
|
||||
|
||||
#[test]
|
||||
fn impress_writes_numbers_as_text() {
|
||||
let table = read(IMPRESS).expect("readable").expect("a table");
|
||||
assert_eq!(table.columns.offset, 3929);
|
||||
assert_eq!(table.columns.inner, [Line { position: 3996, min: 340, max: 7653, hidden: false }]);
|
||||
assert_eq!(table.rows.right, 1091);
|
||||
assert!(read(&IMPRESS.replace("\"false\"", "\"no\"")).is_err(), "a flag is true or false");
|
||||
assert_eq!(table.rows.ends, None, "an unpatched kernel sends no ends");
|
||||
}
|
||||
|
||||
/// One row, as Impress sends it with patch 0047.
|
||||
const ONE_ROW: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "leftMin": "-152183880", "leftMax": "7823", "rightMin": "170", "rightMax": "152183880", "entries": "" }, "rows": { "tableOffset": "4340", "left": "0", "right": "545", "leftMin": "-152183880", "leftMax": "28", "rightMin": "517", "rightMax": "152183880", "entries": "" } }"#;
|
||||
|
||||
#[test]
|
||||
fn one_line_has_no_inner_lines_and_its_ends_cross_the_wire() {
|
||||
let table = read(ONE_ROW).expect("readable").expect("a table");
|
||||
assert!(table.columns.inner.is_empty() && table.rows.inner.is_empty());
|
||||
assert_eq!(table.rows.ends, Some([(-152_183_880, 28), (517, 152_183_880)]));
|
||||
assert!(read(&ONE_ROW.replace("\"entries\": \"\"", "\"entries\": \"x\"")).is_err(), "text is no list");
|
||||
assert!(read(&ONE_ROW.replace("\"leftMin\": \"-152183880\", ", "")).is_err(), "ends come in fours");
|
||||
let bytes = Table::pack(Some(&table), Pack::new()).bytes();
|
||||
assert_eq!(Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks"), Some(table));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_reads_and_crosses_the_wire() {
|
||||
assert_eq!(read("{ }"), Ok(None), "no table");
|
||||
let table = read(SEEN).expect("readable").expect("a table");
|
||||
assert_eq!(table.columns.offset, 1135);
|
||||
assert_eq!(table.rows.inner[0].position, 276);
|
||||
let text = SEEN.replace("\"left\": 0,", "\"left\": \"zero\",");
|
||||
assert!(read(&text).is_err(), "a word is no number");
|
||||
assert!(read("[]").is_err());
|
||||
let bytes = Table::pack(Some(&table), Pack::new()).bytes();
|
||||
let back = Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks");
|
||||
assert_eq!(back, Some(table));
|
||||
let none = Table::pack(None, Pack::new()).bytes();
|
||||
assert_eq!(Table::unpack(&mut Unpack::new(&none)).expect("unpacks"), None);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use image::{Frame, ImageBuffer, Rgba};
|
||||
|
||||
use crate::{Doc, Events, Rect, Worker};
|
||||
|
||||
mod borders;
|
||||
mod clip;
|
||||
mod clock;
|
||||
mod formula;
|
||||
@@ -130,6 +131,7 @@ pub struct LokView {
|
||||
marked: Option<ime::Marked>,
|
||||
dragging: bool,
|
||||
grip: Option<handles::Grip>,
|
||||
borders: borders::Borders,
|
||||
handle_size: Pixels,
|
||||
states: HashMap<String, String>,
|
||||
graphic: Option<Rect>,
|
||||
@@ -137,6 +139,7 @@ pub struct LokView {
|
||||
/// The last search outcome: its count and matches.
|
||||
search: (u64, Vec<Rect>),
|
||||
menu: Option<String>,
|
||||
right_at: Option<Point<Pixels>>,
|
||||
fonts: Vec<String>,
|
||||
a11y: crate::A11y,
|
||||
tabs: Vec<i32>,
|
||||
@@ -163,7 +166,6 @@ pub fn layout(worker: &mut Worker, doc: &Doc) -> io::Result<Layout> {
|
||||
Ok((doc.size(worker)?, doc.pages(worker)?))
|
||||
}
|
||||
|
||||
|
||||
impl EventEmitter<LokError> for LokView {}
|
||||
|
||||
impl EventEmitter<Reported> for LokView {}
|
||||
@@ -191,10 +193,7 @@ impl LokView {
|
||||
let mut view = Self {
|
||||
worker,
|
||||
doc,
|
||||
geom: Geom {
|
||||
zoom: 1.0,
|
||||
dpi: 1.0,
|
||||
},
|
||||
geom: Geom { zoom: 1.0, dpi: 1.0 },
|
||||
scroll: (0, 0),
|
||||
doc_size: (width as i32, height as i32),
|
||||
generation: 0,
|
||||
@@ -233,12 +232,14 @@ impl LokView {
|
||||
marked: None,
|
||||
dragging: false,
|
||||
grip: None,
|
||||
borders: Default::default(),
|
||||
handle_size: px(6.0),
|
||||
states: HashMap::new(),
|
||||
graphic: None,
|
||||
review: 0,
|
||||
search: (0, Vec::new()),
|
||||
menu: None,
|
||||
right_at: None,
|
||||
fonts: Vec::new(),
|
||||
a11y: crate::A11y::default(),
|
||||
tabs: Vec::new(),
|
||||
@@ -398,6 +399,7 @@ impl LokView {
|
||||
}
|
||||
self.cell = batch.cell;
|
||||
self.graphic = batch.graphic;
|
||||
self.read_borders(batch.table, batch.revision);
|
||||
self.review = batch.review;
|
||||
self.search = (batch.searched, batch.found);
|
||||
self.fonts = batch.fonts;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Dragging the cursor's table borders (DOC-07).
|
||||
|
||||
use gpui::{Bounds, Context, CursorStyle, Pixels, Point, px, size};
|
||||
|
||||
use super::LokView;
|
||||
use crate::table::{Lines, Table};
|
||||
use crate::{Doc, LokError};
|
||||
|
||||
/// Least gap the kernel keeps between lines, in twips.
|
||||
const GAP: i32 = 40;
|
||||
|
||||
/// A border, as `.uno:TableChangeCurrentBorderPosition` names it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Side {
|
||||
ColumnLeft,
|
||||
Column(u16),
|
||||
ColumnRight,
|
||||
Row(u16),
|
||||
RowBottom,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// Column lines stand upright and move across.
|
||||
fn upright(self) -> bool {
|
||||
matches!(self, Side::ColumnLeft | Side::Column(_) | Side::ColumnRight)
|
||||
}
|
||||
|
||||
fn kind(self) -> &'static str {
|
||||
match self {
|
||||
Side::ColumnLeft => "column-left",
|
||||
Side::Column(_) => "column-middle",
|
||||
Side::ColumnRight => "column-right",
|
||||
Side::Row(_) => "row-middle",
|
||||
Side::RowBottom => "row-right",
|
||||
}
|
||||
}
|
||||
|
||||
fn index(self) -> u16 {
|
||||
match self {
|
||||
Side::Column(index) | Side::Row(index) => index,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A held border, with the table read it came from.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Held {
|
||||
side: Side,
|
||||
/// The pointer's offset from the line when pressed.
|
||||
grab: i32,
|
||||
from: i32,
|
||||
to: i32,
|
||||
table: Table,
|
||||
doc: Doc,
|
||||
}
|
||||
|
||||
/// The cursor's table as last read, and a held border.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Borders {
|
||||
table: Option<(Table, Doc)>,
|
||||
/// The revision of the batch the table came from.
|
||||
read: u64,
|
||||
held: Option<Held>,
|
||||
hover: Option<CursorStyle>,
|
||||
}
|
||||
|
||||
/// Each border: side, place, span and room, in twips.
|
||||
fn lines(table: &Table) -> Vec<(Side, i32, (i32, i32), i32)> {
|
||||
let (columns, rows) = (&table.columns, &table.rows);
|
||||
let down = (rows.offset + rows.left, rows.offset + rows.right);
|
||||
let across = (columns.offset + columns.left, columns.offset + columns.right);
|
||||
let mut upright = vec![(Side::ColumnLeft, across.0)];
|
||||
upright.extend(inner(columns).map(|(index, at)| (Side::Column(index), at)));
|
||||
upright.push((Side::ColumnRight, across.1));
|
||||
let mut level: Vec<_> = inner(rows).map(|(index, at)| (Side::Row(index), at)).collect();
|
||||
level.push((Side::RowBottom, down.1));
|
||||
let mut lines = spaced(&upright, None, down);
|
||||
lines.extend(spaced(&level, Some(down.0), across));
|
||||
lines
|
||||
}
|
||||
|
||||
/// Gives each line its span and nearest neighbour's distance.
|
||||
fn spaced(stops: &[(Side, i32)], before: Option<i32>, span: (i32, i32)) -> Vec<(Side, i32, (i32, i32), i32)> {
|
||||
let places: Vec<i32> = before.into_iter().chain(stops.iter().map(|&(_, at)| at)).collect();
|
||||
let skip = places.len() - stops.len();
|
||||
let room = |i: usize| {
|
||||
let prev = i.checked_sub(1).map(|j| places[i] - places[j]);
|
||||
prev.into_iter().chain(places.get(i + 1).map(|next| next - places[i])).min().unwrap_or(i32::MAX)
|
||||
};
|
||||
stops.iter().enumerate().map(|(i, &(side, at))| (side, at, span, room(i + skip))).collect()
|
||||
}
|
||||
|
||||
fn inner(lines: &Lines) -> impl Iterator<Item = (u16, i32)> + '_ {
|
||||
lines.inner.iter().enumerate().filter(|(_, line)| !line.hidden).map(|(index, line)| (index as u16, lines.offset + line.position))
|
||||
}
|
||||
|
||||
/// `offset` as the kernel will clamp it.
|
||||
fn clamp(table: &Table, side: Side, offset: i32) -> i32 {
|
||||
let axis = if side.upright() { &table.columns } else { &table.rows };
|
||||
let (first, last) = (axis.inner.first(), axis.inner.last());
|
||||
// Impress sends its outer bounds; Writer leaves a gap.
|
||||
let (at, low, high) = match (side, axis.ends) {
|
||||
(Side::ColumnLeft, Some([(low, high), _])) => (axis.left, low, high),
|
||||
(Side::ColumnRight | Side::RowBottom, Some([_, (low, high)])) => (axis.right, low, high),
|
||||
(Side::ColumnLeft, None) => (axis.left, i32::MIN, first.map_or(axis.right, |line| line.position) - GAP),
|
||||
(Side::ColumnRight | Side::RowBottom, None) => {
|
||||
(axis.right, last.map_or(axis.left, |line| line.position) + GAP, i32::MAX)
|
||||
}
|
||||
(Side::Column(index) | Side::Row(index), ends) => {
|
||||
let line = axis.inner[usize::from(index)];
|
||||
let high = if ends.is_some() { line.max } else { (line.max - GAP).max(line.min) };
|
||||
(line.position, line.min, high)
|
||||
}
|
||||
};
|
||||
let moved = (at.saturating_add(offset)).clamp(low, high) - at;
|
||||
// A line past its bound never moves against the drag.
|
||||
if moved.signum() == offset.signum() { moved } else { 0 }
|
||||
}
|
||||
|
||||
impl LokView {
|
||||
/// Takes the cursor's table from a batch at `revision`.
|
||||
pub(super) fn read_borders(&mut self, table: Option<Table>, revision: u64) {
|
||||
if revision < self.borders.read {
|
||||
log::debug!("lok: table read at {revision} is older than {}", self.borders.read);
|
||||
return;
|
||||
}
|
||||
if table.is_none() {
|
||||
self.borders.hover = None;
|
||||
}
|
||||
let mut doc = self.doc.clone();
|
||||
doc.rev = revision;
|
||||
self.borders.read = revision;
|
||||
self.borders.table = table.map(|table| (table, doc));
|
||||
}
|
||||
|
||||
/// The border under `position`; short cells keep their middles.
|
||||
fn border_at(&self, position: Point<Pixels>) -> Option<(Side, i32)> {
|
||||
let (table, _) = self.borders.table.as_ref()?;
|
||||
lines(table).into_iter().find_map(|(side, at, (start, end), room)| {
|
||||
let reach = self.handle_size.min(px(self.geom.px_of(room) / 4.0));
|
||||
let (from, to) = if side.upright() {
|
||||
(self.window_point((at, start)), self.window_point((at, end)))
|
||||
} else {
|
||||
(self.window_point((start, at)), self.window_point((end, at)))
|
||||
};
|
||||
let (near, along, low, high) = if side.upright() {
|
||||
((from.x - position.x).abs(), position.y, from.y, to.y)
|
||||
} else {
|
||||
((from.y - position.y).abs(), position.x, from.x, to.x)
|
||||
};
|
||||
(near <= reach && along >= low - reach && along <= high + reach).then_some((side, at))
|
||||
})
|
||||
}
|
||||
|
||||
/// Holds the border under `position`, if any.
|
||||
pub(super) fn hold_border(&mut self, position: Point<Pixels>) -> bool {
|
||||
let Some(((side, at), (table, doc))) = self.border_at(position).zip(self.borders.table.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let (x, y) = self.doc_point(position);
|
||||
let grab = if side.upright() { x } else { y } - at;
|
||||
log::info!("lok: table border {} {} held", side.kind(), side.index());
|
||||
self.borders.held = Some(Held { side, grab, from: at, to: at, table, doc });
|
||||
true
|
||||
}
|
||||
|
||||
/// A held border follows the pointer; a hovered one shows.
|
||||
pub(super) fn move_border(&mut self, position: Point<Pixels>) -> bool {
|
||||
let Some(held) = &self.borders.held else {
|
||||
let side = self.border_at(position).map(|(side, _)| side);
|
||||
self.borders.hover = side.map(|side| if side.upright() { CursorStyle::ResizeColumn } else { CursorStyle::ResizeRow });
|
||||
return false;
|
||||
};
|
||||
let (x, y) = self.doc_point(position);
|
||||
let want = if held.side.upright() { x } else { y } - held.grab;
|
||||
let to = held.from + clamp(&held.table, held.side, want - held.from);
|
||||
if let Some(held) = self.borders.held.as_mut() {
|
||||
held.to = to;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Lets go at `position`: one command at the read's revision.
|
||||
pub(super) fn drop_border(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) -> bool {
|
||||
if !self.move_border(position) {
|
||||
return false;
|
||||
}
|
||||
let Some(held) = self.borders.held.take() else {
|
||||
return false;
|
||||
};
|
||||
let offset = held.to - held.from;
|
||||
if offset == 0 {
|
||||
log::info!("lok: table border let go in place");
|
||||
return true;
|
||||
}
|
||||
log::info!("lok: table border {} {} moves {offset}", held.side.kind(), held.side.index());
|
||||
let (index, offset) = (held.side.index().to_string(), offset.to_string());
|
||||
if let Err(error) = self.run_at(&held.doc, "table.border", &[held.side.kind(), &index, &offset]) {
|
||||
cx.emit(LokError(format!("the table border did not move: {error}")));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn holds_border(&self) -> bool {
|
||||
self.borders.held.is_some()
|
||||
}
|
||||
|
||||
/// The pointer a border asks for, while held or hovered.
|
||||
pub(super) fn border_pointer(&self) -> Option<CursorStyle> {
|
||||
match &self.borders.held {
|
||||
Some(held) if held.side.upright() => Some(CursorStyle::ResizeColumn),
|
||||
Some(_) => Some(CursorStyle::ResizeRow),
|
||||
None => self.borders.hover,
|
||||
}
|
||||
}
|
||||
|
||||
/// The guide line of a held border, in window pixels.
|
||||
pub(super) fn guide(&self) -> Option<Bounds<Pixels>> {
|
||||
let held = self.borders.held.as_ref()?;
|
||||
let (_, _, (start, end), _) = lines(&held.table).into_iter().find(|&(side, ..)| side == held.side)?;
|
||||
Some(if held.side.upright() {
|
||||
let (top, bottom) = (self.window_point((held.to, start)), self.window_point((held.to, end)));
|
||||
Bounds { origin: top, size: size(px(1.0), bottom.y - top.y) }
|
||||
} else {
|
||||
let (left, right) = (self.window_point((start, held.to)), self.window_point((end, held.to)));
|
||||
Bounds { origin: left, size: size(right.x - left.x, px(1.0)) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::table::Line;
|
||||
|
||||
fn table() -> Table {
|
||||
let line = |position, min, max| Line { position, min, max, hidden: false };
|
||||
Table {
|
||||
columns: Lines { left: 0, right: 9000, offset: 1000, inner: vec![line(3000, 300, 6000), line(6000, 3300, 8700)], ends: None },
|
||||
rows: Lines { left: 0, right: 800, offset: 2000, inner: vec![line(400, 100, 800)], ends: None },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn borders_lie_where_the_kernel_puts_them() {
|
||||
let sides: Vec<_> = lines(&table()).into_iter().map(|(side, at, _, room)| (side, at, room)).collect();
|
||||
assert_eq!(
|
||||
sides,
|
||||
[
|
||||
(Side::ColumnLeft, 1000, 3000),
|
||||
(Side::Column(0), 4000, 3000),
|
||||
(Side::Column(1), 7000, 3000),
|
||||
(Side::ColumnRight, 10_000, 3000),
|
||||
(Side::Row(0), 2400, 400),
|
||||
(Side::RowBottom, 2800, 400),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_clamps_as_the_kernel_does() {
|
||||
let table = table();
|
||||
assert_eq!(clamp(&table, Side::Column(0), 500), 500, "inside its bounds");
|
||||
assert_eq!(clamp(&table, Side::Column(0), 9000), 2960, "up to max less the gap");
|
||||
assert_eq!(clamp(&table, Side::Column(1), -9000), -2700, "down to min");
|
||||
assert_eq!(clamp(&table, Side::ColumnLeft, 5000), 2960, "short of the first line");
|
||||
assert_eq!(clamp(&table, Side::ColumnRight, -8000), -2960, "past the last line");
|
||||
assert_eq!(clamp(&table, Side::RowBottom, -800), -360, "a row keeps the gap");
|
||||
assert_eq!(clamp(&table, Side::Row(0), 500), 360, "max less the gap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_ends_bound_the_outer_lines() {
|
||||
let mut table = table();
|
||||
table.columns.ends = Some([(-50_000, 2800), (6200, 50_000)]);
|
||||
table.rows.ends = Some([(-50_000, 380), (717, 50_000)]);
|
||||
assert_eq!(clamp(&table, Side::ColumnLeft, 5000), 2800, "to the first column's minimum");
|
||||
assert_eq!(clamp(&table, Side::ColumnLeft, -900), -900, "outward too");
|
||||
assert_eq!(clamp(&table, Side::ColumnRight, -8000), -2800, "to the last column's minimum");
|
||||
assert_eq!(clamp(&table, Side::RowBottom, -800), -83, "to the last row's minimum");
|
||||
assert_eq!(clamp(&table, Side::Column(0), 9000), 3000, "Impress's max needs no gap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_at_or_past_its_bound_stays_put() {
|
||||
let mut table = table();
|
||||
table.columns.inner[0].max = 3000;
|
||||
assert_eq!(clamp(&table, Side::Column(0), 0), 0, "a still press sends nothing");
|
||||
assert_eq!(clamp(&table, Side::Column(0), 200), 0, "no room toward the bound");
|
||||
assert_eq!(clamp(&table, Side::Column(0), -200), -200, "away from it is free");
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,8 @@ impl LokView {
|
||||
return;
|
||||
};
|
||||
window.focus(&self.focus, cx);
|
||||
if event.button == MouseButton::Left && self.grab(event.position) {
|
||||
self.right_at = (event.button == MouseButton::Right).then_some(event.position);
|
||||
if event.button == MouseButton::Left && (self.grab(event.position) || self.hold_border(event.position)) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
@@ -333,6 +334,10 @@ impl LokView {
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.drop_border(event.position, cx) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if let Some(grip) = self.grip.take() {
|
||||
let to = self.doc_point(event.position);
|
||||
log::info!("lok: handle released");
|
||||
@@ -365,7 +370,7 @@ impl LokView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.dragging || self.grip.is_some() {
|
||||
if self.dragging || self.grip.is_some() || self.holds_border() {
|
||||
self.on_mouse_up(event, window, cx);
|
||||
}
|
||||
}
|
||||
@@ -382,6 +387,10 @@ impl LokView {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.move_border(event.position) {
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
if self.dragging && event.pressed_button == Some(MouseButton::Left) {
|
||||
self.mouse(2, event.position, 1, 1, &event.modifiers);
|
||||
} else if event.pressed_button.is_none() {
|
||||
@@ -391,7 +400,17 @@ impl LokView {
|
||||
}
|
||||
|
||||
pub(super) fn on_copy(&mut self, _: &Copy, _: &mut Window, _: &mut Context<Self>) {
|
||||
self.send(Input::Copy { cut: false });
|
||||
self.copy(false);
|
||||
}
|
||||
|
||||
/// Copies the selection out, or cuts it.
|
||||
pub fn copy(&mut self, cut: bool) {
|
||||
self.send(Input::Copy { cut });
|
||||
}
|
||||
|
||||
/// Pastes the system clipboard.
|
||||
pub fn paste(&mut self) {
|
||||
self.send(Input::Paste);
|
||||
}
|
||||
|
||||
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
|
||||
@@ -410,11 +429,11 @@ impl LokView {
|
||||
}
|
||||
|
||||
pub(super) fn on_cut(&mut self, _: &Cut, _: &mut Window, _: &mut Context<Self>) {
|
||||
self.send(Input::Copy { cut: true });
|
||||
self.copy(true);
|
||||
}
|
||||
|
||||
pub(super) fn on_paste(&mut self, _: &Paste, _: &mut Window, _: &mut Context<Self>) {
|
||||
self.send(Input::Paste);
|
||||
self.paste();
|
||||
}
|
||||
|
||||
/// Pastes values or formulas alone (TABLE-08).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! What the view knows, for apps to read.
|
||||
|
||||
use gpui::FocusHandle;
|
||||
use gpui::{FocusHandle, Pixels, Point};
|
||||
|
||||
use super::LokView;
|
||||
use crate::Rect;
|
||||
@@ -91,9 +91,10 @@ impl LokView {
|
||||
&self.fonts
|
||||
}
|
||||
|
||||
/// Takes the context menu the kernel last offered.
|
||||
pub fn take_menu(&mut self) -> Option<String> {
|
||||
self.menu.take()
|
||||
/// Takes the kernel's last menu and the click that asked.
|
||||
pub fn take_menu(&mut self) -> Option<(String, Option<Point<Pixels>>)> {
|
||||
let menu = self.menu.take()?;
|
||||
Some((menu, self.right_at.take()))
|
||||
}
|
||||
|
||||
/// The paragraph's own tab stops, in 1/100 mm.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use ely_gpui_component::theme::ActiveTheme;
|
||||
use gpui::{
|
||||
BorderStyle, Bounds, ContentMask, Context, Corners, CursorStyle, Edges, ElementInputHandler,
|
||||
InteractiveElement, IntoElement, MouseButton, ParentElement, Render, Role,
|
||||
BorderStyle, Bounds, ContentMask, Context, Corners, CursorStyle, DispatchPhase, Edges,
|
||||
ElementInputHandler, InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, Render, Role,
|
||||
StatefulInteractiveElement, Styled, Window, canvas, div, fill, outline, px, quad, size,
|
||||
transparent_black,
|
||||
};
|
||||
@@ -59,7 +59,7 @@ impl Render for LokView {
|
||||
.aria_value(self.a11y.text.clone())
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.cursor(self.pointer)
|
||||
.cursor(self.border_pointer().unwrap_or(self.pointer))
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(canvas_color)
|
||||
@@ -164,6 +164,9 @@ impl Render for LokView {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(guide) = painted.guide {
|
||||
window.paint_quad(fill(guide, frame_color));
|
||||
}
|
||||
if let Some(caret) = painted.caret.filter(|_| focus.is_focused(window))
|
||||
{
|
||||
let caret = Bounds {
|
||||
@@ -173,6 +176,19 @@ impl Render for LokView {
|
||||
window.paint_quad(fill(caret, caret_color));
|
||||
}
|
||||
});
|
||||
if painted.guide.is_some() {
|
||||
// A held border follows the pointer out of the view.
|
||||
let this = this.clone();
|
||||
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, app| {
|
||||
if phase == DispatchPhase::Bubble {
|
||||
this.update(app, |view, cx| {
|
||||
if view.move_border(event.position) {
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
window.handle_input(&focus, ElementInputHandler::new(bounds, this), app);
|
||||
},
|
||||
)
|
||||
@@ -196,6 +212,8 @@ pub(super) struct Painted {
|
||||
pub(super) frame: Option<(Bounds<Pixels>, Vec<Bounds<Pixels>>)>,
|
||||
/// The references of a formula being typed.
|
||||
pub(super) marks: Vec<(Bounds<Pixels>, gpui::Hsla)>,
|
||||
/// A held table border's guide line.
|
||||
pub(super) guide: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
impl LokView {
|
||||
@@ -357,6 +375,7 @@ impl LokView {
|
||||
cell: self.cell.cursor.map(rect),
|
||||
frame,
|
||||
marks: self.marks(),
|
||||
guide: self.guide(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use super::*;
|
||||
|
||||
mod borders;
|
||||
mod clip;
|
||||
mod commands;
|
||||
mod fit;
|
||||
mod input;
|
||||
mod large;
|
||||
mod show_borders;
|
||||
mod theme;
|
||||
use gpui::{App, Bounds, Entity, point, size};
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
use super::*;
|
||||
use crate::client::tests::poll_until;
|
||||
use gpui::{CursorStyle, MouseButton, MouseDownEvent, MouseUpEvent, TestAppContext};
|
||||
|
||||
const TABLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
|
||||
<office:body><office:text>
|
||||
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
|
||||
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell><text:p>down</text:p></table:table-cell><table:table-cell><text:p>more</text:p></table:table-cell></table:table-row>
|
||||
</table:table><text:p>after</text:p></office:text></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// The view takes the cursor's table the kernel reports.
|
||||
fn borders_read(view: &mut LokView) -> crate::table::Table {
|
||||
let doc = view.doc.clone();
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
worker.post_key(&doc, 0, 1027).expect("Right moves the cursor");
|
||||
let seen = poll_until(&mut worker, &doc, &Events::default(), WAIT, |seen| seen.table.is_some());
|
||||
let table = seen.table.expect("the cursor's table");
|
||||
let now = worker.now(&doc).expect("the revision");
|
||||
drop(worker);
|
||||
view.doc = now;
|
||||
view.read_borders(Some(table.clone()), view.doc.rev);
|
||||
table
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_border_moves_once_where_its_guide_shows() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
|
||||
let (line, moved) = cx.update(|app| {
|
||||
view.update(app, |view, cx| {
|
||||
let table = borders_read(view);
|
||||
let (columns, rows) = (&table.columns, &table.rows);
|
||||
let line = columns.offset + columns.inner[0].position;
|
||||
let middle = rows.offset + (rows.left + rows.right) / 2;
|
||||
let on = view.window_point((line, middle));
|
||||
view.move_border(on);
|
||||
assert_eq!(view.border_pointer(), Some(CursorStyle::ResizeColumn), "a hovered line asks to resize");
|
||||
assert!(view.hold_border(on), "the line is held");
|
||||
assert!(view.drop_border(on, cx), "let go in place");
|
||||
assert!(view.hold_border(on));
|
||||
let end = on + gpui::point(px(40.0), px(3.0));
|
||||
assert!(view.move_border(end));
|
||||
let guide = view.guide().expect("a guide line");
|
||||
let to = view.doc_point(guide.origin).0;
|
||||
assert!(to > line, "the guide follows the pointer: {line} to {to}");
|
||||
assert!(view.drop_border(end, cx), "the drop runs");
|
||||
assert!(!view.holds_border());
|
||||
(line, to - line)
|
||||
})
|
||||
});
|
||||
for _ in 0..80 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
cx.update(|app| {
|
||||
let view = view.read(app);
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
|
||||
seen.table.as_ref().is_some_and(|table| table.columns.offset + table.columns.inner[0].position != line)
|
||||
});
|
||||
let table = seen.table.expect("still in the table");
|
||||
let landed = table.columns.offset + table.columns.inner[0].position;
|
||||
assert!((landed - line - moved).abs() <= 1, "the kernel lands it at the guide: {line} + {moved} vs {landed}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_border_keeps_its_table_and_a_newer_read_refuses_it() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
|
||||
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let line = cx.update(|app| {
|
||||
let errors = errors.clone();
|
||||
app.subscribe(&view, move |_, error: &LokError, _| errors.borrow_mut().push(error.0.clone())).detach();
|
||||
view.update(app, |view, cx| {
|
||||
let table = borders_read(view);
|
||||
let (columns, rows) = (&table.columns, &table.rows);
|
||||
let line = columns.offset + columns.inner[0].position;
|
||||
let on = view.window_point((line, rows.offset + (rows.left + rows.right) / 2));
|
||||
assert!(view.hold_border(on), "the line is held");
|
||||
let doc = view.doc.clone();
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
worker.post_key(&doc, 'a' as u32, 512).expect("a types");
|
||||
view.doc = worker.now(&doc).expect("the revision");
|
||||
drop(worker);
|
||||
let mut gone = table.clone();
|
||||
gone.columns.inner.clear();
|
||||
view.read_borders(Some(gone), view.doc.rev);
|
||||
let end = on + gpui::point(px(40.0), px(0.0));
|
||||
assert!(view.move_border(end), "it moves by its own table");
|
||||
assert!(view.guide().is_some(), "its guide still shows");
|
||||
assert!(view.drop_border(end, cx), "the drop runs");
|
||||
line
|
||||
})
|
||||
});
|
||||
for _ in 0..60 {
|
||||
for _ in 0..20 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
if !errors.borrow().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(errors.borrow().iter().any(|error| error.contains("stale")), "{:?}", errors.borrow());
|
||||
cx.update(|app| {
|
||||
let view = view.read(app);
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
worker.post_key(&view.doc, 0, 1027).expect("Right moves the cursor");
|
||||
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| seen.table.is_some());
|
||||
let table = seen.table.expect("still in the table");
|
||||
assert_eq!(table.columns.offset + table.columns.inner[0].position, line, "the old drag never lands");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_in_a_short_row_reaches_the_document() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
|
||||
let window = cx.add_window(|_, _| super::input::Blank);
|
||||
cx.update_window(window.into(), |_, window, app| {
|
||||
view.update(app, |view, cx| {
|
||||
view.set_zoom(0.5, cx);
|
||||
let table = borders_read(view);
|
||||
let (columns, rows) = (&table.columns, &table.rows);
|
||||
let x = columns.offset + (columns.left + columns.inner[0].position) / 2;
|
||||
let middle = view.window_point((x, rows.offset + (rows.inner[0].position + rows.right) / 2));
|
||||
let line = view.window_point((x, rows.offset + rows.inner[0].position));
|
||||
assert!(f32::from(line.y - middle.y).abs() < 6.0, "the row is shorter than two reaches");
|
||||
let down = |position| MouseDownEvent {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
modifiers: Default::default(),
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
};
|
||||
let up = MouseUpEvent { button: MouseButton::Left, position: middle, modifiers: Default::default(), click_count: 1 };
|
||||
view.on_mouse_down(&down(middle), window, cx);
|
||||
assert!(!view.holds_border(), "the row's middle goes to the document");
|
||||
view.on_mouse_up(&up, window, cx);
|
||||
view.on_mouse_down(&down(line), window, cx);
|
||||
assert!(view.holds_border(), "the line itself is held");
|
||||
assert!(view.drop_border(line, cx));
|
||||
})
|
||||
})
|
||||
.expect("the window lives");
|
||||
}
|
||||
|
||||
/// The batch whose table has `count` inner columns.
|
||||
fn table_batch(view: &LokView, count: usize) -> Events {
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
|
||||
seen.table.as_ref().is_some_and(|table| table.columns.inner.len() == count)
|
||||
});
|
||||
assert_eq!(seen.table.as_ref().map(|table| table.columns.inner.len()), Some(count));
|
||||
seen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_batch_keeps_its_own_revision() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
|
||||
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let before = cx.update(|app| {
|
||||
let errors = errors.clone();
|
||||
app.subscribe(&view, move |_, error: &LokError, _| errors.borrow_mut().push(error.0.clone())).detach();
|
||||
view.update(app, |view, cx| {
|
||||
borders_read(view);
|
||||
let old = table_batch(view, 1);
|
||||
let table = old.table.clone().expect("the old table");
|
||||
let now = {
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
worker.run(&view.doc, "table.column_right", &[]).expect("a column goes in");
|
||||
worker.now(&view.doc).expect("the revision")
|
||||
};
|
||||
let before = table_batch(view, 2).table.expect("the new table").columns.inner[0].position;
|
||||
view.seen_revision(now.rev);
|
||||
view.apply_batch(old, None, cx);
|
||||
let on = view.window_point((table.columns.offset + table.columns.inner[0].position, table.rows.offset + 100));
|
||||
assert!(view.hold_border(on), "the old table's line is held");
|
||||
let end = on + gpui::point(px(20.0), px(0.0));
|
||||
assert!(view.drop_border(end, cx), "the drop runs");
|
||||
before
|
||||
})
|
||||
});
|
||||
for _ in 0..60 {
|
||||
for _ in 0..20 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
if !errors.borrow().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(errors.borrow().iter().any(|error| error.contains("stale")), "{:?}", errors.borrow());
|
||||
let after = cx.update(|app| view.update(app, |view, _| table_batch(view, 2).table.expect("the table").columns.inner[0].position));
|
||||
assert_eq!(after, before, "the old read never moves the new table");
|
||||
}
|
||||
|
||||
/// The real view, narrower than its window.
|
||||
struct Narrow(gpui::Entity<LokView>);
|
||||
|
||||
impl gpui::Render for Narrow {
|
||||
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement, Styled};
|
||||
gpui::div().w(px(450.0)).h(px(300.0)).child(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_border_let_go_outside_the_view_lands_there() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
|
||||
let (_, vcx) = cx.add_window_view(|_, _| Narrow(view.clone()));
|
||||
vcx.run_until_parked();
|
||||
let (line, on) = vcx.update(|_, app| {
|
||||
view.update(app, |view, cx| {
|
||||
let table = borders_read(view);
|
||||
cx.notify();
|
||||
let line = table.columns.offset + table.columns.inner[0].position;
|
||||
(line, view.window_point((line, table.rows.offset + 100)))
|
||||
})
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
assert!(on.x > px(380.0) && on.x < px(440.0), "the line sits near the view's edge: {on:?}");
|
||||
vcx.simulate_mouse_down(on, MouseButton::Left, Default::default());
|
||||
assert!(vcx.update(|_, app| view.read(app).holds_border()), "the line is held");
|
||||
let inside = gpui::point(px(445.0), on.y);
|
||||
vcx.simulate_mouse_move(inside, MouseButton::Left, Default::default());
|
||||
let at = |vcx: &mut gpui::VisualTestContext| vcx.update(|_, app| view.read(app).guide().expect("a guide").origin.x);
|
||||
let held = at(vcx);
|
||||
let outside = gpui::point(px(490.0), on.y);
|
||||
vcx.simulate_mouse_move(outside, MouseButton::Left, Default::default());
|
||||
let out = at(vcx);
|
||||
assert!(out > held, "the guide follows out of the view: {held:?} to {out:?}");
|
||||
let wanted = vcx.update(|_, app| view.read(app).doc_point(gpui::point(out, on.y)).0);
|
||||
vcx.simulate_mouse_up(outside, MouseButton::Left, Default::default());
|
||||
assert!(!vcx.update(|_, app| view.read(app).holds_border()), "let go");
|
||||
vcx.run_until_parked();
|
||||
let landed = vcx.update(|_, app| {
|
||||
let view = view.read(app);
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
|
||||
seen.table.as_ref().is_some_and(|table| table.columns.offset + table.columns.inner[0].position != line)
|
||||
});
|
||||
let table = seen.table.expect("still in the table");
|
||||
table.columns.offset + table.columns.inner[0].position
|
||||
});
|
||||
assert!((landed - wanted).abs() <= 1, "it lands at the release: {landed} vs {wanted}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_click_beside_a_border_sends_nothing() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
|
||||
let (_, vcx) = cx.add_window_view(|_, _| Narrow(view.clone()));
|
||||
vcx.run_until_parked();
|
||||
let on = vcx.update(|_, app| {
|
||||
view.update(app, |view, cx| {
|
||||
let table = borders_read(view);
|
||||
cx.notify();
|
||||
let line = table.columns.offset + table.columns.inner[0].position;
|
||||
view.window_point((line, table.rows.offset + 100)) + gpui::point(px(3.0), px(0.0))
|
||||
})
|
||||
});
|
||||
vcx.run_until_parked();
|
||||
let sent = vcx.update(|_, app| view.read(app).sent());
|
||||
vcx.simulate_mouse_down(on, MouseButton::Left, Default::default());
|
||||
assert!(vcx.update(|_, app| view.read(app).holds_border()), "the line is held off its centre");
|
||||
vcx.simulate_mouse_up(on, MouseButton::Left, Default::default());
|
||||
assert!(!vcx.update(|_, app| view.read(app).holds_border()), "let go");
|
||||
assert_eq!(vcx.update(|_, app| view.read(app).sent()), sent, "a still click sends no command");
|
||||
}
|
||||
@@ -60,7 +60,7 @@ fn typing_and_composing_reach_the_document() {
|
||||
assert_eq!(saved(&view, &mut cx), ["ab你", "Hello 世界"]);
|
||||
}
|
||||
|
||||
struct Blank;
|
||||
pub(super) struct Blank;
|
||||
|
||||
impl gpui::Render for Blank {
|
||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
use super::*;
|
||||
use crate::client::tests::{poll_until, show::DECK};
|
||||
use crate::table::Table;
|
||||
use gpui::{Pixels, Point, TestAppContext};
|
||||
|
||||
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// A line's place along its axis, in twips.
|
||||
type Place = fn(&Table) -> i32;
|
||||
|
||||
/// A new table on the slide, read into the view.
|
||||
fn table_in(view: &mut LokView, (columns, rows): (&str, &str)) -> Table {
|
||||
let doc = view.doc.clone();
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
worker.run(&doc, "table.insert", &[columns, rows]).expect("a table");
|
||||
let seen = poll_until(&mut worker, &doc, &Events::default(), WAIT, |seen| seen.table.is_some());
|
||||
assert!(seen.faults.is_empty(), "{:?}", seen.faults);
|
||||
let table = seen.table.expect("the selected table");
|
||||
view.doc = worker.now(&doc).expect("the revision");
|
||||
drop(worker);
|
||||
view.read_borders(Some(table.clone()), view.doc.rev);
|
||||
table
|
||||
}
|
||||
|
||||
/// The view's table once `place` moves off `from`.
|
||||
fn moved(view: &mut LokView, place: Place, from: i32) -> Table {
|
||||
let mut worker = view.worker.lock().expect("the worker lock");
|
||||
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
|
||||
seen.table.as_ref().is_some_and(|table| place(table) != from)
|
||||
});
|
||||
let table = seen.table.expect("still the table");
|
||||
view.doc = worker.now(&view.doc).expect("the revision");
|
||||
drop(worker);
|
||||
view.read_borders(Some(table.clone()), view.doc.rev);
|
||||
table
|
||||
}
|
||||
|
||||
/// The last drag's place: before, guide, landed, undone.
|
||||
fn drag(size: (&str, &str), place: Place, upright: bool, bys: &[Point<Pixels>]) -> Option<[i32; 4]> {
|
||||
let install = crate::client::tests::install()?;
|
||||
let cx = TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
|
||||
let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, size)));
|
||||
let mut last = [0; 3];
|
||||
for &by in bys {
|
||||
let (before, guide) = cx.update(|app| {
|
||||
view.update(app, |view, cx| {
|
||||
let at = place(&table);
|
||||
let across = if upright { &table.rows } else { &table.columns };
|
||||
// A quarter along: halfway may cross another line.
|
||||
let along = across.offset + across.left + (across.right - across.left) / 4;
|
||||
let on = view.window_point(if upright { (at, along) } else { (along, at) });
|
||||
assert!(view.hold_border(on), "the line is held");
|
||||
assert!(view.move_border(on + by));
|
||||
let guide = view.doc_point(view.guide().expect("a guide").origin);
|
||||
assert!(view.drop_border(on + by, cx), "the drop runs");
|
||||
(at, if upright { guide.0 } else { guide.1 })
|
||||
})
|
||||
});
|
||||
for _ in 0..80 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
table = cx.update(|app| view.update(app, |view, _| moved(view, place, before)));
|
||||
last = [before, guide, place(&table)];
|
||||
}
|
||||
let undone = cx.update(|app| {
|
||||
view.update(app, |view, _| {
|
||||
view.worker.lock().expect("the worker lock").run(&view.doc, "edit.undo", &[]).expect("undoes");
|
||||
place(&moved(view, place, last[2]))
|
||||
})
|
||||
});
|
||||
Some([last[0], last[1], last[2], undone])
|
||||
}
|
||||
|
||||
/// Lands at its guide; one undo puts it back.
|
||||
#[track_caller]
|
||||
fn lands([before, guide, landed, undone]: [i32; 4]) {
|
||||
assert!((landed - guide).abs() <= 2, "{before} to the guide {guide}, landed {landed}");
|
||||
assert!((undone - before).abs() <= 1, "one undo: {undone} vs {before}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inner_line_lands_at_its_guide() {
|
||||
let place: Place = |t| t.columns.offset + t.columns.inner[0].position;
|
||||
let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else {
|
||||
return eprintln!("SKIP: no pinned LOK build");
|
||||
};
|
||||
assert!(moved[1] > moved[0], "the guide went right");
|
||||
lands(moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_left_edge_narrows_the_first_column_and_moves_the_table() {
|
||||
let place: Place = |t| t.columns.offset + t.columns.left;
|
||||
let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else {
|
||||
return eprintln!("SKIP: no pinned LOK build");
|
||||
};
|
||||
assert!(moved[1] > moved[0], "the guide went right");
|
||||
lands(moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_row_bottom_grows() {
|
||||
let place: Place = |t| t.rows.offset + t.rows.right;
|
||||
let Some(moved) = drag(("2", "1"), place, false, &[gpui::point(px(0.0), px(40.0))]) else {
|
||||
return eprintln!("SKIP: no pinned LOK build");
|
||||
};
|
||||
assert!(moved[1] > moved[0], "the guide went down");
|
||||
lands(moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grown_bottom_stops_at_the_last_rows_minimum() {
|
||||
let place: Place = |t| t.rows.offset + t.rows.right;
|
||||
let Some(moved) = drag(("2", "2"), place, false, &[gpui::point(px(0.0), px(60.0)), gpui::point(px(0.0), px(-200.0))]) else {
|
||||
return eprintln!("SKIP: no pinned LOK build");
|
||||
};
|
||||
assert!(moved[1] < moved[0] && moved[1] > moved[0] - 3000, "the guide stops at the minimum: {moved:?}");
|
||||
lands(moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_column_right_edge_stops_at_the_columns_minimum() {
|
||||
let place: Place = |t| t.columns.offset + t.columns.right;
|
||||
let Some(moved) = drag(("1", "2"), place, true, &[gpui::point(px(-600.0), px(0.0))]) else {
|
||||
return eprintln!("SKIP: no pinned LOK build");
|
||||
};
|
||||
assert!(moved[1] > moved[0] - 9000, "the guide stops at the minimum: {moved:?}");
|
||||
lands(moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_press_beside_a_minimum_column_sends_nothing() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
return eprintln!("SKIP: no pinned LOK build");
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
|
||||
let right: Place = |t| t.columns.offset + t.columns.right;
|
||||
let quarter = |t: &Table| t.rows.offset + t.rows.left + (t.rows.right - t.rows.left) / 4;
|
||||
let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, ("2", "2"))));
|
||||
// Its minimum width drops once it narrows; shrink twice.
|
||||
for _ in 0..2 {
|
||||
let before = cx.update(|app| {
|
||||
view.update(app, |view, cx| {
|
||||
let on = view.window_point((right(&table), quarter(&table)));
|
||||
let to = on + gpui::point(px(-600.0), px(0.0));
|
||||
assert!(view.hold_border(on) && view.move_border(to) && view.drop_border(to, cx));
|
||||
right(&table)
|
||||
})
|
||||
});
|
||||
for _ in 0..80 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
table = cx.update(|app| view.update(app, |view, _| moved(view, right, before)));
|
||||
}
|
||||
let sent = cx.update(|app| {
|
||||
view.update(app, |view, cx| {
|
||||
let line = table.columns.inner[0];
|
||||
assert_eq!(line.position, line.max, "the right column is at its minimum");
|
||||
let on = view.window_point((table.columns.offset + line.position, quarter(&table)));
|
||||
let sent = view.sent();
|
||||
assert!(view.hold_border(on));
|
||||
assert!(view.drop_border(on, cx));
|
||||
sent
|
||||
})
|
||||
});
|
||||
for _ in 0..80 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
assert_eq!(cx.update(|app| view.read(app).sent()), sent, "a still press sends no command");
|
||||
}
|
||||
@@ -82,5 +82,5 @@ fn pack_events(batch: Events) -> Vec<u8> {
|
||||
.i64(signed(a11y.caret))
|
||||
.i64(signed(start))
|
||||
.i64(signed(end));
|
||||
batch.cell.pack(pack).bytes()
|
||||
crate::table::Table::pack(batch.table.as_ref(), batch.cell.pack(pack)).bytes()
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod engine;
|
||||
pub mod files;
|
||||
mod find;
|
||||
pub mod lok;
|
||||
pub mod menu;
|
||||
mod open;
|
||||
mod recover;
|
||||
pub mod panel;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Ely's right-click menus, gated by the kernel's (DOC-02).
|
||||
|
||||
use std::{cell::Cell, io};
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::IconName,
|
||||
};
|
||||
use ely_lok::{LokView, menu::Offered};
|
||||
use gpui::{App, Entity, Pixels, Point};
|
||||
|
||||
/// One menu, asked for at a point in one tab.
|
||||
pub struct Offer<W> {
|
||||
pub tab: u64,
|
||||
pub what: W,
|
||||
/// Bumps for each menu, so each opens once.
|
||||
request: (u64, Point<Pixels>),
|
||||
/// Handed to a host; a remounted host must not reopen.
|
||||
served: Cell<bool>,
|
||||
}
|
||||
|
||||
impl<W> Offer<W> {
|
||||
/// The next menu after `last`.
|
||||
pub fn new(last: Option<&Offer<W>>, tab: u64, what: W, at: Point<Pixels>) -> Self {
|
||||
let number = last.map_or(1, |last| last.request.0 + 1);
|
||||
Offer { tab, what, request: (number, at), served: Cell::new(false) }
|
||||
}
|
||||
|
||||
/// Its host; only the first render opens it.
|
||||
pub fn host(&self, id: &'static str, menu: Menu) -> ContextMenu {
|
||||
let request = (!self.served.replace(true)).then_some(self.request);
|
||||
ContextMenu::new(id, menu).manual(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's last kernel menu and where it opens.
|
||||
pub fn take(view: &Entity<LokView>, cx: &mut App) -> Option<io::Result<(Offered, Point<Pixels>)>> {
|
||||
let (json, at) = view.update(cx, |view, _| view.take_menu())?;
|
||||
let offered = match ely_lok::menu::read(&json) {
|
||||
Ok(offered) => offered,
|
||||
Err(error) => return Some(Err(error)),
|
||||
};
|
||||
// Without a click it opens at the caret.
|
||||
let caret = || view.read(cx).cursor_bounds().map(|caret| caret.bottom_left());
|
||||
let Some(at) = at.or_else(caret) else {
|
||||
log::warn!("office: a menu with no click and no caret");
|
||||
return None;
|
||||
};
|
||||
log::info!("office: a {:?} menu", offered.target);
|
||||
Some(Ok((offered, at)))
|
||||
}
|
||||
|
||||
/// A menu row: its label key and its gating command.
|
||||
pub trait Pick: Copy + 'static {
|
||||
fn row(self) -> (&'static str, &'static str);
|
||||
}
|
||||
|
||||
/// A row, a submenu or a line.
|
||||
pub enum Entry<P: 'static> {
|
||||
Row(P),
|
||||
Sub(&'static str, &'static [P]),
|
||||
Line,
|
||||
}
|
||||
|
||||
/// Cut, copy and paste as one row of icons.
|
||||
pub fn clipboard<P: Pick>(menu: Menu, picks: [P; 3], item: impl Fn(P) -> MenuItem) -> Menu {
|
||||
let icons = [IconName::Scissors, IconName::Copy, IconName::Clipboard];
|
||||
let row = picks.into_iter().zip(icons).map(|(pick, icon)| item(pick).icon(icon));
|
||||
menu.buttons(row)
|
||||
}
|
||||
|
||||
/// Adds entries; a submenu is on when any row is.
|
||||
pub fn entries<P: Pick>(
|
||||
menu: Menu,
|
||||
entries: &[Entry<P>],
|
||||
cx: &App,
|
||||
on: impl Fn(P) -> bool,
|
||||
item: impl Fn(P) -> MenuItem,
|
||||
) -> Menu {
|
||||
entries.iter().fold(menu, |menu, entry| match entry {
|
||||
Entry::Row(pick) => menu.item(item(*pick)),
|
||||
Entry::Sub(key, picks) => {
|
||||
let sub = picks.iter().fold(Menu::new(), |sub, pick| sub.item(item(*pick)));
|
||||
let any = picks.iter().any(|pick| on(*pick));
|
||||
menu.item(MenuItem::submenu(crate::text(cx, key, &[]), sub).disabled(!any))
|
||||
}
|
||||
Entry::Line => menu.separator(),
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,7 @@ arboard.workspace = true
|
||||
directories.workspace = true
|
||||
ely-ipc.workspace = true
|
||||
env_logger.workspace = true
|
||||
getrandom.workspace = true
|
||||
image.workspace = true
|
||||
log.workspace = true
|
||||
semver.workspace = true
|
||||
|
||||
@@ -74,6 +74,11 @@ pub fn read() -> Result<Option<Clip>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The clipboard's HTML, if any; gpui's reads omit it.
|
||||
pub fn html() -> Result<Option<String>> {
|
||||
with_board(|board| absent_ok(board.get().html()))
|
||||
}
|
||||
|
||||
/// Empty or other-format clipboards read as None.
|
||||
fn absent_ok<T>(outcome: Result<T, Error>) -> Result<Option<T>> {
|
||||
match outcome {
|
||||
|
||||
@@ -1,27 +1,56 @@
|
||||
//! One running copy per app; two would wipe shared data.
|
||||
|
||||
use std::{fs::File, io, path::Path, sync::OnceLock};
|
||||
mod door;
|
||||
|
||||
use std::{
|
||||
fs::File,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
pub use door::Door;
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
/// The lock this process holds while it runs.
|
||||
static HELD: OnceLock<File> = OnceLock::new();
|
||||
|
||||
/// Takes `app`'s instance lock; a running copy refuses this one.
|
||||
pub fn hold(app: AppId) -> io::Result<()> {
|
||||
/// How long a second copy waits for the door.
|
||||
const WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// What starting a copy found.
|
||||
pub enum Start {
|
||||
/// This copy runs; its door takes later copies' files.
|
||||
First(Door),
|
||||
/// A running copy took the files; this one ends.
|
||||
Handed,
|
||||
}
|
||||
|
||||
/// Takes `app`'s lock, or hands `files` to its holder.
|
||||
pub fn hold(app: AppId, files: &[PathBuf]) -> io::Result<Start> {
|
||||
let dir = crate::data_dir(app).map_err(io::Error::other)?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
take(app, &dir)
|
||||
start(app, &dir, files)
|
||||
}
|
||||
|
||||
fn start(app: AppId, dir: &Path, files: &[PathBuf]) -> io::Result<Start> {
|
||||
match take(app, dir) {
|
||||
Ok(()) => Ok(Start::First(Door::open(dir)?)),
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => match door::hand(dir, files, WAIT) {
|
||||
Ok(()) => Ok(Start::Handed),
|
||||
Err(handing) => Err(io::Error::new(handing.kind(), format!("{error} and did not take the files: {handing}"))),
|
||||
},
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn take(app: AppId, dir: &Path) -> io::Result<()> {
|
||||
let file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(dir.join("instance.lock"))?;
|
||||
match lock(&file)? {
|
||||
None => HELD.set(file).map_err(|_| io::Error::other("this process took its instance lock twice")),
|
||||
Some(holder) => Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} is already running ({holder}); open files in its window", app.name()),
|
||||
)),
|
||||
Some(holder) => Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} is already running ({holder})", app.name()))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +130,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_copy_hands_its_files_to_the_first() {
|
||||
use std::io::BufRead;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let exe = std::env::current_exe().expect("this test");
|
||||
let mut first = std::process::Command::new(&exe)
|
||||
.args(["--exact", "instance::tests::first_copy", "--nocapture"])
|
||||
.env("ELY_FIRST_DIR", dir.path())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("the first copy");
|
||||
let mut said = std::io::BufReader::new(first.stdout.take().expect("its output")).lines().map(|line| line.expect("a line"));
|
||||
assert!(said.by_ref().any(|line| line.contains("HELD")), "the first copy did not start");
|
||||
let file = std::path::PathBuf::from("C:/notes/a b.md");
|
||||
let second = start(AppId::Doc, dir.path(), std::slice::from_ref(&file)).expect("handed over");
|
||||
assert!(matches!(second, Start::Handed));
|
||||
let got = said.find(|line| line.contains("GOT"));
|
||||
assert_eq!(got.as_deref().and_then(|line| line.split("GOT ").nth(1)), Some(file.to_str().expect("text")));
|
||||
assert!(first.wait().expect("ends").success());
|
||||
}
|
||||
|
||||
/// The first copy: holds, then reports one hand-over.
|
||||
#[test]
|
||||
fn first_copy() {
|
||||
let Ok(dir) = std::env::var("ELY_FIRST_DIR") else {
|
||||
return;
|
||||
};
|
||||
let Ok(Start::First(door)) = start(AppId::Doc, Path::new(&dir), &[]) else {
|
||||
panic!("the first copy did not hold");
|
||||
};
|
||||
let (sender, heard) = std::sync::mpsc::channel();
|
||||
let sender = std::sync::Mutex::new(sender);
|
||||
door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok()).expect("served");
|
||||
println!("HELD");
|
||||
let files = heard.recv_timeout(std::time::Duration::from_secs(20)).expect("a hand-over");
|
||||
println!("GOT {}", files[0].display());
|
||||
}
|
||||
|
||||
/// One copy, in a process of its own.
|
||||
#[test]
|
||||
fn one_copy() {
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The running copy's door: a second copy hands it files.
|
||||
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const FILE: &str = "instance.door";
|
||||
const TOKEN: u8 = 1;
|
||||
const PATH: u8 = 2;
|
||||
const DONE: u8 = 3;
|
||||
const REFUSED: u8 = 4;
|
||||
const OPEN: u8 = 5;
|
||||
/// How long one caller may take, start to end.
|
||||
const PATIENCE: Duration = Duration::from_secs(5);
|
||||
/// The largest frame before the token is shown.
|
||||
const SHOWN: usize = 64;
|
||||
/// The largest path frame: a whole Windows path.
|
||||
const LONGEST: usize = 128 * 1024;
|
||||
|
||||
/// The running copy's listener; its token keeps others out.
|
||||
pub struct Door {
|
||||
listener: TcpListener,
|
||||
token: [u8; 32],
|
||||
}
|
||||
|
||||
impl Door {
|
||||
/// Opens a local door; its address goes in `dir`.
|
||||
pub(super) fn open(dir: &Path) -> io::Result<Door> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
|
||||
let mut token = [0; 32];
|
||||
getrandom::fill(&mut token).map_err(|_| io::Error::other("system randomness failed"))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let text = format!("{port} {} {}\n", std::process::id(), hex(&token));
|
||||
// Whole or absent to a reader.
|
||||
let fresh = dir.join("instance.door.new");
|
||||
write_private(&fresh, text.as_bytes())?;
|
||||
fs::rename(&fresh, dir.join(FILE))?;
|
||||
Ok(Door { listener, token })
|
||||
}
|
||||
|
||||
/// Hands each caller's files to `deliver`; false means not taken.
|
||||
pub fn serve(
|
||||
self,
|
||||
deliver: impl Fn(Vec<PathBuf>) -> bool + Send + Sync + 'static,
|
||||
) -> io::Result<()> {
|
||||
let deliver = Arc::new(deliver);
|
||||
thread::Builder::new()
|
||||
.name("instance door".into())
|
||||
.spawn(move || {
|
||||
for stream in self.listener.incoming() {
|
||||
let (token, deliver) = (self.token, deliver.clone());
|
||||
// A stalled caller must not hold up the next.
|
||||
let answered = stream.and_then(|stream| {
|
||||
thread::Builder::new()
|
||||
.name("instance caller".into())
|
||||
.spawn(move || {
|
||||
if let Err(error) = answer(&stream, &token, &*deliver) {
|
||||
log::warn!("instance: a hand-over failed: {error}");
|
||||
}
|
||||
})
|
||||
.map(drop)
|
||||
});
|
||||
if let Err(error) = answered {
|
||||
log::warn!("instance: a caller was not answered: {error}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(
|
||||
stream: &TcpStream,
|
||||
token: &[u8],
|
||||
deliver: &dyn Fn(Vec<PathBuf>) -> bool,
|
||||
) -> io::Result<()> {
|
||||
let mut timed = Timed {
|
||||
stream,
|
||||
until: Instant::now() + PATIENCE,
|
||||
};
|
||||
let (tag, shown) = next(&mut timed, SHOWN)?;
|
||||
if tag != TOKEN || !same(&shown, token) {
|
||||
ely_ipc::write_frame(&mut &*stream, REFUSED, 0, &[])?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"a caller without the token",
|
||||
));
|
||||
}
|
||||
ely_ipc::write_frame(&mut &*stream, OPEN, 0, &[])?;
|
||||
let mut files = Vec::new();
|
||||
loop {
|
||||
match next(&mut timed, LONGEST)? {
|
||||
(PATH, bytes) => files.push(path(bytes)?),
|
||||
(DONE, _) => break,
|
||||
(tag, _) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("frame {tag} is not a hand-over's"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("instance: another copy handed over {} files", files.len());
|
||||
// No receipt: the caller reports the files untaken.
|
||||
if !deliver(files) {
|
||||
return Err(io::Error::other("the app is quitting and took no files"));
|
||||
}
|
||||
ely_ipc::write_frame(&mut &*stream, DONE, 0, &[])
|
||||
}
|
||||
|
||||
/// A stream whose reads all end by one deadline.
|
||||
struct Timed<'a> {
|
||||
stream: &'a TcpStream,
|
||||
until: Instant,
|
||||
}
|
||||
|
||||
impl Read for Timed<'_> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let left = self.until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"a caller held the door too long",
|
||||
));
|
||||
}
|
||||
self.stream.set_read_timeout(Some(left))?;
|
||||
(&mut &*self.stream).read(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands `files` to `dir`'s door, waiting up to `wait`.
|
||||
pub(super) fn hand(dir: &Path, files: &[PathBuf], wait: Duration) -> io::Result<()> {
|
||||
let deadline = Instant::now() + wait;
|
||||
loop {
|
||||
// Nothing handed yet, so every failure here may retry.
|
||||
match knock(dir).and_then(|(stream, token)| Ok((admit(&stream, &token)?, stream))) {
|
||||
Ok((true, stream)) => return give(&stream, files),
|
||||
// An old door file; the new one is coming.
|
||||
Ok((false, _)) if Instant::now() < deadline => {}
|
||||
Ok((false, _)) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"it refused the door's token",
|
||||
));
|
||||
}
|
||||
Err(_) if Instant::now() < deadline => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects to `dir`'s door; nothing sent, so retries are safe.
|
||||
fn knock(dir: &Path) -> io::Result<(TcpStream, Vec<u8>)> {
|
||||
let text = fs::read_to_string(dir.join(FILE))?;
|
||||
let bad = || io::Error::new(io::ErrorKind::InvalidData, format!("an unreadable {FILE}"));
|
||||
let mut words = text.split_whitespace();
|
||||
let port: u16 = words
|
||||
.next()
|
||||
.and_then(|word| word.parse().ok())
|
||||
.ok_or_else(bad)?;
|
||||
let pid: u32 = words
|
||||
.next()
|
||||
.and_then(|word| word.parse().ok())
|
||||
.ok_or_else(bad)?;
|
||||
let token = words.next().and_then(unhex).ok_or_else(bad)?;
|
||||
let stream =
|
||||
TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), PATIENCE)?;
|
||||
stream.set_read_timeout(Some(PATIENCE))?;
|
||||
lend_foreground(pid);
|
||||
Ok((stream, token))
|
||||
}
|
||||
|
||||
/// Shows the token; false when the door refuses it.
|
||||
fn admit(stream: &TcpStream, token: &[u8]) -> io::Result<bool> {
|
||||
ely_ipc::write_frame(&mut &*stream, TOKEN, 0, token)?;
|
||||
match next(&mut &*stream, SHOWN)? {
|
||||
(OPEN, _) => Ok(true),
|
||||
(REFUSED, _) => Ok(false),
|
||||
(tag, _) => Err(unexpected(tag)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the files once admitted; never retried.
|
||||
fn give(stream: &TcpStream, files: &[PathBuf]) -> io::Result<()> {
|
||||
for file in files {
|
||||
ely_ipc::write_frame(&mut &*stream, PATH, 0, &bytes(file))?;
|
||||
}
|
||||
ely_ipc::write_frame(&mut &*stream, DONE, 0, &[])?;
|
||||
// The app may answer after the door's own wait.
|
||||
stream.set_read_timeout(Some(PATIENCE * 2))?;
|
||||
match next(&mut &*stream, SHOWN)? {
|
||||
(DONE, _) => Ok(()),
|
||||
(tag, _) => Err(unexpected(tag)),
|
||||
}
|
||||
}
|
||||
|
||||
fn unexpected(tag: u8) -> io::Error {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("it answered frame {tag}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn next(stream: &mut impl Read, cap: usize) -> io::Result<(u8, Vec<u8>)> {
|
||||
match ely_ipc::read_capped(stream, cap)? {
|
||||
Some((tag, _, payload)) => Ok((tag, payload)),
|
||||
None => Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"the other copy hung up",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets the running copy bring its window to the front.
|
||||
#[cfg(windows)]
|
||||
fn lend_foreground(pid: u32) {
|
||||
// Refused when not in front; then nothing to lend.
|
||||
unsafe { windows_sys::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow(pid) };
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn lend_foreground(_: u32) {}
|
||||
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
options.open(path)?.write_all(bytes)
|
||||
}
|
||||
|
||||
/// Equal tokens, compared in constant time.
|
||||
fn same(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0, |diff, (x, y)| diff | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn unhex(text: &str) -> Option<Vec<u8>> {
|
||||
let pairs = text.as_bytes().chunks(2);
|
||||
pairs
|
||||
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn bytes(path: &Path) -> Vec<u8> {
|
||||
std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str()).to_vec()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn path(bytes: Vec<u8>) -> io::Result<PathBuf> {
|
||||
Ok(<OsString as std::os::unix::ffi::OsStringExt>::from_vec(bytes).into())
|
||||
}
|
||||
|
||||
/// UTF-16 units, little-endian, so any Windows name survives.
|
||||
#[cfg(windows)]
|
||||
fn bytes(path: &Path) -> Vec<u8> {
|
||||
std::os::windows::ffi::OsStrExt::encode_wide(path.as_os_str())
|
||||
.flat_map(u16::to_le_bytes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn path(bytes: Vec<u8>) -> io::Result<PathBuf> {
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"a cut UTF-16 path",
|
||||
));
|
||||
}
|
||||
let wide: Vec<u16> = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
|
||||
.collect();
|
||||
Ok(<OsString as std::os::windows::ffi::OsStringExt>::from_wide(&wide).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn opened(dir: &Path) -> std::sync::mpsc::Receiver<Vec<PathBuf>> {
|
||||
let (sender, heard) = std::sync::mpsc::channel();
|
||||
let sender = std::sync::Mutex::new(sender);
|
||||
let door = Door::open(dir).expect("a door");
|
||||
door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok())
|
||||
.expect("served");
|
||||
heard
|
||||
}
|
||||
|
||||
/// Writes a door file whose token is wrong.
|
||||
fn forge(dir: &Path) -> String {
|
||||
let door = dir.join(FILE);
|
||||
let text = fs::read_to_string(&door).expect("the door");
|
||||
let (head, _) = text.rsplit_once(' ').expect("a token");
|
||||
fs::write(&door, format!("{head} {}\n", hex(&[7; 32]))).expect("a forged door");
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_over_brings_every_path_whole() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let heard = opened(dir.path());
|
||||
let files = vec![
|
||||
PathBuf::from("C:/a b/笔记 #1.md"),
|
||||
PathBuf::from("/tmp/x.md"),
|
||||
];
|
||||
hand(dir.path(), &files, Duration::from_secs(2)).expect("handed");
|
||||
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
|
||||
hand(dir.path(), &[], Duration::from_secs(2)).expect("an empty hand-over raises");
|
||||
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_hands_nothing_over() {
|
||||
for _ in 0..12 {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let heard = opened(dir.path());
|
||||
forge(dir.path());
|
||||
let refused = hand(
|
||||
dir.path(),
|
||||
&[PathBuf::from("x.md")],
|
||||
Duration::from_millis(300),
|
||||
);
|
||||
assert_eq!(
|
||||
refused.map_err(|error| error.kind()),
|
||||
Err(io::ErrorKind::PermissionDenied)
|
||||
);
|
||||
assert!(heard.try_recv().is_err(), "nothing reached the copy");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_door_file_is_waited_out() {
|
||||
for _ in 0..5 {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let heard = opened(dir.path());
|
||||
let real = forge(dir.path());
|
||||
let door = dir.path().join(FILE);
|
||||
let fixer = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
fs::write(door, real).expect("the real door");
|
||||
});
|
||||
let files = vec![PathBuf::from("x.md")];
|
||||
hand(dir.path(), &files, Duration::from_secs(3))
|
||||
.expect("handed once the door is right");
|
||||
fixer.join().expect("fixed");
|
||||
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dripping_caller_neither_blocks_nor_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let heard = opened(dir.path());
|
||||
let text = fs::read_to_string(dir.path().join(FILE)).expect("the door");
|
||||
let port: u16 = text
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|word| word.parse().ok())
|
||||
.expect("a port");
|
||||
let mut drip = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).expect("connected");
|
||||
let started = Instant::now();
|
||||
let dripper = thread::spawn(move || {
|
||||
// A token frame's header, a byte at a time.
|
||||
for byte in [32u8, 0, 0, 0, TOKEN, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
.into_iter()
|
||||
.chain([0; 32])
|
||||
{
|
||||
if drip.write_all(&[byte]).is_err() {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
let files = vec![PathBuf::from("x.md")];
|
||||
hand(dir.path(), &files, Duration::from_secs(2)).expect("handed past the drip");
|
||||
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
|
||||
dripper.join().expect("dripped");
|
||||
let held = started.elapsed();
|
||||
assert!(
|
||||
held < PATIENCE + Duration::from_secs(2),
|
||||
"the drip was cut at the deadline, not after {held:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_the_app_did_not_take_are_reported() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
Door::open(dir.path())
|
||||
.expect("a door")
|
||||
.serve(|_| false)
|
||||
.expect("served");
|
||||
let started = Instant::now();
|
||||
let untaken = hand(dir.path(), &[PathBuf::from("x.md")], Duration::from_secs(3));
|
||||
assert!(untaken.is_err(), "untaken files are no success");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(1),
|
||||
"sent paths are not sent again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_door_fails_after_the_wait() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let started = Instant::now();
|
||||
let missing = hand(dir.path(), &[], Duration::from_millis(300));
|
||||
assert_eq!(
|
||||
missing.map_err(|error| error.kind()),
|
||||
Err(io::ErrorKind::NotFound)
|
||||
);
|
||||
assert!(started.elapsed() >= Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ log.workspace = true
|
||||
notify.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
gpui_windows = { package = "gpui-pre-windows", version = "=0.3.8" }
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ mod calc;
|
||||
mod data;
|
||||
mod doc;
|
||||
mod mark;
|
||||
mod mark_menus;
|
||||
mod motion;
|
||||
mod office;
|
||||
mod output;
|
||||
@@ -146,6 +147,8 @@ pub fn install(cx: &mut App) {
|
||||
|
||||
/// Installs the catalogs speaking `locale` (en-US or zh-CN).
|
||||
pub fn install_as(locale: &'static str, cx: &mut App) {
|
||||
#[cfg(target_os = "windows")]
|
||||
gpui_windows::set_text_language(locale);
|
||||
let (en, zh) = catalogs();
|
||||
cx.set_global(
|
||||
I18n::new(locale)
|
||||
@@ -179,6 +182,7 @@ fn catalogs() -> (Catalog, Catalog) {
|
||||
.chain(tree_ops::EN)
|
||||
.chain(tree_places::EN)
|
||||
.chain(mark::EN)
|
||||
.chain(mark_menus::EN)
|
||||
.chain(settings::EN)
|
||||
.copied()
|
||||
.collect();
|
||||
@@ -202,6 +206,7 @@ fn catalogs() -> (Catalog, Catalog) {
|
||||
.chain(tree_ops::ZH_CN)
|
||||
.chain(tree_places::ZH_CN)
|
||||
.chain(mark::ZH_CN)
|
||||
.chain(mark_menus::ZH_CN)
|
||||
.chain(settings::ZH_CN)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
@@ -6,6 +6,7 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mark.pane.search", "Search"),
|
||||
("mark.recent", "Recent"),
|
||||
("mark.recent.none", "Nothing opened yet"),
|
||||
("mark.recent.clear", "Clear Recent Files"),
|
||||
("mark.outline.none", "No headings"),
|
||||
("mark.search", "Results"),
|
||||
("mark.search.file", "This file"),
|
||||
@@ -16,10 +17,11 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mark.table.insert", "Insert Table"),
|
||||
("mark.table.row_above", "Add Row Above"),
|
||||
("mark.table.row_below", "Add Row Below"),
|
||||
("mark.table.column_left", "Add Column Left"),
|
||||
("mark.table.column_right", "Add Column Right"),
|
||||
("mark.table.column_left", "Add Column Before"),
|
||||
("mark.table.column_right", "Add Column After"),
|
||||
("mark.table.delete_row", "Delete Row"),
|
||||
("mark.table.delete_column", "Delete Column"),
|
||||
("mark.table.delete", "Delete Table"),
|
||||
("mark.table.none", "Put the caret in a table first"),
|
||||
("mark.table.kept", "A table keeps its head row and one column"),
|
||||
("mark.table.beyond", "This cell is past the head's columns"),
|
||||
@@ -65,15 +67,15 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mark.copy.failed", "Copying failed: {error}"),
|
||||
("mark.export.failed", "The export failed: {error}"),
|
||||
("mark.look.focus", "Focus Mode"),
|
||||
("mark.look.typewriter", "Typewriter Scrolling"),
|
||||
("mark.look.typewriter", "Typewriter Mode"),
|
||||
("mark.look.narrow", "Narrow Text"),
|
||||
("mark.look.medium", "Medium Text"),
|
||||
("mark.look.wide", "Wide Text"),
|
||||
("mark.look.sans", "Sans-Serif Font"),
|
||||
("mark.look.serif", "Serif Font"),
|
||||
("mark.look.mono", "Monospace Font"),
|
||||
("mark.look.larger", "Larger Text"),
|
||||
("mark.look.smaller", "Smaller Text"),
|
||||
("mark.look.larger", "Zoom In"),
|
||||
("mark.look.smaller", "Zoom Out"),
|
||||
("mark.look.actual", "Actual Size"),
|
||||
("mark.git.unchanged", "Git: unchanged"),
|
||||
("mark.git.changed", "Git: changed"),
|
||||
@@ -105,6 +107,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mark.pane.search", "搜索"),
|
||||
("mark.recent", "最近"),
|
||||
("mark.recent.none", "还没有打开过文件"),
|
||||
("mark.recent.clear", "清除最近文件"),
|
||||
("mark.outline.none", "没有标题"),
|
||||
("mark.search", "结果"),
|
||||
("mark.search.file", "本文件"),
|
||||
@@ -113,12 +116,13 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mark.search.missed", "{name} 未能搜索"),
|
||||
("mark.menu.table", "表格"),
|
||||
("mark.table.insert", "插入表格"),
|
||||
("mark.table.row_above", "在上方插入行"),
|
||||
("mark.table.row_below", "在下方插入行"),
|
||||
("mark.table.column_left", "在左侧插入列"),
|
||||
("mark.table.column_right", "在右侧插入列"),
|
||||
("mark.table.row_above", "上方插入行"),
|
||||
("mark.table.row_below", "下方插入行"),
|
||||
("mark.table.column_left", "左侧插入列"),
|
||||
("mark.table.column_right", "右侧插入列"),
|
||||
("mark.table.delete_row", "删除行"),
|
||||
("mark.table.delete_column", "删除列"),
|
||||
("mark.table.delete", "删除表格"),
|
||||
("mark.table.none", "请先把插入点放进表格"),
|
||||
("mark.table.kept", "表格保留表头行和至少一列"),
|
||||
("mark.table.beyond", "此单元格在表头的列之外"),
|
||||
@@ -164,15 +168,15 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mark.copy.failed", "复制失败:{error}"),
|
||||
("mark.export.failed", "导出失败:{error}"),
|
||||
("mark.look.focus", "专注模式"),
|
||||
("mark.look.typewriter", "打字机滚动"),
|
||||
("mark.look.typewriter", "打字机模式"),
|
||||
("mark.look.narrow", "窄正文"),
|
||||
("mark.look.medium", "中等正文"),
|
||||
("mark.look.wide", "宽正文"),
|
||||
("mark.look.sans", "无衬线字体"),
|
||||
("mark.look.serif", "衬线字体"),
|
||||
("mark.look.mono", "等宽字体"),
|
||||
("mark.look.larger", "放大文字"),
|
||||
("mark.look.smaller", "缩小文字"),
|
||||
("mark.look.larger", "放大"),
|
||||
("mark.look.smaller", "缩小"),
|
||||
("mark.look.actual", "实际大小"),
|
||||
("mark.git.unchanged", "Git:未改动"),
|
||||
("mark.git.changed", "Git:有改动"),
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! ElyMark's Typora menus, in Typora's own words.
|
||||
|
||||
pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mark.menu.paragraph", "Paragraph"),
|
||||
("mark.menu.format", "Format"),
|
||||
("mark.block.h1", "Heading 1"),
|
||||
("mark.block.h2", "Heading 2"),
|
||||
("mark.block.h3", "Heading 3"),
|
||||
("mark.block.h4", "Heading 4"),
|
||||
("mark.block.h5", "Heading 5"),
|
||||
("mark.block.h6", "Heading 6"),
|
||||
("mark.block.paragraph", "Paragraph"),
|
||||
("mark.block.promote", "Increase Heading Level"),
|
||||
("mark.block.demote", "Decrease Heading Level"),
|
||||
("mark.block.math", "Math Block"),
|
||||
("mark.block.code", "Code Fences"),
|
||||
("mark.block.quote", "Quote"),
|
||||
("mark.block.ordered", "Ordered List"),
|
||||
("mark.block.bullet", "Unordered List"),
|
||||
("mark.block.task", "Task List"),
|
||||
("mark.block.before", "Insert Paragraph Before"),
|
||||
("mark.block.after", "Insert Paragraph After"),
|
||||
("mark.block.link_reference", "Link Reference"),
|
||||
("mark.block.footnote", "Footnotes"),
|
||||
("mark.block.rule", "Horizontal Line"),
|
||||
("mark.block.toc", "Table of Contents"),
|
||||
("mark.block.front_matter", "YAML Front Matter"),
|
||||
("mark.format.strong", "Strong"),
|
||||
("mark.format.emphasis", "Emphasis"),
|
||||
("mark.format.underline", "Underline"),
|
||||
("mark.format.code", "Code"),
|
||||
("mark.format.strike", "Strike"),
|
||||
("mark.format.link", "Hyperlink"),
|
||||
("mark.format.image", "Insert Image"),
|
||||
("mark.format.clear", "Clear Format"),
|
||||
("mark.table.move_row_up", "Move Row Up"),
|
||||
("mark.table.move_row_down", "Move Row Down"),
|
||||
("mark.table.move_column_left", "Move Column Left"),
|
||||
("mark.table.move_column_right", "Move Column Right"),
|
||||
("mark.table.tidy", "Prettify Source Code"),
|
||||
(
|
||||
"mark.table.edge",
|
||||
"Nothing lies past this row or column that way",
|
||||
),
|
||||
("mark.view.source", "Source Code Mode"),
|
||||
("mark.copy.markdown", "Copy as Markdown"),
|
||||
("mark.paste.plain", "Paste as Plain Text"),
|
||||
("mark.paste.none", "The clipboard holds no text"),
|
||||
("mark.refused.code", "This does not work inside code"),
|
||||
("mark.refused.math", "This does not work inside math"),
|
||||
("mark.refused.html", "This does not work inside HTML"),
|
||||
(
|
||||
"mark.refused.front_matter",
|
||||
"This does not work inside front matter",
|
||||
),
|
||||
(
|
||||
"mark.refused.outside",
|
||||
"Put the caret in a paragraph, heading or cell first",
|
||||
),
|
||||
("mark.refused.table", "This does not work inside a table"),
|
||||
("mark.refused.present", "The file has front matter already"),
|
||||
(
|
||||
"mark.refused.across",
|
||||
"The selection crosses a block, a link or another format",
|
||||
),
|
||||
(
|
||||
"mark.refused.too_long",
|
||||
"The file is too long to read; edit it as source",
|
||||
),
|
||||
("mark.refused.busy", "The last edit is still being read"),
|
||||
(
|
||||
"mark.refused.changed",
|
||||
"The text changed before the edit was ready; try again",
|
||||
),
|
||||
(
|
||||
"mark.refused.misread",
|
||||
"This would change how the text reads, so nothing changed",
|
||||
),
|
||||
("mark.edit.cut", "Cut"),
|
||||
("mark.edit.copy", "Copy"),
|
||||
("mark.edit.paste", "Paste"),
|
||||
("mark.edit.delete", "Delete Selected"),
|
||||
("mark.menu.copy_as", "Copy / Paste As"),
|
||||
("mark.menu.insert", "Insert"),
|
||||
("mark.copy.plain", "Copy as Plain Text"),
|
||||
("mark.copy.html", "Copy as HTML Code"),
|
||||
("mark.link.open", "Open Link"),
|
||||
("mark.link.copy", "Copy Link Address"),
|
||||
("mark.code.copy", "Copy Code Content"),
|
||||
("mark.image.location", "Open Image Location"),
|
||||
("mark.image.browser", "Open Image in Browser"),
|
||||
("mark.image.delete", "Delete Image"),
|
||||
(
|
||||
"mark.image.no_file",
|
||||
"This picture has no file here to show",
|
||||
),
|
||||
("mark.block.edit", "Edit"),
|
||||
("mark.block.delete", "Delete {block}"),
|
||||
("mark.insert.image", "Image"),
|
||||
("mark.insert.before", "Paragraph (before)"),
|
||||
("mark.insert.after", "Paragraph (after)"),
|
||||
("mark.kind.list", "List"),
|
||||
("mark.kind.alert", "Alert"),
|
||||
("mark.kind.html", "HTML Block"),
|
||||
("mark.refused.no_image", "Put the caret on a picture first"),
|
||||
];
|
||||
|
||||
pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mark.menu.paragraph", "段落"),
|
||||
("mark.menu.format", "格式"),
|
||||
("mark.block.h1", "一级标题"),
|
||||
("mark.block.h2", "二级标题"),
|
||||
("mark.block.h3", "三级标题"),
|
||||
("mark.block.h4", "四级标题"),
|
||||
("mark.block.h5", "五级标题"),
|
||||
("mark.block.h6", "六级标题"),
|
||||
("mark.block.paragraph", "段落"),
|
||||
("mark.block.promote", "提升标题级别"),
|
||||
("mark.block.demote", "降低标题级别"),
|
||||
("mark.block.math", "公式块"),
|
||||
("mark.block.code", "代码块"),
|
||||
("mark.block.quote", "引用"),
|
||||
("mark.block.ordered", "有序列表"),
|
||||
("mark.block.bullet", "无序列表"),
|
||||
("mark.block.task", "任务列表"),
|
||||
("mark.block.before", "在上方插入段落"),
|
||||
("mark.block.after", "在下方插入段落"),
|
||||
("mark.block.link_reference", "链接引用"),
|
||||
("mark.block.footnote", "脚注"),
|
||||
("mark.block.rule", "水平分割线"),
|
||||
("mark.block.toc", "内容目录"),
|
||||
("mark.block.front_matter", "YAML Front Matter"),
|
||||
("mark.format.strong", "加粗"),
|
||||
("mark.format.emphasis", "斜体"),
|
||||
("mark.format.underline", "下划线"),
|
||||
("mark.format.code", "代码"),
|
||||
("mark.format.strike", "删除线"),
|
||||
("mark.format.link", "超链接"),
|
||||
("mark.format.image", "插入图片"),
|
||||
("mark.format.clear", "清除样式"),
|
||||
("mark.table.move_row_up", "上移该行"),
|
||||
("mark.table.move_row_down", "下移该行"),
|
||||
("mark.table.move_column_left", "左移该列"),
|
||||
("mark.table.move_column_right", "右移该列"),
|
||||
("mark.table.tidy", "格式化表格源码"),
|
||||
("mark.table.edge", "这一方向已到表格边缘"),
|
||||
("mark.view.source", "源代码模式"),
|
||||
("mark.copy.markdown", "复制为 Markdown"),
|
||||
("mark.paste.plain", "粘贴为纯文本"),
|
||||
("mark.paste.none", "剪贴板里没有文字"),
|
||||
("mark.refused.code", "代码里用不了这条命令"),
|
||||
("mark.refused.math", "公式里用不了这条命令"),
|
||||
("mark.refused.html", "HTML 里用不了这条命令"),
|
||||
("mark.refused.front_matter", "front matter 里用不了这条命令"),
|
||||
("mark.refused.outside", "请先把插入点放进段落、标题或单元格"),
|
||||
("mark.refused.table", "表格里用不了这条命令"),
|
||||
("mark.refused.present", "文件已有 front matter"),
|
||||
("mark.refused.across", "选区跨过了块、链接或别的格式"),
|
||||
(
|
||||
"mark.refused.too_long",
|
||||
"文件太长,无法解析;请在源码里编辑",
|
||||
),
|
||||
("mark.refused.busy", "上一次编辑还在处理"),
|
||||
("mark.refused.changed", "编辑完成前文字已改变,请重试"),
|
||||
("mark.refused.misread", "这样会改变文字的读法,未作改动"),
|
||||
("mark.edit.cut", "剪切"),
|
||||
("mark.edit.copy", "复制"),
|
||||
("mark.edit.paste", "粘贴"),
|
||||
("mark.edit.delete", "删除所选范围"),
|
||||
("mark.menu.copy_as", "复制/粘贴为"),
|
||||
("mark.menu.insert", "插入"),
|
||||
("mark.copy.plain", "复制为纯文本"),
|
||||
("mark.copy.html", "复制为 HTML 代码"),
|
||||
("mark.link.open", "打开链接"),
|
||||
("mark.link.copy", "复制链接地址"),
|
||||
("mark.code.copy", "复制代码块内容"),
|
||||
("mark.image.location", "打开图片位置"),
|
||||
("mark.image.browser", "在浏览器中打开图片"),
|
||||
("mark.image.delete", "删除图片"),
|
||||
("mark.image.no_file", "这张图片在本机没有可显示的文件"),
|
||||
("mark.block.edit", "编辑"),
|
||||
("mark.block.delete", "删除 {block}"),
|
||||
("mark.insert.image", "图像"),
|
||||
("mark.insert.before", "段落(上方)"),
|
||||
("mark.insert.after", "段落(下方)"),
|
||||
("mark.kind.list", "列表"),
|
||||
("mark.kind.alert", "警告框"),
|
||||
("mark.kind.html", "HTML 内容"),
|
||||
("mark.refused.no_image", "请先把插入点放在图片上"),
|
||||
];
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user