Compare commits
63
Commits
v0.1.1
...
dff9ed3bf4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff9ed3bf4
|
||
|
|
a354340237
|
||
|
|
aad589a6ab
|
||
|
|
8429d9e421
|
||
|
|
90f6d17bec
|
||
|
|
6b3ba569dd
|
||
|
|
bc133169e3
|
||
|
|
3d20036bff
|
||
|
|
7e479a96f8
|
||
|
|
86bb0d4e1c
|
||
|
|
2182b71f1f
|
||
|
|
bd7bc46d8c
|
||
|
|
547dbf2cc5
|
||
|
|
a28e618c07
|
||
|
|
15a54bcf2e
|
||
|
|
a49b0b5db3
|
||
|
|
301365a7e6
|
||
|
|
d7a4c37c1f
|
||
|
|
4cec71703b
|
||
|
|
f01150be05
|
||
|
|
da81ebd777
|
||
|
|
b46fedb327
|
||
|
|
33df906449
|
||
|
|
b7e2c56272
|
||
|
|
5c345b6f7c
|
||
|
|
41a99962aa
|
||
|
|
fd6e651478
|
||
|
|
f27e73575d
|
||
|
|
843657a6b3
|
||
|
|
18158a8282
|
||
|
|
759d84e20b
|
||
|
|
345e248ff7
|
||
|
|
6145b5cea2
|
||
|
|
0c87b0bbf7
|
||
|
|
4d7ec45027
|
||
|
|
798e00571a
|
||
|
|
7a12a28b67
|
||
|
|
201aa4fc5a
|
||
|
|
40cd554953
|
||
|
|
022978bb9b
|
||
|
|
fde97a01a9
|
||
|
|
b03c9f2853
|
||
|
|
fa273aff13
|
||
|
|
096b7f8fd4
|
||
|
|
9b1b64884b | ||
|
|
623bc085f6
|
||
|
|
383a1ea467
|
||
|
|
6bf5d6c7e6
|
||
|
|
c9bfccc91c
|
||
|
|
4d8d5a6ef5
|
||
|
|
04040a2a03
|
||
|
|
139e8f6250
|
||
|
|
1f4807c81c
|
||
|
|
ec87e28a46
|
||
|
|
8a915ea354
|
||
|
|
943411fb1a
|
||
|
|
ab85673eea
|
||
|
|
f9bf82e3f8
|
||
|
|
a6d8a1de43
|
||
|
|
ecf977364b
|
||
|
|
c52e1a3b16
|
||
|
|
63bf0cc828
|
||
|
|
380e6783d6
|
No files matched your search
@@ -1,77 +0,0 @@
|
||||
# Builds the patched engines once; release.yml downloads them.
|
||||
name: engines
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 355
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 6G
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Key
|
||||
id: key
|
||||
run: echo "key=$(scripts/engines-key.sh)" >> "$GITHUB_OUTPUT"
|
||||
- name: Already built?
|
||||
id: done
|
||||
run: |
|
||||
if gh release view "engines-${{ steps.key.outputs.key }}" --json assets \
|
||||
--jq '.assets[].name' 2>/dev/null | grep -qx "engines-macos-${{ matrix.arch }}.tar.zst"; then
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Tools
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: brew install make gnu-sed bison flex gettext autoconf automake libtool ccache nasm zstd
|
||||
- name: Compiler cache
|
||||
if: steps.done.outputs.built != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
|
||||
restore-keys: ccache-macos-${{ matrix.arch }}-
|
||||
- name: Upstream sources
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: scripts/fetch-references.sh
|
||||
- name: LibreOffice (patched)
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: scripts/build-libreoffice.sh
|
||||
- name: MuPDF (patched), Git, git-lfs, OCR data, CEF
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: |
|
||||
scripts/build-mupdf.sh
|
||||
scripts/build-git.sh
|
||||
scripts/fetch-git-lfs.sh
|
||||
scripts/fetch-tessdata.sh
|
||||
scripts/fetch-cef.sh
|
||||
- name: Keep the compiler cache, even after a timeout
|
||||
if: always() && steps.done.outputs.built != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
|
||||
- name: Publish
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: |
|
||||
archive=engines-macos-${{ matrix.arch }}.tar.zst
|
||||
tar -C engines -cf - lo-src/instdir mupdf-build mupdf-src git git-src/COPYING git-src/LGPL-2.1 cef tessdata |
|
||||
zstd -19 -T0 -o "$archive"
|
||||
tag=engines-${{ steps.key.outputs.key }}
|
||||
gh release view "$tag" >/dev/null 2>&1 ||
|
||||
gh release create "$tag" --prerelease --title "$tag" --notes "Pinned, patched engines; built by engines.yml."
|
||||
gh release upload "$tag" "$archive" --clobber
|
||||
@@ -1,58 +0,0 @@
|
||||
# Builds macOS apps for a release tag and attaches them.
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to attach the packages to, such as v0.1.0
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
- name: Engines
|
||||
run: |
|
||||
brew install zstd
|
||||
tag=engines-$(scripts/engines-key.sh)
|
||||
gh release download "$tag" --pattern "engines-macos-${{ matrix.arch }}.tar.zst" ||
|
||||
{ echo "::error::no $tag for ${{ matrix.arch }}; run the engines workflow first"; exit 1; }
|
||||
mkdir -p engines
|
||||
zstd -dc "engines-macos-${{ matrix.arch }}.tar.zst" | tar -C engines -xf -
|
||||
- name: Upstream sources and ElyMark's page
|
||||
run: |
|
||||
scripts/fetch-references.sh
|
||||
scripts/build-mark-content.sh
|
||||
- name: Tests that need no window
|
||||
run: |
|
||||
tests/scripts/types.sh
|
||||
tests/scripts/icons.sh
|
||||
python3 tests/scripts/comments.py
|
||||
- name: Packages
|
||||
run: packaging/macos/release.sh 1
|
||||
- name: Attach to the release
|
||||
run: |
|
||||
version=${{ inputs.tag }}
|
||||
gh release view "$version" >/dev/null 2>&1 || gh release create "$version" --title "Ely Office $version" --notes "Built by release.yml."
|
||||
gh release upload "$version" dist/*.dmg --clobber
|
||||
if [ "${{ matrix.arch }}" = arm64 ]; then
|
||||
gh release upload "$version" dist/ElyOffice-*-source.tar.gz dist/ElyOffice-*-upstream-source.tar.gz --clobber
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
# Contributing
|
||||
|
||||
## Opening a pull request on git.zacharyzhang.com
|
||||
|
||||
Forking is disabled on this server. Open pull requests with AGit instead:
|
||||
|
||||
1. Register an account at https://git.zacharyzhang.com
|
||||
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
|
||||
|
||||
```bash
|
||||
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
|
||||
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
|
||||
```
|
||||
|
||||
3. To update the pull request, push again with the same `topic`.
|
||||
Generated
+18
-17
@@ -1919,7 +1919,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "ely-agent"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -1927,7 +1927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-doc"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"csv",
|
||||
@@ -1948,7 +1948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-files"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"ely-platform",
|
||||
@@ -1964,7 +1964,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=b0dbfd429c17819f6c143fd365852244d497514a#b0dbfd429c17819f6c143fd365852244d497514a"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=5d6efd46ec0c93b381ec7578561a1273c366025b#5d6efd46ec0c93b381ec7578561a1273c366025b"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -2001,14 +2001,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ipc"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-lok"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"ely-gpui-component",
|
||||
@@ -2028,7 +2028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mail"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -2036,7 +2036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mark"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
@@ -2058,7 +2058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-office"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2078,7 +2078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2094,7 +2094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"ely-ipc",
|
||||
@@ -2110,7 +2110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-platform"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2143,7 +2143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-show"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2162,7 +2162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-table"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-gpui-component",
|
||||
"ely-lok",
|
||||
@@ -2178,7 +2178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-tree"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2196,7 +2196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ui"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2207,13 +2207,14 @@ dependencies = [
|
||||
"gpui-pre-platform",
|
||||
"log",
|
||||
"notify",
|
||||
"semver",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-web-content"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cef",
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
|
||||
exclude = ["engines", "reference"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -17,7 +17,7 @@ encoding_rs = "0.8"
|
||||
jiff = "0.2"
|
||||
anyhow = "1"
|
||||
directories = "6"
|
||||
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "b0dbfd429c17819f6c143fd365852244d497514a" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "5d6efd46ec0c93b381ec7578561a1273c366025b" }
|
||||
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"] }
|
||||
|
||||
@@ -529,7 +529,7 @@ AI 或脚本修改文件与其他外部修改使用同一套处理规则。ElyMa
|
||||
|
||||
### 15.2 接入基线
|
||||
|
||||
后端以 Kigi CLI(https://github.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
|
||||
后端以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
|
||||
|
||||
会话恢复只在 Kigi 的会话存储支持并通过验证时启用。不能把本地历史回放当成后端会话已经恢复。
|
||||
|
||||
@@ -870,10 +870,10 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
核对日期:2026-10-01。来源用于确认接口、项目结构和许可条件,不证明 Ely 已完成接入。产品功能、默认设置与性能目标由本文定义。正式实现应记录所采用版本的固定提交和许可证,而不是只引用默认分支。
|
||||
|
||||
[S1] Ely GPUI Components,README,组件库使用方式、主题及许可证。
|
||||
`https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
|
||||
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
|
||||
|
||||
[S2] Ely GPUI Components,TASKS,组件分类与 host/prove 等状态定义。
|
||||
`https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
|
||||
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
|
||||
|
||||
[S3] LibreOfficeKit 官方文档,C/C++ 接口、分块渲染和输入回调。
|
||||
`https://docs.libreoffice.org/libreofficekit.html`
|
||||
|
||||
@@ -39,7 +39,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `ely-gpui-component`(pin b0dbfd4,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 5d6efd4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
|
||||
| 需要 | 用 |
|
||||
| --- | --- |
|
||||
@@ -158,6 +158,9 @@ tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久
|
||||
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
|
||||
tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴)
|
||||
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
|
||||
tests/scripts/bad-settings.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 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
|
||||
tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限)
|
||||
@@ -184,8 +187,8 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
|
||||
## 决策
|
||||
|
||||
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 b0dbfd4(2026-10-09 起)。
|
||||
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并经 SSH 推送(本机 HTTPS keychain 令牌失效)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
|
||||
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 5d6efd4(2026-10-09 起)。
|
||||
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
|
||||
- 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
|
||||
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high;本机 `codex` 别名已带 `--yolo`,重复传参会报错)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。
|
||||
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定Line truncated
|
||||
@@ -200,8 +203,8 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
|
||||
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
|
||||
- 2026-10-04(用户定):macOS 包标识前缀 `ely.zacharyzhang.office`(`packaging/macos/bundle.sh`);发布者 ZacharyZhang,网址 zacharyzhang.com(macOS `NSHumanReadableCopyright`,Windows 卸载项 `Publisher` 与 `URLInfoAbout`;Linux 桌面文件无此字段)。界面语言英文与简体中文,以后再加。拼写词典只随英文:LibreOffice 的词典库没有中文(中文不用 Hunspell 拼写)。内核界面语言固定英文,函数名照英文;ElyTable 把内核英文的选区统计(Sum、Average 等七个固定名)换成界面语言,数值照内核。批注与修订的作者取各用户的账户全名(无全名用登录名,与内核自身次序一致),由补丁 0013 的 `setOption("author")` 在 worker 启动时设定;作者设置页随设置任务接入。邮件合并字段写法 `«列名»` 由我定(用户交我决定)。
|
||||
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。清单地址由设置给出,尚未配置。
|
||||
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://github.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
|
||||
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。2026-10-09 起(用户定:0.1.2 加自动更新):每个应用一份清单,`<slug>.json`,随发布上传,应用经 git.zacharyzhang.com 的 API 读最新发布(`api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest`),在其文件里找 `<slug>.json`(`ely_platform::release::asset_url`;Gitea 没有 `releases/latest/download` 路径;`ELY_UPDATE_MANIFEST` 直接给清单地址,测试用);只在从 `.app` 运行时可用(开发构建没有);照 PRD §4.2 自动检查默认关闭:设置“通用”的“启动时检查更新”(通用设置 `check_updates`)打开后启动时查一次、查不到只记日志,应用菜单的“检查更新…”随时手动查(已是最新或查不到都以提示写明);有新版即在窗口上方显示组件库的 `UpdateBanner`(文案进内建中英目录,组件库 5d6efd4):横幅在标题栏下方(`Shell` 的页眉里,`Updates` 自己是一个视图);下载(进度)、校验散列、“重新启动以更新”只走正常的退出流程(有未保存的照常询问;取消即什么也不装,按钮仍在);应用真正退出时(`on_app_quit`)以本程序的安装模式(`--ely-install-update dmg 包 进程号`,`ely_ui::run` 最先识别)起一个独立进程:等本进程结束,`ely_platform::install` 挂载 dmg、把同名 .app 拷到旁边再换掉原包(换上前失败即放回原包),无论成败都 `open` 该包;失败的原因写在数据目录 `updates/failed.txt`,重开的应用以提示写明。下载完成只归属于它下载的那个版本;下载中或已下载时再查不覆盖。`packaging/macos/release.sh` 为每个应用写出清单(版本、本机架构的 dmg 地址与 SHA-256),dmg 里另有指向“应用程序”的替身,可直接拖入。新包换上即为装好,旧副本删不掉只记警告。`tests/scripts/update.sh`:装好的 ElyMark 在默认设置下启动不发任何请求;按“检查更新…”后从本机服务的 9.9.9 下载;有未保存的文字时“重新启动以更新”先问,取消则原包不动,“不保存”后装上新版并重新打开(新版本身的进程在跑)。
|
||||
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
|
||||
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
|
||||
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
|
||||
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF、CEF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
|
||||
@@ -271,3 +274,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁、并挤回当前可见区;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表压在数据上或旁边的另一块数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:LOK 下有请求区域时,图表放进数据区右侧(隔一列)第一块够大、无合并格的空单元格(逐列向右找,跨过旁边的数据块),右侧没有依次找下方、左侧、上方,再按行带找全表第一块够大的空处(尺寸从候选格起累加,表尾也快),不受可见区限制;空处按图表所在的工作表查,数据在别的表时从光标起找;整张表没有空处即插入失败、文档不动;`LokView` 在新选中对象(原先无选中)时把它滚入视口,拖动中的对象不滚动。
|
||||
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
|
||||
- 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(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。
|
||||
@@ -85,6 +85,14 @@ impl Host for Docs {
|
||||
self.offer_spelling(id, view, cx);
|
||||
}
|
||||
|
||||
/// A dialog about a closed tab goes with it.
|
||||
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
|
||||
if matches!(self.dialog, Some(Dialog::Output { tab: of, .. }) if of == tab.id) {
|
||||
self.dialog = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn search_leaves(&mut self, cx: &mut Context<Self>) {
|
||||
self.mark_place(cx);
|
||||
}
|
||||
@@ -126,7 +134,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
|
||||
docs.update(cx, |docs, cx| {
|
||||
docs.core.root = root.downgrade();
|
||||
docs.core.window = Some(window.window_handle());
|
||||
docs.own_window(window, cx);
|
||||
window.focus(&docs.core.focus, cx);
|
||||
});
|
||||
let launched = ely_ui::launch_files(cx);
|
||||
@@ -209,7 +217,6 @@ impl Render for Docs {
|
||||
.on_action(cx.listener(Self::on_open))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
|
||||
@@ -36,6 +36,10 @@ pub fn menu(cx: &gpui::App) -> gpui::Menu {
|
||||
pub struct Merge {
|
||||
docs: WeakEntity<Docs>,
|
||||
pub table: Option<Table>,
|
||||
/// The template's tab, active when the data was chosen.
|
||||
pub source: Option<u64>,
|
||||
/// Tabs opened as previews; none is a template.
|
||||
pub previews: std::collections::HashSet<u64>,
|
||||
/// The record to preview, from 1.
|
||||
record: usize,
|
||||
pdf: bool,
|
||||
@@ -46,6 +50,8 @@ impl Merge {
|
||||
Merge {
|
||||
docs,
|
||||
table: None,
|
||||
source: None,
|
||||
previews: std::collections::HashSet::new(),
|
||||
record: 1,
|
||||
pdf: false,
|
||||
}
|
||||
@@ -62,11 +68,20 @@ impl Docs {
|
||||
}
|
||||
|
||||
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
|
||||
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
|
||||
let Some(source) = self.core.active else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
if self.merge.read(cx).previews.contains(&source) {
|
||||
let text = Docs::text(cx, "doc.merge.from_preview", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
self.pick(false, cx, move |docs, path, cx| match read_csv(&path) {
|
||||
Ok(table) => {
|
||||
log::info!("doc: merge data has {} rows", table.rows.len());
|
||||
docs.merge.update(cx, |merge, cx| {
|
||||
merge.table = Some(table);
|
||||
merge.source = Some(source);
|
||||
merge.record = 1;
|
||||
cx.notify();
|
||||
});
|
||||
@@ -84,20 +99,20 @@ impl Docs {
|
||||
});
|
||||
}
|
||||
|
||||
/// The tab a merge starts from: the active one.
|
||||
/// The template's tab, while it is open.
|
||||
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
|
||||
if self.core.active.is_none() {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
let source = self.merge.read(cx).source.filter(|source| self.tab(*source).is_some());
|
||||
if source.is_none() {
|
||||
let text = Docs::text(cx, "doc.merge.gone", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
self.core.active
|
||||
source
|
||||
}
|
||||
|
||||
/// Types a field's marker at the cursor.
|
||||
/// Types a field's marker at the template's cursor.
|
||||
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
let Some(tab) = self.source(cx).and_then(|source| self.tab(source)) else {
|
||||
return;
|
||||
};
|
||||
tab.view.update(cx, |view, _| view.insert(&marker(header)));
|
||||
}
|
||||
@@ -194,7 +209,12 @@ impl Render for Merge {
|
||||
})
|
||||
};
|
||||
let row = || div().flex().flex_wrap().items_center().gap_1();
|
||||
let template = self.source.and_then(|source| {
|
||||
let docs = self.docs.upgrade()?;
|
||||
Some(docs.read(cx).tab(source)?.name.clone())
|
||||
});
|
||||
let group = PropertyGroup::new(table.name.clone())
|
||||
.row(text("doc.merge.template"), div().child(template.unwrap_or_else(|| text("doc.merge.gone"))))
|
||||
.row(text("doc.merge.data"), choose)
|
||||
.row(text("doc.merge.fields"), row().children(fields))
|
||||
.row(
|
||||
|
||||
@@ -164,6 +164,7 @@ impl Docs {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::info!("doc: previewing merge record {}", at + 1);
|
||||
self.merge.update(cx, |merge, _| merge.previews.insert(into));
|
||||
self.load_into(into, Source::Copy(copy), cx);
|
||||
self.core.waiting.push((
|
||||
into,
|
||||
|
||||
@@ -22,9 +22,9 @@ fn records_fill_previews_and_files() {
|
||||
let csv = dir.path().join("people.csv");
|
||||
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
|
||||
type_into(&mut cx, &docs, " of ");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("City", cx)));
|
||||
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
@@ -171,7 +171,7 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.insert_field("Name", cx);
|
||||
docs.field_here("Name", cx);
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
docs.core.active = Some(other);
|
||||
})
|
||||
@@ -191,6 +191,14 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
});
|
||||
let preview = dir.path().join("preview.odt");
|
||||
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
|
||||
// The preview is active now; the template stays.
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.core.active = Some(id);
|
||||
assert_eq!(docs.source(cx), Some(source), "the template stays after a preview");
|
||||
assert!(docs.merge.read(cx).previews.contains(&id), "the preview is known");
|
||||
})
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, preview.clone(), None, window, cx)
|
||||
});
|
||||
@@ -227,7 +235,7 @@ fn two_generations_keep_their_own_templates() {
|
||||
for text in ["SourceA ", "SourceB "] {
|
||||
sources.push(blank(&mut cx, &docs));
|
||||
type_into(&mut cx, &docs, text);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
|
||||
settled(&cx, &docs);
|
||||
}
|
||||
let csv = dir.path().join("one.csv");
|
||||
@@ -265,3 +273,40 @@ fn two_generations_keep_their_own_templates() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_template_chosen_with_the_data_stays_through_a_preview() {
|
||||
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;
|
||||
};
|
||||
let template = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Dear ");
|
||||
let csv = dir.path().join("two.csv");
|
||||
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.choose_csv(cx)));
|
||||
cx.simulate_path_prompt_response(move |_| Some(vec![csv]));
|
||||
settle(&cx, |app| docs.read(app).merge.read(app).table.is_some());
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
let table = cx.update(|app| docs.read(app).merge.read(app).table.clone().expect("the data"));
|
||||
// As the panel's buttons do: preview, then generate.
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
let source = docs.source(cx).expect("the template");
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
}));
|
||||
settle(&cx, |app| tabs(app, &docs) == 2 && docs.read(app).core.active != Some(template));
|
||||
let out = dir.path().join("out");
|
||||
std::fs::create_dir(&out).expect("the folder");
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
let source = docs.source(cx).expect("the template, not the preview");
|
||||
assert_eq!(source, template);
|
||||
docs.generate(source, table.clone(), false, out.clone(), cx);
|
||||
}));
|
||||
let stem = cx.update(|app| docs.read(app).tab(template).expect("a tab").name.to_string());
|
||||
let target = |n: usize| out.join(format!("{stem} {n}.docx"));
|
||||
settle(&cx, |_| target(2).exists());
|
||||
let (first, second) = (text_of(&mut cx, &docs, &target(1)), text_of(&mut cx, &docs, &target(2)));
|
||||
assert!(first.contains("Dear Ada") && second.contains("Dear Grace"), "{first:?} {second:?}");
|
||||
}
|
||||
@@ -29,7 +29,7 @@ impl Docs {
|
||||
self.ask_output(true, cx);
|
||||
}
|
||||
|
||||
fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
|
||||
pub(crate) fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
@@ -81,10 +81,8 @@ impl Docs {
|
||||
let path = source.work.join("print.pdf");
|
||||
let printing = path.clone();
|
||||
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
|
||||
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
|
||||
docs.output_failed(&error, cx)
|
||||
}),
|
||||
Err(error) => docs.output_failed(&error.to_string(), cx),
|
||||
Ok(()) => ely_office::print_later(printing, cx),
|
||||
Err(error) => docs.print_failed(&error.to_string(), cx),
|
||||
});
|
||||
}
|
||||
let Some(dir) = ely_office::folder(source) else {
|
||||
|
||||
@@ -463,3 +463,12 @@ fn an_outside_change_asks_and_keep_mine_overwrites() {
|
||||
mod find;
|
||||
mod more;
|
||||
mod overlap;
|
||||
|
||||
impl Docs {
|
||||
/// Chooses the active tab as template, then types a field.
|
||||
pub(crate) fn field_here(&mut self, header: &str, cx: &mut gpui::Context<Self>) {
|
||||
let active = self.core.active;
|
||||
self.merge.update(cx, |merge, _| merge.source = active);
|
||||
self.insert_field(header, cx);
|
||||
}
|
||||
}
|
||||
@@ -215,3 +215,51 @@ fn an_edit_that_just_landed_still_asks_and_selection_does_not() {
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).tab(id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quit_from_either_window_asks_first() {
|
||||
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;
|
||||
};
|
||||
let id = blank(&mut cx, &docs);
|
||||
commit(&mut cx, &docs, "Unsaved");
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
settle(&cx, |app| view.read(app).settled());
|
||||
cx.update(ely_ui::commands::bind_quit);
|
||||
let home = cx.add_window(|_, _| Blank);
|
||||
cx.update_window(home.into(), |_, window, app| docs.update(app, |docs, cx| docs.own_window(window, cx)))
|
||||
.expect("the window lives");
|
||||
// The settings window has no quit of its own.
|
||||
let settings = cx.add_window(|_, _| Blank);
|
||||
for from in [home.into(), settings.into()] {
|
||||
cx.dispatch_action(from, ely_ui::commands::Quit);
|
||||
settle(&cx, |app| matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. })));
|
||||
assert!(cx.update(|app| docs.read(app).tab(id).is_some()), "the tab waits for an answer");
|
||||
assert_eq!(cx.update(|app| app.active_window()), Some(home.into()), "the question's window comes forward");
|
||||
// Cancel, as the dialog's button does.
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
docs.close_cancelled(cx);
|
||||
docs.core.dialog = None;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_tab_takes_its_export_dialog() {
|
||||
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;
|
||||
};
|
||||
let id = blank(&mut cx, &docs);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.ask_output(false, cx)));
|
||||
assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(crate::actions::Dialog::Output { .. }))));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| docs.request_close(id, After::Tab, window, cx));
|
||||
settle(&cx, |app| docs.read(app).tab(id).is_none());
|
||||
// Drawn now, a dialog for the gone tab would crash.
|
||||
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "the dialog went with its tab");
|
||||
}
|
||||
@@ -86,10 +86,10 @@ fn interleaved_previews_keep_their_sources() {
|
||||
};
|
||||
let alpha = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceAlpha ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
|
||||
let beta = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceBeta ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
|
||||
settled(&cx, &docs);
|
||||
let csv = dir.path().join("two.csv");
|
||||
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
|
||||
|
||||
@@ -29,6 +29,14 @@ impl Desk {
|
||||
return;
|
||||
}
|
||||
tab.extra.heard = changed;
|
||||
self.save_after_pause(id, cx);
|
||||
}
|
||||
|
||||
/// Counts unsaved text as an edit; saves after the pause.
|
||||
pub fn save_after_pause(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
tab.extra.policy.edit(Instant::now());
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let wait = DEBOUNCE + Duration::from_millis(50);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use ely_files::{Fingerprint, Policy, PolicyEngine};
|
||||
use ely_files::Fingerprint;
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
git::{self as merging, Region, Take, ThreeWayMerge},
|
||||
@@ -102,7 +102,7 @@ impl Desk {
|
||||
Choice::Mine => {
|
||||
if let Some(tab) = self.tab_mut(id) {
|
||||
tab.session.rebase(fingerprint);
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
tab.extra.restart_autosave();
|
||||
}
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
self.save(id, None, window, cx);
|
||||
@@ -120,7 +120,7 @@ impl Desk {
|
||||
return self.set_outside(id, Outside::Unread(why), cx);
|
||||
}
|
||||
tab.extra.base = disk.text.clone();
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
tab.extra.restart_autosave();
|
||||
tab.doc.lock().expect("the text lock").source = disk;
|
||||
let view = tab.view.clone();
|
||||
view.update(cx, |view, cx| view.adopt(merged, cx));
|
||||
|
||||
@@ -81,6 +81,11 @@ impl Host for Desk {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
/// A quit records the open tabs first.
|
||||
fn quitting(&mut self, cx: &mut Context<Self>) {
|
||||
self.leave(cx);
|
||||
}
|
||||
|
||||
/// A quit or close stopped: the kept list follows again.
|
||||
fn close_cancelled(&mut self, cx: &mut Context<Self>) {
|
||||
self.keeping.leaving = false;
|
||||
@@ -215,7 +220,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let asked = ely_ui::launch_files(cx);
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.core.root = root.downgrade();
|
||||
desk.core.window = Some(window.window_handle());
|
||||
desk.own_window(window, cx);
|
||||
window.focus(&desk.core.focus, cx);
|
||||
// Launch files stand alone; unsaved text still returns.
|
||||
if asked.is_empty() {
|
||||
@@ -403,10 +408,6 @@ impl Render for Desk {
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.on_action(cx.listener(|desk, quit, window, cx| {
|
||||
desk.leave(cx);
|
||||
desk.on_quit(quit, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx)))
|
||||
.on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx)))
|
||||
.on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx)))
|
||||
|
||||
@@ -343,9 +343,7 @@ impl Desk {
|
||||
if !job.lost.whole() {
|
||||
self.warn_lost(&job.lost, None, cx);
|
||||
}
|
||||
return ely_office::print_later(pdf, cx, |desk, why, cx| {
|
||||
desk.exported(Err(io::Error::other(why)), cx)
|
||||
});
|
||||
return ely_office::print_later(pdf, cx);
|
||||
};
|
||||
// Never over the open file, not even through a link.
|
||||
let moved = not_open(self, &to)
|
||||
|
||||
@@ -55,6 +55,14 @@ pub struct Extra {
|
||||
pub restored: bool,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
/// Autosave from scratch; a write in flight no longer counts.
|
||||
pub fn restart_autosave(&mut self) {
|
||||
self.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
self.writing = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Extra {
|
||||
fn default() -> Self {
|
||||
Extra {
|
||||
@@ -217,11 +225,26 @@ impl Desk {
|
||||
Ok(read) => read,
|
||||
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
|
||||
};
|
||||
// Keys the page has not sent yet count first.
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |desk, _, cx| desk.disk_read(id, path, Ok((fingerprint, disk)), cx));
|
||||
}
|
||||
let current = tab.doc.lock().expect("the text lock").current.clone();
|
||||
let kept = disk.text == tab.extra.base;
|
||||
let dirty = tab.dirty(cx);
|
||||
log::info!("mark: {} changed outside; unsaved {dirty}", path.display());
|
||||
match (disk.text == current, dirty) {
|
||||
(true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx),
|
||||
// The same text: only the file underneath is new.
|
||||
(true, _) => {
|
||||
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
|
||||
// What shows is now saved: the tab is clean.
|
||||
let saved = crate::engine::mark(&disk.text);
|
||||
view.update(cx, |view, cx| view.saved(&saved, cx));
|
||||
}
|
||||
}
|
||||
// The text as last saved: the edits stay unsaved.
|
||||
(false, true) if kept => self.keep_edits(id, disk, fingerprint, cx),
|
||||
(false, false) => self.take_disk(id, disk, fingerprint, cx),
|
||||
(false, true) => {
|
||||
let tab = self.tab_mut(id).expect("open");
|
||||
tab.session.external_modified();
|
||||
@@ -247,14 +270,47 @@ impl Desk {
|
||||
fingerprint: Fingerprint,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
|
||||
view.update(cx, |view, cx| view.refresh(disk, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new file with the saved text; edits stay.
|
||||
fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
tab.session.rebase(fingerprint);
|
||||
tab.doc.lock().expect("the text lock").source = disk;
|
||||
// A conflict stopped autosave, maybe mid-write; it resumes.
|
||||
tab.extra.restart_autosave();
|
||||
let (registry, path) = (tab.registry, tab.session.path().map(Path::to_path_buf));
|
||||
if let (Some(registry), Some(path)) = (registry, path)
|
||||
&& let Err(error) = self.core.sessions.track(registry, &path)
|
||||
{
|
||||
log::warn!("mark: the replaced file did not register: {error}");
|
||||
}
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
self.save_after_pause(id, cx);
|
||||
}
|
||||
|
||||
/// The tab takes the file as saved; returns its view.
|
||||
fn adopt_disk(
|
||||
&mut self,
|
||||
id: u64,
|
||||
disk: &Source,
|
||||
fingerprint: Fingerprint,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<gpui::Entity<crate::view::MarkView>> {
|
||||
let tab = self.tab_mut(id)?;
|
||||
if let Err(error) = tab.session.reload_at(fingerprint) {
|
||||
return self.set_outside(id, Outside::Unread(error.to_string()), cx);
|
||||
self.set_outside(id, Outside::Unread(error.to_string()), cx);
|
||||
return None;
|
||||
}
|
||||
tab.extra.base = disk.text.clone();
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
tab.extra.restart_autosave();
|
||||
// Its bytes (BOM, line ends) are what saves keep now.
|
||||
tab.doc.lock().expect("the text lock").source = disk.clone();
|
||||
let (view, registry) = (tab.view.clone(), tab.registry);
|
||||
let path = tab.session.path().map(Path::to_path_buf);
|
||||
// An atomic replace is a new file underneath.
|
||||
@@ -263,8 +319,8 @@ impl Desk {
|
||||
{
|
||||
log::warn!("mark: the replaced file did not register: {error}");
|
||||
}
|
||||
view.update(cx, |view, cx| view.refresh(disk, cx));
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
Some(view)
|
||||
}
|
||||
|
||||
pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context<Self>) {
|
||||
@@ -345,3 +401,29 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
|
||||
log::info!("mark: watching {} folders", dirs.len());
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_files::Action;
|
||||
|
||||
use super::Extra;
|
||||
|
||||
#[test]
|
||||
fn a_restarted_autosave_saves_the_next_edit() {
|
||||
let mut extra = Extra::default();
|
||||
extra.policy.set_has_path();
|
||||
extra.policy.edit(Instant::now());
|
||||
// A write was in flight when the disk changed.
|
||||
extra.writing = Some(1);
|
||||
extra.restart_autosave();
|
||||
// As autosave settles a heard write, then edits.
|
||||
if let Some(revision) = extra.writing.take() {
|
||||
extra.policy.save_finished(revision, true);
|
||||
}
|
||||
let now = Instant::now();
|
||||
extra.policy.edit(now);
|
||||
assert!(matches!(extra.policy.tick(now + Duration::from_secs(5)), Action::SaveDue(_)));
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, ca
|
||||
use crate::content::{Aim, Tool};
|
||||
use crate::layout::Placed;
|
||||
use crate::pages::{rect_on, to_view};
|
||||
use crate::view::PdfView;
|
||||
use crate::view::{PdfView, Unread};
|
||||
|
||||
/// How far a click may move, in window pixels.
|
||||
const CLICK: f32 = 3.0;
|
||||
pub(crate) const CLICK: f32 = 3.0;
|
||||
/// Pages read for their annotations at once.
|
||||
const BATCH: usize = 20;
|
||||
|
||||
@@ -77,31 +77,49 @@ impl PdfView {
|
||||
if self.reading_notes {
|
||||
return;
|
||||
}
|
||||
let wanted: Vec<usize> = pages.into_iter().filter(|page| !self.notes.contains_key(page)).take(BATCH).collect();
|
||||
let wanted: Vec<usize> = (pages.into_iter())
|
||||
.filter(|page| !self.notes.contains_key(page) && !self.unread.contains(&Unread::Notes(*page)))
|
||||
.take(BATCH)
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
return;
|
||||
}
|
||||
let asked = wanted.clone();
|
||||
self.reading_notes = true;
|
||||
let (doc, version) = (self.doc, self.version);
|
||||
self.off(
|
||||
// Page by page: one bad page spares the rest.
|
||||
move |worker| {
|
||||
(wanted.into_iter())
|
||||
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?)))
|
||||
.collect::<std::io::Result<Vec<_>>>()
|
||||
let read = (wanted.into_iter())
|
||||
.map(|page| (page, worker.marks(doc, page).and_then(|marks| Ok((marks, worker.links(doc, page)?)))))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(read)
|
||||
},
|
||||
move |view, read, cx| {
|
||||
move |view, read: std::io::Result<Vec<_>>, cx| {
|
||||
view.reading_notes = false;
|
||||
if view.version != version {
|
||||
return cx.notify();
|
||||
}
|
||||
match read {
|
||||
Ok(read) => {
|
||||
for (page, marks, links) in read {
|
||||
// No worker at all: every page asked failed.
|
||||
let read = read.unwrap_or_else(|error| {
|
||||
let why = error.to_string();
|
||||
asked.into_iter().map(|page| (page, Err(std::io::Error::other(why.clone())))).collect()
|
||||
});
|
||||
let mut first = None;
|
||||
for (page, page_read) in read {
|
||||
match page_read {
|
||||
Ok((marks, links)) => {
|
||||
view.notes.insert(page, marks);
|
||||
view.links.insert(page, links);
|
||||
}
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Notes(page));
|
||||
first.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
Err(error) => view.fail("reading annotations", &error, cx),
|
||||
}
|
||||
if let Some(error) = first {
|
||||
view.fail("reading annotations", &error, cx);
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
@@ -111,7 +129,7 @@ impl PdfView {
|
||||
|
||||
/// Reads every field name in the form, once an edit.
|
||||
pub fn ask_names(&mut self, cx: &mut Context<Self>) {
|
||||
if self.form_names.is_some() || self.reading_names {
|
||||
if self.form_names.is_some() || self.reading_names || self.unread.contains(&Unread::Names) {
|
||||
return;
|
||||
}
|
||||
self.reading_names = true;
|
||||
@@ -125,7 +143,10 @@ impl PdfView {
|
||||
}
|
||||
match read {
|
||||
Ok(names) => view.form_names = Some(names),
|
||||
Err(error) => view.fail("reading field names", &error, cx),
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Names);
|
||||
view.fail("reading field names", &error, cx)
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
@@ -253,11 +274,12 @@ impl PdfView {
|
||||
if annots.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.settled_edit(what, cx, move |worker, doc| {
|
||||
let name = ely_platform::user::name()?;
|
||||
let author = ely_ui::settings::current(cx).author.clone();
|
||||
self.edit_at(what, move |worker, doc| {
|
||||
let name = ely_platform::user::named(&author)?;
|
||||
annots.iter_mut().for_each(|annot| annot.author = name.clone());
|
||||
worker.annotate(doc, &annots)
|
||||
});
|
||||
}, cx);
|
||||
}
|
||||
|
||||
/// A press with a drawing or form tool.
|
||||
|
||||
@@ -136,7 +136,7 @@ impl Viewer {
|
||||
}
|
||||
|
||||
fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) {
|
||||
if error.kind() == io::ErrorKind::PermissionDenied {
|
||||
if ely_pdf_engine::refused(error).is_some() {
|
||||
return self.failed(error, cx);
|
||||
}
|
||||
let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]);
|
||||
|
||||
@@ -15,7 +15,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::document::{RemoveAttachment, SaveAttachment};
|
||||
use crate::view::PdfView;
|
||||
use crate::view::{PdfView, Unread};
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
/// A size as people read it.
|
||||
@@ -30,7 +30,7 @@ pub fn size(bytes: u64) -> String {
|
||||
impl PdfView {
|
||||
/// Reads the attachments once an edit.
|
||||
pub fn ask_files(&mut self, cx: &mut Context<Self>) {
|
||||
if self.files.is_some() || self.reading_files {
|
||||
if self.files.is_some() || self.reading_files || self.unread.contains(&Unread::Files) {
|
||||
return;
|
||||
}
|
||||
self.reading_files = true;
|
||||
@@ -44,7 +44,10 @@ impl PdfView {
|
||||
}
|
||||
match read {
|
||||
Ok(files) => view.files = Some(files),
|
||||
Err(error) => view.fail("reading attachments", &error, cx),
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Files);
|
||||
view.fail("reading attachments", &error, cx)
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
|
||||
+32
-10
@@ -8,7 +8,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, di
|
||||
|
||||
use crate::layout::Placed;
|
||||
use crate::pages::rect_on;
|
||||
use crate::view::PdfView;
|
||||
use crate::view::{PdfView, Unread};
|
||||
|
||||
/// What a press on a page does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -76,6 +76,10 @@ pub struct Drag {
|
||||
pub from: (f32, f32),
|
||||
pub now: (f32, f32),
|
||||
pub scale: bool,
|
||||
/// Where the press landed, in window pixels.
|
||||
pub pressed: (f32, f32),
|
||||
/// The pointer went past a click's slack.
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
/// The corner handle's reach, in page points.
|
||||
@@ -107,6 +111,11 @@ fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
|
||||
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
|
||||
}
|
||||
|
||||
/// Whether `outer` holds all of `inner`.
|
||||
fn covers(outer: &[f32; 4], inner: &[f32; 4]) -> bool {
|
||||
outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3]
|
||||
}
|
||||
|
||||
impl PdfView {
|
||||
/// A drag or drawing is under way.
|
||||
pub fn gesturing(&self) -> bool {
|
||||
@@ -130,7 +139,9 @@ impl PdfView {
|
||||
if !self.tool.edits() || self.reading_layer {
|
||||
return;
|
||||
}
|
||||
let Some(page) = pages.iter().copied().find(|page| !self.layers.contains_key(page)) else {
|
||||
let Some(page) = (pages.iter().copied())
|
||||
.find(|page| !self.layers.contains_key(page) && !self.unread.contains(&Unread::Layer(*page)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.reading_layer = true;
|
||||
@@ -147,7 +158,10 @@ impl PdfView {
|
||||
Ok(layer) => {
|
||||
view.layers.insert(page, layer);
|
||||
}
|
||||
Err(error) => view.fail("reading what the page holds", &error, cx),
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Layer(page));
|
||||
view.fail("reading what the page holds", &error, cx)
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
@@ -174,16 +188,21 @@ impl PdfView {
|
||||
Some(Aim::Object { page: on, index }) if on == page => layer.drawn.get(index).filter(|d| corner(&d.rect)).map(|_| (index, true)),
|
||||
_ => None,
|
||||
};
|
||||
let hit = held.or_else(|| layer.drawn.iter().rposition(|d| inside(&d.rect, point)).map(|index| (index, false)));
|
||||
let text = layer.blocks.iter().position(|b| b.editable && inside(&b.rect, point));
|
||||
// Text wins over an object drawn all around it.
|
||||
let around = |rect: &[f32; 4]| text.is_some_and(|t| covers(rect, &layer.blocks[t].rect));
|
||||
let hit = held.or_else(|| {
|
||||
let object = layer.drawn.iter().rposition(|d| inside(&d.rect, point) && !around(&d.rect));
|
||||
object.map(|index| (index, false))
|
||||
});
|
||||
self.aim = match hit {
|
||||
Some((index, scale)) => {
|
||||
let rect = layer.drawn[index].rect;
|
||||
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale });
|
||||
let pressed = (f32::from(at.x), f32::from(at.y));
|
||||
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale, pressed, moved: false });
|
||||
Some(Aim::Object { page, index })
|
||||
}
|
||||
None => (layer.blocks.iter())
|
||||
.position(|b| b.editable && inside(&b.rect, point))
|
||||
.map(|index| Aim::Text { page, index }),
|
||||
None => text.map(|index| Aim::Text { page, index }),
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
@@ -193,6 +212,8 @@ impl PdfView {
|
||||
return;
|
||||
};
|
||||
if let Some(drag) = self.drag.as_mut().filter(|drag| drag.page == page) {
|
||||
let far = (f32::from(at.x) - drag.pressed.0).abs() + (f32::from(at.y) - drag.pressed.1).abs();
|
||||
drag.moved |= far >= crate::annotate::CLICK;
|
||||
drag.now = point;
|
||||
cx.notify();
|
||||
}
|
||||
@@ -203,7 +224,8 @@ impl PdfView {
|
||||
let Some(drag) = self.drag.take() else {
|
||||
return;
|
||||
};
|
||||
if (drag.now.0 - drag.from.0).abs() < 1.0 && (drag.now.1 - drag.from.1).abs() < 1.0 {
|
||||
// A hand that shakes a little still clicks.
|
||||
if !drag.moved {
|
||||
return cx.notify();
|
||||
}
|
||||
// A queued edit may renumber what was dragged.
|
||||
@@ -264,7 +286,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_drag_moves_or_scales_from_the_top_left() {
|
||||
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false };
|
||||
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false, pressed: (0.0, 0.0), moved: true };
|
||||
assert_eq!(drag.change(), [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
|
||||
assert_eq!(drag.shown(), [20.0, 25.0, 120.0, 75.0]);
|
||||
drag = Drag { from: (110.0, 70.0), now: (210.0, 80.0), scale: true, ..drag };
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct Queued {
|
||||
doc: Doc,
|
||||
/// The edit may add, drop, move or reshape pages.
|
||||
pages: bool,
|
||||
/// Aimed at a point: runs on the state it meets.
|
||||
follow: bool,
|
||||
}
|
||||
|
||||
/// The document as an edit left it.
|
||||
@@ -41,6 +43,23 @@ impl PdfView {
|
||||
self.queue_edit(what, None, false, op, cx);
|
||||
}
|
||||
|
||||
/// Queues a point-aimed edit; only moving pages refuse it.
|
||||
pub fn edit_at(
|
||||
&mut self,
|
||||
what: &'static str,
|
||||
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.moving || self.queue.iter().any(|queued| queued.pages) {
|
||||
let text = crate::viewer::Viewer::text(cx, "pdf.pages.changed", &[]);
|
||||
cx.emit(crate::view::Failed(text.to_string()));
|
||||
return cx.notify();
|
||||
}
|
||||
log::info!("pdf: queues {what}");
|
||||
self.queue.push_back(Queued { what, op: Box::new(op), pick: None, doc: self.doc, pages: false, follow: true });
|
||||
self.next_edit(cx);
|
||||
}
|
||||
|
||||
/// Queues an edit to the pages themselves.
|
||||
pub fn edit_pages(
|
||||
&mut self,
|
||||
@@ -62,7 +81,7 @@ impl PdfView {
|
||||
) {
|
||||
log::info!("pdf: queues {what}");
|
||||
// Aimed at what is shown now; later states refuse it.
|
||||
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages });
|
||||
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages, follow: false });
|
||||
self.next_edit(cx);
|
||||
}
|
||||
|
||||
@@ -70,10 +89,12 @@ impl PdfView {
|
||||
if self.editing {
|
||||
return;
|
||||
}
|
||||
let Some(Queued { what, op, pick, doc, pages }) = self.queue.pop_front() else {
|
||||
let Some(Queued { what, op, pick, doc, pages, follow }) = self.queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
self.editing = true;
|
||||
// Earlier edits have landed; a point still means the same.
|
||||
let doc = if follow { self.doc } else { doc };
|
||||
(self.editing, self.moving) = (true, pages);
|
||||
self.off(
|
||||
move |worker| {
|
||||
let mark = op(worker, doc)?;
|
||||
@@ -83,7 +104,7 @@ impl PdfView {
|
||||
Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? })
|
||||
},
|
||||
move |view, fresh, cx| {
|
||||
view.editing = false;
|
||||
(view.editing, view.moving) = (false, false);
|
||||
match fresh {
|
||||
Ok(fresh) => {
|
||||
view.refresh(fresh, pages, cx);
|
||||
@@ -115,19 +136,22 @@ impl PdfView {
|
||||
// A page may now stand where another stood.
|
||||
self.retire_tiles();
|
||||
self.sizes = measured;
|
||||
self.thumbs_stale.clear();
|
||||
for (_, image) in self.thumbs.drain() {
|
||||
self.retired.push(image);
|
||||
}
|
||||
} else {
|
||||
// Same pages: old tiles stay under them until repainted.
|
||||
// Same pages: old pictures stay until repainted.
|
||||
self.keep_all();
|
||||
self.thumbs_stale.extend(self.thumbs.keys().copied());
|
||||
let read = fresh.first.len();
|
||||
self.sizes[..read].copy_from_slice(&measured[..read]);
|
||||
}
|
||||
self.marks = fresh.marks;
|
||||
self.failed.clear();
|
||||
self.unread.clear();
|
||||
self.text.clear();
|
||||
self.asking_text.clear();
|
||||
for (_, image) in self.thumbs.drain() {
|
||||
self.retired.push(image);
|
||||
}
|
||||
self.thumbs_failed.clear();
|
||||
// Indices shift with any edit; aims start over.
|
||||
(self.layers, self.aim, self.drag) = (Default::default(), None, None);
|
||||
@@ -149,8 +173,10 @@ impl PdfView {
|
||||
/// The file now holds history `mark`.
|
||||
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
|
||||
self.saved = mark.to_string();
|
||||
self.saves += 1;
|
||||
// The file changed; read it again.
|
||||
self.signatures = None;
|
||||
self.unread.retain(|unread| !matches!(unread, crate::view::Unread::Signatures(_)));
|
||||
cx.emit(Reported);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,10 +165,12 @@ impl Viewer {
|
||||
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
|
||||
return self.toast(summary, Severity::Info, cx);
|
||||
};
|
||||
self.on_same(&view, run.version, cx, |view, cx| {
|
||||
let laid = self.on_same(&view, run.version, cx, |view, cx| {
|
||||
view.edit("adding recognised text", move |worker, doc| worker.text_layer(doc, &pages, replace), cx)
|
||||
});
|
||||
self.toast(summary, Severity::Success, cx);
|
||||
if laid {
|
||||
self.toast(summary, Severity::Success, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The OCR dialog: the ask, then the progress.
|
||||
|
||||
@@ -95,7 +95,7 @@ impl Viewer {
|
||||
id,
|
||||
move |worker, doc| worker.get()?.print_copy(*doc, &out),
|
||||
move |viewer, done, _, cx| match done {
|
||||
Ok(()) => ely_office::print_later(printing, cx, |viewer, error, cx| viewer.panel_failed(&error, cx)),
|
||||
Ok(()) => ely_office::print_later(printing, cx),
|
||||
Err(error) => viewer.failed(&error, cx),
|
||||
},
|
||||
cx,
|
||||
@@ -203,6 +203,8 @@ impl Viewer {
|
||||
Err(error) => return self.failed(&error, cx),
|
||||
}
|
||||
let (work, path, revision) = (tab.work.join("export.out"), tab.session.path().map(PathBuf::from), tab.session.revision());
|
||||
// Unsaved edits: the file on disk is not "before".
|
||||
let compared = path.clone().filter(|_| !tab.view.read(cx).modified());
|
||||
let opened = target.clone();
|
||||
log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" });
|
||||
self.read_in(
|
||||
@@ -214,7 +216,7 @@ impl Viewer {
|
||||
}
|
||||
let bytes = std::fs::read(&work)?;
|
||||
std::fs::remove_file(&work)?;
|
||||
let before = path.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len());
|
||||
let before = compared.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len());
|
||||
ely_files::export(&target, path.as_deref(), &bytes, revision)?;
|
||||
Ok((before, bytes.len() as u64))
|
||||
},
|
||||
|
||||
@@ -46,9 +46,9 @@ fn masked(window: &mut Window, cx: &mut Context<Viewer>) -> Entity<TextInput> {
|
||||
impl Viewer {
|
||||
/// A refusal names what is missing.
|
||||
pub(crate) fn failed(&self, error: &io::Error, cx: &mut App) {
|
||||
match error.kind() {
|
||||
io::ErrorKind::PermissionDenied => self.toast(crate::view::restricted(&error.to_string(), cx), Severity::Warning, cx),
|
||||
_ => self.panel_failed(&error.to_string(), cx),
|
||||
match ely_pdf_engine::refused(error) {
|
||||
Some(needed) => self.toast(crate::view::restricted(needed, cx), Severity::Warning, cx),
|
||||
None => self.panel_failed(&error.to_string(), cx),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Page pictures, and bookmarks if any (PDF-01, PDF-02).
|
||||
|
||||
use std::{collections::BTreeSet, ops::Range};
|
||||
use std::{collections::BTreeSet, ops::Range, sync::Arc};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
@@ -9,7 +9,7 @@ use ely_gpui_component::{
|
||||
theme::{ActiveTheme as _, ControlSize},
|
||||
};
|
||||
use gpui::{
|
||||
Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, Role,
|
||||
Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, RenderImage, Role,
|
||||
StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, img, prelude::FluentBuilder as _, px,
|
||||
uniform_list,
|
||||
};
|
||||
@@ -104,7 +104,8 @@ impl PdfView {
|
||||
if self.thumbing {
|
||||
return;
|
||||
}
|
||||
let Some(page) = rows.clone().find(|page| !self.thumbs.contains_key(&(*page, dark)) && !self.thumbs_failed.contains(page)) else {
|
||||
let due = |page: &usize| !self.thumbs.contains_key(&(*page, dark)) || self.thumbs_stale.contains(&(*page, dark));
|
||||
let Some(page) = rows.clone().find(|page| due(page) && !self.thumbs_failed.contains(page)) else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = self.sizes[page];
|
||||
@@ -127,6 +128,7 @@ impl PdfView {
|
||||
});
|
||||
match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) {
|
||||
Ok(Some(image)) => {
|
||||
view.thumbs_stale.remove(&(page, dark));
|
||||
if let Some(old) = view.thumbs.insert((page, dark), image) {
|
||||
view.retired.push(old);
|
||||
}
|
||||
@@ -145,13 +147,20 @@ impl PdfView {
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfView {
|
||||
/// The picture a row draws; an old one until repainted.
|
||||
pub fn picture(&self, page: usize, dark: bool) -> Option<Arc<RenderImage>> {
|
||||
self.thumbs.get(&(page, dark)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// One row: the page number and its picture.
|
||||
fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyElement {
|
||||
let read = view.read(cx);
|
||||
let colors = &cx.theme().colors;
|
||||
let (w, h) = read.sizes[page];
|
||||
let picked = read.picked.contains(&page);
|
||||
let picture = read.thumbs.get(&(page, dark)).cloned();
|
||||
let picture = read.picture(page, dark);
|
||||
let count = read.sizes.len();
|
||||
let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
|
||||
let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone());
|
||||
|
||||
@@ -17,7 +17,7 @@ use ely_platform::trust::{self, Trust};
|
||||
use gpui::{AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, Styled as _, TaskExt as _, Window, div};
|
||||
|
||||
use crate::redact::beside;
|
||||
use crate::view::PdfView;
|
||||
use crate::view::{PdfView, Unread};
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
/// A signature field and the system's word on its certificate.
|
||||
@@ -47,11 +47,13 @@ pub struct Signer {
|
||||
impl PdfView {
|
||||
/// Reads and checks the signatures of the file at `path`.
|
||||
pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
||||
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures {
|
||||
let failed = self.unread.contains(&Unread::Signatures(path.clone()));
|
||||
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures || failed {
|
||||
return;
|
||||
}
|
||||
self.reading_signatures = true;
|
||||
let (doc, asked) = (self.doc, path.clone());
|
||||
// A save in between makes this read stale: dropped.
|
||||
let (doc, asked, saves) = (self.doc, path.clone(), self.saves);
|
||||
self.off(
|
||||
move |worker| {
|
||||
let fields = worker.signatures(doc, &path)?;
|
||||
@@ -64,9 +66,16 @@ impl PdfView {
|
||||
},
|
||||
move |view, read: io::Result<Vec<Checked>>, cx| {
|
||||
view.reading_signatures = false;
|
||||
if view.saves != saves {
|
||||
log::info!("pdf: a signature read from before a save is dropped");
|
||||
return cx.notify();
|
||||
}
|
||||
match read {
|
||||
Ok(read) => view.signatures = Some((asked, read)),
|
||||
Err(error) => view.fail("reading signatures", &error, cx),
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Signatures(asked));
|
||||
view.fail("reading signatures", &error, cx)
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ mod notes;
|
||||
mod organize;
|
||||
mod output;
|
||||
mod protect;
|
||||
mod reads;
|
||||
mod removal;
|
||||
mod sign;
|
||||
mod structure;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Editing text and objects from the app (C11.3).
|
||||
|
||||
use ely_pdf_engine::sample::{Extras, pdf_with};
|
||||
use ely_pdf_engine::sample::{Extras, one_page, pdf_with};
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::input::over;
|
||||
@@ -339,3 +339,91 @@ fn a_drag_follows_the_hand_when_the_page_shifts_under_it() {
|
||||
let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect);
|
||||
assert!((rect[0] - 122.0).abs() < 0.5, "moved as far as the hand: {rect:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_full_page_picture_aims_at_the_text_and_a_shake_stays_a_click() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("scan.pdf");
|
||||
let extras = Extras { picture: true, backdrop: true, ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &extras)).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.zoom = crate::layout::Zoom::Scale(1.0);
|
||||
view.set_tool(Tool::Edit, cx)
|
||||
}));
|
||||
settle(cx, |app| view.read(app).layers.contains_key(&0));
|
||||
click(cx, &view, (80.0, 66.0));
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the picture under it");
|
||||
// A two-pixel wobble on the picture moves nothing.
|
||||
let from = over(cx, &view, 0, (300.0, 400.0));
|
||||
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
|
||||
let to = from + gpui::point(gpui::px(2.0), gpui::px(0.0));
|
||||
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
|
||||
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
|
||||
frames(cx);
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { .. })), "the picture is aimed");
|
||||
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "and not moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_picture_on_text_under_a_page_form_still_aims() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("layered.pdf");
|
||||
// The page-size form comes last, on top of both.
|
||||
let content = "q 10 0 0 10 80 722 cm /Im1 Do Q BT /F1 18 Tf 72 720 Td (Hello world) Tj ET q /Fm1 Do Q";
|
||||
let form = "0 0 1 rg 500 50 20 20 re f";
|
||||
let objects = [
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{form}\nendstream", form.len()),
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".to_string(),
|
||||
];
|
||||
let page = " /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R /Im1 7 0 R >> >>";
|
||||
std::fs::write(&path, one_page("", page, content, &objects)).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.zoom = crate::layout::Zoom::Scale(1.0);
|
||||
view.set_tool(Tool::Edit, cx)
|
||||
}));
|
||||
settle(cx, |app| view.read(app).layers.contains_key(&0));
|
||||
// The picture sits at 80..90 across, 60..70 down.
|
||||
click(cx, &view, (85.0, 65.0));
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the small picture");
|
||||
click(cx, &view, (140.0, 66.0));
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_overtaken_by_an_edit_says_so() {
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (viewer, cx, view) = editing(&mut cx, dir.path());
|
||||
// The window's toasts live on its root.
|
||||
let root = cx.update(|window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
|
||||
root
|
||||
});
|
||||
click(cx, &view, (80.0, 66.0));
|
||||
type_in(cx, &viewer, "Hi world\nSecond line");
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| {
|
||||
// A new font asks for a preview first.
|
||||
viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into());
|
||||
viewer.write_text(false, cx);
|
||||
// An edit lands before the preview answers.
|
||||
if let Some(view) = viewer.active_view() {
|
||||
view.update(cx, |view, _| view.version += 1);
|
||||
}
|
||||
}));
|
||||
let changed = cx.update(|_, app| Viewer::text(app, "pdf.object.changed", &[]));
|
||||
settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, severity)| *severity == Some(Severity::Warning) && *text == changed));
|
||||
frames(cx);
|
||||
assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "no stale preview");
|
||||
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "nothing written");
|
||||
}
|
||||
@@ -326,3 +326,31 @@ fn old_tiles_are_painted_and_freed_with_their_tab() {
|
||||
cx.update(|_, app| view.update(app, |view, _| view.retire_all()));
|
||||
assert!(cx.update(|_, app| view.read(app).backdrop.is_empty()), "closing hands every old tile over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_keeps_page_pictures_until_they_repaint() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
|
||||
let view = shown(cx, &viewer);
|
||||
let dark = cx.update(|_, app| ely_gpui_component::theme::ActiveTheme::theme(app).is_dark());
|
||||
settle(cx, |app| view.read(app).picture(0, dark).is_some() && repainted(view.read(app)));
|
||||
let old = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
|
||||
// A picture on its way holds back the repaint.
|
||||
cx.update(|_, app| view.update(app, |view, _| view.thumbing = true));
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
|
||||
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
|
||||
// Dropped from the atlas, it returns only if drawn.
|
||||
cx.update(|window, _| window.drop_image(old.clone())).expect("drops");
|
||||
super::frames(cx);
|
||||
assert!(cx.update(|window, _| window.has_image_atlas_entry(&old)), "the old picture shows meanwhile");
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.thumbing = false;
|
||||
cx.notify()
|
||||
}));
|
||||
settle(cx, |app| view.read(app).thumbs_stale.is_empty());
|
||||
let new = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
|
||||
assert!(!std::sync::Arc::ptr_eq(&new, &old), "the new picture replaces it");
|
||||
super::frames(cx);
|
||||
assert!(cx.update(|window, _| window.has_image_atlas_entry(&new)), "and is drawn");
|
||||
}
|
||||
@@ -315,3 +315,48 @@ fn a_default_name_avoids_a_field_with_no_widget() {
|
||||
send(cx, &viewer, Change::Design(typed, Vec::new()));
|
||||
assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_strokes_queue_behind_each_other() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("one.pdf");
|
||||
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
|
||||
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
let note = |y: f32| ely_pdf_engine::NewAnnot { page: 0, shape: ely_pdf_engine::Shape::Note((100.0, y)), rgb: None, contents: "n".into(), author: String::new() };
|
||||
// The second lands while the first still runs.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.annotate("a first note", vec![note(500.0)], cx);
|
||||
view.annotate("a second note", vec![note(400.0)], cx);
|
||||
}));
|
||||
settle(cx, |app| view.read(app).settled());
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| {
|
||||
side.pane = crate::side::Pane::Notes;
|
||||
cx.notify();
|
||||
})));
|
||||
let count = |cx: &mut gpui::VisualTestContext| {
|
||||
settle(cx, |app| view.read(app).settled() && view.read(app).notes.contains_key(&0));
|
||||
cx.update(|_, app| view.read(app).notes[&0].annots.len())
|
||||
};
|
||||
assert_eq!(count(cx), 2, "both notes landed");
|
||||
let rotate = |view: &mut PdfView, cx: &mut gpui::Context<PdfView>| {
|
||||
view.edit_pages("rotating", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx)
|
||||
};
|
||||
// A running rotation would move the point: refused.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
rotate(view, cx);
|
||||
view.annotate("a note while turning", vec![note(300.0)], cx);
|
||||
}));
|
||||
assert_eq!(count(cx), 2, "refused while pages move");
|
||||
// A rotation waiting behind another edit: refused too.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.annotate("a third note", vec![note(250.0)], cx);
|
||||
rotate(view, cx);
|
||||
view.annotate("a note behind a turn", vec![note(200.0)], cx);
|
||||
}));
|
||||
assert_eq!(count(cx), 3, "only the note before the turn");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.annotate("a settled note", vec![note(150.0)], cx)));
|
||||
assert_eq!(count(cx), 4, "taken once pages are still");
|
||||
}
|
||||
@@ -33,6 +33,39 @@ fn pages_export_as_images_and_text() {
|
||||
assert!(text.contains("First") && text.contains("Second"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_write_the_system_refuses_reports_it_without_a_crash() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"])]);
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
shown(cx, &viewer);
|
||||
let locked = dir.path().join("locked");
|
||||
std::fs::create_dir(&locked).expect("a folder");
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).expect("read-only");
|
||||
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
|
||||
let txt = locked.join("doc.txt");
|
||||
let how = Optimizing { compress: true, from: 300, to: 0 };
|
||||
// The window's toasts live on its root.
|
||||
let root = cx.update(|window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
|
||||
root
|
||||
});
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Text, how, txt.clone(), cx)));
|
||||
let failed = |app: &gpui::App| {
|
||||
let toasts = root.read(app).toasts(app);
|
||||
toasts.iter().any(|(text, severity)| {
|
||||
*severity == Some(ely_gpui_component::primitives::Severity::Danger) && text.contains("Permission denied")
|
||||
})
|
||||
};
|
||||
settle(cx, failed);
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("writable again");
|
||||
assert!(!txt.exists(), "nothing is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
@@ -162,3 +195,27 @@ fn a_picture_never_drawn_is_checked_before_an_overwrite() {
|
||||
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
|
||||
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save turned into Save As");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edited_document_does_not_quote_the_files_size() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"])]);
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
// The window's toasts live on its root.
|
||||
let root = cx.update(|window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
|
||||
root
|
||||
});
|
||||
let note = ely_pdf_engine::NewAnnot { page: 0, shape: ely_pdf_engine::Shape::Note((100.0, 500.0)), rgb: None, contents: "n".into(), author: String::new() };
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.annotate("a note", vec![note], cx)));
|
||||
settle(cx, |app| view.read(app).settled() && view.read(app).modified());
|
||||
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
|
||||
let out = dir.path().join("small.pdf");
|
||||
let how = Optimizing { compress: true, from: 300, to: 150 };
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Optimized, how, out.clone(), cx)));
|
||||
let done = cx.update(|_, app| crate::viewer::Viewer::text(app, "pdf.export.done", &[("name", "small.pdf")]));
|
||||
settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == done));
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Reads that fail are kept, not asked each frame (P-08).
|
||||
|
||||
use ely_pdf_engine::sample::{Extras, pdf_with};
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::{settle, shown, windowed};
|
||||
|
||||
/// Two pages; the first's square has an unreadable rectangle.
|
||||
fn bad_then_good() -> Vec<u8> {
|
||||
let nines = "9".repeat(50);
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [5 0 R] >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [6 0 R] >>".to_string(),
|
||||
format!("<< /Type /Annot /Subtype /Square /Rect [0 0 {nines} 100] >>"),
|
||||
"<< /Type /Annot /Subtype /Text /Rect [100 500 120 520] /Contents (fine) >>".to_string(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
|
||||
for offset in offsets {
|
||||
pdf.extend(format!("{offset:010} 00000 n \n").bytes());
|
||||
}
|
||||
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_page_spares_the_rest_of_its_batch() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("two.pdf");
|
||||
std::fs::write(&path, bad_then_good()).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
settle(cx, |app| !view.read(app).reading_notes);
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.notes.clear();
|
||||
view.unread.clear();
|
||||
view.ask_notes([0, 1], cx)
|
||||
}));
|
||||
settle(cx, |app| !view.read(app).reading_notes);
|
||||
let (good, bad) = cx.update(|_, app| {
|
||||
let view = view.read(app);
|
||||
(view.notes.contains_key(&1), view.unread.contains(&crate::view::Unread::Notes(0)))
|
||||
});
|
||||
assert!(bad, "the bad page is kept as failed");
|
||||
assert!(good, "the good page is read all the same");
|
||||
assert!(!cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Notes(1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_read_from_before_a_save_counts_for_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("one.pdf");
|
||||
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
|
||||
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
let gone = dir.path().join("gone.pdf");
|
||||
// The read fails, but a save lands before its answer.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.ask_signatures(gone.clone(), cx);
|
||||
let same = view.saved.clone();
|
||||
view.saved(&same, cx);
|
||||
}));
|
||||
settle(cx, |app| !view.read(app).reading_signatures);
|
||||
assert!(cx.update(|_, app| view.read(app).unread.is_empty()), "a stale failure blocks nothing");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(gone.clone(), cx)));
|
||||
assert!(cx.update(|_, app| view.read(app).reading_signatures), "asked again after the save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_read_is_not_asked_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("one.pdf");
|
||||
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
|
||||
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
assert!(cx.update(|_, app| view.read(app).form_names.is_none()), "not read yet");
|
||||
// A dead worker fails every read.
|
||||
let pid = cx.update(|_, app| viewer.read(app).core.worker.lock().expect("the lock").get().expect("a worker").pid());
|
||||
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().expect("kills");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
|
||||
settle(cx, |app| !view.read(app).reading_names);
|
||||
assert!(cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Names)), "the failure is kept");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
|
||||
assert!(!cx.update(|_, app| view.read(app).reading_names), "not asked again");
|
||||
}
|
||||
@@ -234,3 +234,33 @@ fn a_cancelled_run_cannot_touch_the_next() {
|
||||
assert!(cx.update(|_, app| viewer.read(app).ocr.is_some()), "the second run goes on");
|
||||
settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_text_layer_reports_no_success() {
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = scanned(dir.path());
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
// The window's toasts live on its root.
|
||||
let root = cx.update(|window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
|
||||
root
|
||||
});
|
||||
let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
|
||||
let run = cx.update(|window, app| viewer.update(app, |viewer, cx| {
|
||||
viewer.ask_ocr(window, cx);
|
||||
viewer.start_ocr(cx);
|
||||
viewer.ocr_runs - 1
|
||||
}));
|
||||
// An edit still runs when the text is laid.
|
||||
cx.update(|_, app| view.update(app, |view, _| view.editing = true));
|
||||
let line = ely_pdf_engine::OcrLine { rect: [72.0, 700.0, 300.0, 720.0], text: "Hello".into() };
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ocr_read((tab, run, 0), Ok(Some(vec![line])), cx)));
|
||||
settle(cx, |app| viewer.read(app).ocr.is_none());
|
||||
let toasts = cx.update(|_, app| root.read(app).toasts(app));
|
||||
assert!(toasts.iter().any(|(_, severity)| *severity == Some(Severity::Warning)), "{toasts:?}");
|
||||
assert!(!toasts.iter().any(|(_, severity)| *severity == Some(Severity::Success)), "{toasts:?}");
|
||||
}
|
||||
@@ -23,6 +23,17 @@ pub struct Failed(pub String);
|
||||
/// The document changed what it shows.
|
||||
pub struct Reported;
|
||||
|
||||
/// A page or document read that failed once.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Unread {
|
||||
Notes(usize),
|
||||
Layer(usize),
|
||||
Names,
|
||||
Files,
|
||||
/// The signatures of the file at this path.
|
||||
Signatures(std::path::PathBuf),
|
||||
}
|
||||
|
||||
pub struct PdfView {
|
||||
worker: Arc<Mutex<Mupdf>>,
|
||||
pub doc: Doc,
|
||||
@@ -41,6 +52,8 @@ pub struct PdfView {
|
||||
pub painted: f64,
|
||||
/// Tiles the worker could not draw; not asked again.
|
||||
pub failed: HashSet<TileKey>,
|
||||
/// Reads that failed; not asked again until an edit.
|
||||
pub unread: HashSet<Unread>,
|
||||
/// Old tiles shown under their page until covered.
|
||||
pub backdrop: Vec<crate::backdrop::Old>,
|
||||
pub text: HashMap<usize, Arc<Vec<Char>>>,
|
||||
@@ -65,12 +78,18 @@ pub struct PdfView {
|
||||
/// Edits waiting their turn, and whether one runs.
|
||||
pub queue: VecDeque<crate::edits::Queued>,
|
||||
pub editing: bool,
|
||||
/// The running edit may move pages.
|
||||
pub moving: bool,
|
||||
/// History marks: now, and at the last load or save.
|
||||
pub mark: String,
|
||||
pub saved: String,
|
||||
/// Saves so far: a read from before one is stale.
|
||||
pub saves: u64,
|
||||
/// Page pictures for the side panel, by page and darkness.
|
||||
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
|
||||
pub thumbs_failed: HashSet<usize>,
|
||||
/// Pictures from before an edit; shown until repainted.
|
||||
pub thumbs_stale: HashSet<(usize, bool)>,
|
||||
/// A picture is on its way from the worker.
|
||||
pub thumbing: bool,
|
||||
/// Pages picked in the side panel, and the anchor.
|
||||
@@ -169,6 +188,7 @@ impl PdfView {
|
||||
retired: Default::default(),
|
||||
painted: 0.0,
|
||||
failed: HashSet::new(),
|
||||
unread: HashSet::new(),
|
||||
backdrop: Vec::new(),
|
||||
text: HashMap::new(),
|
||||
asking_text: HashSet::new(),
|
||||
@@ -184,10 +204,13 @@ impl PdfView {
|
||||
edited: 0,
|
||||
queue: VecDeque::new(),
|
||||
editing: false,
|
||||
moving: false,
|
||||
saved: loaded.mark.clone(),
|
||||
saves: 0,
|
||||
mark: loaded.mark,
|
||||
thumbs: HashMap::new(),
|
||||
thumbs_failed: HashSet::new(),
|
||||
thumbs_stale: HashSet::new(),
|
||||
thumbing: false,
|
||||
picked: BTreeSet::new(),
|
||||
anchor: None,
|
||||
@@ -227,10 +250,10 @@ impl PdfView {
|
||||
pub fn fail(&self, what: &str, error: &io::Error, cx: &mut Context<Self>) {
|
||||
// Engine text may quote the document: the user sees it.
|
||||
log::error!("pdf: {what} failed ({:?})", error.kind());
|
||||
let text = match error.kind() {
|
||||
let text = match ely_pdf_engine::refused(error) {
|
||||
_ if ely_pdf_engine::is_stale(error) => ely_ui::i18n::text(cx, "pdf.object.changed", &[]),
|
||||
io::ErrorKind::PermissionDenied => restricted(&error.to_string(), cx),
|
||||
_ => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
|
||||
Some(needed) => restricted(needed, cx),
|
||||
None => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
|
||||
};
|
||||
cx.emit(Failed(text.to_string()));
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, viewer.clone().into(), &commands::FILE, crate::keys::menus(cx), window, cx);
|
||||
viewer.update(cx, |viewer, cx| {
|
||||
viewer.core.root = root.downgrade();
|
||||
viewer.core.window = Some(window.window_handle());
|
||||
viewer.own_window(window, cx);
|
||||
window.focus(&viewer.core.focus, cx);
|
||||
});
|
||||
for path in ely_ui::launch_files(cx) {
|
||||
@@ -247,7 +247,6 @@ impl Render for Viewer {
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
|
||||
@@ -92,15 +92,17 @@ impl PdfView {
|
||||
rgb: 0,
|
||||
bx: [at[0], at[1], self.sizes[page].0 - at[0]],
|
||||
};
|
||||
self.settled_edit("placing a typed signature", cx, move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||
let write = move |worker: &mut ely_pdf_engine::Worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||
Wrote::Done(mark) => Ok(mark),
|
||||
Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))),
|
||||
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
|
||||
Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")),
|
||||
});
|
||||
};
|
||||
self.edit_at("placing a typed signature", write, cx);
|
||||
}
|
||||
Stamp::Picture { path, aspect } => {
|
||||
let at = placed(aspect, rect, click);
|
||||
self.settled_edit("placing a signature picture", cx, move |worker, doc| worker.add_image(doc, page, &path, at));
|
||||
self.edit_at("placing a signature picture", move |worker, doc| worker.add_image(doc, page, &path, at), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ impl Viewer {
|
||||
let (asked, viewer) = (writing.clone(), cx.weak_entity());
|
||||
view.update(cx, |view, cx| {
|
||||
let doc = view.doc;
|
||||
view.read(
|
||||
// A stale answer still reaches `previewed`, which says so.
|
||||
view.off(
|
||||
move |worker| worker.put_text(doc, page, &asked, PREVIEW),
|
||||
move |_, answer, cx| {
|
||||
// The viewer edits this view; wait until it is free.
|
||||
@@ -95,7 +96,19 @@ impl Viewer {
|
||||
replacing: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let same = self.tab(tab).is_some_and(|open| open.view.read(cx).version == version);
|
||||
if !same {
|
||||
let text = Self::text(cx, "pdf.object.changed", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
match answer {
|
||||
Ok(Wrote::Unwritable) => {
|
||||
let note = Self::text(cx, "pdf.text.unwritable", &[]);
|
||||
self.edit_panel.update(cx, |panel, cx| {
|
||||
panel.note = Some(note);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Ok(Wrote::Missing(c)) => {
|
||||
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
|
||||
self.edit_panel.update(cx, |panel, cx| {
|
||||
@@ -128,6 +141,7 @@ impl Viewer {
|
||||
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||
Wrote::Done(mark) => Ok(mark),
|
||||
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
|
||||
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
|
||||
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
|
||||
},
|
||||
cx,
|
||||
@@ -168,25 +182,27 @@ impl Viewer {
|
||||
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
||||
viewer.on_same(&view, version, cx, |view, cx| {
|
||||
view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx)
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs `step` on `view` if no edit came since `version`.
|
||||
/// Whether `step` ran: no edit came since `version`.
|
||||
pub(crate) fn on_same(
|
||||
&self,
|
||||
view: &gpui::Entity<crate::view::PdfView>,
|
||||
version: u64,
|
||||
cx: &mut Context<Self>,
|
||||
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
|
||||
) {
|
||||
) -> bool {
|
||||
// Landed or queued, an edit since may move the target.
|
||||
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
|
||||
if !open || view.read(cx).version != version || !view.read(cx).settled() {
|
||||
let text = Self::text(cx, "pdf.object.changed", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
return false;
|
||||
}
|
||||
view.update(cx, step);
|
||||
true
|
||||
}
|
||||
|
||||
/// Asks for a picture; it lands mid-page.
|
||||
|
||||
@@ -186,8 +186,8 @@ impl Deck {
|
||||
return log::info!("show: a chart dialog for tab {tab} came late");
|
||||
}
|
||||
let editing = Editing::new(tab, chart, window, cx);
|
||||
deck.dialog = Some(Dialog::Chart(Box::new(editing)));
|
||||
cx.notify();
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = deck.open_dialog(Dialog::Chart(Box::new(editing)), cx);
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
+20
-10
@@ -108,6 +108,21 @@ pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Shows a finding's slide; master view numbers masters instead.
|
||||
pub(crate) fn go_to_finding(&mut self, part: usize, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
if tab.extra.masters {
|
||||
let text = Deck::text(cx, "show.masters.busy", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
let view = tab.view.clone();
|
||||
view.update(cx, |view, _| view.show_part(part));
|
||||
self.dialog = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reads every slide and master, then lists what to fix.
|
||||
pub fn check_show(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
@@ -128,8 +143,8 @@ impl Deck {
|
||||
Ok((check, slides)) if deck.dialog_due(asked, id) => {
|
||||
let found = findings(&check, &slides, &missing);
|
||||
log::info!("show: the check found {} things", found.len());
|
||||
deck.dialog = Some(Dialog::Check(found));
|
||||
cx.notify();
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = deck.open_dialog(Dialog::Check(found), cx);
|
||||
}
|
||||
Ok(_) => log::info!("show: the check is no longer due"),
|
||||
Err(error) => deck.engine_failed(&format!("the check: {error}"), cx),
|
||||
@@ -183,14 +198,9 @@ impl Deck {
|
||||
let firsts: Vec<Option<usize>> = found.iter().map(|f| f.slides.first().copied()).collect();
|
||||
let list = list.on_change(move |keys, _, cx| {
|
||||
let first = keys.first().and_then(|key| key.parse::<usize>().ok()).and_then(|at| firsts[at]);
|
||||
this.update(cx, |deck, cx| {
|
||||
if let (Some(part), Some(tab)) = (first, deck.active_tab()) {
|
||||
let view = tab.view.clone();
|
||||
view.update(cx, |view, _| view.show_part(part));
|
||||
deck.dialog = None;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
if let Some(part) = first {
|
||||
this.update(cx, |deck, cx| deck.go_to_finding(part, cx));
|
||||
}
|
||||
});
|
||||
card.child(list).into_any_element()
|
||||
}
|
||||
|
||||
+15
-63
@@ -1,16 +1,15 @@
|
||||
//! ElyShow's window: decks over one LOK worker (§10.1).
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{feedback::EmptyState, primitives::IconName, shell::StatusBar};
|
||||
use ely_lok::{
|
||||
LokView, Sheets, Worker,
|
||||
history::Step,
|
||||
show::{Design, Objects},
|
||||
LokView, Worker,
|
||||
show::Design,
|
||||
};
|
||||
use ely_office::{Core, Kind, prelude::*};
|
||||
use ely_platform::AppId;
|
||||
@@ -18,61 +17,13 @@ use ely_ui::{Root, Shell, commands};
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, RenderImage, SharedString, Styled as _, Window, div,
|
||||
ParentElement as _, Render, SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::design::Panel;
|
||||
use crate::slides::Strip;
|
||||
|
||||
/// Parts, edits, caret and selected frame.
|
||||
pub type Seen = (u64, u64, bool, Option<ely_lok::Rect>);
|
||||
|
||||
/// What ElyShow keeps per deck.
|
||||
#[derive(Default)]
|
||||
pub struct Extra {
|
||||
pub slides: Sheets,
|
||||
/// The document as the last read saw it.
|
||||
pub read_at: Option<ely_lok::Doc>,
|
||||
/// What the view showed at the last read.
|
||||
pub read: Option<Seen>,
|
||||
pub reading: bool,
|
||||
pub design: Option<Design>,
|
||||
pub objects: Option<Objects>,
|
||||
pub notes: Option<ely_lok::show::Notes>,
|
||||
pub remarks: Vec<ely_lok::show::Remark>,
|
||||
/// The shown slide's transition and effects; none on a master.
|
||||
pub motion: Option<(ely_lok::show::Transition, Vec<ely_lok::show::Effect>)>,
|
||||
pub media: Vec<ely_lok::show::Media>,
|
||||
/// Counts completed reads.
|
||||
pub reads: u64,
|
||||
/// The slide size in twips.
|
||||
pub size: (i64, i64),
|
||||
/// Undo and redo lists at the last read.
|
||||
pub history: Option<(Vec<Step>, Vec<Step>)>,
|
||||
/// A size sent, in 1/100 mm, until read back.
|
||||
pub size_asked: Option<(i64, i64)>,
|
||||
/// A transition sent for a slide, until read back.
|
||||
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
|
||||
/// Pictures by slide name; stale ones show until repainted.
|
||||
pub thumbs: HashMap<String, Arc<RenderImage>>,
|
||||
pub retired: ely_gpui_component::primitives::Retired,
|
||||
pub stale: HashSet<String>,
|
||||
/// Pictures that failed; retried after the next read.
|
||||
pub failed: HashSet<String>,
|
||||
pub painting: bool,
|
||||
/// The parts are masters.
|
||||
pub masters: bool,
|
||||
/// The scheme the pictures were painted with.
|
||||
pub dark: Option<bool>,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
/// The size to show, in 1/100 mm: asked, else read.
|
||||
pub fn shown_size(&self) -> (i64, i64) {
|
||||
self.size_asked
|
||||
.unwrap_or_else(|| crate::design::mm100(self.size))
|
||||
}
|
||||
}
|
||||
pub use crate::extra::Extra;
|
||||
|
||||
pub struct Deck {
|
||||
pub core: Core<Deck>,
|
||||
@@ -137,7 +88,13 @@ impl Host for Deck {
|
||||
}
|
||||
|
||||
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
view.update(cx, |view, cx| view.fit_page(cx));
|
||||
// A new deck fits once; a zoom then stays.
|
||||
if let Some(tab) = self.tab_mut(id)
|
||||
&& !tab.extra.fitted
|
||||
{
|
||||
tab.extra.fitted = true;
|
||||
view.update(cx, |view, cx| view.fit_page(cx));
|
||||
}
|
||||
// Pictures follow the kernel's light or dark scheme.
|
||||
let dark = view.read(cx).dark();
|
||||
if let Some(tab) = self.tab_mut(id)
|
||||
@@ -176,7 +133,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, deck.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
|
||||
deck.update(cx, |deck, cx| {
|
||||
deck.core.root = root.downgrade();
|
||||
deck.core.window = Some(window.window_handle());
|
||||
deck.own_window(window, cx);
|
||||
window.focus(&deck.core.focus, cx);
|
||||
});
|
||||
let launched = ely_ui::launch_files(cx);
|
||||
@@ -263,6 +220,7 @@ impl Deck {
|
||||
}
|
||||
(tab.extra.reading, tab.extra.read) = (true, Some(seen));
|
||||
let landed = tab.extra.transition_landed(view.read(cx));
|
||||
let sized = tab.extra.size_landed(view.read(cx));
|
||||
let doc = tab.doc.clone();
|
||||
ely_lok::read_off(
|
||||
&self.core.worker,
|
||||
@@ -339,12 +297,6 @@ impl Deck {
|
||||
{
|
||||
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
|
||||
}
|
||||
let read = crate::design::mm100(size);
|
||||
if tab.extra.size_asked.is_some_and(|(w, h)| {
|
||||
(w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
|
||||
}) {
|
||||
tab.extra.size_asked = None;
|
||||
}
|
||||
(tab.extra.slides, tab.extra.design) = (slides, Some(design));
|
||||
tab.extra.objects = Some(objects);
|
||||
(tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks);
|
||||
@@ -352,6 +304,7 @@ impl Deck {
|
||||
tab.extra.settle_transition(landed);
|
||||
tab.extra.reads += 1;
|
||||
tab.extra.size = size;
|
||||
tab.extra.settle_size(sized);
|
||||
tab.extra.failed.clear();
|
||||
deck.paint_next(id, cx);
|
||||
deck.read_fonts(id, cx);
|
||||
@@ -491,7 +444,6 @@ impl Render for Deck {
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
|
||||
@@ -59,9 +59,18 @@ impl Deck {
|
||||
log::info!("show: the slides already measure {w} x {h}");
|
||||
return;
|
||||
}
|
||||
tab.extra.size_asked = Some((w, h));
|
||||
let (w, h) = (w.to_string(), h.to_string());
|
||||
self.design_command("slide.size", &[&w, &h, "true"], cx);
|
||||
let (id, view) = (tab.id, tab.view.clone());
|
||||
let before = view.read(cx).sent();
|
||||
tab.extra.size_asked = Some(((w, h), before + 1));
|
||||
let (sw, sh) = (w.to_string(), h.to_string());
|
||||
self.run_in(Some(id), "slide.size", &[&sw, &sh, "true"], cx);
|
||||
// Refused before sending: run_in said so already.
|
||||
if view.read(cx).sent() == before
|
||||
&& let Some(tab) = self.tab_mut(id)
|
||||
{
|
||||
tab.extra.size_asked = None;
|
||||
}
|
||||
self.force_reread(id, cx);
|
||||
}
|
||||
|
||||
/// Enters or leaves the master view.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! ElyShow's one open dialog.
|
||||
|
||||
use ely_gpui_component::forms::TextInput;
|
||||
use ely_gpui_component::{forms::TextInput, primitives::Severity};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{App, Context, Entity, IntoElement as _};
|
||||
|
||||
use crate::deck::Deck;
|
||||
@@ -41,6 +42,18 @@ pub(crate) fn edit(
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Opens `dialog`, or hands it back while another is open.
|
||||
pub(crate) fn open_dialog(&mut self, dialog: Dialog, cx: &mut Context<Self>) -> Result<(), Dialog> {
|
||||
if self.dialog.is_some() {
|
||||
let text = Deck::text(cx, "show.dialog.busy", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
return Err(dialog);
|
||||
}
|
||||
self.dialog = Some(dialog);
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A read that will open a dialog; earlier ones lapse.
|
||||
pub fn ask_dialog(&mut self) -> u64 {
|
||||
self.dialog_asked += 1;
|
||||
@@ -56,9 +69,9 @@ impl Deck {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether read `asked` may open its dialog now.
|
||||
/// Whether read `asked` still stands; `open_dialog` checks the rest.
|
||||
pub fn dialog_due(&self, asked: u64, id: u64) -> bool {
|
||||
self.dialog_asked == asked && self.dialog.is_none() && self.core.active == Some(id)
|
||||
self.dialog_asked == asked && self.core.active == Some(id)
|
||||
}
|
||||
|
||||
/// The open dialog, drawn.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! What ElyShow keeps per deck.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ely_lok::{
|
||||
Sheets,
|
||||
history::Step,
|
||||
show::{Design, Objects},
|
||||
};
|
||||
use gpui::RenderImage;
|
||||
|
||||
/// Parts, edits, caret and selected frame.
|
||||
pub type Seen = (u64, u64, bool, Option<ely_lok::Rect>);
|
||||
|
||||
/// What ElyShow keeps per deck.
|
||||
#[derive(Default)]
|
||||
pub struct Extra {
|
||||
pub slides: Sheets,
|
||||
/// The document as the last read saw it.
|
||||
pub read_at: Option<ely_lok::Doc>,
|
||||
/// What the view showed at the last read.
|
||||
pub read: Option<Seen>,
|
||||
pub reading: bool,
|
||||
pub design: Option<Design>,
|
||||
pub objects: Option<Objects>,
|
||||
pub notes: Option<ely_lok::show::Notes>,
|
||||
pub remarks: Vec<ely_lok::show::Remark>,
|
||||
/// The shown slide's transition and effects; none on a master.
|
||||
pub motion: Option<(ely_lok::show::Transition, Vec<ely_lok::show::Effect>)>,
|
||||
pub media: Vec<ely_lok::show::Media>,
|
||||
/// Counts completed reads.
|
||||
pub reads: u64,
|
||||
/// The slide size in twips.
|
||||
pub size: (i64, i64),
|
||||
/// Undo and redo lists at the last read.
|
||||
pub history: Option<(Vec<Step>, Vec<Step>)>,
|
||||
/// A size sent (1/100 mm) and its input, until read.
|
||||
pub size_asked: Option<((i64, i64), u64)>,
|
||||
/// A transition sent for a slide, until read back.
|
||||
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
|
||||
/// Pictures by slide name; stale ones show until repainted.
|
||||
pub thumbs: HashMap<String, Arc<RenderImage>>,
|
||||
pub retired: ely_gpui_component::primitives::Retired,
|
||||
pub stale: HashSet<String>,
|
||||
/// Pictures that failed; retried after the next read.
|
||||
pub failed: HashSet<String>,
|
||||
pub painting: bool,
|
||||
/// The parts are masters.
|
||||
pub masters: bool,
|
||||
/// The scheme the pictures were painted with.
|
||||
pub dark: Option<bool>,
|
||||
/// The view was fitted to the slide once.
|
||||
pub fitted: bool,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
/// The size to show, in 1/100 mm: asked, else read.
|
||||
pub fn shown_size(&self) -> (i64, i64) {
|
||||
self.size_asked
|
||||
.map_or_else(|| crate::design::mm100(self.size), |(size, _)| size)
|
||||
}
|
||||
|
||||
/// The size input that landed before this read began.
|
||||
pub fn size_landed(&self, view: &ely_lok::LokView) -> Option<u64> {
|
||||
let (_, seq) = self.size_asked?;
|
||||
view.reached(seq).then_some(seq)
|
||||
}
|
||||
|
||||
/// Forgets the asked size once read back, taken or refused.
|
||||
pub fn settle_size(&mut self, landed: Option<u64>) {
|
||||
let read = crate::design::mm100(self.size);
|
||||
if self.size_asked.is_some_and(|((w, h), seq)| {
|
||||
landed == Some(seq) || (w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
|
||||
}) {
|
||||
self.size_asked = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod check;
|
||||
mod deck;
|
||||
mod design;
|
||||
mod dialog;
|
||||
mod extra;
|
||||
mod guides;
|
||||
mod insert;
|
||||
mod list;
|
||||
|
||||
@@ -34,8 +34,8 @@ impl Deck {
|
||||
match ely_lok::templates("presnt") {
|
||||
Ok(templates) => {
|
||||
log::info!("show: {} bundled templates", templates.len());
|
||||
self.dialog = Some(Dialog::New(Choosing { templates }));
|
||||
cx.notify();
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = self.open_dialog(Dialog::New(Choosing { templates }), cx);
|
||||
}
|
||||
Err(error) => self.engine_failed(&error.to_string(), cx),
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub struct NotesField {
|
||||
field: Option<Entity<TextInput>>,
|
||||
shown: Option<(u64, usize, String)>,
|
||||
/// The state the shown slide number was read in.
|
||||
at: Option<ely_lok::Doc>,
|
||||
pub(crate) at: Option<ely_lok::Doc>,
|
||||
_events: Option<gpui::Subscription>,
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ impl Deck {
|
||||
return log::warn!("show: notes for slide {part} have no read state");
|
||||
};
|
||||
log::info!("show: notes for slide {part} changed");
|
||||
self.notes.shown = Some((tab, part, text.clone()));
|
||||
// `shown` stays the kernel's until a read confirms the send.
|
||||
self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx);
|
||||
self.force_reread(tab, cx);
|
||||
}
|
||||
@@ -90,6 +90,9 @@ impl Deck {
|
||||
if !typing && self.notes.shown.as_ref() != Some(&read) {
|
||||
field.update(cx, |field, cx| field.set_text(notes.text, cx));
|
||||
self.notes.shown = Some(read);
|
||||
} else if let Some(shown) = self.notes.shown.as_mut().filter(|shown| (shown.0, shown.1) == (id, part)) {
|
||||
// A read while typing moves the base, not the draft.
|
||||
shown.2 = notes.text;
|
||||
}
|
||||
Some(field)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@ impl Deck {
|
||||
|worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?),
|
||||
move |deck, read, _, cx| match read {
|
||||
Ok(slides) if deck.dialog_due(asked, id) => {
|
||||
deck.dialog = Some(Dialog::Output(Output {
|
||||
// Refused while another dialog is open; it says so.
|
||||
let output = Output {
|
||||
tab: id,
|
||||
print,
|
||||
kind: Kind::Slides,
|
||||
@@ -156,8 +157,8 @@ impl Deck {
|
||||
per: 3,
|
||||
current,
|
||||
hidden: slides.iter().map(|slide| slide.hidden).collect(),
|
||||
}));
|
||||
cx.notify();
|
||||
};
|
||||
let _ = deck.open_dialog(Dialog::Output(output), cx);
|
||||
}
|
||||
Ok(_) => log::info!("show: the output dialog is no longer due"),
|
||||
Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx),
|
||||
@@ -185,7 +186,7 @@ impl Deck {
|
||||
let path = work.join("print.pdf");
|
||||
let printing = path.clone();
|
||||
return self.write_pdf(&output, path, cx, move |_, cx| {
|
||||
ely_office::print_later(printing, cx, |deck, error, cx| deck.output_failed(&error, cx))
|
||||
ely_office::print_later(printing, cx)
|
||||
});
|
||||
}
|
||||
let Some(dir) = ely_office::folder(source) else {
|
||||
|
||||
@@ -13,6 +13,11 @@ use super::{Kind, Output, Range};
|
||||
use crate::deck::Deck;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
/// A typed slide number on the deck; 0 means all.
|
||||
fn slide_number(value: f64, last: u32) -> u32 {
|
||||
value.round().clamp(1.0, f64::from(last.max(1))) as u32
|
||||
}
|
||||
|
||||
/// What goes out, which slides, how many a page.
|
||||
pub fn output_dialog(
|
||||
this: gpui::Entity<Deck>,
|
||||
@@ -79,7 +84,7 @@ pub fn output_dialog(
|
||||
let bound = |id: &'static str, value: u32, first: bool| {
|
||||
// A typed number counts before blur commits it.
|
||||
let set = move |this: &gpui::Entity<Deck>, value: f64, cx: &mut gpui::App| {
|
||||
let value = value.round() as u32;
|
||||
let value = slide_number(value, last);
|
||||
change(this, cx, &|output| {
|
||||
if let Range::Slides(from, to) = &mut output.range {
|
||||
*(if first { from } else { to }) = value;
|
||||
@@ -135,3 +140,16 @@ pub fn output_dialog(
|
||||
.on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.output(cx)))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::slide_number;
|
||||
|
||||
#[test]
|
||||
fn a_typed_number_stays_on_the_deck() {
|
||||
assert_eq!(slide_number(0.0, 5), 1, "0 would export every slide");
|
||||
assert_eq!(slide_number(-3.0, 5), 1);
|
||||
assert_eq!(slide_number(2.6, 5), 3);
|
||||
assert_eq!(slide_number(9.0, 5), 5);
|
||||
}
|
||||
}
|
||||
@@ -19,20 +19,21 @@ use gpui::{
|
||||
use crate::deck::Deck;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
/// Each comment as a thread quoted with its slide.
|
||||
fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
|
||||
/// Threads quoted with their slides; unreadable comments apart.
|
||||
fn threads(remarks: &[Remark], cx: &App) -> (Vec<Thread>, Vec<(String, String)>) {
|
||||
let authors: Vec<&str> = remarks
|
||||
.iter()
|
||||
.map(|remark| remark.author.as_str())
|
||||
.collect();
|
||||
remarks
|
||||
let (good, bad): (Vec<_>, Vec<_>) = remarks
|
||||
.iter()
|
||||
.map(|remark| {
|
||||
let at = remark
|
||||
.at
|
||||
.parse::<jiff::civil::DateTime>()
|
||||
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
|
||||
.map_err(|error| format!("a bad comment time {}: {error}", remark.at))?;
|
||||
.map_err(|error| (remark.at.clone(), error.to_string()))?
|
||||
.timestamp();
|
||||
let hue = authors
|
||||
.iter()
|
||||
.position(|name| *name == remark.author)
|
||||
@@ -43,14 +44,16 @@ fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
|
||||
quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])),
|
||||
comments: vec![Comment {
|
||||
author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
|
||||
at: at.timestamp(),
|
||||
at,
|
||||
body: remark.text.clone().into(),
|
||||
reactions: Vec::new(),
|
||||
}],
|
||||
resolved: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.partition(Result::is_ok);
|
||||
let bad = bad.into_iter().filter_map(Result::err).collect();
|
||||
(good.into_iter().filter_map(Result::ok).collect(), bad)
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
@@ -77,8 +80,8 @@ impl Deck {
|
||||
input.set_text(text, cx);
|
||||
input
|
||||
});
|
||||
self.dialog = Some(Dialog::Remark { tab, edit, field });
|
||||
cx.notify();
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = self.open_dialog(Dialog::Remark { tab, edit, field }, cx);
|
||||
}
|
||||
|
||||
/// Sends the dialog's text: a new comment or an edit.
|
||||
@@ -166,14 +169,15 @@ impl Deck {
|
||||
.active_tab()
|
||||
.map(|tab| tab.extra.remarks.clone())
|
||||
.unwrap_or_default();
|
||||
let threads = match threads(&remarks, cx) {
|
||||
Ok(threads) => threads,
|
||||
Err(error) => {
|
||||
log::error!("show: {error}");
|
||||
let message = InlineMessage::new(Severity::Danger, error);
|
||||
return div().p_2().child(message).into_any_element();
|
||||
}
|
||||
};
|
||||
let (threads, bad) = threads(&remarks, cx);
|
||||
for (at, error) in &bad {
|
||||
log::error!("show: a bad comment time {at}: {error}");
|
||||
}
|
||||
// One line however many: the comments below stay in view.
|
||||
let bad = bad.first().map(|(at, _)| {
|
||||
let n = bad.len().to_string();
|
||||
InlineMessage::new(Severity::Danger, Deck::text(cx, "show.remark.bad", &[("n", &n), ("at", at)]))
|
||||
});
|
||||
let active = self.shown_remark();
|
||||
let act = |id: &'static str,
|
||||
key: &str,
|
||||
@@ -227,6 +231,7 @@ impl Deck {
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.child(head)
|
||||
.children(bad)
|
||||
.child(div().flex_1().min_h_0().child(sidebar))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -289,3 +294,32 @@ impl Deck {
|
||||
(!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ely_lok::show::Remark;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::threads;
|
||||
|
||||
#[test]
|
||||
fn one_unreadable_time_spares_the_other_comments() {
|
||||
let cx = TestAppContext::single();
|
||||
cx.update(|cx| ely_ui::i18n::install_as("en-US", cx));
|
||||
let remark = |id: &str, at: &str| Remark {
|
||||
id: id.into(),
|
||||
author: "A".into(),
|
||||
text: "t".into(),
|
||||
at: at.into(),
|
||||
part: 0,
|
||||
anchor: (0, 0, 0, 0),
|
||||
};
|
||||
// The kernel writes an empty date as 0-00-00.
|
||||
let remarks = [remark("1", "2025-03-01"), remark("2", "0-00-00"), remark("3", "2025-03-01T10:15:00")];
|
||||
let (good, bad) = cx.update(|cx| threads(&remarks, cx));
|
||||
let keys: Vec<_> = good.iter().map(|thread| thread.key.to_string()).collect();
|
||||
assert_eq!(keys, ["1", "3"]);
|
||||
assert_eq!(bad.len(), 1);
|
||||
assert_eq!(bad[0].0, "0-00-00");
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,9 @@ impl Deck {
|
||||
/// Plays a copy from the first or shown slide.
|
||||
pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) {
|
||||
if self.show.is_some() || self.show_starting {
|
||||
return log::info!("show: already playing or starting");
|
||||
log::info!("show: already playing or starting");
|
||||
let text = Deck::text(cx, "show.play.busy", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
let Some(id) = self.core.active else {
|
||||
return;
|
||||
@@ -180,7 +182,9 @@ impl Deck {
|
||||
let Some(first) = first.filter(|_| deck.core.active == Some(id)) else {
|
||||
discard(worker, doc, path, cx);
|
||||
if deck.core.active != Some(id) {
|
||||
return log::info!("show: tab {id} is no longer shown");
|
||||
log::info!("show: tab {id} is no longer shown");
|
||||
let text = Deck::text(cx, "show.play.gone", &[]);
|
||||
return deck.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
let text = Deck::text(cx, "show.play.none", &[]);
|
||||
return deck.toast(text, Severity::Warning, cx);
|
||||
@@ -196,11 +200,11 @@ impl Deck {
|
||||
};
|
||||
match cx.displays().len() {
|
||||
1 => deck.open_show(starting, cx),
|
||||
_ => {
|
||||
deck.show_starting = true;
|
||||
deck.dialog = Some(Dialog::Show(starting));
|
||||
cx.notify();
|
||||
}
|
||||
_ => match deck.open_dialog(Dialog::Show(starting), cx) {
|
||||
Ok(()) => deck.show_starting = true,
|
||||
Err(Dialog::Show(refused)) => discard(worker, refused.doc, refused.copy, cx),
|
||||
Err(_) => unreachable!("the show dialog comes back"),
|
||||
},
|
||||
}
|
||||
},
|
||||
cx,
|
||||
@@ -214,6 +218,8 @@ impl Deck {
|
||||
let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience))
|
||||
else {
|
||||
log::warn!("show: the tab or screen {} is gone", starting.audience);
|
||||
let text = Deck::text(cx, "show.play.gone", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
return discard(worker, starting.doc, starting.copy, cx);
|
||||
};
|
||||
let presenter = (displays.iter())
|
||||
@@ -347,4 +353,45 @@ mod tests {
|
||||
};
|
||||
assert_eq!(super::bounds(area), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_whose_screen_is_gone_says_so_and_drops_its_copy() {
|
||||
use ely_office::prelude::*;
|
||||
use gpui::AppContext as _;
|
||||
|
||||
use crate::tests::{settle, update};
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let Some(deck) = crate::tests::deck(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
|
||||
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| !tab.extra.slides.list.is_empty()));
|
||||
// The window's toasts live on its root.
|
||||
let handle = cx.update(|app| deck.read(app).core.window).expect("a window");
|
||||
let root = cx
|
||||
.update_window(handle, |_, window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Show, deck.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
deck.update(app, |deck, _| deck.core.root = root.downgrade());
|
||||
root
|
||||
})
|
||||
.expect("the window lives");
|
||||
let path = dir.path().join("copy.odp");
|
||||
let starting = cx.update(|app| {
|
||||
let deck = deck.read(app);
|
||||
let tab = deck.active_tab().expect("a tab");
|
||||
let mut worker = deck.core.worker.lock().expect("the worker lock");
|
||||
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy");
|
||||
super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false }
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.show_starting = true;
|
||||
deck.open_show(starting, cx);
|
||||
});
|
||||
settle(&cx, |_| !path.exists());
|
||||
let (toasts, gone) = cx.update(|app| (root.read(app).toasts(app), crate::deck::Deck::text(app, "show.play.gone", &[])));
|
||||
assert!(toasts.iter().any(|(text, _)| *text == gone), "{toasts:?}");
|
||||
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,8 @@ use crate::dialog::{Dialog, edit};
|
||||
impl Deck {
|
||||
pub fn ask_table(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(tab) = self.core.active {
|
||||
self.dialog = Some(Dialog::Table {
|
||||
tab,
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
});
|
||||
cx.notify();
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = self.open_dialog(Dialog::Table { tab, columns: 3, rows: 3 }, cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
|
||||
use crate::deck::Deck;
|
||||
|
||||
mod motion;
|
||||
mod notes;
|
||||
mod objects;
|
||||
mod output;
|
||||
mod play;
|
||||
@@ -44,7 +45,7 @@ pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>>
|
||||
|
||||
/// Steps the scheduler until `done` or ten seconds pass.
|
||||
#[track_caller]
|
||||
fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
||||
pub(super) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !cx.executor().tick() {
|
||||
@@ -58,7 +59,7 @@ fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
||||
panic!("the state never settled");
|
||||
}
|
||||
|
||||
fn update(
|
||||
pub(super) fn update(
|
||||
cx: &mut TestAppContext,
|
||||
deck: &Entity<Deck>,
|
||||
f: impl FnOnce(&mut Deck, &mut gpui::Context<Deck>),
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The notes field under the slide (SHOW-08).
|
||||
|
||||
use ely_office::prelude::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::objects::blank;
|
||||
use super::slides::{in_window, type_in};
|
||||
use super::{settle, update};
|
||||
|
||||
#[test]
|
||||
fn typed_notes_reach_the_slide() {
|
||||
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;
|
||||
};
|
||||
let notes = |app: &gpui::App| {
|
||||
deck.read(app)
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.extra.notes.clone())
|
||||
};
|
||||
settle(&cx, |app| notes(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
deck.notes_input(window, cx)
|
||||
});
|
||||
let field = field.expect("the field");
|
||||
type_in(&mut cx, &field, "Start with the totals");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| {
|
||||
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_refused_as_stale_stay_typed_and_go_next_time() {
|
||||
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;
|
||||
};
|
||||
let notes = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone());
|
||||
let text = |app: &gpui::App| notes(app).map(|notes| notes.text);
|
||||
settle(&cx, |app| notes(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
|
||||
let stale = cx.update(|app| deck.read(app).notes.at.clone());
|
||||
type_in(&mut cx, &field, "first");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| text(app).as_deref() == Some("first"));
|
||||
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
|
||||
// Typing began before the edit above was read back.
|
||||
type_in(&mut cx, &field, "second");
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.notes.at = stale;
|
||||
deck.commit_notes(cx);
|
||||
});
|
||||
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.view.read(app).settled()));
|
||||
cx.run_until_parked();
|
||||
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
|
||||
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the typing stays");
|
||||
assert_eq!(cx.update(|app| text(app)).as_deref(), Some("first"), "the stale send was refused");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| text(app).as_deref() == Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_while_typing_keeps_the_new_draft() {
|
||||
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;
|
||||
};
|
||||
let text = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone()).map(|notes| notes.text);
|
||||
settle(&cx, |app| text(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
|
||||
type_in(&mut cx, &field, "first");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
// Back in the field before the send is read back.
|
||||
in_window(&mut cx, &deck, |_, window, cx| window.focus(&gpui::Focusable::focus_handle(field.read(cx), cx), cx));
|
||||
type_in(&mut cx, &field, "second");
|
||||
settle(&cx, |app| text(app).as_deref() == Some("first"));
|
||||
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
|
||||
// The blur redraws before its commit runs.
|
||||
in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
window.blur(cx);
|
||||
deck.notes_input(window, cx)
|
||||
});
|
||||
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the draft stays");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| text(app).as_deref() == Some("second"));
|
||||
}
|
||||
@@ -145,3 +145,56 @@ fn a_click_on_media_finds_it_and_elsewhere_does_not() {
|
||||
assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0));
|
||||
assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_start_says_the_show_is_busy() {
|
||||
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;
|
||||
};
|
||||
// The window's toasts live on its root.
|
||||
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
let this = cx.entity();
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
|
||||
deck.core.root = root.downgrade();
|
||||
root
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.show_starting = true;
|
||||
deck.start_show(false, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let (toasts, busy) = cx.update(|app| (root.read(app).toasts(app), Deck::text(app, "show.play.busy", &[])));
|
||||
assert!(toasts.iter().any(|(text, _)| *text == busy), "{toasts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_left_for_another_tab_says_so() {
|
||||
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;
|
||||
};
|
||||
let first = cx.update(|app| deck.read(app).core.active.expect("a tab"));
|
||||
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
|
||||
settle(&cx, |app| deck.read(app).core.tabs.len() == 2 && deck.read(app).core.active != Some(first));
|
||||
let second = cx.update(|app| deck.read(app).core.active.expect("a tab"));
|
||||
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
let this = cx.entity();
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
|
||||
deck.core.root = root.downgrade();
|
||||
root
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.core.active = Some(first);
|
||||
deck.start_show(false, cx);
|
||||
// Another tab comes forward while the copy is read.
|
||||
deck.core.active = Some(second);
|
||||
});
|
||||
let gone = cx.update(|app| Deck::text(app, "show.play.gone", &[]));
|
||||
settle(&cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == gone));
|
||||
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use crate::deck::Deck;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
/// Runs `f` on the deck inside its window.
|
||||
fn in_window<R>(
|
||||
pub(super) fn in_window<R>(
|
||||
cx: &mut TestAppContext,
|
||||
deck: &Entity<Deck>,
|
||||
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
|
||||
@@ -25,7 +25,7 @@ fn in_window<R>(
|
||||
.expect("the window lives")
|
||||
}
|
||||
|
||||
fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
|
||||
pub(super) fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
|
||||
let text = text.to_string();
|
||||
cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx)));
|
||||
}
|
||||
@@ -105,28 +105,20 @@ fn the_table_dialog_inserts_a_table() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_notes_reach_the_slide() {
|
||||
fn an_open_dialog_is_not_pushed_aside() {
|
||||
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;
|
||||
};
|
||||
let notes = |app: &gpui::App| {
|
||||
deck.read(app)
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.extra.notes.clone())
|
||||
};
|
||||
settle(&cx, |app| notes(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
deck.notes_input(window, cx)
|
||||
});
|
||||
let field = field.expect("the field");
|
||||
type_in(&mut cx, &field, "Start with the totals");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| {
|
||||
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| deck.ask_table(cx));
|
||||
update(&mut cx, &deck, |deck, cx| deck.ask_new(cx));
|
||||
// The chart dialog opens after a background read.
|
||||
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
|
||||
cx.run_until_parked();
|
||||
let kept = cx.update(|app| matches!(deck.read(app).dialog, Some(crate::dialog::Dialog::Table { .. })));
|
||||
assert!(kept, "the table dialog stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,3 +376,88 @@ fn a_comment_picked_on_another_slide_shows_its_slide_again() {
|
||||
settle(&cx, |app| current(app) == Some(0));
|
||||
assert_eq!(cx.update(|app| deck.read(app).shown_remark()), Some(key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_stays_through_later_reports() {
|
||||
use ely_gpui_component::shell::ZoomStep;
|
||||
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;
|
||||
};
|
||||
let view = |app: &gpui::App| deck.read(app).active_tab().expect("a tab").view.clone();
|
||||
settle(&cx, |app| view(app).read(app).fitting());
|
||||
update(&mut cx, &deck, |deck, cx| deck.zoom(ZoomStep::In, cx));
|
||||
let zoom = cx.update(|app| view(app).read(app).zoom());
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
let (id, view) = deck.active_tab().map(|tab| (tab.id, tab.view.clone())).expect("a tab");
|
||||
deck.observed(id, &view, cx);
|
||||
});
|
||||
cx.update(|app| {
|
||||
let view = view(app);
|
||||
assert!(!view.read(app).fitting(), "a report does not fit the slide again");
|
||||
assert_eq!(view.read(app).zoom(), zoom, "the zoom stays");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_row_does_not_jump_among_masters() {
|
||||
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;
|
||||
};
|
||||
update(&mut cx, &deck, |deck, cx| deck.masters(true, cx));
|
||||
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.extra.masters));
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.dialog = Some(crate::dialog::Dialog::Check(Vec::new()));
|
||||
deck.go_to_finding(0, cx);
|
||||
});
|
||||
assert!(cx.update(|app| deck.read(app).dialog.is_some()), "the check stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_size_does_not_stay_shown() {
|
||||
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;
|
||||
};
|
||||
let before = cx.update(|app| deck.read(app).active_tab().expect("a tab").extra.size);
|
||||
update(&mut cx, &deck, |deck, cx| deck.resize((-500, -500), cx));
|
||||
settle(&cx, |app| {
|
||||
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
|
||||
});
|
||||
let extra = cx.update(|app| {
|
||||
let tab = deck.read(app).active_tab().expect("a tab");
|
||||
(tab.extra.size, tab.extra.shown_size())
|
||||
});
|
||||
assert_eq!(extra.0, before, "the kernel kept its size");
|
||||
assert_eq!(extra.1, crate::design::mm100(before), "the fields show it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_the_kernel_does_not_take_clears_after_its_read() {
|
||||
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;
|
||||
};
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.resize((30_000, 19_050), cx);
|
||||
// As if the kernel refused: it keeps another size.
|
||||
let id = deck.core.active.expect("a tab");
|
||||
let tab = deck.tab_mut(id).expect("a tab");
|
||||
let (_, seq) = tab.extra.size_asked.expect("asked");
|
||||
tab.extra.size_asked = Some(((40_000, 19_050), seq));
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
|
||||
});
|
||||
let read = cx.update(|app| crate::design::mm100(deck.read(app).active_tab().expect("a tab").extra.size));
|
||||
assert!((read.0 - 30_000).abs() <= 50, "the kernel's own size shows: {read:?}");
|
||||
}
|
||||
@@ -81,7 +81,7 @@ impl Book {
|
||||
}
|
||||
(Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx),
|
||||
(_, Ok(Found::Pivot(Some(pivot)))) => {
|
||||
book.dialog = Some(Dialog::Pivot {
|
||||
book.open_dialog(Dialog::Pivot {
|
||||
tab: id,
|
||||
source: pivot.source,
|
||||
fields: pivot.fields,
|
||||
@@ -120,7 +120,7 @@ impl Book {
|
||||
if picked.top.iter().any(String::is_empty) {
|
||||
return self.warn_key("table.pivot.headers", cx);
|
||||
}
|
||||
self.dialog = Some(Dialog::Pivot {
|
||||
self.open_dialog(Dialog::Pivot {
|
||||
tab: id,
|
||||
source: picked.data,
|
||||
fields: picked.top,
|
||||
@@ -146,7 +146,7 @@ impl Book {
|
||||
range: ely_office::chart::field(range, "table.chart.range", window, cx),
|
||||
look: ely_office::chart::LookFields::new(&look, window, cx),
|
||||
};
|
||||
self.dialog = Some(Dialog::Chart {
|
||||
self.open_dialog(Dialog::Chart {
|
||||
tab: id,
|
||||
chart: chart.map(Box::new),
|
||||
look: Box::new(look),
|
||||
|
||||
@@ -101,6 +101,17 @@ impl Host for Book {
|
||||
self.sync_bars(view, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed tab's dialog and list go with it.
|
||||
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
|
||||
if self.dialog.as_ref().and_then(crate::dialog::Dialog::tab) == Some(tab.id) {
|
||||
self.dialog = None;
|
||||
}
|
||||
if self.choices.as_ref().is_some_and(|choices| choices.tab == tab.id) {
|
||||
self.choices = None;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// The window root with a blank workbook.
|
||||
@@ -143,7 +154,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, book.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
|
||||
book.update(cx, |book, cx| {
|
||||
book.core.root = root.downgrade();
|
||||
book.core.window = Some(window.window_handle());
|
||||
book.own_window(window, cx);
|
||||
window.focus(&book.core.focus, cx);
|
||||
});
|
||||
let launched = ely_ui::launch_files(cx);
|
||||
@@ -372,7 +383,6 @@ impl Render for Book {
|
||||
.on_action(cx.listener(Self::on_open_file))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
|
||||
@@ -121,7 +121,7 @@ impl Book {
|
||||
|worker, doc| doc.selection_text(worker),
|
||||
move |book, text, _, cx| match text {
|
||||
Ok(text) if filled(&text) > 1 => {
|
||||
book.dialog = Some(Dialog::Merge { tab });
|
||||
book.open_dialog(Dialog::Merge { tab });
|
||||
cx.notify();
|
||||
}
|
||||
Ok(_) => book.run_in(id, "cells.merge", &["false"], cx),
|
||||
@@ -150,7 +150,7 @@ impl Book {
|
||||
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
|
||||
};
|
||||
let field = cx.new(|cx| TextInput::new(window, cx));
|
||||
self.dialog = Some(Dialog::Size { tab, column, field });
|
||||
self.open_dialog(Dialog::Size { tab, column, field });
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Book {
|
||||
fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) {
|
||||
log::info!("table: {asked:?} on {}", picked.range);
|
||||
let header = picked.header;
|
||||
self.dialog = Some(match asked {
|
||||
self.open_dialog(match asked {
|
||||
Asked::Sort => {
|
||||
let (first, ..) = picked.area.expect("a data area");
|
||||
Dialog::Sort {
|
||||
@@ -314,7 +314,7 @@ impl Book {
|
||||
return book.warn_key("table.columns.same", cx);
|
||||
}
|
||||
let other = cx.new(|cx| TextInput::new(window, cx));
|
||||
book.dialog = Some(Dialog::Columns {
|
||||
book.open_dialog(Dialog::Columns {
|
||||
tab: id,
|
||||
separators: vec!["9".into()],
|
||||
other,
|
||||
@@ -394,9 +394,10 @@ impl Book {
|
||||
.filter(|entry| entry.selected)
|
||||
.map(|entry| SharedString::from(entry.text.clone()))
|
||||
.collect();
|
||||
book.dialog = Some(Dialog::Column {
|
||||
book.open_dialog(Dialog::Column {
|
||||
tab: id,
|
||||
column,
|
||||
row,
|
||||
entries: filter.entries,
|
||||
picked,
|
||||
});
|
||||
@@ -413,12 +414,15 @@ impl Book {
|
||||
let Some(Dialog::Column {
|
||||
tab,
|
||||
column,
|
||||
row,
|
||||
entries,
|
||||
picked,
|
||||
}) = self.dialog.take()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The kernel filters the cursor's range: go to the button.
|
||||
self.run_in(Some(tab), "cell.goto", &[&sheet::address(column, row)], cx);
|
||||
match sort {
|
||||
Some(rising) => {
|
||||
let key = SortKey {
|
||||
|
||||
@@ -94,6 +94,8 @@ pub enum Dialog {
|
||||
Column {
|
||||
tab: u64,
|
||||
column: u32,
|
||||
/// The button's row: its cell names the filtered range.
|
||||
row: u32,
|
||||
entries: Vec<Entry>,
|
||||
picked: Vec<SharedString>,
|
||||
},
|
||||
@@ -150,6 +152,35 @@ pub enum Dialog {
|
||||
},
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
/// The workbook it asks about; imports have none yet.
|
||||
pub fn tab(&self) -> Option<u64> {
|
||||
match self {
|
||||
Dialog::Rename { tab, .. }
|
||||
| Dialog::Show { tab, .. }
|
||||
| Dialog::Delete { tab, .. }
|
||||
| Dialog::Size { tab, .. }
|
||||
| Dialog::Merge { tab, .. }
|
||||
| Dialog::Define { tab, .. }
|
||||
| Dialog::Names { tab, .. }
|
||||
| Dialog::Sort { tab, .. }
|
||||
| Dialog::Filter { tab, .. }
|
||||
| Dialog::Dedupe { tab, .. }
|
||||
| Dialog::Columns { tab, .. }
|
||||
| Dialog::Column { tab, .. }
|
||||
| Dialog::Validation { tab, .. }
|
||||
| Dialog::Conditional { tab, .. }
|
||||
| Dialog::Formats { tab, .. }
|
||||
| Dialog::Pivot { tab, .. }
|
||||
| Dialog::Chart { tab, .. }
|
||||
| Dialog::Export { tab, .. }
|
||||
| Dialog::Page { tab, .. }
|
||||
| Dialog::Protect { tab, .. } => Some(*tab),
|
||||
Dialog::Import(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Book {
|
||||
/// The define dialog's sheets: its own workbook's.
|
||||
pub(crate) fn define_scopes(&self) -> Option<Vec<String>> {
|
||||
|
||||
@@ -116,7 +116,7 @@ impl Book {
|
||||
let name = cx.new(|cx| TextInput::new(window, cx));
|
||||
let range = cx.new(|cx| TextInput::new(window, cx));
|
||||
range.update(cx, |range, cx| range.set_text(refers, cx));
|
||||
book.dialog = Some(Dialog::Define {
|
||||
book.open_dialog(Dialog::Define {
|
||||
tab: id,
|
||||
name,
|
||||
range,
|
||||
@@ -206,7 +206,7 @@ impl Book {
|
||||
let range = cx.new(|cx| TextInput::new(window, cx));
|
||||
let formula = first.formula.clone();
|
||||
range.update(cx, |range, cx| range.set_text(formula, cx));
|
||||
book.dialog = Some(Dialog::Names {
|
||||
book.open_dialog(Dialog::Names {
|
||||
tab: id,
|
||||
names,
|
||||
chosen: 0,
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Book {
|
||||
let Some(tab) = self.core.active else {
|
||||
return self.warn_key("command.blocked.no_document", cx);
|
||||
};
|
||||
self.dialog = Some(Dialog::Export {
|
||||
self.open_dialog(Dialog::Export {
|
||||
tab,
|
||||
format: Format::Pdf,
|
||||
});
|
||||
@@ -144,10 +144,8 @@ impl Book {
|
||||
tab,
|
||||
move |worker, doc| doc.export_pdf(worker, &url, &WHOLE),
|
||||
move |book, written, _, cx| match written {
|
||||
Ok(()) => ely_office::print_later(path, cx, |book, error, cx| {
|
||||
book.output_failed(&error, cx)
|
||||
}),
|
||||
Err(error) => book.output_failed(&error.to_string(), cx),
|
||||
Ok(()) => ely_office::print_later(path, cx),
|
||||
Err(error) => book.print_failed(&error.to_string(), cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -82,6 +82,15 @@ fn cell_ok(cell: &str) -> bool {
|
||||
&& row_number(digits).is_some()
|
||||
}
|
||||
|
||||
/// Whole columns or rows, like `$A:$D`; one `$` each.
|
||||
fn whole(from: &str, to: &str, number: fn(&str) -> Option<u32>) -> bool {
|
||||
let end = |end: &str| {
|
||||
let end = end.strip_prefix('$').unwrap_or(end);
|
||||
end.bytes().all(|byte| byte.is_ascii_alphanumeric()).then(|| number(&end.to_ascii_uppercase()))?
|
||||
};
|
||||
matches!((end(from), end(to)), (Some(first), Some(last)) if first <= last)
|
||||
}
|
||||
|
||||
/// Areas as the kernel takes them, or None.
|
||||
fn areas(text: &str) -> Option<String> {
|
||||
let tokens: Vec<&str> = text
|
||||
@@ -90,7 +99,9 @@ fn areas(text: &str) -> Option<String> {
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect();
|
||||
let ok = |token: &&str| match token.split_once(':') {
|
||||
Some((from, to)) => cell_ok(from) && cell_ok(to),
|
||||
Some((from, to)) => {
|
||||
cell_ok(from) && cell_ok(to) || whole(from, to, column_number) || whole(from, to, row_number)
|
||||
}
|
||||
None => cell_ok(token),
|
||||
};
|
||||
tokens.iter().all(ok).then(|| tokens.join(","))
|
||||
@@ -143,7 +154,7 @@ impl Book {
|
||||
rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx),
|
||||
columns: field(sheet::columns_ref(layout.columns), "table.page.columns_hint", cx),
|
||||
};
|
||||
self.dialog = Some(Dialog::Page {
|
||||
self.open_dialog(Dialog::Page {
|
||||
tab: id,
|
||||
landscape: layout.landscape,
|
||||
scale: layout.scale,
|
||||
@@ -228,6 +239,11 @@ mod tests {
|
||||
assert_eq!(areas("A1:B"), None);
|
||||
assert_eq!(areas("XFE1"), None);
|
||||
assert_eq!(areas("S!A1"), None);
|
||||
assert_eq!(areas("$A:$D, 1:3").as_deref(), Some("$A:$D,1:3"));
|
||||
assert_eq!(areas("A:3"), None);
|
||||
for bad in ["$$A:$$D", "$$1:$$3", "$A:$$D", "A:D,$$E:$$F", "+1:2", "D:A"] {
|
||||
assert_eq!(areas(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(span("$1:$3", row_number), Some(Some((1, 3))));
|
||||
assert_eq!(span("2", row_number), Some(Some((2, 2))));
|
||||
assert_eq!(span("3:1", row_number), None);
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Book {
|
||||
confirm: masked("table.protect.confirm", cx),
|
||||
now,
|
||||
};
|
||||
book.dialog = Some(Dialog::Protect {
|
||||
book.open_dialog(Dialog::Protect {
|
||||
tab: id,
|
||||
protecting: Box::new(protecting),
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ impl Book {
|
||||
} else {
|
||||
rule
|
||||
};
|
||||
self.dialog = Some(Dialog::Validation {
|
||||
self.open_dialog(Dialog::Validation {
|
||||
tab: id,
|
||||
rule: Box::new(rule),
|
||||
fields: Box::new(fields),
|
||||
@@ -275,7 +275,7 @@ impl Book {
|
||||
};
|
||||
let value1 = cx.new(|cx| TextInput::new(window, cx));
|
||||
let value2 = cx.new(|cx| TextInput::new(window, cx));
|
||||
self.dialog = Some(Dialog::Conditional {
|
||||
self.open_dialog(Dialog::Conditional {
|
||||
tab: id,
|
||||
kind: "cell",
|
||||
operator: "greater",
|
||||
@@ -338,7 +338,7 @@ impl Book {
|
||||
book.toast(text, Severity::Info, cx);
|
||||
}
|
||||
Ok(formats) => {
|
||||
book.dialog = Some(Dialog::Formats {
|
||||
book.open_dialog(Dialog::Formats {
|
||||
tab: id,
|
||||
formats,
|
||||
chosen: 0,
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::book::Book;
|
||||
|
||||
/// The open choices menu: tab, values, request.
|
||||
pub struct Choices {
|
||||
tab: u64,
|
||||
pub(crate) tab: u64,
|
||||
pub(crate) values: Vec<String>,
|
||||
request: (u64, Point<Pixels>),
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Book {
|
||||
return self.warn("table.show.none", cx);
|
||||
};
|
||||
let tab = tab.id;
|
||||
self.dialog = Some(Dialog::Show {
|
||||
self.open_dialog(Dialog::Show {
|
||||
tab,
|
||||
hidden,
|
||||
chosen,
|
||||
@@ -246,7 +246,7 @@ impl Book {
|
||||
};
|
||||
let field = cx.new(|cx| TextInput::new(window, cx));
|
||||
field.update(cx, |field, cx| field.set_text(name, cx));
|
||||
self.dialog = Some(Dialog::Rename { tab, at, index, field });
|
||||
self.open_dialog(Dialog::Rename { tab, at, index, field });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ impl Book {
|
||||
},
|
||||
move |book, read, _, cx| match read {
|
||||
Ok((references, at, name)) => {
|
||||
book.dialog = Some(Dialog::Delete {
|
||||
book.open_dialog(Dialog::Delete {
|
||||
tab: id,
|
||||
at,
|
||||
index,
|
||||
|
||||
@@ -300,3 +300,41 @@ fn the_panel_reads_number_formats_and_find_counts_cells() {
|
||||
.is_some_and(|finder| finder.all.len() == 3)
|
||||
});
|
||||
}
|
||||
|
||||
/// Two blocks: keys in A, other numbers in C.
|
||||
const BLOCKS: &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.spreadsheet">
|
||||
<office:body><office:spreadsheet><table:table table:name="S">
|
||||
<table:table-row><table:table-cell><text:p>K</text:p></table:table-cell><table:table-cell/><table:table-cell><text:p>L</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="9"><text:p>9</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="7"><text:p>7</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="8"><text:p>8</text:p></table:table-cell></table:table-row>
|
||||
</table:table></office:spreadsheet></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
#[test]
|
||||
fn a_column_button_sorts_its_own_block_not_the_cursors() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.new_from_text("blocks.fods", BLOCKS, cx)
|
||||
});
|
||||
settle(&cx, |app| sheet_names(app, &book) == ["S"]);
|
||||
run(&mut cx, &book, "cell.goto", &["A1"]);
|
||||
run(&mut cx, &book, "data.autofilter", &[]);
|
||||
run(&mut cx, &book, "cell.goto", &["C3"]);
|
||||
let id = cx.update(|app| book.read(app).core.active.expect("a tab"));
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.ask_column(id, &Request::Filter { column: 0, row: 0 }, cx)
|
||||
});
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Column { .. }));
|
||||
update(&mut cx, &book, |book, cx| book.column_asked(Some(true), cx));
|
||||
assert_eq!(cells(&mut cx, &book, "A2:A4"), "1\n2\n3");
|
||||
assert_eq!(cells(&mut cx, &book, "C2:C4"), "9\n7\n8");
|
||||
}
|
||||
@@ -44,3 +44,27 @@ fn a_failed_command_still_moves_the_state_on() {
|
||||
update(&mut cx, &book, |book, cx| book.run_active("sheet.rename", &["Plan", "1"], cx));
|
||||
settle(&cx, |app| sheet_names(app, &book) == ["Plan"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_print_says_printing_failed() {
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
// The window's toasts live on its root.
|
||||
let root = window
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Table, book.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
|
||||
book.update(cx, |book, _| book.core.root = root.downgrade());
|
||||
root
|
||||
})
|
||||
.expect("the window");
|
||||
let gone = dir.path().join("gone.pdf");
|
||||
update(&mut cx, &book, |_, cx| ely_office::print_later(gone, cx));
|
||||
cx.run_until_parked();
|
||||
let (toasts, prefix) = cx.update(|app| (root.read(app).toasts(app), ely_office::text(app, "office.print.failed", &[("error", "")])));
|
||||
assert!(toasts.iter().any(|(text, severity)| *severity == Some(Severity::Danger) && text.starts_with(prefix.as_ref())), "{toasts:?}");
|
||||
}
|
||||
@@ -377,3 +377,124 @@ fn text_files_wait_their_turn_for_the_import_dialog() {
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.csv", "b.csv"], "both files open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_waits_behind_a_dialog_that_displaces_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
blank(&mut cx, &book);
|
||||
let path = dir.path().join("wait.csv");
|
||||
std::fs::write(&path, "Id,Ignored\n1E3,hidden\n").expect("writes");
|
||||
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
|
||||
update(&mut cx, &book, |book, _| {
|
||||
if let Some(Dialog::Import(pending)) = &mut book.dialog {
|
||||
pending.how.columns = vec![Column::Text, Column::Skip];
|
||||
}
|
||||
});
|
||||
window
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
book.update(cx, |book, cx| book.on_export(&ely_ui::commands::Export, window, cx))
|
||||
})
|
||||
.expect("the window");
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Export { .. }));
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.dialog = None;
|
||||
cx.notify();
|
||||
});
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if pending.path.ends_with("wait.csv")));
|
||||
cx.run_until_parked();
|
||||
update(&mut cx, &book, |book, cx| book.import_asked(cx));
|
||||
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.name == "wait.csv"));
|
||||
// Text keeps `1E3`; the skipped column stays empty.
|
||||
assert_eq!(cells(&mut cx, &book, "A2:B2").trim_end(), "1E3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_tab_takes_its_dialog_and_frees_the_import() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
blank(&mut cx, &book);
|
||||
window
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
book.update(cx, |book, cx| book.on_export(&ely_ui::commands::Export, window, cx))
|
||||
})
|
||||
.expect("the window");
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Export { .. }));
|
||||
let id = cx.update(|app| book.read(app).core.tabs[0].id);
|
||||
update(&mut cx, &book, |book, cx| book.close_tab(id, cx));
|
||||
let path = dir.path().join("after.csv");
|
||||
std::fs::write(&path, "Name\nx\n").expect("writes");
|
||||
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_column_print_area_still_applies() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
blank(&mut cx, &book);
|
||||
update(&mut cx, &book, |book, cx| book.ask_page(cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
|
||||
type_in(&mut cx, &book, area, "A:D");
|
||||
update(&mut cx, &book, |book, cx| book.page_asked(cx));
|
||||
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
|
||||
assert_eq!(layout.areas.len(), 1, "set: {:?}", layout.areas);
|
||||
// The kernel's own text comes back into the dialog.
|
||||
update(&mut cx, &book, |book, cx| book.ask_page(cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
|
||||
let shown = cx.update(|app| match book.read(app).dialog.as_ref() {
|
||||
Some(dialog) => area(dialog).read(app).text().to_string(),
|
||||
None => unreachable!("a page dialog"),
|
||||
});
|
||||
assert_eq!(shown, layout.areas[0]);
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
if let Some(Dialog::Page { landscape, .. }) = &mut book.dialog {
|
||||
*landscape = true;
|
||||
}
|
||||
book.page_asked(cx)
|
||||
});
|
||||
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
|
||||
assert!(layout.landscape, "applied with {shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_encoding_follows_the_apps_language() {
|
||||
use ely_gpui_component::i18n::I18n;
|
||||
use ely_lok::sheet::csv::Encoding;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let path = dir.path().join("gb.csv");
|
||||
// 你好 in GB18030: not UTF-8.
|
||||
std::fs::write(&path, [0xC4, 0xE3, 0xBA, 0xC3, b'\n']).expect("writes");
|
||||
for (tag, expected) in [("zh-CN", Encoding::Gb18030), ("en-US", Encoding::Windows1252)] {
|
||||
cx.update(|app| I18n::set_locale(tag, app));
|
||||
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(_)));
|
||||
let found = cx.update(|app| match book.read(app).dialog.as_ref() {
|
||||
Some(Dialog::Import(pending)) => pending.how.encoding,
|
||||
_ => unreachable!("an import dialog"),
|
||||
});
|
||||
assert_eq!(found, expected, "{tag}");
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.dialog = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
+30
-16
@@ -154,22 +154,20 @@ fn decode(bytes: &[u8], encoding: Encoding) -> Option<String> {
|
||||
.map(|text| text.into_owned())
|
||||
}
|
||||
|
||||
/// Rows as Calc's text import reads them (impex.cxx).
|
||||
fn split(text: &str, how: &Import) -> Vec<Vec<String>> {
|
||||
/// Calc's text-import rows (impex.cxx), one at a time.
|
||||
fn split<'a>(text: &'a str, how: &'a Import) -> impl Iterator<Item = Vec<String>> + 'a {
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut rows = Vec::new();
|
||||
while chars.peek().is_some() {
|
||||
std::iter::from_fn(move || {
|
||||
chars.peek()?;
|
||||
let mut row = Vec::new();
|
||||
loop {
|
||||
let (value, more) = field(&mut chars, how);
|
||||
row.push(value);
|
||||
if !more {
|
||||
break;
|
||||
return Some(row);
|
||||
}
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
rows
|
||||
})
|
||||
}
|
||||
|
||||
/// One field, and whether the record goes on.
|
||||
@@ -233,15 +231,19 @@ impl Book {
|
||||
|
||||
pub(crate) fn ask_import(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
||||
let reading = path.clone();
|
||||
let chinese = ely_ui::i18n::resolve(&ely_platform::locale::system()) == "zh-CN";
|
||||
// The language the app shows, not the system's.
|
||||
let chinese = cx.global::<ely_gpui_component::i18n::I18n>().locale().tag == "zh-CN";
|
||||
cx.spawn(async move |this, cx| {
|
||||
let bytes = cx
|
||||
.background_spawn(async move { std::fs::read(&reading) })
|
||||
// Guessing checks the whole file: off the window thread.
|
||||
let pending = cx
|
||||
.background_spawn(async move {
|
||||
std::fs::read(&reading).map(|bytes| Pending::new(reading, bytes, chinese))
|
||||
})
|
||||
.await;
|
||||
in_window(&this, cx, |book, _, cx| match bytes {
|
||||
Ok(bytes) => {
|
||||
log::info!("table: import asks for {} bytes", bytes.len());
|
||||
book.imports.push_back(Pending::new(path, bytes, chinese));
|
||||
in_window(&this, cx, |book, _, cx| match pending {
|
||||
Ok(pending) => {
|
||||
log::info!("table: import asks for {} bytes", pending.bytes.len());
|
||||
book.imports.push_back(pending);
|
||||
book.next_import(cx);
|
||||
}
|
||||
Err(error) => book.failed_open(&path.display().to_string(), &error, cx),
|
||||
@@ -250,14 +252,26 @@ impl Book {
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// Opens `dialog`; a displaced import waits its turn again.
|
||||
pub(crate) fn open_dialog(&mut self, dialog: Dialog) {
|
||||
if let Some(Dialog::Import(pending)) = self.dialog.replace(dialog) {
|
||||
log::info!("table: import waits behind another dialog");
|
||||
self.imports.push_front(*pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// The next waiting import asks, once no dialog is open.
|
||||
pub(crate) fn next_import(&mut self, cx: &mut Context<Self>) {
|
||||
if self.dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(pending) = self.imports.pop_front() {
|
||||
// A finished scan keeps the reader's column choices.
|
||||
let unread = pending.reading;
|
||||
self.dialog = Some(Dialog::Import(Box::new(pending)));
|
||||
self.scan_import(cx);
|
||||
if unread {
|
||||
self.scan_import(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ mod doc;
|
||||
mod feed;
|
||||
mod frames;
|
||||
mod install;
|
||||
mod load;
|
||||
pub use install::{install_dir, templates};
|
||||
use install::worker_bin;
|
||||
pub use doc::{Pdf, Sheet, Sheets};
|
||||
@@ -37,6 +38,8 @@ pub struct Worker {
|
||||
session: u64,
|
||||
stderr: ely_ipc::Relay,
|
||||
check: Check,
|
||||
/// The name new comments sign with, once set.
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
/// The start-up check: to run, passed, or why it failed.
|
||||
@@ -73,9 +76,16 @@ pub struct Doc {
|
||||
id: u64,
|
||||
/// The document's revision when this handle was taken.
|
||||
pub(crate) rev: u64,
|
||||
/// LOK_DOCTYPE: 0 text, 1 spreadsheet, 2 presentation.
|
||||
kind: u64,
|
||||
}
|
||||
|
||||
impl Doc {
|
||||
/// Whether this is a spreadsheet.
|
||||
pub fn is_sheet(&self) -> bool {
|
||||
self.kind == 1
|
||||
}
|
||||
|
||||
/// Whether this handle saw a later state than `other`.
|
||||
pub fn newer_than(&self, other: &Doc) -> bool {
|
||||
self.rev > other.rev
|
||||
@@ -181,6 +191,7 @@ impl Worker {
|
||||
session: SESSIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||
stderr,
|
||||
check: Check::Pending(profile_dir.to_path_buf()),
|
||||
author: None,
|
||||
};
|
||||
Ok(worker)
|
||||
}
|
||||
@@ -194,47 +205,15 @@ impl Worker {
|
||||
/// The name new comments and tracked changes carry.
|
||||
pub fn set_author(&mut self, name: &str) -> io::Result<()> {
|
||||
log::info!("lok: the author is set");
|
||||
// Wanted even if this worker fails; a restart sends it.
|
||||
self.author = Some(name.to_string());
|
||||
self.request(tag::SET_AUTHOR, &Pack::new().str(name).bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads `url`; macros never run (LOK-11).
|
||||
pub fn load(&mut self, url: &str) -> io::Result<Doc> {
|
||||
self.unlock(url, "")
|
||||
}
|
||||
|
||||
/// Loads text `url` as a sheet, read as `how` says.
|
||||
pub fn import(&mut self, url: &str, how: &crate::sheet::csv::Import) -> io::Result<Doc> {
|
||||
use crate::sheet::csv::{COLUMNS, ENCODINGS};
|
||||
let at = |found: Option<usize>| found.expect("a listed choice") as u64;
|
||||
let pack = Pack::new()
|
||||
.str(url)
|
||||
.u64(u64::from(how.separator))
|
||||
.u64(how.quote.map_or(0, u64::from))
|
||||
.u64(at(ENCODINGS.iter().position(|e| *e == how.encoding)))
|
||||
.u64(how.columns.len() as u64);
|
||||
let pack = how.columns.iter().fold(pack, |pack, column| {
|
||||
pack.u64(at(COLUMNS.iter().position(|c| c == column)))
|
||||
});
|
||||
let payload = self.request(tag::IMPORT, &pack.bytes())?;
|
||||
let id = Unpack::new(&payload).u64()?;
|
||||
self.feed.opened(id);
|
||||
Ok(Doc { worker: self.session, id, rev: 0 })
|
||||
}
|
||||
|
||||
/// Loads `url` with its password; a locked one is PermissionDenied.
|
||||
pub fn unlock(&mut self, url: &str, password: &str) -> io::Result<Doc> {
|
||||
let request = Pack::new().str(url).str(password).bytes();
|
||||
let payload = self.request(tag::LOAD, &request).map_err(|error| {
|
||||
if error.to_string() == crate::ipc::LOCKED {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, error.to_string())
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
let id = Unpack::new(&payload).u64()?;
|
||||
self.feed.opened(id);
|
||||
Ok(Doc { worker: self.session, id, rev: 0 })
|
||||
/// The name last asked for; a restart signs with it.
|
||||
pub fn author(&self) -> Option<&str> {
|
||||
self.author.as_deref()
|
||||
}
|
||||
|
||||
/// Runs a macro by its URL (LOK-11 probe).
|
||||
|
||||
@@ -98,6 +98,15 @@ impl Doc {
|
||||
Ok((unpack.i64()?, unpack.i64()?))
|
||||
}
|
||||
|
||||
/// The size and the client zoom it was measured at.
|
||||
pub fn size_at(&self, worker: &mut Worker) -> io::Result<((i64, i64), (i32, i32))> {
|
||||
let payload = worker.request(tag::DOC_SIZE, &Pack::new().u64(worker.doc_id(self)?).bytes())?;
|
||||
let mut unpack = Unpack::new(&payload);
|
||||
let size = (unpack.i64()?, unpack.i64()?);
|
||||
let number = |value: i64| i32::try_from(value).map_err(io::Error::other);
|
||||
Ok((size, (number(unpack.i64()?)?, number(unpack.i64()?)?)))
|
||||
}
|
||||
|
||||
/// Page rectangles in twips; empty without pages.
|
||||
pub fn pages(&self, worker: &mut Worker) -> io::Result<Vec<Rect>> {
|
||||
let payload = worker.request(tag::PAGES, &Pack::new().u64(worker.doc_id(self)?).bytes())?;
|
||||
|
||||
@@ -181,17 +181,17 @@ mod tests {
|
||||
};
|
||||
feed.push(1, first);
|
||||
feed.push(1, second);
|
||||
let batch = feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 0, 0).expect("a batch");
|
||||
let batch = feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 0, 0).expect("a batch");
|
||||
assert_eq!((batch.seq, batch.all, batch.rects.len()), (2, true, 1));
|
||||
assert_eq!(
|
||||
batch.states,
|
||||
[("b".to_string(), "1".to_string()), ("a".into(), "2".into())]
|
||||
);
|
||||
let idle = feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 2, 0).expect("the snapshot");
|
||||
let idle = feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 2, 0).expect("the snapshot");
|
||||
assert!(idle.rects.is_empty() && idle.states.is_empty() && idle.seq == 2);
|
||||
feed.closed(1);
|
||||
assert!(
|
||||
feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 2, 0).is_err(),
|
||||
feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 2, 0).is_err(),
|
||||
"a closed document"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Loading documents: by URL, with a password, or as text.
|
||||
|
||||
use std::io;
|
||||
|
||||
use super::{Doc, Worker};
|
||||
use crate::ipc::{Pack, Unpack, tag};
|
||||
|
||||
impl Worker {
|
||||
/// Loads `url`; macros never run (LOK-11).
|
||||
pub fn load(&mut self, url: &str) -> io::Result<Doc> {
|
||||
self.unlock(url, "")
|
||||
}
|
||||
|
||||
/// Loads text `url` as a sheet, read as `how` says.
|
||||
pub fn import(&mut self, url: &str, how: &crate::sheet::csv::Import) -> io::Result<Doc> {
|
||||
use crate::sheet::csv::{COLUMNS, ENCODINGS};
|
||||
let at = |found: Option<usize>| found.expect("a listed choice") as u64;
|
||||
let pack = Pack::new()
|
||||
.str(url)
|
||||
.u64(u64::from(how.separator))
|
||||
.u64(how.quote.map_or(0, u64::from))
|
||||
.u64(at(ENCODINGS.iter().position(|e| *e == how.encoding)))
|
||||
.u64(how.columns.len() as u64);
|
||||
let pack = how.columns.iter().fold(pack, |pack, column| {
|
||||
pack.u64(at(COLUMNS.iter().position(|c| c == column)))
|
||||
});
|
||||
let payload = self.request(tag::IMPORT, &pack.bytes())?;
|
||||
self.opened(&payload)
|
||||
}
|
||||
|
||||
/// Loads `url` with its password; a locked one is PermissionDenied.
|
||||
pub fn unlock(&mut self, url: &str, password: &str) -> io::Result<Doc> {
|
||||
let request = Pack::new().str(url).str(password).bytes();
|
||||
let payload = self.request(tag::LOAD, &request).map_err(|error| {
|
||||
if error.to_string() == crate::ipc::LOCKED {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, error.to_string())
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
self.opened(&payload)
|
||||
}
|
||||
|
||||
/// A load's reply: the document's number and kind.
|
||||
fn opened(&mut self, payload: &[u8]) -> io::Result<Doc> {
|
||||
let mut reply = Unpack::new(payload);
|
||||
let (id, kind) = (reply.u64()?, reply.u64()?);
|
||||
self.feed.opened(id);
|
||||
Ok(Doc { worker: self.session, id, rev: 0, kind })
|
||||
}
|
||||
}
|
||||
@@ -256,3 +256,21 @@ fn commands_from_one_read_run_together_and_hovering_changes_nothing() {
|
||||
assert!(crate::is_stale(&stale), "{stale}");
|
||||
assert_eq!(only(&mut worker), ["One", "Two", "U"], "nothing else was renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_act_after_a_click_is_not_stale() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (_dir, mut worker, doc) = open_book(SHEETS);
|
||||
let read = worker.now(&doc).expect("the state");
|
||||
// A click moves the revision; rows and columns stay.
|
||||
for kind in [0, 1] {
|
||||
let click = crate::Mouse { kind, x: 500, y: 500, count: 1, buttons: 1, modifiers: 0 };
|
||||
worker.post_mouse(&read, click).expect("clicks");
|
||||
}
|
||||
assert_ne!(worker.now(&doc).expect("the state"), read, "the click counts as a change");
|
||||
worker.run(&read, "select.column", &["2", "0"]).expect("the column header still selects");
|
||||
worker.run(&read, "columns.width", &["800", "3"]).expect("the column's width still sets");
|
||||
}
|
||||
@@ -61,7 +61,7 @@ pub struct Command {
|
||||
|
||||
/// Arguments that count things as a read saw them.
|
||||
const NUMBERED: &[&str] = &[
|
||||
"Index", "nTabNumber", "TabNo", "Part", "Row", "Col", "Column", "StartColumn", "StartRow", "EndColumn", "EndRow",
|
||||
"Index", "nTabNumber", "TabNo", "Part", "StartColumn", "StartRow", "EndColumn", "EndRow",
|
||||
];
|
||||
|
||||
impl Command {
|
||||
@@ -306,7 +306,9 @@ mod tests {
|
||||
assert!(find("sheet.delete").expect("the row").addressed(&["1"]));
|
||||
assert!(!find("cell.enter").expect("the row").addressed(&["1"]));
|
||||
assert!(find("notes.set").expect("the row").addressed(&["text", "1"]), "a slide by number");
|
||||
assert!(find("select.row").expect("the row").addressed(&["3", "0"]), "a row by number");
|
||||
// Clicks renumber no row or column: never stale.
|
||||
assert!(!find("select.row").expect("the row").addressed(&["3", "0"]), "a header row");
|
||||
assert!(!find("columns.width").expect("the row").addressed(&["500", "2"]), "a column's width");
|
||||
assert!(!find("rows.height_selected").expect("the row").addressed(&["500"]), "a size names nothing");
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ struct Slot {
|
||||
result: Option<(String, bool)>,
|
||||
/// The seq last pushed to the client.
|
||||
pushed: u64,
|
||||
/// The client zoom last set: tile pixels, tile twips.
|
||||
zoom: (i32, i32),
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -447,6 +449,23 @@ pub fn wait_cropping(id: u64, timeout: Duration) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client zoom last set on `id`, in tile units.
|
||||
pub fn zoom(id: u64) -> (i32, i32) {
|
||||
slots().lock().expect("the events lock").get(&id).map_or((0, 0), |slot| slot.zoom)
|
||||
}
|
||||
|
||||
/// A new client zoom resizes the document unannounced (Calc).
|
||||
pub fn rescaled(id: u64, zoom: (i32, i32)) {
|
||||
if let Some(slot) = slots().lock().expect("the events lock").get_mut(&id)
|
||||
&& slot.zoom != zoom
|
||||
{
|
||||
slot.zoom = zoom;
|
||||
slot.batch.resized += 1;
|
||||
slot.batch.seq += 1;
|
||||
}
|
||||
signal().notify_all();
|
||||
}
|
||||
|
||||
/// Seeds the a11y text from the kernel's snapshot (§7 tail).
|
||||
pub fn seed_a11y(id: u64, paragraph: &str) {
|
||||
if let Some(slot) = slots().lock().expect("the events lock").get_mut(&id) {
|
||||
|
||||
@@ -18,11 +18,16 @@ pub use client::{
|
||||
};
|
||||
#[cfg(feature = "view")]
|
||||
pub use view::{
|
||||
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, TILE_TWIPS, ZOOM_STEPS,
|
||||
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS,
|
||||
Zoomed, init, layout, paper, read_off, scheme,
|
||||
};
|
||||
#[cfg(feature = "view")]
|
||||
mod view;
|
||||
|
||||
/// One tile edge in twips.
|
||||
pub const TILE_TWIPS: i32 = 3840;
|
||||
/// Twips per pixel at zoom 1.0 and DPI 1.0.
|
||||
pub(crate) const TWIPS_PER_PIXEL: f32 = 15.0;
|
||||
pub use ipc::STALE;
|
||||
pub use ipc::file_url_of as file_url;
|
||||
pub use ipc::path_of_file_url as file_path;
|
||||
|
||||
@@ -33,10 +33,7 @@ pub use input::{CONTEXT, Copy, Cut, Paste, init};
|
||||
pub use off::read_off;
|
||||
pub use render::scheme;
|
||||
|
||||
/// One tile edge in twips.
|
||||
pub const TILE_TWIPS: i32 = 3840;
|
||||
/// Twips per pixel at zoom 1.0 and DPI 1.0.
|
||||
pub(crate) const TWIPS_PER_PIXEL: f32 = 15.0;
|
||||
pub(crate) use crate::{TILE_TWIPS, TWIPS_PER_PIXEL};
|
||||
|
||||
/// Document/view conversion under one zoom and DPI (LOK-04).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -147,7 +144,7 @@ pub struct LokView {
|
||||
/// A document's size and page frames, in twips.
|
||||
pub type Layout = ((i64, i64), Vec<Rect>);
|
||||
|
||||
type Measured = io::Result<Layout>;
|
||||
use sheet::{Measured, measure};
|
||||
|
||||
/// A page's colour; the kernel's Ely schemes share it.
|
||||
pub fn paper(cx: &gpui::App) -> gpui::Hsla {
|
||||
@@ -165,10 +162,6 @@ pub fn layout(worker: &mut Worker, doc: &Doc) -> io::Result<Layout> {
|
||||
Ok((doc.size(worker)?, doc.pages(worker)?))
|
||||
}
|
||||
|
||||
/// Reads the layout under the worker lock.
|
||||
fn measure(worker: &Mutex<Worker>, doc: &Doc) -> Measured {
|
||||
layout(&mut worker.lock().expect("the worker lock"), doc)
|
||||
}
|
||||
|
||||
impl EventEmitter<LokError> for LokView {}
|
||||
|
||||
@@ -362,8 +355,8 @@ impl LokView {
|
||||
// What the view shows is the document at this revision.
|
||||
self.seen_revision(batch.revision);
|
||||
match measured {
|
||||
Some(Ok(((width, height), pages))) => {
|
||||
self.doc_size = (width as i32, height as i32);
|
||||
Some(Ok((((width, height), pages), zoom))) => {
|
||||
self.doc_size = self.at_scale((width as i32, height as i32), zoom);
|
||||
if pages.len() != self.pages.len() {
|
||||
log::info!("lok: {} pages", pages.len());
|
||||
}
|
||||
@@ -375,8 +368,9 @@ impl LokView {
|
||||
}
|
||||
if batch.cursor != self.cursor {
|
||||
self.moves += 1;
|
||||
// A moved cursor stays in view.
|
||||
// A moved cursor stays in view, but not mid-drag.
|
||||
if self.viewport.1 > 0.0
|
||||
&& !self.dragging
|
||||
&& let Some(cursor) = batch.cursor
|
||||
{
|
||||
self.show(cursor, cx);
|
||||
@@ -385,6 +379,7 @@ impl LokView {
|
||||
// A moved cell cursor stays in view too.
|
||||
if batch.cell.cursor != self.cell.cursor
|
||||
&& self.viewport.1 > 0.0
|
||||
&& !self.dragging
|
||||
&& let Some(cell) = batch.cell.cursor
|
||||
{
|
||||
self.show(cell, cx);
|
||||
|
||||
@@ -251,6 +251,10 @@ impl LokView {
|
||||
|
||||
/// Room beside a narrow document, which sits centred.
|
||||
pub(super) fn slack(&self) -> (f32, f32) {
|
||||
// A sheet's grid grows with the view; it starts left.
|
||||
if self.doc.is_sheet() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let extent = self.extent();
|
||||
let slack = |view: f32, doc: i32| (view - self.geom.px_of(doc)).max(0.0) / 2.0;
|
||||
// A fitted page sits centred both ways.
|
||||
|
||||
@@ -61,11 +61,11 @@ impl LokView {
|
||||
let need = self.print_of((x + w + vw + fx, y + h + vh + fy));
|
||||
self.reach = (self.reach.0.max(need.0), self.reach.1.max(need.1));
|
||||
}
|
||||
if x >= fx && (x < fx + self.scroll.0 || x + w > fx + self.scroll.0 + vw) {
|
||||
self.scroll.0 = x - fx - vw / 2;
|
||||
if x >= fx {
|
||||
self.scroll.0 = least(x - fx, w, self.scroll.0, vw);
|
||||
}
|
||||
if y >= fy && (y < fy + self.scroll.1 || y + h > fy + self.scroll.1 + vh) {
|
||||
self.scroll.1 = y - fy - vh / 3;
|
||||
if y >= fy {
|
||||
self.scroll.1 = least(y - fy, h, self.scroll.1, vh);
|
||||
}
|
||||
self.clamp_scroll();
|
||||
cx.notify();
|
||||
@@ -111,10 +111,20 @@ impl LokView {
|
||||
if self.fit {
|
||||
return self.doc_size;
|
||||
}
|
||||
self.tile_of((
|
||||
self.doc_size.0.max(self.reach.0),
|
||||
self.doc_size.1.max(self.reach.1),
|
||||
))
|
||||
// The kernel sizes a sheet in tile twips already.
|
||||
let reach = self.tile_of(self.reach);
|
||||
let (w, h) = (self.doc_size.0.max(reach.0), self.doc_size.1.max(reach.1));
|
||||
let Some(sheet) = &self.sheet else {
|
||||
return (w, h);
|
||||
};
|
||||
// Sheets scroll ahead of the kernel, to their end.
|
||||
let end = self.tile_of((sheet.columns.start(sheet.columns.count()), sheet.rows.start(sheet.rows.count())));
|
||||
let (fx, fy) = self.frozen_tiles();
|
||||
let ahead = (
|
||||
fx + self.scroll.0 + 2 * self.geom.twips_of(self.viewport.0),
|
||||
fy + self.scroll.1 + 2 * self.geom.twips_of(self.viewport.1),
|
||||
);
|
||||
(w.max(ahead.0.min(end.0)), h.max(ahead.1.min(end.1)))
|
||||
}
|
||||
|
||||
/// The print-twips document point under a view position (LOK-04).
|
||||
@@ -172,3 +182,10 @@ impl LokView {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The scroll nearest `scroll` that shows `start..start + len`.
|
||||
fn least(start: i32, len: i32, scroll: i32, view: i32) -> i32 {
|
||||
// A span wider than the view keeps covering it.
|
||||
let end = start + len - view;
|
||||
scroll.clamp(start.min(end), start.max(end))
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
//! Calc reports print twips; tiles and clicks take tile twips.
|
||||
|
||||
use std::{io, sync::Mutex};
|
||||
|
||||
use gpui::Context;
|
||||
|
||||
use super::{LokView, TILE_TWIPS};
|
||||
use crate::{Rect, sheet::Geometry};
|
||||
use super::{Layout, LokView, TILE_TWIPS};
|
||||
use crate::{Doc, Rect, Worker, sheet::Geometry};
|
||||
|
||||
/// A layout and the client zoom it was measured at.
|
||||
pub(super) type Measured = io::Result<(Layout, (i32, i32))>;
|
||||
|
||||
/// Reads the layout under the worker lock.
|
||||
pub(super) fn measure(worker: &Mutex<Worker>, doc: &Doc) -> Measured {
|
||||
let mut worker = worker.lock().expect("the worker lock");
|
||||
let (size, zoom) = doc.size_at(&mut worker)?;
|
||||
Ok(((size, doc.pages(&mut worker)?), zoom))
|
||||
}
|
||||
|
||||
impl LokView {
|
||||
/// Lays the sheet out the kernel's way.
|
||||
@@ -51,11 +63,29 @@ impl LokView {
|
||||
/// Rescales, keeping the scrolling pane's start in place.
|
||||
pub(super) fn keep_scroll(&mut self, change: impl FnOnce(&mut Self)) {
|
||||
let start = self.scrolled_start();
|
||||
change(self);
|
||||
self.carry_size(change);
|
||||
let ((x, y), (fx, fy)) = (self.tile_of(start), self.frozen_tiles());
|
||||
self.scroll = (x - fx, y - fy);
|
||||
}
|
||||
|
||||
/// Rescales; the measured size moves to the new scale too.
|
||||
pub(super) fn carry_size(&mut self, change: impl FnOnce(&mut Self)) {
|
||||
// Until measured again, scrolls clamp against this.
|
||||
let print = self.print_of(self.doc_size);
|
||||
change(self);
|
||||
self.doc_size = self.tile_of(print);
|
||||
}
|
||||
|
||||
/// A sheet size the kernel measured at `zoom`, at ours.
|
||||
pub(super) fn at_scale(&self, size: (i32, i32), (pixels, twips): (i32, i32)) -> (i32, i32) {
|
||||
let Some(sheet) = self.sheet.as_ref().filter(|_| pixels > 0 && twips > 0) else {
|
||||
return size;
|
||||
};
|
||||
let theirs = f64::from(pixels) / f64::from(twips);
|
||||
let print = (sheet.columns.print(size.0, theirs), sheet.rows.print(size.1, theirs));
|
||||
self.tile_of(print)
|
||||
}
|
||||
|
||||
/// Where the scrolling pane starts, in print twips.
|
||||
pub fn scrolled_start(&self) -> (i32, i32) {
|
||||
let (fx, fy) = self.frozen_tiles();
|
||||
|
||||
@@ -82,14 +82,22 @@ fn far_down(cx: &mut gpui::TestAppContext, install: &std::path::Path) -> (Entity
|
||||
});
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let row = cell(&geometry, 1, 4997);
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) };
|
||||
cx.update(|app| {
|
||||
view.update(app, |view, cx| {
|
||||
view.geom.dpi = 2.0;
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) });
|
||||
view.show(row, cx);
|
||||
view.prepare(bounds);
|
||||
})
|
||||
});
|
||||
// The first scale's report and size land before the move.
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.show(row, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
(view, geometry)
|
||||
}
|
||||
|
||||
@@ -186,7 +194,17 @@ fn a_sheet_brings_far_cut_and_frozen_cells_into_view() {
|
||||
let (view, worker) = live_view_of(app, &install, "short.fods", SHORT_ROWS);
|
||||
let json = worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry");
|
||||
view.update(app, |view, cx| {
|
||||
let wide = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(6000.0), px(200.0)) };
|
||||
view.prepare(wide);
|
||||
assert_eq!(view.spare, (0.0, 0.0), "before its geometry a sheet starts left");
|
||||
view.set_geometry(crate::sheet::geometry(&json).expect("parsed"), cx);
|
||||
view.prepare(wide);
|
||||
assert_eq!(view.spare, (0.0, 0.0), "and stays left after it");
|
||||
let size = view.doc_size;
|
||||
view.doc_size = view.tile_of((view.print_of(size).0 + 20 * 1280, size.1));
|
||||
let extent = view.extent();
|
||||
assert!(extent.0 >= view.doc_size.0 && extent.1 >= view.doc_size.1, "the kernel's size is uncut");
|
||||
view.doc_size = size;
|
||||
view.viewport = (800.0, 200.0);
|
||||
let (width, height) = (view.geom.twips_of(view.viewport.0), view.geom.twips_of(view.viewport.1));
|
||||
let shows = |view: &LokView, rect: crate::Rect| {
|
||||
@@ -202,6 +220,16 @@ fn a_sheet_brings_far_cut_and_frozen_cells_into_view() {
|
||||
let cut = (width - 200, 0, 1280, 256);
|
||||
view.show(cut, cx);
|
||||
assert!(view.scroll.0 > 0 && shows(view, cut), "a cell cut at the right edge comes into view");
|
||||
let (x, _, w, _) = view.tile_rect(cut);
|
||||
assert_eq!(view.scroll.0, x + w - width, "it scrolls no further than the cell's edge");
|
||||
let before = view.scroll;
|
||||
view.show((0, 0, view.print_of((x + w + width, 0)).0, 256), cx);
|
||||
assert_eq!(view.scroll, before, "a cell wider than the view and covering it keeps still");
|
||||
view.dragging = true;
|
||||
let cell = crate::Cell { cursor: Some((1418, 90_000, 1280, 277)), ..crate::Cell::default() };
|
||||
view.apply_batch(crate::Events { cell, ..crate::Events::default() }, None, cx);
|
||||
assert_eq!(view.scroll, before, "a drag does not scroll under the pointer");
|
||||
view.dragging = false;
|
||||
view.scroll = (0, 0);
|
||||
view.set_frozen((0, 600), cx);
|
||||
let frozen = view.place((0, 300)).y;
|
||||
@@ -239,3 +267,204 @@ fn a_newly_selected_object_comes_into_view_a_moved_one_stays() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Data out to the last cell sets the size.
|
||||
const FULL_SHEET: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style: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.spreadsheet">
|
||||
<office:automatic-styles><style:style style:name="co1" style:family="table-column"><style:table-column-properties style:column-width="0.4cm"/></style:style><style:style style:name="ro1" style:family="table-row"><style:table-row-properties style:row-height="0.5cm" style:use-optimal-row-height="false"/></style:style></office:automatic-styles>
|
||||
<office:body><office:spreadsheet><table:table table:name="S">
|
||||
<table:table-column table:style-name="co1" table:number-columns-repeated="2048"/>
|
||||
<table:table-row table:style-name="ro1" table:number-rows-repeated="5999"><table:table-cell table:number-columns-repeated="2048"/></table:table-row>
|
||||
<table:table-row table:style-name="ro1"><table:table-cell table:number-columns-repeated="2047"/><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell></table:table-row>
|
||||
</table:table></office:spreadsheet></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
#[test]
|
||||
fn a_zoom_measures_the_sheet_again() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, _| view.prepare(bounds)));
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
let before = cx.update(|app| view.read(app).doc_size);
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_zoom(1.25, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
// The kernel's size at the new zoom, read fresh.
|
||||
let kernel = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
let (w, h) = read.doc.size(&mut read.worker.lock().expect("the worker lock")).expect("a size");
|
||||
(w as i32, h as i32)
|
||||
})
|
||||
};
|
||||
// The kernel takes the zoom, then the view its size.
|
||||
let (mut seen, mut now) = (before, before);
|
||||
for _ in 0..2000 {
|
||||
cx.executor().tick();
|
||||
(seen, now) = (cx.update(|app| view.read(app).doc_size), kernel(&mut cx));
|
||||
if now != before && seen == now {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_ne!(now, before, "the zoom changes the kernel's size here");
|
||||
assert_eq!(seen, now, "the view takes the size the kernel has at the new zoom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_at_the_end_of_a_sheet_keeps_the_row_in_the_middle() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
// Scrolled to the end, as by the wheel.
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.scroll_by(0.0, -1.0e7, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
let middle = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
let at = read.origin() + point(px(200.0), px(100.0));
|
||||
geometry.rows.at(read.doc_point(at).1)
|
||||
})
|
||||
};
|
||||
let before = middle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_zoom(1.25, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
let right_after = middle(&mut cx);
|
||||
for _ in 0..400 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
let measured = middle(&mut cx);
|
||||
assert!(before.abs_diff(right_after) <= 1, "row {before} became {right_after} at the zoom");
|
||||
assert!(before.abs_diff(measured) <= 1, "row {before} became {measured} once measured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_size_from_the_last_zoom_keeps_the_row() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
let settle = |cx: &mut gpui::TestAppContext| {
|
||||
for _ in 0..200 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
};
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.set_zoom(1.25, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
settle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.scroll_by(0.0, -1.0e7, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
settle(&mut cx);
|
||||
// Measured at 125%, landing after the zoom to 150%.
|
||||
let late = cx.update(|app| super::super::measure(&view.read(app).worker, &view.read(app).doc));
|
||||
let middle = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
geometry.rows.at(read.doc_point(read.origin() + point(px(200.0), px(100.0))).1)
|
||||
})
|
||||
};
|
||||
cx.update(|app| view.update(app, |view, cx| view.set_zoom(1.5, cx)));
|
||||
let before = middle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| view.apply_batch(crate::Events::default(), Some(late), cx)));
|
||||
let after = middle(&mut cx);
|
||||
assert!(before.abs_diff(after) <= 1, "row {before} became {after} when the old size landed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_size_has_a_scale_and_keeps_the_row() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
// Read before any visible area: the load set a scale.
|
||||
let first = cx.update(|app| super::super::measure(&view.read(app).worker, &view.read(app).doc));
|
||||
assert_eq!(first.as_ref().map(|(_, zoom)| *zoom).ok(), Some((256, 3840)), "a known scale");
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(bounds);
|
||||
view.scroll_by(0.0, -1.0e7, cx);
|
||||
}));
|
||||
let middle = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
geometry.rows.at(read.doc_point(read.origin() + point(px(200.0), px(100.0))).1)
|
||||
})
|
||||
};
|
||||
cx.update(|app| view.update(app, |view, cx| view.set_zoom(1.25, cx)));
|
||||
let before = middle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| view.apply_batch(crate::Events::default(), Some(first), cx)));
|
||||
let after = middle(&mut cx);
|
||||
assert!(before.abs_diff(after) <= 1, "row {before} became {after} when the first size landed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_sheet_scrolls_on_without_waiting() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "short.fods", SHORT_ROWS));
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
// Each step a viewport down, before the kernel answers.
|
||||
for step in 1..=20 {
|
||||
let moved = cx.update(|app| view.update(app, |view, cx| {
|
||||
let from = view.scroll.1;
|
||||
view.scroll_by(0.0, -200.0, cx);
|
||||
view.scroll.1 - from
|
||||
}));
|
||||
let full = cx.update(|app| view.read(app).geom.twips_of(200.0));
|
||||
assert_eq!(moved, full, "step {step} went the whole way");
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl LokView {
|
||||
let under = self.print_of((self.scroll.0 + self.geom.twips_of(sx), self.scroll.1 + self.geom.twips_of(sy)));
|
||||
self.fit = false;
|
||||
if (zoom - self.geom.zoom).abs() > 0.0001 {
|
||||
self.geom.zoom = zoom;
|
||||
self.carry_size(|view| view.geom.zoom = zoom);
|
||||
self.retire_tiles();
|
||||
self.failed.clear();
|
||||
}
|
||||
|
||||
@@ -187,13 +187,17 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
|
||||
.str()
|
||||
.and_then(|url| Ok((url, unpack.str()?)))
|
||||
.and_then(|(url, password)| load(lok, &url, password, ""))
|
||||
.map(|id| Pack::new().u64(id).bytes()),
|
||||
tag::IMPORT => text::import(lok, unpack).map(|id| Pack::new().u64(id).bytes()),
|
||||
.and_then(loaded),
|
||||
tag::IMPORT => text::import(lok, unpack).and_then(loaded),
|
||||
tag::EXPORT_TEXT => text::export(unpack).map(|()| Vec::new()),
|
||||
tag::DOC_SIZE => unpack
|
||||
.u64()
|
||||
.and_then(|id| with_doc(id, |doc| doc.size()))
|
||||
.map(|(width, height)| Pack::new().i64(width).i64(height).bytes()),
|
||||
.and_then(|id| Ok((with_doc(id, |doc| doc.size())?, crate::events::zoom(id))))
|
||||
.map(|((width, height), (pixels, twips))| {
|
||||
// The scale the kernel measured at; 0 if never set.
|
||||
let pack = Pack::new().i64(width).i64(height);
|
||||
pack.i64(i64::from(pixels)).i64(i64::from(twips)).bytes()
|
||||
}),
|
||||
tag::SAVE_AS => save(unpack).map(|()| Vec::new()),
|
||||
tag::EXPORT_PDF => export_pdf(unpack).map(|()| Vec::new()),
|
||||
tag::CLOSE_DOC => unpack.u64().map(|id| {
|
||||
@@ -255,6 +259,7 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
|
||||
doc.set_client_zoom(pixels, twips);
|
||||
doc.set_visible_area(x, y, w, h)
|
||||
})
|
||||
.inspect(|_| crate::events::rescaled(id, (pixels, twips)))
|
||||
})
|
||||
.map(|()| Vec::new()),
|
||||
tag::SELECTION_TEXT => unpack
|
||||
@@ -331,6 +336,12 @@ fn load(lok: &Lok, url: &str, password: String, filter: &str) -> io::Result<u64>
|
||||
crate::events::seed_a11y(id, &doc.a11y_paragraph()?);
|
||||
// Settle the initial layout now; later paints see it (LOK-03).
|
||||
doc.post_uno(".uno:Repaginate", None)?;
|
||||
// A sheet's size has a known scale from the start.
|
||||
if doc.kind() == 1 {
|
||||
let zoom = (crate::TILE_TWIPS / crate::TWIPS_PER_PIXEL as i32, crate::TILE_TWIPS);
|
||||
doc.set_client_zoom(zoom.0, zoom.1);
|
||||
crate::events::rescaled(id, zoom);
|
||||
}
|
||||
let mut docs = docs().lock().expect("the docs lock");
|
||||
docs.insert(id, doc);
|
||||
log::info!("lok-worker: {} documents open", docs.len());
|
||||
@@ -339,6 +350,12 @@ fn load(lok: &Lok, url: &str, password: String, filter: &str) -> io::Result<u64>
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The reply to a load: the number and LOK_DOCTYPE.
|
||||
fn loaded(id: u64) -> io::Result<Vec<u8>> {
|
||||
let kind = with_doc(id, |doc| doc.kind())?;
|
||||
Ok(Pack::new().u64(id).u64(kind as u64).bytes())
|
||||
}
|
||||
|
||||
/// Stops watching and frees document `id`.
|
||||
fn close(id: u64) {
|
||||
let mut docs = docs().lock().expect("the docs lock");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_ui::commands::{CloseTab, Open, Quit, Save, SaveAs};
|
||||
use ely_ui::commands::{CloseTab, Open, Save, SaveAs};
|
||||
use gpui::{App, AppContext as _, Context, PathPromptOptions, TaskExt as _, Window};
|
||||
|
||||
use crate::{Dialog, Engine, Host, Opening, Saving, Tabs, in_window, text};
|
||||
@@ -41,8 +41,37 @@ pub trait Closing: Host {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_quit(&mut self, _: &Quit, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.proceed(After::Quit, window, cx);
|
||||
/// Takes `window` as home; quit anywhere asks here first.
|
||||
fn own_window(&mut self, window: &Window, cx: &mut Context<Self>) {
|
||||
let handle = window.window_handle();
|
||||
self.core_mut().window = Some(handle);
|
||||
let host = cx.entity().downgrade();
|
||||
ely_ui::commands::route_quit(cx, move |cx| {
|
||||
let host = host.clone();
|
||||
// The asking window is busy until its action ends.
|
||||
cx.defer(move |cx| {
|
||||
let asked = handle.update(cx, |_, window, cx| {
|
||||
// The question shows in front, with the keys.
|
||||
window.activate_window();
|
||||
host.update(cx, |host, cx| {
|
||||
host.quitting(cx);
|
||||
host.proceed(After::Quit, window, cx);
|
||||
})
|
||||
});
|
||||
match asked {
|
||||
Ok(Ok(())) => {}
|
||||
// No home window is left to ask in.
|
||||
_ if handle.update(cx, |_, _, _| ()).is_err() => cx.quit(),
|
||||
Ok(Err(error)) | Err(error) => log::error!("office: the quit was not asked: {error}"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A close or quit stopped: no update installs on it.
|
||||
fn close_stopped(&mut self, cx: &mut Context<Self>) {
|
||||
ely_ui::updates::quit_stopped(cx);
|
||||
self.close_cancelled(cx);
|
||||
}
|
||||
|
||||
fn no_document(&self, cx: &mut Context<Self>) {
|
||||
|
||||
@@ -87,7 +87,7 @@ pub trait Dialogs: Host {
|
||||
// Cancel stops the whole close.
|
||||
let cancelled = move |window: &mut Window, cx: &mut App| {
|
||||
owner.update(cx, |host, cx| {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
host.dialog_done(window, cx)
|
||||
})
|
||||
};
|
||||
@@ -119,7 +119,7 @@ pub trait Dialogs: Host {
|
||||
let cancelled = move |window: &mut Window, cx: &mut App| {
|
||||
owner.update(cx, |host, cx| {
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
host.dialog_done(window, cx)
|
||||
})
|
||||
@@ -135,7 +135,7 @@ pub trait Dialogs: Host {
|
||||
move |host, _, cx| {
|
||||
// Reloading ends the close that waited.
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
host.reload(tab, cx)
|
||||
},
|
||||
|
||||
@@ -72,6 +72,8 @@ pub trait Host: Sized + 'static {
|
||||
fn observed(&mut self, id: u64, view: &Entity<View<Self>>, cx: &mut Context<Self>);
|
||||
/// Runs before a search first moves the cursor.
|
||||
fn search_leaves(&mut self, _: &mut Context<Self>) {}
|
||||
/// Runs as a quit starts, before any tab closes.
|
||||
fn quitting(&mut self, _: &mut Context<Self>) {}
|
||||
/// Runs when a close stops: question or save cancelled.
|
||||
fn close_cancelled(&mut self, _: &mut Context<Self>) {}
|
||||
/// An outside change met a save; true if shown.
|
||||
@@ -240,11 +242,7 @@ pub fn in_window<H: Host, R>(
|
||||
}
|
||||
|
||||
/// Opens the print panel after GPUI releases the app.
|
||||
pub fn print_later<H: Host>(
|
||||
pdf: std::path::PathBuf,
|
||||
cx: &mut Context<H>,
|
||||
failed: impl FnOnce(&mut H, String, &mut Context<H>) + 'static,
|
||||
) {
|
||||
pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
|
||||
// The panel's modal loop calls back into GPUI.
|
||||
cx.spawn(async move |this, cx| {
|
||||
let printed = ely_platform::print::print_pdf(&pdf);
|
||||
@@ -256,7 +254,7 @@ pub fn print_later<H: Host>(
|
||||
Ok(printed) => log::info!("office: print done, printed {printed}"),
|
||||
Err(error) => {
|
||||
if this
|
||||
.update(cx, |host, cx| failed(host, error.to_string(), cx))
|
||||
.update(cx, |host, cx| host.print_failed(&error.to_string(), cx))
|
||||
.is_err()
|
||||
{
|
||||
log::error!("office: printing failed after its window closed: {error}");
|
||||
@@ -291,6 +289,11 @@ pub trait Tabs: Host {
|
||||
self.toast_unlogged(text, severity, cx);
|
||||
}
|
||||
|
||||
fn print_failed(&self, error: &str, cx: &mut App) {
|
||||
let text = text(cx, "office.print.failed", &[("error", error)]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// A success toast with a button that opens `path`.
|
||||
fn toast_output(&self, text: SharedString, path: std::path::PathBuf, cx: &mut App) {
|
||||
log::info!("{}: {text}", Self::KIND.app.slug());
|
||||
|
||||
@@ -77,9 +77,14 @@ impl Engine for Lok {
|
||||
}
|
||||
|
||||
fn restart(app: AppId, worker: &mut Worker) -> io::Result<()> {
|
||||
*worker = spawn(app)?;
|
||||
worker.ready()?;
|
||||
worker.set_author(&ely_platform::user::name()?)
|
||||
let author = worker.author().map(str::to_string);
|
||||
let author = author.ok_or_else(|| io::Error::other("the ended worker had no author"))?;
|
||||
// The ended worker stays until the new one is ready.
|
||||
let mut fresh = spawn(app)?;
|
||||
fresh.ready()?;
|
||||
fresh.set_author(&author)?;
|
||||
*worker = fresh;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn settled(view: &LokView) -> bool {
|
||||
@@ -129,10 +134,13 @@ pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc<Mutex<Worker>>, PathBu
|
||||
let worker = Arc::new(Mutex::new(spawn(app)?));
|
||||
// The window opens now; the kernel finishes starting behind it.
|
||||
let starting = Arc::clone(&worker);
|
||||
// Settings as read at launch; defaults if unreadable.
|
||||
let wanted = Arc::new(Mutex::new(ely_ui::settings::current(cx).author.clone()));
|
||||
let first = Arc::clone(&wanted);
|
||||
let started = cx.background_executor().spawn(async move {
|
||||
let mut worker = starting.lock().expect("the worker lock");
|
||||
worker.ready()?;
|
||||
worker.set_author(&ely_platform::user::name()?)
|
||||
sign(&mut worker, &first)
|
||||
});
|
||||
cx.spawn(async move |_| {
|
||||
if let Err(error) = started.await {
|
||||
@@ -140,10 +148,16 @@ pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc<Mutex<Worker>>, PathBu
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
follow_author(&worker, cx);
|
||||
follow_author(&worker, wanted, cx);
|
||||
Ok((worker, work))
|
||||
}
|
||||
|
||||
/// Sends the newest wanted name, read under the worker lock.
|
||||
fn sign(worker: &mut Worker, wanted: &Mutex<String>) -> io::Result<()> {
|
||||
let author = wanted.lock().expect("the author lock").clone();
|
||||
worker.set_author(&ely_platform::user::named(&author)?)
|
||||
}
|
||||
|
||||
/// A worker process on the app's profile, not yet checked.
|
||||
fn spawn(app: AppId) -> io::Result<Worker> {
|
||||
let data = ely_platform::data_dir(app).map_err(io::Error::other)?;
|
||||
@@ -154,19 +168,19 @@ fn spawn(app: AppId) -> io::Result<Worker> {
|
||||
}
|
||||
|
||||
/// A new author name signs from the next comment on.
|
||||
fn follow_author(worker: &Arc<Mutex<Worker>>, cx: &mut App) {
|
||||
fn follow_author(worker: &Arc<Mutex<Worker>>, wanted: Arc<Mutex<String>>, cx: &mut App) {
|
||||
let worker = Arc::clone(worker);
|
||||
let mut signed = ely_ui::settings::current(cx).author.clone();
|
||||
cx.observe_global::<ely_ui::settings::Current>(move |cx| {
|
||||
let author = ely_ui::settings::current(cx).author.clone();
|
||||
if author == signed {
|
||||
if *wanted.lock().expect("the author lock") == author {
|
||||
return;
|
||||
}
|
||||
signed = author;
|
||||
let worker = Arc::clone(&worker);
|
||||
*wanted.lock().expect("the author lock") = author;
|
||||
let (worker, wanted) = (Arc::clone(&worker), Arc::clone(&wanted));
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
let set = ely_platform::user::name().and_then(|name| worker.lock().expect("the worker lock").set_author(&name));
|
||||
// Any order of these tasks ends on the newest name.
|
||||
let set = sign(&mut worker.lock().expect("the worker lock"), &wanted);
|
||||
if let Err(error) = set {
|
||||
log::error!("lok: the new author name is not set: {error}");
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ pub trait Saving: Host {
|
||||
}
|
||||
// No save: a close that waited on it stops.
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -419,7 +419,7 @@ pub trait Saving: Host {
|
||||
failed => {
|
||||
self.report(id, failed, cx);
|
||||
if then.is_some() {
|
||||
self.close_cancelled(cx);
|
||||
self.close_stopped(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! A failed engine restart keeps the author for retries.
|
||||
|
||||
use ely_office::{Engine as _, Lok};
|
||||
use ely_platform::AppId;
|
||||
|
||||
#[test]
|
||||
fn a_restart_signs_with_the_newest_name_and_survives_a_failure() {
|
||||
let Ok(install) = ely_lok::install_dir() else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let home = tempfile::tempdir().expect("a temp dir");
|
||||
// The one test here: nothing else reads HOME.
|
||||
unsafe { std::env::set_var("HOME", home.path()) };
|
||||
let profile = ely_platform::data_dir(AppId::Doc).expect("a data dir").join("lok-profile");
|
||||
std::fs::create_dir_all(&profile).expect("the profile");
|
||||
let mut worker = ely_lok::Worker::launch(&install, &profile).expect("the worker");
|
||||
worker.ready().expect("checks");
|
||||
worker.set_author("Ada").expect("signs");
|
||||
// The new worker's check cannot write its sample here.
|
||||
let blocker = profile.join("lok-verify.fodt");
|
||||
if blocker.exists() {
|
||||
std::fs::remove_file(&blocker).expect("the last sample goes");
|
||||
}
|
||||
std::fs::create_dir(&blocker).expect("a folder in the way");
|
||||
assert!(Lok::restart(AppId::Doc, &mut worker).is_err(), "the check fails");
|
||||
assert_eq!(worker.author(), Some("Ada"), "the author survives a failed restart");
|
||||
std::fs::remove_dir(&blocker).expect("cleared");
|
||||
Lok::restart(AppId::Doc, &mut worker).expect("the next try restarts");
|
||||
assert_eq!(worker.author(), Some("Ada"));
|
||||
// A name asked while down signs after the restart.
|
||||
worker.shutdown().expect("stops");
|
||||
assert!(worker.set_author("Latest").is_err(), "a stopped engine cannot take it");
|
||||
Lok::restart(AppId::Doc, &mut worker).expect("restarts");
|
||||
assert_eq!(worker.author(), Some("Latest"), "the newest name signs");
|
||||
}
|
||||
@@ -13,10 +13,10 @@ typedef struct
|
||||
int page;
|
||||
} inner_link;
|
||||
|
||||
/* Takes page references off the source's annotations, links noted. */
|
||||
static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room)
|
||||
/* Takes page references off; links noted, `*n` counted as kept. */
|
||||
static void unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room, int *n)
|
||||
{
|
||||
int i, j, n = 0, count = pdf_count_pages(ctx, src);
|
||||
int i, j, count = pdf_count_pages(ctx, src);
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots));
|
||||
@@ -26,7 +26,7 @@ static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int roo
|
||||
pdf_obj *page = ely_target(ctx, src, annot);
|
||||
int to = page ? pdf_lookup_page_number(ctx, src, page) : -1;
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(P));
|
||||
if (to < 0 || n == room)
|
||||
if (to < 0 || *n == room)
|
||||
continue;
|
||||
/* The view after the page: /XYZ, /Fit and so on. */
|
||||
pdf_obj *dest = pdf_dict_get(ctx, annot, PDF_NAME(Dest));
|
||||
@@ -36,16 +36,17 @@ static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int roo
|
||||
dest = pdf_lookup_dest(ctx, src, dest);
|
||||
if (pdf_is_dict(ctx, dest))
|
||||
dest = pdf_dict_get(ctx, dest, PDF_NAME(D));
|
||||
links[n].annot = pdf_keep_obj(ctx, annot);
|
||||
links[n].rest = pdf_copy_array(ctx, dest);
|
||||
pdf_array_delete(ctx, links[n].rest, 0);
|
||||
links[n].page = to;
|
||||
n++;
|
||||
/* Counted as soon as kept: a throw still frees it. */
|
||||
links[*n].annot = pdf_keep_obj(ctx, annot);
|
||||
links[*n].rest = NULL;
|
||||
links[*n].page = to;
|
||||
(*n)++;
|
||||
links[*n - 1].rest = pdf_copy_array(ctx, dest);
|
||||
pdf_array_delete(ctx, links[*n - 1].rest, 0);
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(Dest));
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(A));
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The top of the field tree `widget` belongs to. */
|
||||
@@ -338,7 +339,7 @@ int ely_insert_file(fz_context *ctx, fz_document *doc, int at, const char *path,
|
||||
for (i = 0; i < *count; i++)
|
||||
room += pdf_array_len(ctx, pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots)));
|
||||
links = fz_calloc(ctx, room + 1, sizeof *links);
|
||||
n = unhook(ctx, src, links, room);
|
||||
unhook(ctx, src, links, room, &n);
|
||||
map = pdf_new_graft_map(ctx, pdf);
|
||||
pdf_begin_operation(ctx, pdf, "Insert pages");
|
||||
begun = 1;
|
||||
|
||||
@@ -29,13 +29,13 @@ static int unicode_of(pdf_font_desc *desc, int cid)
|
||||
return n == 1 && ucs[0] > 0 && ucs[0] != FZ_REPLACEMENT_CHARACTER ? ucs[0] : -1;
|
||||
}
|
||||
|
||||
/* Unicode and code pairs of `desc`; its codes are CIDs. */
|
||||
static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
|
||||
/* `desc` Unicode-to-code pairs; -1 when codes are not CIDs. */
|
||||
static int own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
|
||||
{
|
||||
pdf_cmap *enc = desc->encoding;
|
||||
int cid, end;
|
||||
if (!enc || strcmp(enc->cmap_name, "Identity-H") || enc->codespace_len != 1 || enc->codespace[0].n > 2)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font uses codes Ely cannot write");
|
||||
return -1;
|
||||
p->bytes = enc->codespace[0].n;
|
||||
end = 1 << (8 * p->bytes);
|
||||
p->map = fz_malloc_array(ctx, 2 * end, int);
|
||||
@@ -45,10 +45,11 @@ static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
|
||||
if (u > 0 && pdf_font_cid_to_gid(ctx, desc, cid) > 0)
|
||||
p->map[2 * p->mapped] = u, p->map[2 * p->mapped + 1] = cid, p->mapped++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The page font drawn as `own`, with its codes. */
|
||||
static void own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
|
||||
/* The page font drawn as `own`; -1: cannot write. */
|
||||
static int own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
|
||||
{
|
||||
pdf_obj *res = pdf_page_resources(ctx, page);
|
||||
pdf_resource_stack stack = { NULL, res };
|
||||
@@ -63,16 +64,15 @@ static void own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
|
||||
continue;
|
||||
}
|
||||
p->desc = desc;
|
||||
/* A direct font cannot be named again after the edit. */
|
||||
p->num = pdf_to_num(ctx, pdf_dict_get_val(ctx, fonts, i));
|
||||
if (!p->num)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font is not an object of its own");
|
||||
own_codes(ctx, desc, p);
|
||||
return p->num ? own_codes(ctx, desc, p) : -1;
|
||||
}
|
||||
if (!p->desc)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block's font is not a font of its page");
|
||||
/* Drawn from elsewhere, as in a form: not writable here. */
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p)
|
||||
int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p)
|
||||
{
|
||||
static const struct { const char *choice, *name; } latin[] = {
|
||||
{ "helvetica", "Helvetica" }, { "times", "Times-Roman" }, { "courier", "Courier" } };
|
||||
@@ -85,22 +85,21 @@ void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no script font was given");
|
||||
p->kind = PEN_CID;
|
||||
p->font = fz_new_font_from_memory(ctx, "GreatVibes", script_data, (int)script_len, 0, 0);
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
if (!strcmp(choice, "original"))
|
||||
{
|
||||
if (!own)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block has no font of its own");
|
||||
p->kind = PEN_OWN;
|
||||
own_font(ctx, page, own, p);
|
||||
return;
|
||||
return own_font(ctx, page, own, p);
|
||||
}
|
||||
for (i = 0; i < nelem(latin); i++)
|
||||
if (!strcmp(choice, latin[i].choice))
|
||||
{
|
||||
p->kind = PEN_SIMPLE;
|
||||
p->font = fz_new_base14_font(ctx, latin[i].name);
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
for (i = 0; i < nelem(cjk); i++)
|
||||
if (!strcmp(choice, cjk[i].choice))
|
||||
@@ -108,7 +107,7 @@ void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page
|
||||
p->kind = PEN_CJK;
|
||||
p->ordering = cjk[i].ordering;
|
||||
p->font = fz_new_cjk_font(ctx, cjk[i].ordering);
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no font %s", choice);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ typedef struct
|
||||
int *map, mapped;
|
||||
} ely_pen;
|
||||
|
||||
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p);
|
||||
int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p);
|
||||
const char *ely_pen_resource(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_pen *p, char *name, size_t room);
|
||||
int ely_pen_has(fz_context *ctx, ely_pen *p, int u);
|
||||
float ely_pen_advance(fz_context *ctx, ely_pen *p, int u, float size);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
#include "mupdf/ucdn.h"
|
||||
|
||||
/* The page's text, read the same way every time. */
|
||||
static fz_stext_page *read_text(fz_context *ctx, fz_document *doc, int number)
|
||||
@@ -29,6 +30,12 @@ typedef struct
|
||||
int *c;
|
||||
int n;
|
||||
int hit;
|
||||
/* Page-wide: each target's block, and hits per block. */
|
||||
int *owner;
|
||||
int *hits;
|
||||
/* The glyph each block last counted, so once each. */
|
||||
int *seen;
|
||||
int glyph;
|
||||
} targets;
|
||||
|
||||
/* The glyphs of `block` with their user-space origins. */
|
||||
@@ -52,25 +59,79 @@ static void block_targets(fz_context *ctx, fz_stext_block *block, fz_matrix to_u
|
||||
}
|
||||
}
|
||||
|
||||
/* `c` as stext spells it (stext-device.c); ligatures split. */
|
||||
static int spell(int c, int *out)
|
||||
{
|
||||
static const char *named[] = { "ff", "fi", "fl", "ffi", "ffl", "st", "st" };
|
||||
int i, n = 0;
|
||||
uint32_t lig[18];
|
||||
if (c >= 0xFB00 && c <= 0xFB06)
|
||||
{
|
||||
for (i = 0; named[c - 0xFB00][i]; i++)
|
||||
out[n++] = named[c - 0xFB00][i];
|
||||
return n;
|
||||
}
|
||||
if ((c >= 0xfb00 && c <= 0xfdff) || (c >= 0xfe70 && c <= 0xfefc))
|
||||
n = ucdn_compat_decompose((uint32_t)c, lig);
|
||||
for (i = 0; i < n; i++)
|
||||
out[i] = (int)lig[i];
|
||||
if (n > 0)
|
||||
return n;
|
||||
out[0] = c;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Targets from `i` one glyph spells: a ligature spells several. */
|
||||
static int spelled(targets *t, int i, const int *ucs, int ucslen)
|
||||
{
|
||||
int j = 1;
|
||||
if (ucslen < 2 || ucs[0] != t->c[i])
|
||||
return 1;
|
||||
while (j < ucslen && i + j < t->n && t->c[i + j] == ucs[j] && (!t->owner || t->owner[i + j] == t->owner[i]))
|
||||
j++;
|
||||
return j;
|
||||
}
|
||||
|
||||
/* Removes a glyph drawn at one of the targets. */
|
||||
static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_matrix trm, fz_matrix ctm,
|
||||
fz_rect bbox, int tr, float ca, float CA)
|
||||
{
|
||||
targets *t = opaque;
|
||||
fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm);
|
||||
int i, k;
|
||||
/* MuPDF gives at most PDF_MRANGE_CAP; each spells to 18. */
|
||||
int i, k, found = 0, spelt[18 * PDF_MRANGE_CAP], n = 0;
|
||||
t->glyph++;
|
||||
if (ucslen > PDF_MRANGE_CAP)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a glyph with %d characters", ucslen);
|
||||
/* Match what stext reads: ligature code points spelled out. */
|
||||
for (k = 0; k < ucslen; k++)
|
||||
n += spell(ucs[k], spelt + n);
|
||||
ucs = spelt;
|
||||
ucslen = n;
|
||||
for (i = 0; i < t->n; i++)
|
||||
{
|
||||
int match = 0;
|
||||
if (fabsf(at.x - t->at[i].x) > 0.5f || fabsf(at.y - t->at[i].y) > 0.5f)
|
||||
continue;
|
||||
for (k = 0; k < ucslen; k++)
|
||||
if (ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER)
|
||||
{
|
||||
t->hit++;
|
||||
return 1;
|
||||
}
|
||||
for (k = 0; k < ucslen && !match; k++)
|
||||
match = ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER;
|
||||
if (!match)
|
||||
continue;
|
||||
if (!t->owner)
|
||||
{
|
||||
t->hit += spelled(t, i, ucs, ucslen);
|
||||
return 1;
|
||||
}
|
||||
/* Every block this glyph matches counts it once. */
|
||||
if (t->seen[t->owner[i]] != t->glyph)
|
||||
{
|
||||
t->seen[t->owner[i]] = t->glyph;
|
||||
t->hits[t->owner[i]] += spelled(t, i, ucs, ucslen);
|
||||
}
|
||||
found = 1;
|
||||
}
|
||||
return 0;
|
||||
t->hit += found;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Runs the glyph filter over the page; `dry` changes nothing. */
|
||||
@@ -99,7 +160,7 @@ fz_matrix ely_to_user(fz_context *ctx, pdf_page *page)
|
||||
/* Whether all of `block` sits in the page's own content. */
|
||||
static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_block *block)
|
||||
{
|
||||
targets t = { NULL, NULL, 0, 0 };
|
||||
targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 };
|
||||
int ok = 0;
|
||||
fz_var(t.at);
|
||||
fz_var(t.c);
|
||||
@@ -122,6 +183,70 @@ static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stex
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Each text block's glyphs found in page content, one pass. */
|
||||
static void reachable_all(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_page *text, int n, int *ok)
|
||||
{
|
||||
targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 };
|
||||
int *count = NULL, all = 0, b;
|
||||
fz_matrix to_user = ely_to_user(ctx, page);
|
||||
fz_stext_block *block;
|
||||
fz_stext_line *line;
|
||||
fz_stext_char *ch;
|
||||
fz_var(t.at);
|
||||
fz_var(t.c);
|
||||
fz_var(t.owner);
|
||||
fz_var(t.hits);
|
||||
fz_var(t.seen);
|
||||
fz_var(count);
|
||||
pdf_begin_operation(ctx, pdf, "Look at text");
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
if (block->type == FZ_STEXT_BLOCK_TEXT)
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
all++;
|
||||
t.at = fz_malloc_array(ctx, all + 1, fz_point);
|
||||
t.c = fz_malloc_array(ctx, all + 1, int);
|
||||
t.owner = fz_malloc_array(ctx, all + 1, int);
|
||||
t.hits = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
t.seen = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
count = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
b = 0;
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
{
|
||||
if (block->type != FZ_STEXT_BLOCK_TEXT)
|
||||
continue;
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
if (!(ch->flags & FZ_STEXT_SYNTHETIC))
|
||||
{
|
||||
t.at[t.n] = fz_transform_point(ch->origin, to_user);
|
||||
t.c[t.n] = ch->c;
|
||||
t.owner[t.n++] = b;
|
||||
count[b]++;
|
||||
}
|
||||
b++;
|
||||
}
|
||||
filter_glyphs(ctx, pdf, page, &t, 1);
|
||||
for (b = 0; b < n; b++)
|
||||
ok[b] = count[b] > 0 && t.hits[b] == count[b];
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_abandon_operation(ctx, pdf);
|
||||
ely_forget_resources(ctx, pdf);
|
||||
fz_free(ctx, t.at);
|
||||
fz_free(ctx, t.c);
|
||||
fz_free(ctx, t.owner);
|
||||
fz_free(ctx, t.hits);
|
||||
fz_free(ctx, t.seen);
|
||||
fz_free(ctx, count);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* The text of `block`, lines on lines. */
|
||||
static void block_text(fz_context *ctx, fz_buffer *buf, fz_stext_block *block)
|
||||
{
|
||||
@@ -153,18 +278,24 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
|
||||
pdf_page *page = NULL;
|
||||
fz_buffer *buf = NULL, *words = NULL;
|
||||
fz_output *out = NULL;
|
||||
int *reached = NULL;
|
||||
fz_var(text);
|
||||
fz_var(page);
|
||||
fz_var(buf);
|
||||
fz_var(words);
|
||||
fz_var(out);
|
||||
fz_var(reached);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
fz_stext_block *block;
|
||||
int first = 1;
|
||||
int first = 1, n = 0, at = 0;
|
||||
text = read_text(ctx, doc, number);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
n += block->type == FZ_STEXT_BLOCK_TEXT;
|
||||
reached = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
reachable_all(ctx, pdf, page, text, n, reached);
|
||||
buf = fz_new_buffer(ctx, 1024);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_string(ctx, out, "[");
|
||||
@@ -175,7 +306,8 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
|
||||
if (block->type != FZ_STEXT_BLOCK_TEXT)
|
||||
continue;
|
||||
ch = block->u.t.first_line->first_char;
|
||||
editable = upright(block) && reachable(ctx, pdf, page, block);
|
||||
editable = reached[at++];
|
||||
editable = editable && upright(block);
|
||||
fz_drop_buffer(ctx, words);
|
||||
words = NULL;
|
||||
words = fz_new_buffer(ctx, 256);
|
||||
@@ -198,6 +330,7 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
|
||||
fz_drop_buffer(ctx, words);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
fz_free(ctx, reached);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user