Compare commits
No files matched your search
@@ -0,0 +1,57 @@
|
|||||||
|
# A tag builds, packages and uploads all three platforms.
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
jobs:
|
||||||
|
macos:
|
||||||
|
runs-on: macos-arm64
|
||||||
|
timeout-minutes: 720
|
||||||
|
steps:
|
||||||
|
- name: Build, package and upload for macOS
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
|
||||||
|
run: |
|
||||||
|
dir=$HOME/ely-ci/ElyOffice
|
||||||
|
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||||
|
git -C "$dir" fetch --force --tags origin
|
||||||
|
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||||
|
"$dir/scripts/ci.sh" macos "${{ github.ref_name }}"
|
||||||
|
windows:
|
||||||
|
runs-on: windows
|
||||||
|
timeout-minutes: 360
|
||||||
|
steps:
|
||||||
|
- name: Build, package and upload for Windows
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
ELY_SHARE: /c/Users/zhang/Documents/Github/ElyOffice
|
||||||
|
ELY_PATH: /c/Program Files/CMake/bin:/c/Users/zhang/AppData/Local/Microsoft/WinGet/Packages/Ninja-build.Ninja_Microsoft.Winget.Source_8wekyb3d8bbwe
|
||||||
|
run: |
|
||||||
|
dir=$HOME/ely-ci/ElyOffice
|
||||||
|
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||||
|
git -C "$dir" fetch --force --tags origin
|
||||||
|
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||||
|
"$dir/scripts/ci.sh" windows "${{ github.ref_name }}"
|
||||||
|
linux:
|
||||||
|
# WSL on the Windows runner keeps a built kernel.
|
||||||
|
runs-on: windows
|
||||||
|
needs: windows
|
||||||
|
timeout-minutes: 360
|
||||||
|
steps:
|
||||||
|
- name: Build, package and upload for Linux
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
WSLENV: ELY_RELEASE_TOKEN
|
||||||
|
run: |
|
||||||
|
wsl -d Ubuntu --exec bash -lc '
|
||||||
|
set -euo pipefail
|
||||||
|
dir=$HOME/ely-ci/ElyOffice
|
||||||
|
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||||
|
git -C "$dir" fetch --force --tags origin
|
||||||
|
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||||
|
ELY_SHARE=$HOME/Documents/Github/ElyOffice "$dir/scripts/ci.sh" linux "${{ github.ref_name }}"
|
||||||
|
'
|
||||||
@@ -16,3 +16,4 @@ node_modules/
|
|||||||
/docs/acceptance.md
|
/docs/acceptance.md
|
||||||
/docs/test-map.md
|
/docs/test-map.md
|
||||||
/assets/papirus_icons-master.zip
|
/assets/papirus_icons-master.zip
|
||||||
|
/Gitea.md
|
||||||
Generated
+28
-21
@@ -1919,15 +1919,16 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-agent"
|
name = "ely-agent"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
|
"embed-resource",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-doc"
|
name = "ely-doc"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"csv",
|
"csv",
|
||||||
@@ -1938,6 +1939,7 @@ dependencies = [
|
|||||||
"ely-office",
|
"ely-office",
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
|
"embed-resource",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"image",
|
"image",
|
||||||
@@ -1948,7 +1950,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-files"
|
name = "ely-files"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
@@ -1964,7 +1966,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-gpui-component"
|
name = "ely-gpui-component"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=5d6efd46ec0c93b381ec7578561a1273c366025b#5d6efd46ec0c93b381ec7578561a1273c366025b"
|
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=bd014707fd47cd9d0651a9af8c1db6033718a6ea#bd014707fd47cd9d0651a9af8c1db6033718a6ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -2001,14 +2003,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-ipc"
|
name = "ely-ipc"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-lok"
|
name = "ely-lok"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"ely-gpui-component",
|
"ely-gpui-component",
|
||||||
@@ -2028,15 +2030,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-mail"
|
name = "ely-mail"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
|
"embed-resource",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-mark"
|
name = "ely-mark"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -2046,6 +2049,7 @@ dependencies = [
|
|||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
"ely-web-content",
|
"ely-web-content",
|
||||||
|
"embed-resource",
|
||||||
"futures",
|
"futures",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"jiff",
|
"jiff",
|
||||||
@@ -2058,7 +2062,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-office"
|
name = "ely-office"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -2078,7 +2082,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-pdf"
|
name = "ely-pdf"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ely-files",
|
"ely-files",
|
||||||
"ely-gpui-component",
|
"ely-gpui-component",
|
||||||
@@ -2086,6 +2090,7 @@ dependencies = [
|
|||||||
"ely-pdf-engine",
|
"ely-pdf-engine",
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
|
"embed-resource",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
@@ -2094,7 +2099,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-pdf-engine"
|
name = "ely-pdf-engine"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"ely-ipc",
|
"ely-ipc",
|
||||||
@@ -2110,13 +2115,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-platform"
|
name = "ely-platform"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
"core-foundation 0.10.0",
|
"core-foundation 0.10.0",
|
||||||
"core-video",
|
"core-video",
|
||||||
"directories",
|
"directories",
|
||||||
|
"ely-ipc",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"image",
|
"image",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2143,7 +2149,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-show"
|
name = "ely-show"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ely-files",
|
"ely-files",
|
||||||
"ely-gpui-component",
|
"ely-gpui-component",
|
||||||
@@ -2151,6 +2157,7 @@ dependencies = [
|
|||||||
"ely-office",
|
"ely-office",
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
|
"embed-resource",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"gpui-pre-platform",
|
"gpui-pre-platform",
|
||||||
"image",
|
"image",
|
||||||
@@ -2162,13 +2169,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-table"
|
name = "ely-table"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ely-gpui-component",
|
"ely-gpui-component",
|
||||||
"ely-lok",
|
"ely-lok",
|
||||||
"ely-office",
|
"ely-office",
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
|
"embed-resource",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"log",
|
"log",
|
||||||
@@ -2178,12 +2186,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-tree"
|
name = "ely-tree"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
"ely-files",
|
"ely-files",
|
||||||
"ely-gpui-component",
|
"ely-gpui-component",
|
||||||
"ely-platform",
|
"ely-platform",
|
||||||
"ely-ui",
|
"ely-ui",
|
||||||
|
"embed-resource",
|
||||||
"futures",
|
"futures",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"jiff",
|
"jiff",
|
||||||
@@ -2196,7 +2206,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-ui"
|
name = "ely-ui"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -2214,7 +2224,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ely-web-content"
|
name = "ely-web-content"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"cef",
|
"cef",
|
||||||
@@ -2229,6 +2239,7 @@ dependencies = [
|
|||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2956,8 +2967,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-pre"
|
name = "gpui-pre"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d7f0bc3ebba070f8981b3f8e905a12f47d27794a30d029e59cdb9edf7a4b3360"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -3345,8 +3354,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui-pre-windows"
|
name = "gpui-pre-windows"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "371f9bb13f97e3bea2f8b8e5e81232bb74d735153c244ae67f7400e743925b1c"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_windows",
|
"accesskit_windows",
|
||||||
|
|||||||
+10
-4
@@ -5,19 +5,21 @@ members = ["apps/*", "crates/*"]
|
|||||||
exclude = ["engines", "reference"]
|
exclude = ["engines", "reference"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "MIT"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
embed-resource = "=3.0.11"
|
||||||
csv = "1"
|
csv = "1"
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
jiff = "0.2"
|
jiff = "0.2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
directories = "6"
|
directories = "6"
|
||||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "5d6efd46ec0c93b381ec7578561a1273c366025b" }
|
dunce = "1"
|
||||||
|
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "bd014707fd47cd9d0651a9af8c1db6033718a6ea" }
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
|
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
|
||||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
|
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
|
||||||
@@ -40,7 +42,7 @@ serde_json = "1"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
ureq = "3"
|
ureq = "3"
|
||||||
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
|
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
|
||||||
objc2-foundation = { version = "0.3", default-features = false, features = ["NSLocale", "NSPathUtilities", "NSString", "NSURL", "NSDictionary", "NSValue", "NSError", "NSRunLoop", "NSDate", "NSUserDefaults"] }
|
objc2-foundation = { version = "0.3", default-features = false, features = ["NSLocale", "NSPathUtilities", "NSString", "NSURL", "NSDictionary", "NSValue", "NSError", "NSRunLoop", "NSDate", "NSUserDefaults"] }
|
||||||
objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "AVPlayer", "AVPlayerItem", "AVPlayerItemOutput", "AVAsset", "objc2-core-media", "objc2-core-video", "objc2-core-foundation"] }
|
objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "AVPlayer", "AVPlayerItem", "AVPlayerItemOutput", "AVAsset", "objc2-core-media", "objc2-core-video", "objc2-core-foundation"] }
|
||||||
objc2-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] }
|
objc2-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] }
|
||||||
@@ -64,3 +66,7 @@ ely-ui = { path = "crates/ely-ui" }
|
|||||||
|
|
||||||
[workspace.lints.clippy]
|
[workspace.lints.clippy]
|
||||||
all = { level = "warn", priority = -1 }
|
all = { level = "warn", priority = -1 }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
gpui-pre = { path = "vendor/gpui-pre" }
|
||||||
|
gpui-pre-windows = { path = "vendor/gpui-pre-windows" }
|
||||||
@@ -1,661 +1,21 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
MIT License
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
Copyright (c) 2026 Yang Zhang
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this license document, but changing it is not allowed.
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
Preamble
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
furnished to do so, subject to the following conditions:
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
share and change all versions of a program--to make sure it remains free
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
software for all its users.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
When we speak of free software, we are referring to freedom, not
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
SOFTWARE.
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<http://www.gnu.org/licenses/>.
|
|
||||||
@@ -757,7 +757,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
|
|||||||
|
|
||||||
### 21.1 自有代码默认许可
|
### 21.1 自有代码默认许可
|
||||||
|
|
||||||
本 PRD 对新增应用代码采用 AGPL-3.0-or-later 作为发行默认,便于与 MuPDF 的开源使用路线保持一致。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
|
本 PRD 对新增应用代码采用 MIT 作为发行默认(用户 2026-10-19 定,原为 AGPL-3.0-or-later)。MIT 只管 Ely 的自有代码:链接 MuPDF 的 ElyPDF 发行包整体仍须按 MuPDF 的 AGPL 条款提供对应源码。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
|
||||||
|
|
||||||
第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
|
第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
|
||||||
|
|
||||||
|
|||||||
@@ -44,4 +44,4 @@ Packages: `packaging/macos/release.sh` writes each app's `.app` and `.dmg`, the
|
|||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
AGPL-3.0-or-later (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
|
MIT (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
|
||||||
@@ -27,10 +27,11 @@ crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧
|
|||||||
reference/ 上游源码,只读,不属于本项目,不审查
|
reference/ 上游源码,只读,不属于本项目,不审查
|
||||||
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物
|
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物
|
||||||
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
|
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
|
||||||
|
vendor/ crates.io 上游 crate 的副本加本项目的修复,经工作区 `[patch.crates-io]` 换入(gpui-pre、gpui-pre-windows);首个提交为原样,其后是修复
|
||||||
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
|
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
|
||||||
assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README
|
assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README
|
||||||
dist/ 打包产物,不入库
|
dist/ 打包产物,不入库
|
||||||
docs/ 工程风险与已知限制
|
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
|
||||||
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
|
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
|
||||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
|
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
|
||||||
tasks/ TASKS.md 进度真相
|
tasks/ TASKS.md 进度真相
|
||||||
@@ -39,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
|||||||
|
|
||||||
## 组件映射
|
## 组件映射
|
||||||
|
|
||||||
依赖 `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` 核对下表每个名字都是组件库公开项。
|
依赖 `ely-gpui-component`(pin bd01470,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||||
|
|
||||||
| 需要 | 用 |
|
| 需要 | 用 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -146,6 +147,7 @@ tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构
|
|||||||
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
|
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
|
||||||
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
|
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
|
||||||
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
|
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
|
||||||
|
tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明作者与原因(AX 可读),多条时批注与修订列表仍在窗口内(需辅助功能权限)
|
||||||
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
|
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
|
||||||
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
|
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
|
||||||
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
|
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
|
||||||
@@ -156,6 +158,8 @@ tests/scripts/mark-modes.sh 真窗口 ElyMark:文档旁图片、三种模
|
|||||||
tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限)
|
tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限)
|
||||||
tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制)
|
tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制)
|
||||||
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
|
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
|
||||||
|
tests/scripts/mark-links.mjs ElyMark 页面(Playwright,三端):可视模式单击编辑,Cmd(他处 Ctrl)单击交应用,预览单击交应用,地址取文档原文(需 site 的依赖)
|
||||||
|
tests/scripts/mark-broken.mjs ElyMark 页面(Playwright,三端):可视编辑器建不成即退回源码并告知,两者都建不成即报失败(需 site 的依赖)
|
||||||
tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴)
|
tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴)
|
||||||
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
|
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
|
||||||
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
|
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
|
||||||
@@ -187,11 +191,11 @@ 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)、组件库 5d6efd4(2026-10-09 起)。
|
- 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)、组件库 bd01470(2026-10-11 起)。
|
||||||
- 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-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-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-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`、effort xhigh,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。
|
||||||
- 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
|
- 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`(另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删Line truncated
|
||||||
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
|
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
|
||||||
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
|
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
|
||||||
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
|
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
|
||||||
@@ -223,8 +227,8 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
|||||||
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
|
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
|
||||||
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。
|
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。
|
||||||
- 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。
|
- 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。
|
||||||
- 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器异步建(`on_after_created` 交回)。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 必须有,缺了路由不发),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;页面的警告与错误写入日志。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(浏览器在即重载,否则重建;CEF 起不来时只说明),载入成功才报 `Event::Loaded`。浏览器关闭时断开它与表面的互相持有,页面的内存随之释放(释放时写日志)。真窗口(macOS,开发构建与 `dist/ElyMark.app`):页面以应用深色主题画出,点选、键入中英文、回车、退格、方向键、拷贝粘贴、`elyQuery` 往返;`tests/scripts/webview.sh` 以输入法回调组“你好”、键入、读回焦点、主题变量与缩放 1.25,核对页面跳不出Line truncated
|
- 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器在泵里同步建(`browser_host_create_browser_sync`;原为异步,建好之前视图已释放时浏览器无人关闭),视图要么在建之前已走(不建),要么有浏览器可关。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 与 `onFailure` 都必须有,缺一路由即不发;页面载入时路由未就绪,早到的查询丢失),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;`WebView::open_inert` 开的页面不运行脚本(`BrowserSettings.javascript` 关),ElyMark 的打印与导出 PDF 暂存页用它,文档里的 HTML 脚本不运行(M-15);页面的警告与错误只记行号(警告文字与来源都可由文档伪造,不进日志);页面自己的诊断经 `elyQuery` 交应用记录。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(Line truncated
|
||||||
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1);状态栏显示所用 Git 的路径与版本。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
|
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
|
||||||
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
|
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
|
||||||
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
|
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
|
||||||
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据、CEF 与页面的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
|
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据、CEF 与页面的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
|
||||||
@@ -256,7 +260,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
|||||||
- 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name <user.email>`,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。
|
- 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name <user.email>`,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。
|
||||||
- 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。
|
- 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。
|
||||||
- 2026-10-07(用户定):ElyTree 的 Git。随包自建钉住的 Git v2.56.0(a018953)加 git-lfs v3.8.0(各平台散列钉在 `scripts/fetch-git-lfs.sh`),`scripts/build-git.sh` 以 RUNTIME_PREFIX 构建到 `engines/git`,LFS 放在 `libexec/git-core`。查找次序:`ELY_GIT`(系统 Git,低于 2.40 即拒绝),否则应用旁或 `Resources/git`,否则本构建的 `engines/git`;没有即报错,不退回别的 Git。每次运行都是参数数组,带 `GIT_OPTIONAL_LOCKS=0`(读取不抢索引锁)、`GIT_TERMINAL_PROMPT=0`(从不在终端问凭据)、`GIT_LITERAL_PATHSPECS=1`(路径不当通配)。
|
- 2026-10-07(用户定):ElyTree 的 Git。随包自建钉住的 Git v2.56.0(a018953)加 git-lfs v3.8.0(各平台散列钉在 `scripts/fetch-git-lfs.sh`),`scripts/build-git.sh` 以 RUNTIME_PREFIX 构建到 `engines/git`,LFS 放在 `libexec/git-core`。查找次序:`ELY_GIT`(系统 Git,低于 2.40 即拒绝),否则应用旁或 `Resources/git`,否则本构建的 `engines/git`;没有即报错,不退回别的 Git。每次运行都是参数数组,带 `GIT_OPTIONAL_LOCKS=0`(读取不抢索引锁)、`GIT_TERMINAL_PROMPT=0`(从不在终端问凭据)、`GIT_LITERAL_PATHSPECS=1`(路径不当通配)。
|
||||||
- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径、Git 版本、随附或系统与所用 Git 的路径。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。
|
- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径(所用 Git 的版本与路径只记日志,2026-10-11 用户定从状态栏去掉)。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。
|
||||||
- 2026-10-07:ElyPDF 输出与文件恢复(C11.8,PDF-13、14,QA-13)。“文件›导出…”(Cmd+E)一个对话框三种:页面图片(PNG,72、150、300 dpi,全部或所选页,总是浅色,选文件夹,`名称-序号.png` 按页数补零,先以 `ely_files::claim_all` 占下全部目标,任一失败即删去本次全部);文字(UTF-8,各页以换页符分开,MuPDF 的 stext);优化的 PDF(可看可改的设置:压缩数据流、字体与图片;图片高于某 dpi 降到某 dpi,双三次重采样到确切目标,重压缩:无损的仍无损、有损的保持原格式,默认 300 降到 150;降到的 dpi 须低于阈值,否则不可导出;完成提示写明原文件与新文件大小)。导出图片与文字要“复制”许可,优化副本要“修改”许可。“打印…”(Cmd+P)把文档此刻写成副本(要“打印”许可,worker 的 `PRINT_COPY`)再交系统打印面板(`ely_office::print_later`)。文件恢复:MuPDF 打开时修复了文件结构(`pdf_was_repaired`),worker 即把修复后的文档整份写成临时副本、改开这份副本(日志与快照从此正常;原先修复过的文件不能保存),关闭文档时删去;打开时 MuPDF 的警告记下作修复报告(`ely_open` 期间接管警告回调,照样转给原回调)。外壳的 `Engine::signed` 改为 `Engine::risk`(宏由外壳判,签名、修复由引擎答),加 `Risk::Repaired`:提示条说明打开的是修复的副本、“另存为”写新文件、原件不动,带“报告…”按钮显示 MuPDF 的原话;保存一律另存,另存到原件被拒;签名或修复的文件另存成功后标签记下“待重开”,到内存与文件一致(已保存、无待落地编辑、不在导出)时才按新文件重开;另存期间又有编辑则留着编辑,下一次保存后再重开。MuPDF 有时读到坏对象才修复(尾部完好、某对象偏移坏):打开与解锁后先把每个对象读一遍,并解码图片以外的每个数据流(`ely_scan`,读不出的对象记入报告);MuPDF 修复了结构或读的过程中有任何警告(如内容流解压失败),都当作损坏文件,改开整份写出的副本、报告列出警告、保存一律另存。图片(用户定)在画出时检查:画图块或缩略图时 MuPDF 有警告即为损坏,报告补上警告,标签转为修复风险(保存一律另存);没画过的图片在原位保存(覆盖文件)之前统一解码检查(外壳的 `Engine::unsound`,有问题即转为另存为)。全部解码放在打开时的代价实测:100 张 2000×2000 的 JPEG(281 MB)打开到首块 2.6 秒(release,本机)。导出图片与文字期间 MuPDF 有警告即报错不写(读不全的内容不当作导出)。打印副本不带加密(已核过打印许可;否则系统打印面板打不开加了打开密码的副本),`print_later` 在面板关闭后删去它。导出对话框的降采样数值随键入生效(不等回车或失焦),直接点导出用的是所见的数。补丁 `0005` 见上(保存打不开输出时的保存中标志)。QA-13:worker 改字、加并填文本字段、移页后保存,以 PDFKit(另一阅读器)读回:页序、新字取代旧字、字段值都对(`tests/independent.rs`,只在 macOS);图片编辑在 PDFKit 里未单独核对(PDFKit 无图片清单接口)。组件库 a6b1bb0 的 `init` 改为返回错误(缺资源时),启动时出错即以 `startup::fail` 报出。真窗口(macOS):损坏文件的提示条与“修复的副本”状态、导出对话框。未验证:Windows 与 Linux 的打印(打印面板只在 macOS 实现);真窗口里实际打印。
|
- 2026-10-07:ElyPDF 输出与文件恢复(C11.8,PDF-13、14,QA-13)。“文件›导出…”(Cmd+E)一个对话框三种:页面图片(PNG,72、150、300 dpi,全部或所选页,总是浅色,选文件夹,`名称-序号.png` 按页数补零,先以 `ely_files::claim_all` 占下全部目标,任一失败即删去本次全部);文字(UTF-8,各页以换页符分开,MuPDF 的 stext);优化的 PDF(可看可改的设置:压缩数据流、字体与图片;图片高于某 dpi 降到某 dpi,双三次重采样到确切目标,重压缩:无损的仍无损、有损的保持原格式,默认 300 降到 150;降到的 dpi 须低于阈值,否则不可导出;完成提示写明原文件与新文件大小)。导出图片与文字要“复制”许可,优化副本要“修改”许可。“打印…”(Cmd+P)把文档此刻写成副本(要“打印”许可,worker 的 `PRINT_COPY`)再交系统打印面板(`ely_office::print_later`)。文件恢复:MuPDF 打开时修复了文件结构(`pdf_was_repaired`),worker 即把修复后的文档整份写成临时副本、改开这份副本(日志与快照从此正常;原先修复过的文件不能保存),关闭文档时删去;打开时 MuPDF 的警告记下作修复报告(`ely_open` 期间接管警告回调,照样转给原回调)。外壳的 `Engine::signed` 改为 `Engine::risk`(宏由外壳判,签名、修复由引擎答),加 `Risk::Repaired`:提示条说明打开的是修复的副本、“另存为”写新文件、原件不动,带“报告…”按钮显示 MuPDF 的原话;保存一律另存,另存到原件被拒;签名或修复的文件另存成功后标签记下“待重开”,到内存与文件一致(已保存、无待落地编辑、不在导出)时才按新文件重开;另存期间又有编辑则留着编辑,下一次保存后再重开。MuPDF 有时读到坏对象才修复(尾部完好、某对象偏移坏):打开与解锁后先把每个对象读一遍,并解码图片以外的每个数据流(`ely_scan`,读不出的对象记入报告);MuPDF 修复了结构或读的过程中有任何警告(如内容流解压失败),都当作损坏文件,改开整份写出的副本、报告列出警告、保存一律另存。图片(用户定)在画出时检查:画图块或缩略图时 MuPDF 有警告即为损坏,报告补上警告,标签转为修复风险(保存一律另存);没画过的图片在原位保存(覆盖文件)之前统一解码检查(外壳的 `Engine::unsound`,有问题即转为另存为)。全部解码放在打开时的代价实测:100 张 2000×2000 的 JPEG(281 MB)打开到首块 2.6 秒(release,本机)。导出图片与文字期间 MuPDF 有警告即报错不写(读不全的内容不当作导出)。打印副本不带加密(已核过打印许可;否则系统打印面板打不开加了打开密码的副本),`print_later` 在面板关闭后删去它。导出对话框的降采样数值随键入生效(不等回车或失焦),直接点导出用的是所见的数。补丁 `0005` 见上(保存打不开输出时的保存中标志)。QA-13:worker 改字、加并填文本字段、移页后保存,以 PDFKit(另一阅读器)读回:页序、新字取代旧字、字段值都对(`tests/independent.rs`,只在 macOS);图片编辑在 PDFKit 里未单独核对(PDFKit 无图片清单接口)。组件库 a6b1bb0 的 `init` 改为返回错误(缺资源时),启动时出错即以 `startup::fail` 报出。真窗口(macOS):损坏文件的提示条与“修复的副本”状态、导出对话框。未验证:Windows 与 Linux 的打印(打印面板只在 macOS 实现);真窗口里实际打印。
|
||||||
- 2026-10-06:ElyPDF 密码与签名(C11.7,PDF-11、12,QA-15,§11.4)。权限:worker 记下每个文档的访问(MuPDF 的认证结果,4 为所有者;未加密即全权),`PERMISSIONS` 按 Ely 的位报出(打印、复制、修改、批注、填表、页面与书签、所有者),每个改动请求先按表核对(`secure::needs`:页面增删移转与书签要“页面与书签”或“修改”,内容、链接、属性、附件、OCR 层、删除与清理要“修改”,批注要“批注”,建改字段要修改加批注,填表要填表或批注,提取要复制,改保护要所有者),不许即以 `restricted:名` 报错,客户端转成 `PermissionDenied`,界面按名说明并提示输入所有者密码;复制文字在应用里按位拦(读文字本身不拦)。“文档”菜单“输入所有者密码…”:对时解除限制;错时 MuPDF 已丢了原密钥(RC4 与 AES-128 实测文字读不出),worker 以原密码重新认证。“密码与权限…”只在所有者权限下:写新文件(AES-256,`ely_protect` 经 `ely_save_copy` 带写出选项),打开密码可空,所有者密码必填且须与打开密码不同,六项许可勾选;或不保护(去掉加密)。只凭用户密码打开的文件改保护即拒绝。签名:MuPDF 以 `HAVE_LIBCRYPTO` 构建,worker 另链 `libmupdf-pkcs7.a` 与 OpenSSL 的静态 `libcrypto.a`(pkg-config 给目录)。`SIGNATURES` 按表单字段树列出全部签名字段(不画在页面上的也列,页与框可无;控件无 /P 时从各页 /Annots 找页):已签的给出摘要核对(`pdf_check_widget_digest`:未改、已改、读不出)、其后有无更新与更新是否越出签名所许(`pdf_signature_incremental_change_since_signing`、`pdf_validate_signature`)、签名者(MuPDF 的识别名与通用名)、签名者自称的时间、原因、地点,以及签名里的证书(签名者在前,DER);单个签名读不出只报该项的原因。证书信任由应用交系统判定(`ely_platform::trust`:macOS 用 `SecTrust`、`SecPolicy::create_x509`、禁网,故从不核验吊销;Windows 与 Linux 未构建,报“此平台未构建”)。界面“文档›签名…”对话框逐个列出完整性、证书、吊销(一律“未核验吊销状态”)、时间(带时区)、原因、地点与所在页,不给笼统的“有效”。新签:“以证书签名…”工具,拖出框或点空的签名字段,对话框选 .p12/.pfx、填证书密码、原因、地点,另存:快照副本上建字段(名 `SignatureN` 避开表单已有名)并签,以增量更新写出(旧签名覆盖的字节不动),写完重开核对新签名的摘要,不过即报错删文件;原文档不动;发起时绑定视图版本与所选位置。补丁 `patches/mupdf/0004-signature-reason-and-location.patch`:原因与地点写进签名字典(MuPDF 原只画进外观;签后再改会落到第二个更新段,字节范围算成负数)。外观只画标签、名字与时间(不画空的识别名各栏,不画 MuPDF 标志)。已签文件(`ely_signed` 数表单里有值的签名字段)打开为 `Risk::Signed`:上方提示条说明修改会使签名失效、“另存为”写出未签名副本,带“签名…”按钮;保存不覆盖原文件。外壳的 `risk` 由布尔改为 `Option<Risk>`(宏或签名),`Engine::signed` 由引擎回答。Ely 一切整份写出(保存、另存、提取、删除、清理、保护)在 `ely_save_copy` 里去掉签名值与其外观(字段留下、空着,去掉 AppendOnly 标志):整份重写本就使签名失效,不留假签名。签名核对的是文件,不是编辑中的文档:`SIGNATURES` 带路径,worker 以文档的密码另开该文件读出再关闭;签名列表按标签当前的文件路径读、按路径缓存,保存后重读。已签文件另存为成功后,标签按新文件重新打开(外壳的 `reload`,同冲突时;撤销历史随之重新开始),打开着的文档、签名列表与以后的签名都对应这份未签名的副本;另存失败时标签与签名都仍是原文件。签名对话框每次打开都重读文件,磁盘上被改动即显示已改。去掉签名值时一并去掉该签名字段自身的只读位(签名时由它的锁设下),以便再签;它锁住的其他字段的只读位留着(分不出原本是否只读,已知限制)。补丁 `patches/mupdf/0005-failed-save-clears-save-in-progress.patch`:MuPDF 的 `prepare_for_save` 置起保存中标志、只有写出过程清除它;打不开输出文件时标志留着,此后一切改动绕过日志,编辑没有撤销步(实测:存到不存在的目录后旋转,历史标记仍为 0);补丁让打开输出或预排签名失败时清除它。可视签名(用户定三种来源):“添加可视签名…”对话框,手写(组件库 `SiLine truncated
|
- 2026-10-06:ElyPDF 密码与签名(C11.7,PDF-11、12,QA-15,§11.4)。权限:worker 记下每个文档的访问(MuPDF 的认证结果,4 为所有者;未加密即全权),`PERMISSIONS` 按 Ely 的位报出(打印、复制、修改、批注、填表、页面与书签、所有者),每个改动请求先按表核对(`secure::needs`:页面增删移转与书签要“页面与书签”或“修改”,内容、链接、属性、附件、OCR 层、删除与清理要“修改”,批注要“批注”,建改字段要修改加批注,填表要填表或批注,提取要复制,改保护要所有者),不许即以 `restricted:名` 报错,客户端转成 `PermissionDenied`,界面按名说明并提示输入所有者密码;复制文字在应用里按位拦(读文字本身不拦)。“文档”菜单“输入所有者密码…”:对时解除限制;错时 MuPDF 已丢了原密钥(RC4 与 AES-128 实测文字读不出),worker 以原密码重新认证。“密码与权限…”只在所有者权限下:写新文件(AES-256,`ely_protect` 经 `ely_save_copy` 带写出选项),打开密码可空,所有者密码必填且须与打开密码不同,六项许可勾选;或不保护(去掉加密)。只凭用户密码打开的文件改保护即拒绝。签名:MuPDF 以 `HAVE_LIBCRYPTO` 构建,worker 另链 `libmupdf-pkcs7.a` 与 OpenSSL 的静态 `libcrypto.a`(pkg-config 给目录)。`SIGNATURES` 按表单字段树列出全部签名字段(不画在页面上的也列,页与框可无;控件无 /P 时从各页 /Annots 找页):已签的给出摘要核对(`pdf_check_widget_digest`:未改、已改、读不出)、其后有无更新与更新是否越出签名所许(`pdf_signature_incremental_change_since_signing`、`pdf_validate_signature`)、签名者(MuPDF 的识别名与通用名)、签名者自称的时间、原因、地点,以及签名里的证书(签名者在前,DER);单个签名读不出只报该项的原因。证书信任由应用交系统判定(`ely_platform::trust`:macOS 用 `SecTrust`、`SecPolicy::create_x509`、禁网,故从不核验吊销;Windows 与 Linux 未构建,报“此平台未构建”)。界面“文档›签名…”对话框逐个列出完整性、证书、吊销(一律“未核验吊销状态”)、时间(带时区)、原因、地点与所在页,不给笼统的“有效”。新签:“以证书签名…”工具,拖出框或点空的签名字段,对话框选 .p12/.pfx、填证书密码、原因、地点,另存:快照副本上建字段(名 `SignatureN` 避开表单已有名)并签,以增量更新写出(旧签名覆盖的字节不动),写完重开核对新签名的摘要,不过即报错删文件;原文档不动;发起时绑定视图版本与所选位置。补丁 `patches/mupdf/0004-signature-reason-and-location.patch`:原因与地点写进签名字典(MuPDF 原只画进外观;签后再改会落到第二个更新段,字节范围算成负数)。外观只画标签、名字与时间(不画空的识别名各栏,不画 MuPDF 标志)。已签文件(`ely_signed` 数表单里有值的签名字段)打开为 `Risk::Signed`:上方提示条说明修改会使签名失效、“另存为”写出未签名副本,带“签名…”按钮;保存不覆盖原文件。外壳的 `risk` 由布尔改为 `Option<Risk>`(宏或签名),`Engine::signed` 由引擎回答。Ely 一切整份写出(保存、另存、提取、删除、清理、保护)在 `ely_save_copy` 里去掉签名值与其外观(字段留下、空着,去掉 AppendOnly 标志):整份重写本就使签名失效,不留假签名。签名核对的是文件,不是编辑中的文档:`SIGNATURES` 带路径,worker 以文档的密码另开该文件读出再关闭;签名列表按标签当前的文件路径读、按路径缓存,保存后重读。已签文件另存为成功后,标签按新文件重新打开(外壳的 `reload`,同冲突时;撤销历史随之重新开始),打开着的文档、签名列表与以后的签名都对应这份未签名的副本;另存失败时标签与签名都仍是原文件。签名对话框每次打开都重读文件,磁盘上被改动即显示已改。去掉签名值时一并去掉该签名字段自身的只读位(签名时由它的锁设下),以便再签;它锁住的其他字段的只读位留着(分不出原本是否只读,已知限制)。补丁 `patches/mupdf/0005-failed-save-clears-save-in-progress.patch`:MuPDF 的 `prepare_for_save` 置起保存中标志、只有写出过程清除它;打不开输出文件时标志留着,此后一切改动绕过日志,编辑没有撤销步(实测:存到不存在的目录后旋转,历史标记仍为 0);补丁让打开输出或预排签名失败时清除它。可视签名(用户定三种来源):“添加可视签名…”对话框,手写(组件库 `SiLine truncated
|
||||||
- 2026-10-06:ElyPDF 内容删除与文档清理(C11.6,PDF-09、10,§11.4,QA-14)。标记只在视图里(“标记文字以删除”按所选文字每行一框,“标记区域以删除”拖框,单击已有标记取消;预览为页面上的深色框;任何编辑落地即清空,页码可能已变),文档本身不动。“删除标记的内容…”与“清理文档…”都写新文件:先把文档快照写出、重开副本(`ely_save_copy`,提取与保存同用),在副本上改,再以 `garbage=2` 整份写出(不增量,旧修订不留),写到工作目录、核对通过后经 `ely_files::export` 交到所选位置,工作副本普通删除(对话框写明:原件、旧备份、同步历史与已发出的副本仍可能含有被删内容;不是磁盘级擦除)。删除:先删去碰到区域的批注与字段(MuPDF 的删除只管链接与文本框批注,其他批注的内容、字段的值会留下),字段失去最后一个控件即连同空了的上级一起从表单去掉(否则值留在字段树里);全部区域的删除做完后才建 Redact 批注(否则后一区域会删掉前一区域的标记)并 `pdf_redact_page`(黑框;图片在区域内的像素由 MuPDF 改成白色;碰到区域的线条整条删去,只删被盖满的会留下可复原的路径;碰到的文字删去,含隐藏文字)。有区域的页再过一遍内容(含表单):标记内容的 /ActualText、/Alt、/E 一律去掉,结构树里属于该页的元素(自身或继承的 `Pg`,或子项 MCR、OBJR 的 `Pg` 指向该页,连同其上级)去掉这三项与 /T;整页去掉而不只区域内的,宁多勿漏。页面缩略图 /Thumb 删去(它可引用原图),核对时区域所在页仍有缩略图即失败。写出后重开核对:区域内的字(与删除同一判法:字框各边缩一成后相交)为零;区域内画出来不是黑色的像素为零;逐页以设备重放,落进区域的路径、渐变、图像蒙版除我们的黑框外为零,图片单独画出在区域内只剩白或黑;文件只有一个版本;任一不过即报错并删去输出。确认时绑定标记与文档版本,选完路径后标记或版本变了即不写。清理:先扫出数量(属性与元数据条数、批注、附件含批注里的文件、有隐藏文字的页、脚本入口),勾选后写出:删 Info;其余按一遍遍历全部对象及其中的直接字典与数组:/Metadata 与 /PieceInfo,文件说明的 /EF 与 /RF 及关联文件 /AF,脚本;删批注(链接与字段不算批注,保留),删附件(名称树与文件批注;附件按带 /EF 的文件说明计数,每个一次),隐藏内容为:内容过滤(包住 MuPDF 的 sanitize 过滤器,用途设为 View 才认关着的图层)删去关着的图层里的一切、3 与 7 渲染模式及零透明度的文字,脚本(名称树 JavaScript;任何对象的 /A、/OpenAction、/Next 与 /AA 各项里 S 为 JavaScript 或带 /JS 的动作,从链中摘除,其后的 /Next 链接回原处,非脚本动作保留),带隐藏或不可见标志的批注(含链接与字段控件:链接不在 MuPDF 的批注表里,按页面 /Annots 删;控件删去时字段连同值从表单去掉);有隐藏文字被删的页,标记内容的替代文字照删除的做法去掉。扫描与写出后的核对都在快照副本上跑同一遍清理来计数,所以计数与执行一致;所选类别核对不为零即报错。扫描回来时须仍是当前标签且版本未变,对话框写明文件名。未验证:Windows 与 Linux。
|
- 2026-10-06:ElyPDF 内容删除与文档清理(C11.6,PDF-09、10,§11.4,QA-14)。标记只在视图里(“标记文字以删除”按所选文字每行一框,“标记区域以删除”拖框,单击已有标记取消;预览为页面上的深色框;任何编辑落地即清空,页码可能已变),文档本身不动。“删除标记的内容…”与“清理文档…”都写新文件:先把文档快照写出、重开副本(`ely_save_copy`,提取与保存同用),在副本上改,再以 `garbage=2` 整份写出(不增量,旧修订不留),写到工作目录、核对通过后经 `ely_files::export` 交到所选位置,工作副本普通删除(对话框写明:原件、旧备份、同步历史与已发出的副本仍可能含有被删内容;不是磁盘级擦除)。删除:先删去碰到区域的批注与字段(MuPDF 的删除只管链接与文本框批注,其他批注的内容、字段的值会留下),字段失去最后一个控件即连同空了的上级一起从表单去掉(否则值留在字段树里);全部区域的删除做完后才建 Redact 批注(否则后一区域会删掉前一区域的标记)并 `pdf_redact_page`(黑框;图片在区域内的像素由 MuPDF 改成白色;碰到区域的线条整条删去,只删被盖满的会留下可复原的路径;碰到的文字删去,含隐藏文字)。有区域的页再过一遍内容(含表单):标记内容的 /ActualText、/Alt、/E 一律去掉,结构树里属于该页的元素(自身或继承的 `Pg`,或子项 MCR、OBJR 的 `Pg` 指向该页,连同其上级)去掉这三项与 /T;整页去掉而不只区域内的,宁多勿漏。页面缩略图 /Thumb 删去(它可引用原图),核对时区域所在页仍有缩略图即失败。写出后重开核对:区域内的字(与删除同一判法:字框各边缩一成后相交)为零;区域内画出来不是黑色的像素为零;逐页以设备重放,落进区域的路径、渐变、图像蒙版除我们的黑框外为零,图片单独画出在区域内只剩白或黑;文件只有一个版本;任一不过即报错并删去输出。确认时绑定标记与文档版本,选完路径后标记或版本变了即不写。清理:先扫出数量(属性与元数据条数、批注、附件含批注里的文件、有隐藏文字的页、脚本入口),勾选后写出:删 Info;其余按一遍遍历全部对象及其中的直接字典与数组:/Metadata 与 /PieceInfo,文件说明的 /EF 与 /RF 及关联文件 /AF,脚本;删批注(链接与字段不算批注,保留),删附件(名称树与文件批注;附件按带 /EF 的文件说明计数,每个一次),隐藏内容为:内容过滤(包住 MuPDF 的 sanitize 过滤器,用途设为 View 才认关着的图层)删去关着的图层里的一切、3 与 7 渲染模式及零透明度的文字,脚本(名称树 JavaScript;任何对象的 /A、/OpenAction、/Next 与 /AA 各项里 S 为 JavaScript 或带 /JS 的动作,从链中摘除,其后的 /Next 链接回原处,非脚本动作保留),带隐藏或不可见标志的批注(含链接与字段控件:链接不在 MuPDF 的批注表里,按页面 /Annots 删;控件删去时字段连同值从表单去掉);有隐藏文字被删的页,标记内容的替代文字照删除的做法去掉。扫描与写出后的核对都在快照副本上跑同一遍清理来计数,所以计数与执行一致;所选类别核对不为零即报错。扫描回来时须仍是当前标签且版本未变,对话框写明文件名。未验证:Windows 与 Linux。
|
||||||
@@ -277,4 +281,14 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
|||||||
- 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-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-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
|
||||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
|
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
|
||||||
|
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
|
||||||
|
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
|
||||||
|
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里按需从零构建引擎,Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage,都由 `packaging/stage.sh` 分装引擎;更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
|
||||||
|
- 2026-10-11:gpui 的 Windows 窗口关闭(W-03 冒烟查出)。`gpui-pre-windows` 0.3.8 在窗口关闭时报三条错:系统已销毁窗口(ElyTree 的关窗交给系统)后 Drop 仍对旧句柄 `RevokeDragDrop` 与 `DestroyWindow`(拖放处理器因此不释放,连带窗口状态);销毁时的失活回报排在异步任务里,执行时窗口已移除(“window not found”);退出后后台任务向已销毁的平台窗口投递(`Invalid window handle`)。修复在 `vendor/gpui-pre-windows`:`WM_DESTROY` 时记下已销毁、在句柄仍有效时撤销拖放、去掉失活回调,Drop 见已销毁即不再动句柄;平台窗口已不在时投递失败只记 debug(应用已退出),其余投递失败照报。三个应用关窗与退出的日志无错。
|
||||||
|
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(`sheet.rs` 的 `a_theme_switch_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
|
||||||
|
- 2026-10-11(用户报:Windows 上标题栏的最小化、最大化、关闭三个按钮都按不动):gpui 回答系统的命中测试(`WM_NCHITTEST`)时按绘制次序取第一个含指针的窗口控制区,标题栏的拖动区先于其中的按钮画出,按钮上一律答“标题栏”,系统把按下当作拖动窗口的开始,点击到不了按钮。修复在 `vendor/gpui-pre`(原样一个提交,其后是修复):按命中测试的次序取最上层的控制区,且只看未被遮挡的部分(同 `is_hovered`,盖在标题栏上的对话框挡住其下的按钮)。实测(发行构建,DPI 感知的探针移动真实指针):自右向左依次答关闭、最大化、最小化、拖动;按下最小化即最小化,最大化即最大化,关闭即退出、worker 随之结束;v0.1.3 的安装版同一探针处处答拖动。该回调只有 Windows 平台调用。
|
||||||
|
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
|
||||||
|
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
|
||||||
|
- 2026-10-11(用户定:ElyTree 状态栏去掉 Git 的版本与路径那一项):所用 Git 的版本与路径只记日志。同时查出 Windows 上 `std::fs::canonicalize` 给出 `\\?\C:\…` 形式,仓库根、Git 目录、工作树与文件的真实路径一律改用 `dunce::canonicalize`(能写成普通路径即普通路径,同一文件仍是同一真实路径),状态栏的仓库路径随之为普通形式;ElyTree 的单测在 Windows 上由 44 个失败降到 8 个,余下 8 个改前同样失败(如写入 Windows 不许的文件名),未处理。
|
||||||
|
- 2026-10-11:ElyMark 的 AppImage(0.1.3 的 Linux 发布查出)。`linuxdeploy` 把可执行文件的 RUNPATH 改成 `$ORIGIN/../lib`,并把它找得到的 `libcef.so` 拷进 `usr/lib`:原先 `libcef.so` 与 CEF 的资料在 `usr/bin`,结果是两份 1.45 GB 的内核、程序载入 `usr/lib` 那份、CEF 在它旁边找不到 ICU 数据而退出(从未在 AppImage 里启动过)。现在 `appimage.sh` 把从 `engines/cef` 分装来的全部条目移进 `usr/lib`(按名字对照 CEF 文件夹,清单仍只在 `stage.sh`),以 `LD_LIBRARY_PATH=AppDir/usr/lib` 运行 `linuxdeploy`,`libcef.so` 只此一份、依赖照样带上,CEF 按所载 `libcef.so` 的文件夹取资料(`ely-web-content` 的 `open`)。ElyMark 的 AppImage 由 715 MB 降到 423 MB;WSL 里启动后 CEF 载入、窗口打开(WSL 里各应用都不出首帧,未在真桌面上验证)。
|
||||||
- 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 地址取。
|
- 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 地址取。
|
||||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
build = "../../packaging/windows/icon.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ely-platform.workspace = true
|
ely-platform.workspace = true
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Release builds open no console on Windows.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use ely_platform::AppId;
|
use ely_platform::AppId;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
build = "../../packaging/windows/icon.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
csv.workspace = true
|
csv.workspace = true
|
||||||
@@ -22,6 +26,7 @@ ely-ui.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
ely-ui = { workspace = true, features = ["test-support"] }
|
ely-ui = { workspace = true, features = ["test-support"] }
|
||||||
|
ely-gpui-component = { workspace = true, features = ["test-support"] }
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ impl Docs {
|
|||||||
let shown = self
|
let shown = self
|
||||||
.active_tab()
|
.active_tab()
|
||||||
.map(|tab| (tab.view.clone(), tab.doc.clone()));
|
.map(|tab| (tab.view.clone(), tab.doc.clone()));
|
||||||
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
|
// Hidden, it follows nothing: no reads on every move.
|
||||||
|
let tab = shown.as_ref().filter(|_| self.navigating).map(|(view, doc)| (view, doc));
|
||||||
self.navigator
|
self.navigator
|
||||||
.update(cx, |navigator, cx| navigator.show(tab, cx));
|
.update(cx, |navigator, cx| navigator.show(tab, cx));
|
||||||
shown.filter(|_| self.navigating)?;
|
shown.filter(|_| self.navigating)?;
|
||||||
|
|||||||
@@ -46,8 +46,14 @@ pub struct Docs {
|
|||||||
pub back: Vec<(u64, ely_lok::Rect)>,
|
pub back: Vec<(u64, ely_lok::Rect)>,
|
||||||
/// Last jump: tab, input, prior cursor moves, target.
|
/// Last jump: tab, input, prior cursor moves, target.
|
||||||
pub going: Option<(u64, u64, u64, ely_lok::Rect)>,
|
pub going: Option<(u64, u64, u64, ely_lok::Rect)>,
|
||||||
|
/// Dialog reads asked; a dialog or tab change voids older.
|
||||||
|
pub dialog_asked: u64,
|
||||||
|
dialog_seen: Seen,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open dialog kinds, Ely's and the shell's, and the tab.
|
||||||
|
type Seen = (Option<std::mem::Discriminant<Dialog>>, Option<std::mem::Discriminant<ely_office::Dialog>>, Option<u64>);
|
||||||
|
|
||||||
/// The right column's panels.
|
/// The right column's panels.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum Side {
|
pub enum Side {
|
||||||
@@ -171,6 +177,7 @@ impl Docs {
|
|||||||
failed(docs, error, cx)
|
failed(docs, error, cx)
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
cx.observe_self(|docs, _| docs.watch_dialogs()).detach();
|
||||||
Docs {
|
Docs {
|
||||||
core: Core::new(worker, work, cx),
|
core: Core::new(worker, work, cx),
|
||||||
dialog: None,
|
dialog: None,
|
||||||
@@ -183,6 +190,18 @@ impl Docs {
|
|||||||
navigating: true,
|
navigating: true,
|
||||||
back: Vec::new(),
|
back: Vec::new(),
|
||||||
going: None,
|
going: None,
|
||||||
|
dialog_asked: 0,
|
||||||
|
dialog_seen: (None, None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dialog or tab change ends earlier dialog reads.
|
||||||
|
fn watch_dialogs(&mut self) {
|
||||||
|
let ely = self.dialog.as_ref().map(std::mem::discriminant);
|
||||||
|
let now = (ely, self.core.dialog.as_ref().map(std::mem::discriminant), self.core.active);
|
||||||
|
if self.dialog_seen != now {
|
||||||
|
self.dialog_seen = now;
|
||||||
|
self.dialog_asked += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-23
@@ -128,29 +128,28 @@ impl Docs {
|
|||||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||||
return self.toast(text, Severity::Warning, cx);
|
return self.toast(text, Severity::Warning, cx);
|
||||||
};
|
};
|
||||||
let listed = self
|
let id = tab.id;
|
||||||
.core
|
// Page style names are read off the window thread.
|
||||||
.worker
|
self.ask_in(
|
||||||
.lock()
|
id,
|
||||||
.expect("the worker lock")
|
|worker, doc| ely_lok::commands::styles(&worker.values(doc, ".uno:StyleApply")?, "8"),
|
||||||
.values(&tab.doc, ".uno:StyleApply")
|
move |docs, listed, _, cx| {
|
||||||
.and_then(|json| ely_lok::commands::styles(&json, "8"));
|
let names = match listed {
|
||||||
let names = match listed {
|
Ok(names) => names,
|
||||||
Ok(names) => names,
|
Err(error) => return docs.engine_failed(&error.to_string(), cx),
|
||||||
Err(error) => {
|
};
|
||||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
let name = (2..)
|
||||||
return self.toast(text, Severity::Danger, cx);
|
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
|
||||||
}
|
.find(|name| !names.contains(name))
|
||||||
};
|
.expect("a free section name");
|
||||||
let name = (2..)
|
log::info!("doc: a new section");
|
||||||
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
|
docs.run_in(Some(id), "style.new", &[&name, "8"], cx);
|
||||||
.find(|name| !names.contains(name))
|
docs.run_in(Some(id), "page.section", &["3", &name], cx);
|
||||||
.expect("a free section name");
|
docs.props.update(cx, |props, _| props.refresh_lists());
|
||||||
log::info!("doc: a new section");
|
cx.notify();
|
||||||
self.run_active("style.new", &[&name, "8"], cx);
|
},
|
||||||
self.run_active("page.section", &["3", &name], cx);
|
cx,
|
||||||
self.props.update(cx, |props, _| props.refresh_lists());
|
);
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context<Self>) {
|
pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Release builds open no console on Windows.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod chrome;
|
mod chrome;
|
||||||
mod compare;
|
mod compare;
|
||||||
|
|||||||
@@ -247,9 +247,11 @@ impl Docs {
|
|||||||
let mut worker = worker.lock().expect("the worker lock");
|
let mut worker = worker.lock().expect("the worker lock");
|
||||||
let staged = template.with_file_name(format!("record.{extension}"));
|
let staged = template.with_file_name(format!("record.{extension}"));
|
||||||
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
|
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
|
||||||
fill(&mut worker, &doc, &table.headers, row)?;
|
let saved = fill(&mut worker, &doc, &table.headers, row)
|
||||||
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?;
|
.and_then(|()| doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension));
|
||||||
worker.close_doc(doc)?;
|
// Closed even when this record failed.
|
||||||
|
let closed = worker.close_doc(doc);
|
||||||
|
saved.and(closed)?;
|
||||||
publish(&staged, target)
|
publish(&staged, target)
|
||||||
});
|
});
|
||||||
made.map_err(|error| (n + 1, error))?;
|
made.map_err(|error| (n + 1, error))?;
|
||||||
|
|||||||
@@ -272,15 +272,13 @@ impl Render for Navigator {
|
|||||||
if let Some(at) = self.outline.iter().position(|heading| heading.current) {
|
if let Some(at) = self.outline.iter().position(|heading| heading.current) {
|
||||||
outline = outline.current(at.to_string());
|
outline = outline.current(at.to_string());
|
||||||
}
|
}
|
||||||
let this = this.clone();
|
// The places shown: a reread may change the list.
|
||||||
|
let places: Vec<_> = self.outline.iter().map(|heading| heading.rect).collect();
|
||||||
|
let docs = self.docs.clone();
|
||||||
outline
|
outline
|
||||||
.on_select(move |key, _, cx| {
|
.on_select(move |key, _, cx| {
|
||||||
let at: usize = key.parse().expect("an outline index");
|
let at: usize = key.parse().expect("an outline index");
|
||||||
let (to, docs) = {
|
Navigator::jump(&docs, places[at], cx);
|
||||||
let navigator = this.read(cx);
|
|
||||||
(navigator.outline[at].rect, navigator.docs.clone())
|
|
||||||
};
|
|
||||||
Navigator::jump(&docs, to, cx);
|
|
||||||
})
|
})
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ fn the_current_heading_follows_the_cursor_across_columns() {
|
|||||||
settle(&cx, |app| current(app) == Some(0));
|
settle(&cx, |app| current(app) == Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pdfs_export_a_range_and_locked_files_ask() {
|
fn pdfs_export_a_range_and_locked_files_ask() {
|
||||||
let dir = tempfile::tempdir().expect("a temp dir");
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
@@ -205,11 +204,17 @@ fn pdfs_export_a_range_and_locked_files_ask() {
|
|||||||
});
|
});
|
||||||
written(&cx, &pdf, None);
|
written(&cx, &pdf, None);
|
||||||
let source = dir.path().join("long.fodt");
|
let source = dir.path().join("long.fodt");
|
||||||
let alias = dir.path().join("alias.pdf");
|
|
||||||
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
|
|
||||||
let link = dir.path().join("link.pdf");
|
let link = dir.path().join("link.pdf");
|
||||||
std::fs::hard_link(&source, &link).expect("the hard link");
|
std::fs::hard_link(&source, &link).expect("the hard link");
|
||||||
for target in [&source, &alias, &link] {
|
#[cfg(unix)]
|
||||||
|
let aliases = {
|
||||||
|
let alias = dir.path().join("alias.pdf");
|
||||||
|
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
|
||||||
|
vec![alias]
|
||||||
|
};
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let aliases: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
for target in [source.clone(), link].iter().chain(&aliases) {
|
||||||
cx.update(|app| {
|
cx.update(|app| {
|
||||||
docs.update(app, |docs, cx| {
|
docs.update(app, |docs, cx| {
|
||||||
docs.export_to(id, target.clone(), ely_lok::Pdf::default(), cx)
|
docs.export_to(id, target.clone(), ely_lok::Pdf::default(), cx)
|
||||||
@@ -252,3 +257,78 @@ fn pdfs_export_a_range_and_locked_files_ask() {
|
|||||||
"the locked text opens"
|
"the locked text opens"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hidden_navigator_reads_nothing() {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
crate::tests::blank(&mut cx, &docs);
|
||||||
|
let follows = |cx: &mut TestAppContext| cx.update(|app| docs.read(app).navigator.read(app).shown.is_some());
|
||||||
|
cx.update(|app| docs.update(app, |docs, cx| docs.left_view(cx)));
|
||||||
|
assert!(follows(&mut cx), "shown: it follows the tab");
|
||||||
|
cx.update(|app| docs.update(app, |docs, cx| {
|
||||||
|
docs.navigating = false;
|
||||||
|
docs.left_view(cx)
|
||||||
|
}));
|
||||||
|
assert!(!follows(&mut cx), "hidden: no tab to reread");
|
||||||
|
cx.update(|app| docs.update(app, |docs, cx| {
|
||||||
|
docs.navigating = true;
|
||||||
|
docs.left_view(cx)
|
||||||
|
}));
|
||||||
|
assert!(follows(&mut cx), "shown again: it reads once more");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The navigator alone as a window's view.
|
||||||
|
struct Host(Entity<Navigator>);
|
||||||
|
|
||||||
|
impl gpui::Render for Host {
|
||||||
|
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||||
|
gpui::div().size_full().child(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_outline_click_goes_where_its_row_was_drawn() {
|
||||||
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||||
|
eprintln!("SKIP: no LOK build");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
cx.update(ely_gpui_component::init_for_tests);
|
||||||
|
crate::tests::blank(&mut cx, &docs);
|
||||||
|
let navigator = cx.update(|app| docs.read(app).navigator.clone());
|
||||||
|
let heading = |n: i32| ely_lok::review::Heading { text: format!("Part {n}"), level: 0, rect: (0, n * 100, 10, 10), current: false };
|
||||||
|
cx.update(|app| navigator.update(app, |navigator, cx| {
|
||||||
|
navigator.outline = vec![heading(1), heading(2), heading(3)];
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
let (_, vcx) = cx.add_window_view(|_, _| Host(navigator.clone()));
|
||||||
|
let target = |vcx: &mut gpui::VisualTestContext| vcx.update(|_, app| docs.read(app).going.map(|going| going.3));
|
||||||
|
// Find where the drawn third row sits.
|
||||||
|
let third = (0..120)
|
||||||
|
.map(|step| gpui::point(gpui::px(40.), gpui::px(step as f32 * 4.)))
|
||||||
|
.find(|at| {
|
||||||
|
vcx.simulate_click(*at, gpui::Modifiers::none());
|
||||||
|
target(vcx) == Some((0, 300, 10, 10))
|
||||||
|
})
|
||||||
|
.expect("the third row is drawn");
|
||||||
|
vcx.update(|_, app| docs.update(app, |docs, _| docs.going = None));
|
||||||
|
// The list shrinks; the click lands on the old frame.
|
||||||
|
vcx.update(|window, app| {
|
||||||
|
navigator.update(app, |navigator, cx| {
|
||||||
|
navigator.outline.truncate(1);
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
let (position, modifiers, button) = (third, gpui::Modifiers::none(), gpui::MouseButton::Left);
|
||||||
|
let down = gpui::MouseDownEvent { position, modifiers, button, click_count: 1, first_mouse: false };
|
||||||
|
window.dispatch_event(gpui::PlatformInput::MouseDown(down), app);
|
||||||
|
let up = gpui::MouseUpEvent { position, modifiers, button, click_count: 1 };
|
||||||
|
window.dispatch_event(gpui::PlatformInput::MouseUp(up), app);
|
||||||
|
});
|
||||||
|
assert_eq!(target(vcx), Some((0, 300, 10, 10)), "the click goes where its row was");
|
||||||
|
}
|
||||||
@@ -81,10 +81,8 @@ impl Docs {
|
|||||||
let path = source.work.join("print.pdf");
|
let path = source.work.join("print.pdf");
|
||||||
let printing = path.clone();
|
let printing = path.clone();
|
||||||
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
|
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
|
||||||
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
|
Ok(()) => ely_office::print_later(printing, cx),
|
||||||
docs.output_failed(&error, cx)
|
Err(error) => docs.print_failed(&error.to_string(), cx),
|
||||||
}),
|
|
||||||
Err(error) => docs.output_failed(&error.to_string(), cx),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let Some(dir) = ely_office::folder(source) else {
|
let Some(dir) = ely_office::folder(source) else {
|
||||||
|
|||||||
+38
-26
@@ -113,43 +113,55 @@ impl Props {
|
|||||||
self.read_styles(cx);
|
self.read_styles(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The installed fonts, read off the window thread.
|
||||||
fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) {
|
fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) {
|
||||||
let read = self
|
ely_lok::read_off(
|
||||||
.worker
|
&self.worker,
|
||||||
.lock()
|
doc,
|
||||||
.expect("the worker lock")
|
|worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?),
|
||||||
.values(doc, ".uno:CharFontName")
|
|props: &mut Props, read, cx| match read {
|
||||||
.and_then(|json| ely_lok::commands::fonts(&json));
|
Ok(fonts) => {
|
||||||
match read {
|
props.fonts = fonts.into_iter().map(Into::into).collect();
|
||||||
Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(),
|
cx.notify();
|
||||||
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
|
}
|
||||||
}
|
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shown document's style lists, read off the window thread.
|
||||||
fn read_styles(&mut self, cx: &mut Context<Self>) {
|
fn read_styles(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(shown) = self.shown.as_mut() else {
|
let Some(shown) = self.shown.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let read = self
|
let view = shown.view.entity_id();
|
||||||
.worker
|
ely_lok::read_off(
|
||||||
.lock()
|
&self.worker,
|
||||||
.expect("the worker lock")
|
&shown.doc,
|
||||||
.values(&shown.doc, ".uno:StyleApply")
|
|worker, doc| {
|
||||||
.and_then(|json| {
|
let json = worker.values(doc, ".uno:StyleApply")?;
|
||||||
let list = |family| -> std::io::Result<Vec<SharedString>> {
|
let list = |family| -> std::io::Result<Vec<SharedString>> {
|
||||||
let names = ely_lok::commands::styles(&json, family)?;
|
let names = ely_lok::commands::styles(&json, family)?;
|
||||||
Ok(names.into_iter().map(Into::into).collect())
|
Ok(names.into_iter().map(Into::into).collect())
|
||||||
};
|
};
|
||||||
Ok((list("2")?, list("1")?, list("8")?))
|
Ok((list("2")?, list("1")?, list("8")?))
|
||||||
});
|
},
|
||||||
match read {
|
move |props: &mut Props, read, cx| {
|
||||||
Ok((paragraph, character, pages)) => {
|
// Another tab shown since: its own read follows.
|
||||||
shown.paragraph = paragraph;
|
let Some(shown) = props.shown.as_mut().filter(|shown| shown.view.entity_id() == view) else {
|
||||||
shown.character = character;
|
return;
|
||||||
shown.pages = pages;
|
};
|
||||||
}
|
match read {
|
||||||
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
|
Ok((paragraph, character, pages)) => {
|
||||||
}
|
(shown.paragraph, shown.character, shown.pages) = (paragraph, character, pages);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-reads the style lists once queued input lands.
|
/// Re-reads the style lists once queued input lands.
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ use ely_office::prelude::*;
|
|||||||
|
|
||||||
/// The panel, shown on the active tab.
|
/// The panel, shown on the active tab.
|
||||||
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> {
|
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> {
|
||||||
cx.update(|app| {
|
let props = cx.update(|app| {
|
||||||
let tab = docs.read(app).active_tab().expect("a tab");
|
let tab = docs.read(app).active_tab().expect("a tab");
|
||||||
let (view, doc) = (tab.view.clone(), tab.doc.clone());
|
let (view, doc) = (tab.view.clone(), tab.doc.clone());
|
||||||
let props = docs.read(app).props.clone();
|
let props = docs.read(app).props.clone();
|
||||||
props.update(app, |props, cx| props.show(Some((&view, &doc)), cx));
|
props.update(app, |props, cx| props.show(Some((&view, &doc)), cx));
|
||||||
props
|
props
|
||||||
})
|
});
|
||||||
|
// The lists come from a background read.
|
||||||
|
settle(cx, |app| {
|
||||||
|
let props = props.read(app);
|
||||||
|
!props.fonts.is_empty() && props.shown.as_ref().is_some_and(|shown| !shown.paragraph.is_empty())
|
||||||
|
});
|
||||||
|
props
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(cx: &mut TestAppContext, props: &Entity<Props>, id: &str, values: &[&str]) {
|
fn run(cx: &mut TestAppContext, props: &Entity<Props>, id: &str, values: &[&str]) {
|
||||||
|
|||||||
+63
-53
@@ -71,35 +71,51 @@ pub fn items(cx: &App) -> Vec<MenuItem> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Docs {
|
impl Docs {
|
||||||
/// A kernel list for the active document; failures toast.
|
/// Reads a kernel list off the window thread, then `then`.
|
||||||
fn read(&self, command: &str, cx: &mut Context<Self>) -> Option<String> {
|
fn read_then(
|
||||||
|
&mut self,
|
||||||
|
command: &'static str,
|
||||||
|
then: impl FnOnce(&mut Self, String, &mut Window, &mut Context<Self>) + 'static,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let Some(tab) = self.active_tab() else {
|
let Some(tab) = self.active_tab() else {
|
||||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||||
self.toast(text, Severity::Warning, cx);
|
return self.toast(text, Severity::Warning, cx);
|
||||||
return None;
|
|
||||||
};
|
};
|
||||||
let read = self
|
let id = tab.id;
|
||||||
.core
|
self.dialog_asked += 1;
|
||||||
.worker
|
let asked = self.dialog_asked;
|
||||||
.lock()
|
self.ask_in(
|
||||||
.expect("the worker lock")
|
id,
|
||||||
.values(&tab.doc, command);
|
move |worker, doc| worker.values(doc, command),
|
||||||
read.map_err(|error| {
|
move |docs, read, window, cx| {
|
||||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
// A dialog opened since, or a later ask, wins.
|
||||||
self.toast(text, Severity::Danger, cx);
|
if docs.dialog_asked != asked || docs.dialog.is_some() || docs.core.dialog.is_some() {
|
||||||
})
|
return log::info!("doc: a dialog read for tab {id} lapsed");
|
||||||
.ok()
|
}
|
||||||
|
match read {
|
||||||
|
Ok(json) => then(docs, json, window, cx),
|
||||||
|
Err(error) => docs.engine_failed(&error.to_string(), cx),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bookmarks(&self, cx: &mut Context<Self>) -> Option<Vec<String>> {
|
/// The bookmarks' names, then `then`.
|
||||||
let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?);
|
fn with_bookmarks(
|
||||||
listed
|
&mut self,
|
||||||
.map(|marks| marks.into_iter().map(|mark| mark.name).collect())
|
then: impl FnOnce(&mut Self, Vec<String>, &mut Window, &mut Context<Self>) + 'static,
|
||||||
.map_err(|error| {
|
cx: &mut Context<Self>,
|
||||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
) {
|
||||||
self.toast(text, Severity::Danger, cx);
|
self.read_then(
|
||||||
})
|
".uno:Bookmarks",
|
||||||
.ok()
|
move |docs, json, window, cx| match ely_lok::review::bookmarks(&json) {
|
||||||
|
Ok(marks) => then(docs, marks.into_iter().map(|mark| mark.name).collect(), window, cx),
|
||||||
|
Err(error) => docs.engine_failed(&error.to_string(), cx),
|
||||||
|
},
|
||||||
|
cx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -126,10 +142,8 @@ impl Docs {
|
|||||||
self.run_active("note.endnote", &[], cx);
|
self.run_active("note.endnote", &[], cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn on_bookmark(&mut self, _: &InsertBookmark, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let Some(taken) = self.bookmarks(cx) {
|
self.with_bookmarks(|docs, taken, window, cx| docs.ask(Ask::Bookmark(taken), "", window, cx), cx);
|
||||||
self.ask(Ask::Bookmark(taken), "", window, cx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -141,19 +155,17 @@ impl Docs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
|
pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(marks) = self.bookmarks(cx) else {
|
self.with_bookmarks(
|
||||||
return;
|
|docs, marks, _, cx| {
|
||||||
};
|
let Some(mark) = marks.first().cloned() else {
|
||||||
let Some(mark) = marks.first().cloned() else {
|
let text = Docs::text(cx, "doc.reference.none", &[]);
|
||||||
let text = Docs::text(cx, "doc.reference.none", &[]);
|
return docs.toast(text, Severity::Warning, cx);
|
||||||
return self.toast(text, Severity::Warning, cx);
|
};
|
||||||
};
|
docs.dialog = Some(Dialog::Reference { marks, mark, show: "2" });
|
||||||
self.dialog = Some(Dialog::Reference {
|
cx.notify();
|
||||||
marks,
|
},
|
||||||
mark,
|
cx,
|
||||||
show: "2",
|
);
|
||||||
});
|
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -173,18 +185,16 @@ impl Docs {
|
|||||||
self.ask(Ask::Caption(category), "", window, cx);
|
self.ask(Ask::Caption(category), "", window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn on_formula(&mut self, _: &InsertFormula, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
let Some(json) = self.read(".uno:Formula", cx) else {
|
self.read_then(
|
||||||
return;
|
".uno:Formula",
|
||||||
};
|
|docs, json, window, cx| match ely_lok::review::formula(&json) {
|
||||||
match ely_lok::review::formula(&json) {
|
Ok(Some(formula)) => docs.ask(Ask::Formula(true), &formula, window, cx),
|
||||||
Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx),
|
Ok(None) => docs.ask(Ask::Formula(false), "", window, cx),
|
||||||
Ok(None) => self.ask(Ask::Formula(false), "", window, cx),
|
Err(error) => docs.engine_failed(&error.to_string(), cx),
|
||||||
Err(error) => {
|
},
|
||||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
cx,
|
||||||
self.toast(text, Severity::Danger, cx);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window
|
|||||||
|
|
||||||
/// The open prompt's request and field.
|
/// The open prompt's request and field.
|
||||||
fn asked(cx: &TestAppContext, docs: &Entity<Docs>) -> (Ask, Entity<TextInput>) {
|
fn asked(cx: &TestAppContext, docs: &Entity<Docs>) -> (Ask, Entity<TextInput>) {
|
||||||
|
// Prompts open once their kernel read comes back.
|
||||||
|
cx.run_until_parked();
|
||||||
cx.update(|app| match &docs.read(app).dialog {
|
cx.update(|app| match &docs.read(app).dialog {
|
||||||
Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()),
|
Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()),
|
||||||
_ => panic!("a prompt is open"),
|
_ => panic!("a prompt is open"),
|
||||||
@@ -35,6 +37,7 @@ fn references_go_in_through_their_dialogs() {
|
|||||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||||
docs.on_reference(&InsertReference, window, cx)
|
docs.on_reference(&InsertReference, window, cx)
|
||||||
});
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
assert!(
|
assert!(
|
||||||
cx.update(|app| docs.read(app).dialog.is_none()),
|
cx.update(|app| docs.read(app).dialog.is_none()),
|
||||||
"no bookmark, no cross-reference dialog"
|
"no bookmark, no cross-reference dialog"
|
||||||
@@ -55,6 +58,7 @@ fn references_go_in_through_their_dialogs() {
|
|||||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||||
docs.on_reference(&InsertReference, window, cx)
|
docs.on_reference(&InsertReference, window, cx)
|
||||||
});
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx)));
|
cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx)));
|
||||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||||
docs.on_link(&InsertLink, window, cx)
|
docs.on_link(&InsertLink, window, cx)
|
||||||
@@ -188,3 +192,49 @@ fn a_long_formula_prefills_whole() {
|
|||||||
let (_, field) = asked(&cx, &docs);
|
let (_, field) = asked(&cx, &docs);
|
||||||
assert_eq!(cx.update(|app| field.read(app).text().to_string()), long);
|
assert_eq!(cx.update(|app| field.read(app).text().to_string()), long);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_late_dialog_read_never_replaces_a_newer_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;
|
||||||
|
};
|
||||||
|
blank(&mut cx, &docs);
|
||||||
|
// The link dialog opens before the bookmarks come back.
|
||||||
|
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||||
|
docs.on_bookmark(&InsertBookmark, window, cx);
|
||||||
|
docs.on_link(&InsertLink, window, cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(Dialog::Link { .. }))), "the link dialog stays");
|
||||||
|
// Opened and closed meanwhile: the old ask lapses too.
|
||||||
|
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||||
|
docs.dialog = None;
|
||||||
|
cx.notify();
|
||||||
|
docs.on_bookmark(&InsertBookmark, window, cx);
|
||||||
|
docs.on_link(&InsertLink, window, cx);
|
||||||
|
});
|
||||||
|
cx.update(|app| docs.update(app, |docs, cx| {
|
||||||
|
docs.dialog = None;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "nothing pops up later");
|
||||||
|
// A close prompt shown and cancelled meanwhile counts too.
|
||||||
|
let id = cx.update(|app| docs.read(app).core.active.expect("a tab"));
|
||||||
|
type_into(&mut cx, &docs, "edited");
|
||||||
|
settled(&cx, &docs);
|
||||||
|
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||||
|
docs.on_bookmark(&InsertBookmark, window, cx);
|
||||||
|
docs.request_close(id, ely_office::After::Tab, window, cx);
|
||||||
|
});
|
||||||
|
assert!(cx.update(|app| docs.read(app).core.dialog.is_some()), "the close asks");
|
||||||
|
cx.update(|app| docs.update(app, |docs, cx| {
|
||||||
|
docs.core.dialog = None;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "no bookmark prompt after the cancel");
|
||||||
|
}
|
||||||
+69
-26
@@ -8,11 +8,11 @@ use std::{
|
|||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
|
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
|
||||||
collab::{Comment, CommentSidebar, Peer, Thread},
|
collab::{Comment, CommentSidebar, Peer, Thread},
|
||||||
feedback::EmptyState,
|
feedback::{EmptyState, InlineMessage},
|
||||||
forms::{Switch, TextInput},
|
forms::{Switch, TextInput},
|
||||||
layout::ScrollArea,
|
layout::ScrollArea,
|
||||||
lists::{List, ListItem},
|
lists::{List, ListItem},
|
||||||
primitives::IconName,
|
primitives::{IconName, Severity},
|
||||||
theme::ControlSize,
|
theme::ControlSize,
|
||||||
};
|
};
|
||||||
use ely_lok::{
|
use ely_lok::{
|
||||||
@@ -20,7 +20,7 @@ use ely_lok::{
|
|||||||
review::{Change, Note},
|
review::{Change, Note},
|
||||||
};
|
};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render,
|
App, Context, Entity, EventEmitter, InteractiveElement as _, IntoElement, Menu, StatefulInteractiveElement as _, MenuItem, ParentElement as _, Render,
|
||||||
SharedString, Styled as _, Subscription, Window, actions, div,
|
SharedString, Styled as _, Subscription, Window, actions, div,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,6 +86,8 @@ pub struct Review {
|
|||||||
shown: Option<Shown>,
|
shown: Option<Shown>,
|
||||||
notes: Vec<Note>,
|
notes: Vec<Note>,
|
||||||
threads: Vec<Thread>,
|
threads: Vec<Thread>,
|
||||||
|
/// Notes whose time does not read: time and why.
|
||||||
|
unread: Vec<Unread>,
|
||||||
pub(crate) changes: Vec<Change>,
|
pub(crate) changes: Vec<Change>,
|
||||||
active: Option<SharedString>,
|
active: Option<SharedString>,
|
||||||
pane: Pane,
|
pane: Pane,
|
||||||
@@ -100,6 +102,7 @@ impl Review {
|
|||||||
shown: None,
|
shown: None,
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
threads: Vec::new(),
|
threads: Vec::new(),
|
||||||
|
unread: Vec::new(),
|
||||||
changes: Vec::new(),
|
changes: Vec::new(),
|
||||||
active: None,
|
active: None,
|
||||||
pane: Pane::Comments,
|
pane: Pane::Comments,
|
||||||
@@ -117,6 +120,7 @@ impl Review {
|
|||||||
self.active = None;
|
self.active = None;
|
||||||
self.notes.clear();
|
self.notes.clear();
|
||||||
self.threads.clear();
|
self.threads.clear();
|
||||||
|
self.unread.clear();
|
||||||
self.changes.clear();
|
self.changes.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
let Some((view, doc)) = tab else {
|
let Some((view, doc)) = tab else {
|
||||||
@@ -161,21 +165,8 @@ impl Review {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
shown.reading = false;
|
shown.reading = false;
|
||||||
let read = read.and_then(|(notes, changes)| {
|
|
||||||
let threads = threads(¬es).map_err(std::io::Error::other)?;
|
|
||||||
Ok((notes, threads, changes))
|
|
||||||
});
|
|
||||||
match read {
|
match read {
|
||||||
Ok((notes, threads, changes)) => {
|
Ok((notes, changes)) => review.take_lists(notes, changes),
|
||||||
log::info!(
|
|
||||||
"doc: review lists {} notes, {} changes",
|
|
||||||
notes.len(),
|
|
||||||
changes.len()
|
|
||||||
);
|
|
||||||
review.notes = notes;
|
|
||||||
review.threads = threads;
|
|
||||||
review.changes = changes;
|
|
||||||
}
|
|
||||||
Err(error) => cx.emit(LokError(format!(
|
Err(error) => cx.emit(LokError(format!(
|
||||||
"the review lists are unreadable: {error}"
|
"the review lists are unreadable: {error}"
|
||||||
))),
|
))),
|
||||||
@@ -366,12 +357,61 @@ impl Render for Review {
|
|||||||
.p_2()
|
.p_2()
|
||||||
.child(head)
|
.child(head)
|
||||||
.child(pane)
|
.child(pane)
|
||||||
|
// Many bad notes scroll; the lists stay in reach.
|
||||||
|
.children(self.unread_alert(cx).map(|alert| {
|
||||||
|
ScrollArea::new("doc-review-unread-scroll").vertical().w_full().max_h(gpui::rems(6.)).flex_none().child(alert)
|
||||||
|
}))
|
||||||
.child(div().flex_1().min_h_0().child(body))
|
.child(div().flex_1().min_h_0().child(body))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The notes as threads under their first comment.
|
/// A note whose time does not read, and why.
|
||||||
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Unread {
|
||||||
|
pub author: String,
|
||||||
|
pub at: String,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Review {
|
||||||
|
/// The kernel's lists; one bad time spares the rest.
|
||||||
|
fn take_lists(&mut self, notes: Vec<Note>, changes: Vec<Change>) {
|
||||||
|
let (threads, unread) = threads(¬es);
|
||||||
|
for bad in &unread {
|
||||||
|
log::error!("doc: a bad comment time {:?}: {}", bad.at, bad.error);
|
||||||
|
}
|
||||||
|
log::info!("doc: review lists {} notes, {} changes", notes.len(), changes.len());
|
||||||
|
(self.notes, self.threads, self.unread, self.changes) = (notes, threads, unread, changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each unreadable note, by its author and the reason.
|
||||||
|
fn unread_lines(&self, cx: &App) -> Vec<SharedString> {
|
||||||
|
let line = |bad: &Unread| {
|
||||||
|
Docs::text(cx, "doc.review.unread", &[("author", &bad.author), ("at", &bad.at), ("error", &bad.error)])
|
||||||
|
};
|
||||||
|
self.unread.iter().map(line).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lines as one alert assistive tech reads whole.
|
||||||
|
fn unread_alert(&self, cx: &App) -> Option<gpui::AnyElement> {
|
||||||
|
let lines = self.unread_lines(cx);
|
||||||
|
let said: SharedString = lines.join("\n").into();
|
||||||
|
(!lines.is_empty()).then(|| {
|
||||||
|
div()
|
||||||
|
.id("doc-review-unread")
|
||||||
|
.role(gpui::Role::Alert)
|
||||||
|
.aria_label(said)
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_1()
|
||||||
|
.children(lines.into_iter().map(|line| InlineMessage::new(Severity::Danger, line)))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notes threaded under their first comment; bad times apart.
|
||||||
|
fn threads(notes: &[Note]) -> (Vec<Thread>, Vec<Unread>) {
|
||||||
let parents: HashMap<&str, &str> = notes
|
let parents: HashMap<&str, &str> = notes
|
||||||
.iter()
|
.iter()
|
||||||
.map(|note| (note.id.as_str(), note.parent.as_str()))
|
.map(|note| (note.id.as_str(), note.parent.as_str()))
|
||||||
@@ -384,13 +424,16 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
|
|||||||
id.to_string()
|
id.to_string()
|
||||||
};
|
};
|
||||||
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
|
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
|
||||||
let mut threads: Vec<Thread> = Vec::new();
|
let (mut threads, mut unread): (Vec<Thread>, Vec<Unread>) = (Vec::new(), Vec::new());
|
||||||
for note in notes {
|
for note in notes {
|
||||||
let at = note
|
let read = note.at.parse::<jiff::civil::DateTime>().and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()));
|
||||||
.at
|
let at = match read {
|
||||||
.parse::<jiff::civil::DateTime>()
|
Ok(at) => at,
|
||||||
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
|
Err(error) => {
|
||||||
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?;
|
unread.push(Unread { author: note.author.clone(), at: note.at.clone(), error: error.to_string() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let hue = authors
|
let hue = authors
|
||||||
.iter()
|
.iter()
|
||||||
.position(|name| *name == note.author)
|
.position(|name| *name == note.author)
|
||||||
@@ -412,7 +455,7 @@ fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(threads)
|
(threads, unread)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -82,3 +82,40 @@ fn comments_thread_and_changes_accept_or_reject() {
|
|||||||
}
|
}
|
||||||
assert!(!text.contains("gone"), "the rejected words go");
|
assert!(!text.contains("gone"), "the rejected words go");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_unreadable_time_spares_the_other_notes() {
|
||||||
|
let note = |id: &str, parent: &str, at: &str| Note {
|
||||||
|
id: id.into(),
|
||||||
|
parent: parent.into(),
|
||||||
|
author: "A".into(),
|
||||||
|
text: "t".into(),
|
||||||
|
resolved: false,
|
||||||
|
at: at.into(),
|
||||||
|
anchor: (0, 0, 0, 0),
|
||||||
|
};
|
||||||
|
// The kernel writes an empty date as 0-00-00.
|
||||||
|
let mut undated = note("2", "0", "0-00-00");
|
||||||
|
undated.author = "Undated".into();
|
||||||
|
let notes = vec![note("1", "0", "2025-03-01T10:00:00"), undated, note("3", "1", "2025-03-02T09:00:00")];
|
||||||
|
let (threads, unread) = threads(¬es);
|
||||||
|
assert_eq!(threads.len(), 1);
|
||||||
|
assert_eq!(threads[0].comments.len(), 2, "the reply stays in its thread");
|
||||||
|
assert_eq!((unread.len(), unread[0].author.as_str(), unread[0].at.as_str()), (1, "Undated", "0-00-00"));
|
||||||
|
// Through the refresh path: changes stay, the line names both.
|
||||||
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
|
let mut cx = gpui::TestAppContext::single();
|
||||||
|
let Some(docs) = crate::tests::docs(&mut cx, dir.path()) else {
|
||||||
|
eprintln!("SKIP: no LOK build");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let review = cx.update(|app| docs.read(app).review.clone());
|
||||||
|
let change = Change { index: 7, author: "B".into(), kind: "Insert".into(), description: String::new(), at: "2025-03-01T10:00:00".into(), anchor: None };
|
||||||
|
let lines = cx.update(|cx| review.update(cx, |review, cx| {
|
||||||
|
review.take_lists(notes, vec![change]);
|
||||||
|
assert_eq!(review.changes.len(), 1, "the changes stay");
|
||||||
|
review.unread_lines(cx)
|
||||||
|
}));
|
||||||
|
assert_eq!(lines.len(), 1);
|
||||||
|
assert!(lines[0].contains("Undated") && lines[0].contains(&unread[0].error), "{}", lines[0]);
|
||||||
|
}
|
||||||
@@ -365,7 +365,7 @@ fn a_macro_file_opens_in_risk_mode_and_saves_only_as_a_copy() {
|
|||||||
);
|
);
|
||||||
cx.update(|app| {
|
cx.update(|app| {
|
||||||
assert!(
|
assert!(
|
||||||
!docs.read(app).active_tab().expect("a tab").risk.is_some(),
|
docs.read(app).active_tab().expect("a tab").risk.is_none(),
|
||||||
"the DOCX copy holds no macros"
|
"the DOCX copy holds no macros"
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -263,3 +263,41 @@ fn closing_a_tab_takes_its_export_dialog() {
|
|||||||
// Drawn now, a dialog for the gone tab would crash.
|
// 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");
|
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "the dialog went with its tab");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tab_on_a_vanished_folder_takes_no_other_file() {
|
||||||
|
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);
|
||||||
|
let gone = dir.path().join("volume");
|
||||||
|
std::fs::create_dir(&gone).expect("a folder");
|
||||||
|
let first = gone.join("first.docx");
|
||||||
|
with_window(&mut cx, &docs, |docs, window, cx| docs.write_as(id, first.clone(), None, window, cx));
|
||||||
|
written(&cx, &first, None);
|
||||||
|
// Its volume goes away; another file still saves.
|
||||||
|
std::fs::remove_dir_all(&gone).expect("the folder goes");
|
||||||
|
let other = dir.path().join("other.docx");
|
||||||
|
let holder = cx.update(|app| docs.read(app).holder(&other).map_err(|error| error.to_string()));
|
||||||
|
assert_eq!(holder, Ok(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_after_a_save_raced_it_counts_again() {
|
||||||
|
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);
|
||||||
|
type_into(&mut cx, &docs, "a");
|
||||||
|
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.modified));
|
||||||
|
// A save's ticket landed after this edit: saved, still modified.
|
||||||
|
cx.update(|app| docs.update(app, |docs, _| docs.tab_mut(id).expect("a tab").session.reverted()));
|
||||||
|
type_into(&mut cx, &docs, "b");
|
||||||
|
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.session.state() == ely_files::SaveState::Dirty));
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
build = "../../packaging/windows/icon.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ely-platform.workspace = true
|
ely-platform.workspace = true
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Release builds open no console on Windows.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
use ely_platform::AppId;
|
use ely_platform::AppId;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
build = "../../packaging/windows/icon.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
@@ -27,4 +31,5 @@ serde_json.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
@@ -112,19 +112,10 @@ impl Desk {
|
|||||||
if open > 0 {
|
if open > 0 {
|
||||||
return log::error!("mark: a merge with {open} open conflicts");
|
return log::error!("mark: a merge with {open} open conflicts");
|
||||||
}
|
}
|
||||||
let Some(tab) = self.tab_mut(id) else {
|
// Disk text is saved; the merge edits it.
|
||||||
return;
|
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
|
||||||
};
|
view.update(cx, |view, cx| view.adopt(merged, cx));
|
||||||
if let Err(error) = tab.session.reload_at(fingerprint) {
|
|
||||||
let why = error.to_string();
|
|
||||||
return self.set_outside(id, Outside::Unread(why), cx);
|
|
||||||
}
|
}
|
||||||
tab.extra.base = disk.text.clone();
|
|
||||||
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));
|
|
||||||
self.set_outside(id, Outside::Clear, cx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ pub struct Desk {
|
|||||||
pub keeping: crate::restore::Keeping,
|
pub keeping: crate::restore::Keeping,
|
||||||
/// The export dialog's choice, while it is open.
|
/// The export dialog's choice, while it is open.
|
||||||
pub output: Option<crate::export::Output>,
|
pub output: Option<crate::export::Output>,
|
||||||
pub printing: Option<crate::export::Printing>,
|
/// Pages printing to PDF, by job; several may run.
|
||||||
|
pub printing: HashMap<u64, crate::export::Printing>,
|
||||||
|
pub print_jobs: u64,
|
||||||
pub look: crate::look::Look,
|
pub look: crate::look::Look,
|
||||||
pub look_file: PathBuf,
|
pub look_file: PathBuf,
|
||||||
/// Git's word on the shown file, and which tab asked.
|
/// Git's word on the shown file, and which tab asked.
|
||||||
@@ -113,7 +115,7 @@ impl Host for Desk {
|
|||||||
self.watch_folders(cx);
|
self.watch_folders(cx);
|
||||||
let look = serde_json::to_value(&self.look).expect("a look serializes");
|
let look = serde_json::to_value(&self.look).expect("a look serializes");
|
||||||
view.update(cx, |view, cx| view.set_look(look, cx));
|
view.update(cx, |view, cx| view.set_look(look, cx));
|
||||||
let changed = view.read(cx).changed;
|
let changed = view.read(cx).changed();
|
||||||
self.autosave(id, changed, cx);
|
self.autosave(id, changed, cx);
|
||||||
let recheck = self.tab_mut(id).is_some_and(|tab| {
|
let recheck = self.tab_mut(id).is_some_and(|tab| {
|
||||||
if !tab.dirty(cx) {
|
if !tab.dirty(cx) {
|
||||||
@@ -200,7 +202,8 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
|||||||
comparing: None,
|
comparing: None,
|
||||||
keeping,
|
keeping,
|
||||||
output: None,
|
output: None,
|
||||||
printing: None,
|
printing: Default::default(),
|
||||||
|
print_jobs: 0,
|
||||||
look: look.clone(),
|
look: look.clone(),
|
||||||
look_file,
|
look_file,
|
||||||
tracked: None,
|
tracked: None,
|
||||||
@@ -228,8 +231,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
|||||||
desk.open_path(path, cx);
|
desk.open_path(path, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for name in desk.keeping.untitled() {
|
for (at, name) in desk.keeping.untitled() {
|
||||||
|
// Tab numbers come from `next` as the load starts.
|
||||||
|
let id = desk.core.next;
|
||||||
desk.new_from_text(&name, "", cx);
|
desk.new_from_text(&name, "", cx);
|
||||||
|
if desk.core.next > id {
|
||||||
|
desk.keeping.made(at, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for path in asked {
|
for path in asked {
|
||||||
desk.open_path(path, cx);
|
desk.open_path(path, cx);
|
||||||
@@ -246,8 +254,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
cx.on_app_quit(|desk, _| {
|
cx.on_app_quit(|desk, cx| {
|
||||||
desk.keeping.close();
|
// A system end skips asking: unsaved tabs keep points.
|
||||||
|
if desk.core.tabs.iter().any(|tab| tab.dirty(cx)) {
|
||||||
|
desk.keeping.hold();
|
||||||
|
} else {
|
||||||
|
desk.keeping.close();
|
||||||
|
}
|
||||||
async {}
|
async {}
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ impl Engine for Mark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn edited(view: &MarkView) -> u64 {
|
fn edited(view: &MarkView) -> u64 {
|
||||||
view.changed
|
view.changed()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit_pending(view: &MarkView) -> bool {
|
fn edit_pending(view: &MarkView) -> bool {
|
||||||
|
|||||||
+45
-37
@@ -15,7 +15,7 @@ use ely_ui::{commands, i18n::text};
|
|||||||
use ely_web_content::{Event, WebView};
|
use ely_web_content::{Event, WebView};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _,
|
AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _,
|
||||||
Subscription, Window,
|
Subscription, Task, Window,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ pub struct Lost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Lost {
|
impl Lost {
|
||||||
fn of(images: Vec<String>, rendered: &Value) -> Lost {
|
pub fn of(images: Vec<String>, rendered: &Value) -> Lost {
|
||||||
let diagrams = rendered["failed"].as_array().into_iter().flatten();
|
let diagrams = rendered["failed"].as_array().into_iter().flatten();
|
||||||
let formulas = rendered["formulas"].as_array().into_iter().flatten();
|
let formulas = rendered["formulas"].as_array().into_iter().flatten();
|
||||||
Lost {
|
Lost {
|
||||||
@@ -108,8 +108,7 @@ impl Desk {
|
|||||||
let Some(id) = self.active_tab().map(|tab| tab.id) else {
|
let Some(id) = self.active_tab().map(|tab| tab.id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let pdf = self.core.work.join(format!("print-{id}.pdf"));
|
self.render_pdf(id, None, cx);
|
||||||
self.render_pdf(id, pdf, None, cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks where, renders the whole text, then writes it.
|
/// Asks where, renders the whole text, then writes it.
|
||||||
@@ -126,24 +125,27 @@ impl Desk {
|
|||||||
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir);
|
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir);
|
||||||
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
|
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
|
||||||
cx.spawn(async move |desk, cx| {
|
cx.spawn(async move |desk, cx| {
|
||||||
let Ok(Ok(Some(path))) = chosen.await else {
|
let chosen = match chosen.await {
|
||||||
return log::info!("mark: the export panel closed without a path");
|
Ok(Ok(path)) => Ok(path),
|
||||||
|
Ok(Err(error)) => Err(error.to_string()),
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
};
|
};
|
||||||
desk.update(cx, |desk, cx| {
|
let done = desk.update(cx, |desk, cx| match chosen {
|
||||||
if let Err(error) = not_open(desk, &path) {
|
Ok(Some(path)) => {
|
||||||
return desk.exported(Err(error), cx);
|
if let Err(error) = not_open(desk, &path) {
|
||||||
|
return desk.exported(Err(error), cx);
|
||||||
|
}
|
||||||
|
match output {
|
||||||
|
Output::Pdf => desk.render_pdf(id, Some(path), cx),
|
||||||
|
_ => desk.write_output(id, output, path, cx),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match output {
|
Ok(None) => log::info!("mark: the export panel was cancelled"),
|
||||||
Output::Pdf => desk.render_pdf(
|
Err(why) => desk.panel_failed(&why, cx),
|
||||||
id,
|
});
|
||||||
desk.core.work.join(format!("export-{id}.pdf")),
|
if done.is_err() {
|
||||||
Some(path),
|
log::info!("mark: the window closed before the export panel");
|
||||||
cx,
|
}
|
||||||
),
|
|
||||||
_ => desk.write_output(id, output, path, cx),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
@@ -257,7 +259,11 @@ impl Desk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The whole text as PDF; then to `to`, else printed.
|
/// The whole text as PDF; then to `to`, else printed.
|
||||||
fn render_pdf(&mut self, id: u64, pdf: PathBuf, to: Option<PathBuf>, cx: &mut Context<Self>) {
|
fn render_pdf(&mut self, id: u64, to: Option<PathBuf>, cx: &mut Context<Self>) {
|
||||||
|
// Each job has its own files; none cancels another.
|
||||||
|
self.print_jobs += 1;
|
||||||
|
let n = self.print_jobs;
|
||||||
|
let pdf = self.core.work.join(format!("print-{n}.pdf"));
|
||||||
let Some(tab) = self.tab(id) else {
|
let Some(tab) = self.tab(id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -273,14 +279,16 @@ impl Desk {
|
|||||||
view.render(
|
view.render(
|
||||||
"html",
|
"html",
|
||||||
move |rendered, cx: &mut App| {
|
move |rendered, cx: &mut App| {
|
||||||
let staged = work.join(format!("print-{id}.html"));
|
let staged = work.join(format!("print-{n}.html"));
|
||||||
let page = rendered.map_err(io::Error::other).and_then(|rendered| {
|
let page = match rendered {
|
||||||
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder);
|
Ok(rendered) => {
|
||||||
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?);
|
let print = paper::Print { folder, style, name, page: staged.clone() };
|
||||||
std::fs::write(&staged, paper::page(&name, &style, &html))?;
|
paper::stage(cx.background_executor(), rendered, print)
|
||||||
Ok(Lost::of(images, &rendered))
|
}
|
||||||
});
|
Err(why) => Task::ready(Err(io::Error::other(why))),
|
||||||
|
};
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
|
let page = page.await;
|
||||||
in_window(&desk, cx, |desk, window, cx| match page {
|
in_window(&desk, cx, |desk, window, cx| match page {
|
||||||
Ok(lost) => {
|
Ok(lost) => {
|
||||||
let job = Job {
|
let job = Job {
|
||||||
@@ -289,7 +297,7 @@ impl Desk {
|
|||||||
open,
|
open,
|
||||||
lost,
|
lost,
|
||||||
};
|
};
|
||||||
desk.print_page(&staged, job, window, cx)
|
desk.print_page(n, &staged, job, window, cx)
|
||||||
}
|
}
|
||||||
Err(error) => desk.exported(Err(error), cx),
|
Err(error) => desk.exported(Err(error), cx),
|
||||||
})
|
})
|
||||||
@@ -303,32 +311,34 @@ impl Desk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Chromium prints the staged page; the PDF goes where asked.
|
/// Chromium prints the staged page; the PDF goes where asked.
|
||||||
fn print_page(&mut self, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
|
fn print_page(&mut self, n: u64, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let root = page
|
let root = page
|
||||||
.parent()
|
.parent()
|
||||||
.expect("a staged page sits in a folder")
|
.expect("a staged page sits in a folder")
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
let view = match WebView::open(page, &root, window, cx) {
|
// A document's own scripts never run here.
|
||||||
|
let view = match WebView::open_inert(page, &root, window, cx) {
|
||||||
Ok(view) => view,
|
Ok(view) => view,
|
||||||
Err(error) => return self.exported(Err(io::Error::other(error)), cx),
|
Err(error) => return self.exported(Err(io::Error::other(error)), cx),
|
||||||
};
|
};
|
||||||
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
|
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
|
||||||
Event::Loaded => view.read(cx).print_pdf(&job.pdf),
|
Event::Loaded => view.read(cx).print_pdf(&job.pdf),
|
||||||
Event::Printed(path, ok) => {
|
Event::Printed(path, ok) => {
|
||||||
desk.printing = None;
|
log::info!("mark: job {n} printed {}, ok {ok}", path.display());
|
||||||
|
desk.printing.remove(&n);
|
||||||
let printed = ok
|
let printed = ok
|
||||||
.then(|| path.clone())
|
.then(|| path.clone())
|
||||||
.ok_or_else(|| io::Error::other("Chromium did not print the page"));
|
.ok_or_else(|| io::Error::other("Chromium did not print the page"));
|
||||||
desk.printed(printed, job.clone(), cx);
|
desk.printed(printed, job.clone(), cx);
|
||||||
}
|
}
|
||||||
Event::Failed(why) => {
|
Event::Failed(why) => {
|
||||||
desk.printing = None;
|
desk.printing.remove(&n);
|
||||||
desk.exported(Err(io::Error::other(why.clone())), cx);
|
desk.exported(Err(io::Error::other(why.clone())), cx);
|
||||||
}
|
}
|
||||||
Event::Query(_, reply) => reply.ok(""),
|
Event::Query(_, reply) => reply.ok(""),
|
||||||
Event::Leave(_) => {}
|
Event::Leave(_) => {}
|
||||||
});
|
});
|
||||||
self.printing = Some(Printing {
|
self.printing.insert(n, Printing {
|
||||||
_view: view,
|
_view: view,
|
||||||
_heard: heard,
|
_heard: heard,
|
||||||
});
|
});
|
||||||
@@ -343,9 +353,7 @@ impl Desk {
|
|||||||
if !job.lost.whole() {
|
if !job.lost.whole() {
|
||||||
self.warn_lost(&job.lost, None, cx);
|
self.warn_lost(&job.lost, None, cx);
|
||||||
}
|
}
|
||||||
return ely_office::print_later(pdf, cx, |desk, why, cx| {
|
return ely_office::print_later(pdf, cx);
|
||||||
desk.exported(Err(io::Error::other(why)), cx)
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
// Never over the open file, not even through a link.
|
// Never over the open file, not even through a link.
|
||||||
let moved = not_open(self, &to)
|
let moved = not_open(self, &to)
|
||||||
|
|||||||
@@ -115,8 +115,12 @@ impl Desk {
|
|||||||
|
|
||||||
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.finder = None;
|
self.finder = None;
|
||||||
if let Some(view) = self.active_view() {
|
// Every tab's page holds its own highlights.
|
||||||
|
let views: Vec<_> = self.core.tabs.iter().map(|tab| tab.view.clone()).collect();
|
||||||
|
for view in views {
|
||||||
view.update(cx, |view, cx| view.unfind(cx));
|
view.update(cx, |view, cx| view.unfind(cx));
|
||||||
|
}
|
||||||
|
if let Some(view) = self.active_view() {
|
||||||
let focus = view.read(cx).focus.clone();
|
let focus = view.read(cx).focus.clone();
|
||||||
window.focus(&focus, cx);
|
window.focus(&focus, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Release builds open no console on Windows.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod attach;
|
mod attach;
|
||||||
mod autosave;
|
mod autosave;
|
||||||
mod compare;
|
mod compare;
|
||||||
@@ -15,6 +18,7 @@ mod places;
|
|||||||
mod rename;
|
mod rename;
|
||||||
mod restore;
|
mod restore;
|
||||||
mod side;
|
mod side;
|
||||||
|
mod tally;
|
||||||
mod text;
|
mod text;
|
||||||
mod vcs;
|
mod vcs;
|
||||||
mod view;
|
mod view;
|
||||||
|
|||||||
@@ -236,11 +236,7 @@ impl Desk {
|
|||||||
match (disk.text == current, dirty) {
|
match (disk.text == current, dirty) {
|
||||||
// The same text: only the file underneath is new.
|
// The same text: only the file underneath is new.
|
||||||
(true, _) => {
|
(true, _) => {
|
||||||
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
|
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.
|
// The text as last saved: the edits stay unsaved.
|
||||||
(false, true) if kept => self.keep_edits(id, disk, fingerprint, cx),
|
(false, true) if kept => self.keep_edits(id, disk, fingerprint, cx),
|
||||||
@@ -294,8 +290,8 @@ impl Desk {
|
|||||||
self.save_after_pause(id, cx);
|
self.save_after_pause(id, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tab takes the file as saved; returns its view.
|
/// The tab and its view take the file as saved.
|
||||||
fn adopt_disk(
|
pub(crate) fn adopt_disk(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: u64,
|
id: u64,
|
||||||
disk: &Source,
|
disk: &Source,
|
||||||
@@ -308,6 +304,9 @@ impl Desk {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
tab.extra.base = disk.text.clone();
|
tab.extra.base = disk.text.clone();
|
||||||
|
// The file holds the disk's text: it is saved.
|
||||||
|
let saved = crate::engine::mark(&disk.text);
|
||||||
|
tab.mark = saved.clone();
|
||||||
tab.extra.restart_autosave();
|
tab.extra.restart_autosave();
|
||||||
// Its bytes (BOM, line ends) are what saves keep now.
|
// Its bytes (BOM, line ends) are what saves keep now.
|
||||||
tab.doc.lock().expect("the text lock").source = disk.clone();
|
tab.doc.lock().expect("the text lock").source = disk.clone();
|
||||||
@@ -320,6 +319,7 @@ impl Desk {
|
|||||||
log::warn!("mark: the replaced file did not register: {error}");
|
log::warn!("mark: the replaced file did not register: {error}");
|
||||||
}
|
}
|
||||||
self.set_outside(id, Outside::Clear, cx);
|
self.set_outside(id, Outside::Clear, cx);
|
||||||
|
view.update(cx, |view, cx| view.saved(&saved, cx));
|
||||||
Some(view)
|
Some(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use ely_web_content::WebView;
|
use ely_web_content::WebView;
|
||||||
use gpui::App;
|
use gpui::App;
|
||||||
use serde_json::json;
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
/// The app's Resources, else this build's `engines/content`.
|
use crate::view::Heading;
|
||||||
|
|
||||||
|
/// Beside the app, its Resources, else this build's.
|
||||||
pub fn content() -> Result<PathBuf> {
|
pub fn content() -> Result<PathBuf> {
|
||||||
let bundled = std::env::current_exe()?
|
let exe = std::env::current_exe()?;
|
||||||
.parent()
|
let dir = exe.parent().unwrap_or(Path::new("."));
|
||||||
.and_then(|dir| Some(dir.parent()?.join("Resources/content")));
|
|
||||||
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
|
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
|
||||||
for dir in bundled.into_iter().chain([built]) {
|
for dir in [dir.join("content"), dir.join("../Resources/content"), built] {
|
||||||
if dir.join("index.html").is_file() {
|
if dir.join("index.html").is_file() {
|
||||||
log::info!("mark: page from {}", dir.display());
|
log::info!("mark: page from {}", dir.display());
|
||||||
return Ok(dir);
|
return Ok(dir);
|
||||||
@@ -75,6 +76,11 @@ pub fn open(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The disk's text in place; reports start a new generation.
|
||||||
|
pub fn refresh(view: &WebView, markdown: &str, load: u64) {
|
||||||
|
view.run_js(&format!("ely.refresh({}, {load})", json!(markdown)));
|
||||||
|
}
|
||||||
|
|
||||||
/// The same text in another mode; pending edits go first.
|
/// The same text in another mode; pending edits go first.
|
||||||
pub fn switch(view: &WebView, mode: Mode) {
|
pub fn switch(view: &WebView, mode: Mode) {
|
||||||
view.run_js(&format!("ely.mode('{}')", mode.name()));
|
view.run_js(&format!("ely.mode('{}')", mode.name()));
|
||||||
@@ -90,3 +96,18 @@ pub fn folder(view: &WebView, folder: &Path) {
|
|||||||
pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) {
|
pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) {
|
||||||
view.run_js(&format!("ely.{name}({argument})"));
|
view.run_js(&format!("ely.{name}({argument})"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Headings from the page; anything malformed is None.
|
||||||
|
pub fn headings(items: &Value) -> Option<Vec<Heading>> {
|
||||||
|
let heading = |item: &Value| {
|
||||||
|
let level = item["level"]
|
||||||
|
.as_u64()
|
||||||
|
.filter(|level| (1..=6).contains(level))?;
|
||||||
|
let title = item["title"].as_str()?.to_owned();
|
||||||
|
Some(Heading {
|
||||||
|
level: level as usize,
|
||||||
|
title,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
items.as_array()?.iter().map(heading).collect()
|
||||||
|
}
|
||||||
@@ -85,6 +85,28 @@ fn kind(path: &Path) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the print page is made of.
|
||||||
|
pub struct Print {
|
||||||
|
pub folder: std::path::PathBuf,
|
||||||
|
pub style: std::path::PathBuf,
|
||||||
|
pub name: String,
|
||||||
|
pub page: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the print page off the window thread, images inline.
|
||||||
|
pub fn stage(
|
||||||
|
background: &gpui::BackgroundExecutor,
|
||||||
|
rendered: serde_json::Value,
|
||||||
|
print: Print,
|
||||||
|
) -> gpui::Task<std::io::Result<crate::export::Lost>> {
|
||||||
|
background.spawn(async move {
|
||||||
|
let (html, images) = Marked::read(&rendered)?.inline(&print.folder);
|
||||||
|
let style = format!("<style>{}</style>", std::fs::read_to_string(&print.style)?);
|
||||||
|
std::fs::write(&print.page, page(&print.name, &style, &html))?;
|
||||||
|
Ok(crate::export::Lost::of(images, &rendered))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The page's HTML; image `src` values replaced by numbered tokens.
|
/// The page's HTML; image `src` values replaced by numbered tokens.
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct Marked {
|
pub struct Marked {
|
||||||
@@ -222,6 +244,31 @@ mod tests {
|
|||||||
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
|
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_print_page_is_written_off_the_window_thread(cx: &mut gpui::TestAppContext) {
|
||||||
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
|
std::fs::write(dir.path().join("a.png"), b"png").expect("an image");
|
||||||
|
std::fs::write(dir.path().join("export.css"), "p {}").expect("a style");
|
||||||
|
let rendered = serde_json::json!({
|
||||||
|
"html": "<p>hi</p><img src=\"T0\"><img src=\"T1\">",
|
||||||
|
"token": "T",
|
||||||
|
"images": ["a.png", "gone.png"],
|
||||||
|
"failed": [3],
|
||||||
|
});
|
||||||
|
let print = Print {
|
||||||
|
folder: dir.path().into(),
|
||||||
|
style: dir.path().join("export.css"),
|
||||||
|
name: "notes".into(),
|
||||||
|
page: dir.path().join("print-1.html"),
|
||||||
|
};
|
||||||
|
let staging = stage(&cx.executor(), rendered, print);
|
||||||
|
assert!(!dir.path().join("print-1.html").exists(), "staged on the window thread");
|
||||||
|
let lost = cx.foreground_executor().block_test(staging).expect("staged");
|
||||||
|
let written = std::fs::read_to_string(dir.path().join("print-1.html")).expect("the page");
|
||||||
|
assert!(written.contains("<style>p {}</style>") && written.contains("data:image/png;base64,cG5n"));
|
||||||
|
assert_eq!(format!("{lost:?}"), r#"Lost { images: ["gone.png"], diagrams: [3], formulas: [] }"#);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn titles_escape() {
|
fn titles_escape() {
|
||||||
assert!(page("a<b>&", "", "").contains("<title>a<b>&</title>"));
|
assert!(page("a<b>&", "", "").contains("<title>a<b>&</title>"));
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ struct Waiting {
|
|||||||
kept: Kept,
|
kept: Kept,
|
||||||
real: Option<PathBuf>,
|
real: Option<PathBuf>,
|
||||||
text: Option<(String, SystemTime)>,
|
text: Option<(String, SystemTime)>,
|
||||||
|
/// The untitled tab made for it, once made.
|
||||||
|
tab: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The session's files and what still waits to restore.
|
/// The session's files and what still waits to restore.
|
||||||
@@ -97,6 +99,7 @@ impl Keeping {
|
|||||||
},
|
},
|
||||||
real: Some(real),
|
real: Some(real),
|
||||||
text: None,
|
text: None,
|
||||||
|
tab: None,
|
||||||
}),
|
}),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!("mark: a kept file does not open: {error}");
|
log::warn!("mark: a kept file does not open: {error}");
|
||||||
@@ -111,6 +114,7 @@ impl Keeping {
|
|||||||
kept,
|
kept,
|
||||||
real: None,
|
real: None,
|
||||||
text,
|
text,
|
||||||
|
tab: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,13 +136,33 @@ impl Keeping {
|
|||||||
Ok((keeping, paths, missing))
|
Ok((keeping, paths, missing))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The untitled tabs to make, with their names.
|
/// The untitled tabs to make: their place here and name.
|
||||||
pub fn untitled(&self) -> Vec<String> {
|
pub fn untitled(&self) -> Vec<(usize, String)> {
|
||||||
let untitled = self
|
let waiting = self.waiting.iter().enumerate();
|
||||||
.waiting
|
let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
|
||||||
.iter()
|
untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect()
|
||||||
.filter(|waiting| waiting.kept.path.is_none());
|
}
|
||||||
untitled.map(|waiting| waiting.kept.name.clone()).collect()
|
|
||||||
|
/// Entry `at` belongs to untitled tab `tab`.
|
||||||
|
pub fn made(&mut self, at: usize, tab: u64) {
|
||||||
|
self.waiting[at].tab = Some(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What tab `id` takes: its file or its copy.
|
||||||
|
fn take(&mut self, real: Option<&PathBuf>, id: u64) -> Option<Waiting> {
|
||||||
|
let at = self.waiting.iter().position(|waiting| match (&waiting.real, real) {
|
||||||
|
(Some(kept), Some(open)) => kept == open,
|
||||||
|
(None, None) => waiting.tab == Some(id),
|
||||||
|
_ => false,
|
||||||
|
})?;
|
||||||
|
Some(self.waiting.remove(at))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An end that never asked: the lock and points stay.
|
||||||
|
pub fn hold(&mut self) {
|
||||||
|
if self.crash.take().is_some() {
|
||||||
|
log::warn!("mark: the session ends unasked; its points stay");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A clean end: no crash next time, no stale points.
|
/// A clean end: no crash next time, no stale points.
|
||||||
@@ -190,21 +214,12 @@ impl Desk {
|
|||||||
.session
|
.session
|
||||||
.path()
|
.path()
|
||||||
.and_then(|path| std::fs::canonicalize(path).ok());
|
.and_then(|path| std::fs::canonicalize(path).ok());
|
||||||
let untitled = tab.session.path().is_none();
|
let Some(Waiting {
|
||||||
let waiting = &mut self.keeping.waiting;
|
kept, text: back, ..
|
||||||
let Some(at) = waiting
|
}) = self.keeping.take(real.as_ref(), id)
|
||||||
.iter()
|
|
||||||
.position(|waiting| match (&waiting.real, &real) {
|
|
||||||
(Some(kept), Some(open)) => kept == open,
|
|
||||||
(None, None) => untitled,
|
|
||||||
_ => false,
|
|
||||||
})
|
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Waiting {
|
|
||||||
kept, text: back, ..
|
|
||||||
} = waiting.remove(at);
|
|
||||||
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
|
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
|
||||||
log::info!("mark: restores tab {id} as {}", mode.name());
|
log::info!("mark: restores tab {id} as {}", mode.name());
|
||||||
let tab = self.tab_mut(id).expect("open");
|
let tab = self.tab_mut(id).expect("open");
|
||||||
@@ -392,4 +407,56 @@ mod tests {
|
|||||||
let place = kept.place.expect("a place");
|
let place = kept.place.expect("a place");
|
||||||
assert_eq!((place.line, place.column), (3, 0));
|
assert_eq!((place.line, place.column), (3, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_untitled_copy_goes_to_the_tab_made_for_it() {
|
||||||
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
|
let kept = |name: &str, mode: &str, slot: &str| super::Kept {
|
||||||
|
path: None,
|
||||||
|
name: name.into(),
|
||||||
|
mode: mode.into(),
|
||||||
|
place: Some(super::Place { line: slot.len() as u64, column: 1, scroll: 0.0 }),
|
||||||
|
slot: slot.into(),
|
||||||
|
};
|
||||||
|
let tabs = vec![kept("Untitled1", "visual", "a"), kept("Untitled2", "source", "bb")];
|
||||||
|
for (slot, text) in [("a", "first"), ("bb", "second")] {
|
||||||
|
let mut points = super::Recovery::new(super::recovery(dir.path(), slot));
|
||||||
|
points.write_once(text.as_bytes(), None, std::time::SystemTime::now()).expect("a point");
|
||||||
|
}
|
||||||
|
let saved = serde_json::to_vec(&super::Saved { tabs: tabs.clone() }).expect("a list");
|
||||||
|
std::fs::write(dir.path().join("session.json"), saved).expect("the list");
|
||||||
|
// A pid no process has: the last run crashed.
|
||||||
|
std::fs::write(dir.path().join("session.lock"), "open 4294967295").expect("a lock");
|
||||||
|
let (mut keeping, ..) = super::Keeping::start(dir.path()).expect("a session");
|
||||||
|
let made: Vec<_> = keeping.untitled();
|
||||||
|
assert_eq!(made, [(0, "Untitled1".to_string()), (1, "Untitled2".to_string())]);
|
||||||
|
for (at, _) in made {
|
||||||
|
keeping.made(at, 10 + at as u64);
|
||||||
|
}
|
||||||
|
assert!(keeping.take(None, 12).is_none(), "a new tab takes nothing");
|
||||||
|
// The second tab shows first: each still takes its own.
|
||||||
|
for (id, want, text) in [(11, &tabs[1], "second"), (10, &tabs[0], "first")] {
|
||||||
|
let got = keeping.take(None, id).expect("its copy");
|
||||||
|
assert_eq!((&got.kept, got.text.map(|(text, _)| text)), (want, Some(text.to_string())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unasked_end_keeps_the_lock_and_points_past_release() {
|
||||||
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
|
let point = dir.path().join("recovery/s/point");
|
||||||
|
let (lock, slot) = (dir.path().join("session.lock"), point.parent().expect("a slot"));
|
||||||
|
let (mut held, ..) = super::Keeping::start(dir.path()).expect("a session");
|
||||||
|
std::fs::create_dir_all(slot).expect("a slot");
|
||||||
|
std::fs::write(&point, "unsaved").expect("a point");
|
||||||
|
held.hold();
|
||||||
|
held.close();
|
||||||
|
assert!(lock.exists() && point.exists(), "release after quit wiped them");
|
||||||
|
drop(held);
|
||||||
|
let (mut clean, ..) = super::Keeping::start(dir.path()).expect("a session");
|
||||||
|
std::fs::create_dir_all(slot).expect("a slot");
|
||||||
|
std::fs::write(&point, "unsaved").expect("a point");
|
||||||
|
clean.close();
|
||||||
|
assert!(!lock.exists() && !point.exists(), "a clean end clears");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! Which page reports count, by page load and by version.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// One page load's change counts and the renders it owes.
|
||||||
|
pub struct Tally<R> {
|
||||||
|
load: u64,
|
||||||
|
/// The newest change the page announced.
|
||||||
|
changed: u64,
|
||||||
|
/// The change whose text arrived.
|
||||||
|
reported: u64,
|
||||||
|
renders: HashMap<u64, R>,
|
||||||
|
asked: u64,
|
||||||
|
/// Why the page is down, until it loads again.
|
||||||
|
down: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R> Tally<R> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Tally { load: 0, changed: 0, reported: 0, renders: HashMap::new(), asked: 0, down: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(&self) -> u64 {
|
||||||
|
self.load
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn changed(&self) -> u64 {
|
||||||
|
self.changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every change's text has arrived.
|
||||||
|
pub fn settled(&self) -> bool {
|
||||||
|
self.reported == self.changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reload or refresh: older reports lapse, owed renders end.
|
||||||
|
pub fn restart(&mut self) -> Vec<R> {
|
||||||
|
self.load += 1;
|
||||||
|
(self.changed, self.reported) = (0, 0);
|
||||||
|
self.lapse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page loaded: it answers again.
|
||||||
|
pub fn loaded(&mut self) -> Vec<R> {
|
||||||
|
self.down = None;
|
||||||
|
self.restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page failed: owed and later renders end with `why`.
|
||||||
|
pub fn fail(&mut self, why: String) -> Vec<R> {
|
||||||
|
self.down = Some(why);
|
||||||
|
self.lapse()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lapse(&mut self) -> Vec<R> {
|
||||||
|
self.renders.drain().map(|(_, render)| render).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a report from page load `generation` still counts.
|
||||||
|
pub fn current(&self, generation: Option<u64>) -> bool {
|
||||||
|
generation.is_none_or(|load| load == self.load)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn change(&mut self, version: u64) {
|
||||||
|
self.changed = self.changed.max(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes text `version` if it is the newest.
|
||||||
|
pub fn text(&mut self, version: u64) -> bool {
|
||||||
|
let newest = version >= self.reported;
|
||||||
|
if newest {
|
||||||
|
self.reported = version;
|
||||||
|
}
|
||||||
|
newest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owes `render`; its number goes to the page.
|
||||||
|
pub fn ask(&mut self, render: R) -> Result<u64, (R, String)> {
|
||||||
|
if let Some(why) = &self.down {
|
||||||
|
return Err((render, why.clone()));
|
||||||
|
}
|
||||||
|
self.asked += 1;
|
||||||
|
self.renders.insert(self.asked, render);
|
||||||
|
Ok(self.asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn answered(&mut self, ask: u64) -> Option<R> {
|
||||||
|
self.renders.remove(&ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::Tally;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_key_from_before_a_refresh_leaves_it_settled() {
|
||||||
|
let mut tally = Tally::<()>::new();
|
||||||
|
let old = tally.load();
|
||||||
|
// The old page counted two versions.
|
||||||
|
tally.change(2);
|
||||||
|
assert!(tally.text(2));
|
||||||
|
tally.restart();
|
||||||
|
// The renderer's change for the old text lands late.
|
||||||
|
if tally.current(Some(old)) {
|
||||||
|
tally.change(3);
|
||||||
|
}
|
||||||
|
assert!(tally.settled(), "an old change does not count");
|
||||||
|
assert_eq!(tally.changed(), 0, "versions start over");
|
||||||
|
// The refreshed page's own edits count again.
|
||||||
|
tally.change(1);
|
||||||
|
assert!(!tally.settled());
|
||||||
|
assert!(tally.text(1), "the new page's first text counts");
|
||||||
|
assert!(tally.settled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_load_or_failure_ends_every_owed_render() {
|
||||||
|
let mut tally = Tally::new();
|
||||||
|
let (html, text) = (tally.ask("html").expect("up"), tally.ask("text").expect("up"));
|
||||||
|
let mut owed = tally.loaded();
|
||||||
|
owed.sort();
|
||||||
|
assert_eq!(owed, ["html", "text"]);
|
||||||
|
// Late answers find nobody waiting.
|
||||||
|
assert_eq!((tally.answered(html), tally.answered(text)), (None, None));
|
||||||
|
let pdf = tally.ask("pdf").expect("up");
|
||||||
|
assert_eq!(tally.fail("denied".into()), ["pdf"]);
|
||||||
|
assert_eq!(tally.answered(pdf), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_page_refuses_renders_until_it_loads() {
|
||||||
|
let mut tally = Tally::new();
|
||||||
|
tally.fail("denied".into());
|
||||||
|
assert_eq!(tally.ask("html"), Err(("html", "denied".to_string())));
|
||||||
|
// A refresh is no load: the page is still down.
|
||||||
|
assert!(tally.restart().is_empty());
|
||||||
|
assert_eq!(tally.ask("text"), Err(("text", "denied".to_string())));
|
||||||
|
assert!(tally.loaded().is_empty());
|
||||||
|
assert!(tally.ask("pdf").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_newest_text_counts() {
|
||||||
|
let mut tally = Tally::<()>::new();
|
||||||
|
tally.change(2);
|
||||||
|
assert!(tally.text(2));
|
||||||
|
assert!(!tally.text(1), "an older text is dropped");
|
||||||
|
assert!(tally.settled());
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-54
@@ -37,20 +37,30 @@ pub const LARGE: usize = 2 * 1024 * 1024;
|
|||||||
/// A render's answer: the page's reply, or why not.
|
/// A render's answer: the page's reply, or why not.
|
||||||
type Rendered = Box<dyn FnOnce(Result<Value, String>, &mut gpui::App)>;
|
type Rendered = Box<dyn FnOnce(Result<Value, String>, &mut gpui::App)>;
|
||||||
|
|
||||||
|
/// Renders the page will never answer end with `why`.
|
||||||
|
fn end_renders(pending: Vec<Rendered>, why: &str, cx: &mut gpui::App) {
|
||||||
|
if pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::warn!("mark: {} renders end: {why}", pending.len());
|
||||||
|
let why = why.to_string();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
for done in pending {
|
||||||
|
done(Err(why.clone()), cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// The page failed; the shell shows why.
|
/// The page failed; the shell shows why.
|
||||||
pub struct Failed(pub String);
|
pub struct Failed(pub String);
|
||||||
|
|
||||||
pub struct MarkView {
|
pub struct MarkView {
|
||||||
pub(crate) web: Entity<WebView>,
|
pub(crate) web: Entity<WebView>,
|
||||||
doc: Doc,
|
doc: Doc,
|
||||||
/// The newest change the page announced.
|
/// Page loads, changes and owed renders.
|
||||||
pub changed: u64,
|
tally: crate::tally::Tally<Rendered>,
|
||||||
/// The change whose text `doc` holds.
|
|
||||||
reported: u64,
|
|
||||||
/// The mark at the last load or save.
|
/// The mark at the last load or save.
|
||||||
saved: String,
|
saved: String,
|
||||||
/// Page loads so far; a reload restarts its versions.
|
|
||||||
load: u64,
|
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
/// Headings as the shown editor reads them.
|
/// Headings as the shown editor reads them.
|
||||||
pub outline: Vec<Heading>,
|
pub outline: Vec<Heading>,
|
||||||
@@ -70,9 +80,6 @@ pub struct MarkView {
|
|||||||
pub place: Option<Place>,
|
pub place: Option<Place>,
|
||||||
/// The writing view, as the page applies it (MARK-09).
|
/// The writing view, as the page applies it (MARK-09).
|
||||||
look: Value,
|
look: Value,
|
||||||
/// Renders asked of the page, by number.
|
|
||||||
renders: std::collections::HashMap<u64, Rendered>,
|
|
||||||
asked: u64,
|
|
||||||
pub focus: FocusHandle,
|
pub focus: FocusHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +102,8 @@ impl MarkView {
|
|||||||
MarkView {
|
MarkView {
|
||||||
web,
|
web,
|
||||||
doc,
|
doc,
|
||||||
changed: 0,
|
tally: crate::tally::Tally::new(),
|
||||||
reported: 0,
|
|
||||||
saved,
|
saved,
|
||||||
load: 0,
|
|
||||||
mode: if large { Mode::Source } else { Mode::Visual },
|
mode: if large { Mode::Source } else { Mode::Visual },
|
||||||
large,
|
large,
|
||||||
outline: Vec::new(),
|
outline: Vec::new(),
|
||||||
@@ -108,8 +113,6 @@ impl MarkView {
|
|||||||
attachable: false,
|
attachable: false,
|
||||||
place: None,
|
place: None,
|
||||||
look: Value::Null,
|
look: Value::Null,
|
||||||
renders: std::collections::HashMap::new(),
|
|
||||||
asked: 0,
|
|
||||||
folder,
|
folder,
|
||||||
focus,
|
focus,
|
||||||
}
|
}
|
||||||
@@ -117,7 +120,12 @@ impl MarkView {
|
|||||||
|
|
||||||
/// Every change's text has arrived.
|
/// Every change's text has arrived.
|
||||||
pub fn settled(&self) -> bool {
|
pub fn settled(&self) -> bool {
|
||||||
self.reported == self.changed
|
self.tally.settled()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest change the page announced.
|
||||||
|
pub fn changed(&self) -> u64 {
|
||||||
|
self.tally.changed()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The text's size now, in bytes.
|
/// The text's size now, in bytes.
|
||||||
@@ -190,8 +198,10 @@ impl MarkView {
|
|||||||
doc.source = source;
|
doc.source = source;
|
||||||
}
|
}
|
||||||
self.saved = mark(&text);
|
self.saved = mark(&text);
|
||||||
(self.changed, self.reported) = (0, 0);
|
// Reports and renders from before belong to the old text.
|
||||||
crate::page::call(self.web.read(cx), "refresh", &Value::from(text));
|
let lapsed = self.tally.restart();
|
||||||
|
end_renders(lapsed, "the text was replaced from disk", cx);
|
||||||
|
crate::page::refresh(self.web.read(cx), &text, self.tally.load());
|
||||||
cx.emit(Reported);
|
cx.emit(Reported);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +223,13 @@ impl MarkView {
|
|||||||
done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static,
|
done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.asked += 1;
|
match self.tally.ask(Box::new(done)) {
|
||||||
self.renders.insert(self.asked, Box::new(done));
|
Ok(ask) => {
|
||||||
let ask = serde_json::json!({ "kind": kind, "ask": self.asked });
|
let ask = serde_json::json!({ "kind": kind, "ask": ask });
|
||||||
crate::page::call(self.web.read(cx), "render", &ask);
|
crate::page::call(self.web.read(cx), "render", &ask);
|
||||||
|
}
|
||||||
|
Err((done, why)) => end_renders(vec![done], &why, cx),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `text` replaces the editor's, as one undoable edit.
|
/// `text` replaces the editor's, as one undoable edit.
|
||||||
@@ -240,7 +253,7 @@ impl MarkView {
|
|||||||
drop(doc);
|
drop(doc);
|
||||||
let mode = if self.large { Mode::Source } else { mode };
|
let mode = if self.large { Mode::Source } else { mode };
|
||||||
self.mode = mode;
|
self.mode = mode;
|
||||||
let at = (mode, self.load, place);
|
let at = (mode, self.tally.load(), place);
|
||||||
crate::page::open(self.web.read(cx), ¤t, at, &self.folder, cx);
|
crate::page::open(self.web.read(cx), ¤t, at, &self.folder, cx);
|
||||||
cx.emit(Reported);
|
cx.emit(Reported);
|
||||||
}
|
}
|
||||||
@@ -262,23 +275,38 @@ impl MarkView {
|
|||||||
match event {
|
match event {
|
||||||
// A reload shows the last text that arrived.
|
// A reload shows the last text that arrived.
|
||||||
Event::Loaded => {
|
Event::Loaded => {
|
||||||
self.load += 1;
|
let lapsed = self.tally.loaded();
|
||||||
|
end_renders(lapsed, "the page reloaded", cx);
|
||||||
self.shown = false;
|
self.shown = false;
|
||||||
(self.changed, self.reported) = (0, 0);
|
|
||||||
let text = self.doc.lock().expect("the text lock").current.clone();
|
let text = self.doc.lock().expect("the text lock").current.clone();
|
||||||
let at = (self.mode, self.load, self.place);
|
let at = (self.mode, self.tally.load(), self.place);
|
||||||
crate::page::open(web.read(cx), &text, at, &self.folder, cx);
|
crate::page::open(web.read(cx), &text, at, &self.folder, cx);
|
||||||
cx.emit(Reported);
|
cx.emit(Reported);
|
||||||
}
|
}
|
||||||
Event::Query(said, reply) => self.query(said, reply, cx),
|
Event::Query(said, reply) => self.query(said, reply, cx),
|
||||||
Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"),
|
Event::Leave(url) => self.follow(url, cx),
|
||||||
Event::Failed(why) => cx.emit(Failed(why.clone())),
|
Event::Failed(why) => {
|
||||||
|
let lapsed = self.tally.fail(why.clone());
|
||||||
|
end_renders(lapsed, why, cx);
|
||||||
|
cx.emit(Failed(why.clone()));
|
||||||
|
}
|
||||||
Event::Printed(path, _) => {
|
Event::Printed(path, _) => {
|
||||||
log::error!("mark: an editor page printed {}", path.display())
|
log::error!("mark: an editor page printed {}", path.display())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A clicked link: web and mail open outside.
|
||||||
|
fn follow(&mut self, url: &str, cx: &mut Context<Self>) {
|
||||||
|
if ely_office::outward(url) {
|
||||||
|
log::info!("mark: opens a link outside");
|
||||||
|
return cx.open_url(url);
|
||||||
|
}
|
||||||
|
log::info!("mark: a link of another kind was refused");
|
||||||
|
let said = ely_ui::i18n::text(cx, "office.link.refused", &[]).to_string();
|
||||||
|
cx.emit(Failed(said));
|
||||||
|
}
|
||||||
|
|
||||||
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
|
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
|
||||||
let said: Value = match serde_json::from_str(said) {
|
let said: Value = match serde_json::from_str(said) {
|
||||||
Ok(said) => said,
|
Ok(said) => said,
|
||||||
@@ -287,10 +315,7 @@ impl MarkView {
|
|||||||
return reply.fail("bad JSON");
|
return reply.fail("bad JSON");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if said["generation"]
|
if !self.tally.current(said["generation"].as_u64()) {
|
||||||
.as_u64()
|
|
||||||
.is_some_and(|load| load != self.load)
|
|
||||||
{
|
|
||||||
log::info!("mark: a report from an earlier page load was dropped");
|
log::info!("mark: a report from an earlier page load was dropped");
|
||||||
return reply.ok("");
|
return reply.ok("");
|
||||||
}
|
}
|
||||||
@@ -307,6 +332,10 @@ impl MarkView {
|
|||||||
self.mode = mode;
|
self.mode = mode;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
if let Some(what) = said["broke"].as_str() {
|
||||||
|
log::error!("mark: the visual editor broke ({what}); source shows");
|
||||||
|
cx.emit(Failed(ely_ui::i18n::text(cx, "mark.visual.broke", &[("what", what)]).to_string()));
|
||||||
|
}
|
||||||
self.shown = true;
|
self.shown = true;
|
||||||
if let Some(place) = self.waiting.take() {
|
if let Some(place) = self.waiting.take() {
|
||||||
crate::page::call(self.web.read(cx), "reveal", &place);
|
crate::page::call(self.web.read(cx), "reveal", &place);
|
||||||
@@ -316,7 +345,19 @@ impl MarkView {
|
|||||||
}
|
}
|
||||||
cx.emit(Reported);
|
cx.emit(Reported);
|
||||||
}
|
}
|
||||||
(Some("outline"), ..) => match headings(&said["items"]) {
|
(Some("failed"), ..) => {
|
||||||
|
let Some(what) = said["what"].as_str() else {
|
||||||
|
log::error!("mark: the page sent a failure without its name");
|
||||||
|
return reply.fail("bad failure");
|
||||||
|
};
|
||||||
|
log::error!("mark: the page built no editor ({what})");
|
||||||
|
cx.emit(Failed(ely_ui::i18n::text(cx, "mark.page.broke", &[("what", what)]).to_string()));
|
||||||
|
}
|
||||||
|
(Some("missed"), ..) => match said["line"].as_u64() {
|
||||||
|
Some(line) => log::warn!("mark: nothing to reveal at line {line}"),
|
||||||
|
None => log::warn!("mark: no such heading to reveal"),
|
||||||
|
},
|
||||||
|
(Some("outline"), ..) => match crate::page::headings(&said["items"]) {
|
||||||
Some(outline) => {
|
Some(outline) => {
|
||||||
self.outline = outline;
|
self.outline = outline;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -336,6 +377,13 @@ impl MarkView {
|
|||||||
return reply.fail("bad search state");
|
return reply.fail("bad search state");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(Some("link"), ..) => match said["href"].as_str() {
|
||||||
|
Some(url) => self.follow(url, cx),
|
||||||
|
None => {
|
||||||
|
log::error!("mark: the page sent a link without its address");
|
||||||
|
return reply.fail("bad link");
|
||||||
|
}
|
||||||
|
},
|
||||||
(Some("table"), ..) if said["done"] == false => {
|
(Some("table"), ..) if said["done"] == false => {
|
||||||
let key = match self.mode {
|
let key = match self.mode {
|
||||||
Mode::Visual => "mark.table.none",
|
Mode::Visual => "mark.table.none",
|
||||||
@@ -351,7 +399,7 @@ impl MarkView {
|
|||||||
(Some("table" | "inserted"), ..) => {}
|
(Some("table" | "inserted"), ..) => {}
|
||||||
(Some("rendered"), ..) => {
|
(Some("rendered"), ..) => {
|
||||||
let ask = said["ask"].as_u64().unwrap_or_default();
|
let ask = said["ask"].as_u64().unwrap_or_default();
|
||||||
let Some(done) = self.renders.remove(&ask) else {
|
let Some(done) = self.tally.answered(ask) else {
|
||||||
log::error!("mark: a render nobody asked for came back");
|
log::error!("mark: a render nobody asked for came back");
|
||||||
return reply.fail("unasked render");
|
return reply.fail("unasked render");
|
||||||
};
|
};
|
||||||
@@ -379,16 +427,18 @@ impl MarkView {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(Some("changed"), Some(version), _) => {
|
(Some("changed"), Some(version), _) => {
|
||||||
self.changed = self.changed.max(version);
|
self.tally.change(version);
|
||||||
cx.emit(Reported);
|
cx.emit(Reported);
|
||||||
}
|
}
|
||||||
// Only the newest text counts.
|
// Only the newest text counts.
|
||||||
(Some("text"), Some(version), Some(text)) if version >= self.reported => {
|
(Some("text"), Some(version), Some(text)) => {
|
||||||
self.doc.lock().expect("the text lock").current = text.to_owned();
|
if !self.tally.text(version) {
|
||||||
self.reported = version;
|
log::info!("mark: an older text was dropped");
|
||||||
cx.emit(Reported);
|
} else {
|
||||||
|
self.doc.lock().expect("the text lock").current = text.to_owned();
|
||||||
|
cx.emit(Reported);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"),
|
|
||||||
_ => {
|
_ => {
|
||||||
log::error!("mark: the page asked something unknown");
|
log::error!("mark: the page asked something unknown");
|
||||||
return reply.fail("unknown request");
|
return reply.fail("unknown request");
|
||||||
@@ -420,21 +470,6 @@ fn found(said: &Value) -> Option<crate::finding::Found> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Headings from the page; anything malformed is None.
|
|
||||||
fn headings(items: &Value) -> Option<Vec<Heading>> {
|
|
||||||
let heading = |item: &Value| {
|
|
||||||
let level = item["level"]
|
|
||||||
.as_u64()
|
|
||||||
.filter(|level| (1..=6).contains(level))?;
|
|
||||||
let title = item["title"].as_str()?.to_owned();
|
|
||||||
Some(Heading {
|
|
||||||
level: level as usize,
|
|
||||||
title,
|
|
||||||
})
|
|
||||||
};
|
|
||||||
items.as_array()?.iter().map(heading).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for MarkView {
|
impl Render for MarkView {
|
||||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
// Dropped files: images go beside the document.
|
// Dropped files: images go beside the document.
|
||||||
@@ -444,3 +479,6 @@ impl Render for MarkView {
|
|||||||
div().size_full().on_drop(drop).child(self.web.clone())
|
div().size_full().on_drop(drop).child(self.web.clone())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn each_owed_render_ends_once_with_the_reason(cx: &mut gpui::TestAppContext) {
|
||||||
|
let got = Rc::new(RefCell::new(Vec::<(u8, Result<Value, String>)>::new()));
|
||||||
|
let pending = (0..2)
|
||||||
|
.map(|n| {
|
||||||
|
let got = got.clone();
|
||||||
|
Box::new(move |rendered, _: &mut gpui::App| got.borrow_mut().push((n, rendered))) as super::Rendered
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
cx.update(|cx| super::end_renders(pending, "denied", cx));
|
||||||
|
cx.run_until_parked();
|
||||||
|
let denied = || Err("denied".to_string());
|
||||||
|
assert_eq!(*got.borrow(), [(0, denied()), (1, denied())]);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ let pending = null
|
|||||||
let current = null
|
let current = null
|
||||||
// A mode asked for while an editor builds.
|
// A mode asked for while an editor builds.
|
||||||
let asked = null
|
let asked = null
|
||||||
|
// The text no editor could show; a mode retries it.
|
||||||
|
let broken = null
|
||||||
// A place to show once the editor is built.
|
// A place to show once the editor is built.
|
||||||
let revealing = null
|
let revealing = null
|
||||||
// The search in force, kept across modes.
|
// The search in force, kept across modes.
|
||||||
@@ -43,6 +45,9 @@ function flush() {
|
|||||||
pending = null
|
pending = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A link the user opens; the app decides. */
|
||||||
|
const linked = (href) => tell({ kind: 'link', generation, href })
|
||||||
|
|
||||||
/** The shown editor's headings, for the app's outline. */
|
/** The shown editor's headings, for the app's outline. */
|
||||||
function outline() {
|
function outline() {
|
||||||
if (current) tell({ kind: 'outline', generation, items: current.outline() })
|
if (current) tell({ kind: 'outline', generation, items: current.outline() })
|
||||||
@@ -64,11 +69,21 @@ function changed(text) {
|
|||||||
revised(text)
|
revised(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An editor for `mode`; visual falls back to source. */
|
/** An editor for `mode`; a failed one leaves no holder. */
|
||||||
async function build(text, mode) {
|
async function build(text, mode) {
|
||||||
const holder = document.createElement('div')
|
const holder = document.createElement('div')
|
||||||
holder.hidden = true
|
holder.hidden = true
|
||||||
document.getElementById('document').append(holder)
|
document.getElementById('document').append(holder)
|
||||||
|
try {
|
||||||
|
return await fill(holder, text, mode)
|
||||||
|
} catch (error) {
|
||||||
|
holder.remove()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds in `holder`; visual falls back to source. */
|
||||||
|
async function fill(holder, text, mode) {
|
||||||
const entry = { mode, holder, fallback: false }
|
const entry = { mode, holder, fallback: false }
|
||||||
if (mode !== 'source') {
|
if (mode !== 'source') {
|
||||||
const [front, body] = split(text)
|
const [front, body] = split(text)
|
||||||
@@ -81,7 +96,13 @@ async function build(text, mode) {
|
|||||||
const place = document.createElement('div')
|
const place = document.createElement('div')
|
||||||
holder.append(place)
|
holder.append(place)
|
||||||
const edited = (write) => changed(() => front + write())
|
const edited = (write) => changed(() => front + write())
|
||||||
const made = await visual(place, body, mode === 'visual', texts, edited, edge)
|
let made
|
||||||
|
try {
|
||||||
|
made = await visual(place, body, mode === 'visual', texts, edited, edge, linked)
|
||||||
|
} catch (error) {
|
||||||
|
// A broken editor falls back to source and says so.
|
||||||
|
entry.broke = error.name
|
||||||
|
}
|
||||||
if (made) {
|
if (made) {
|
||||||
// Lines count from the file's top, front matter included.
|
// Lines count from the file's top, front matter included.
|
||||||
const above = front ? front.split('\n').length - 1 : 0
|
const above = front ? front.split('\n').length - 1 : 0
|
||||||
@@ -117,15 +138,27 @@ function drop(entry) {
|
|||||||
async function show(text, want) {
|
async function show(text, want) {
|
||||||
const turn = ++latest
|
const turn = ++latest
|
||||||
asked = null
|
asked = null
|
||||||
|
broken = null
|
||||||
if (current) drop(current)
|
if (current) drop(current)
|
||||||
current = null
|
current = null
|
||||||
const entry = await build(text, want)
|
let entry
|
||||||
|
try {
|
||||||
|
entry = await build(text, want)
|
||||||
|
} catch (error) {
|
||||||
|
if (turn !== latest) return
|
||||||
|
// Source failed too: tell the app, keep the text.
|
||||||
|
broken = text
|
||||||
|
tell({ kind: 'failed', generation, what: error.name })
|
||||||
|
const next = asked
|
||||||
|
asked = null
|
||||||
|
return next && mode(next)
|
||||||
|
}
|
||||||
if (turn !== latest) return drop(entry)
|
if (turn !== latest) return drop(entry)
|
||||||
current = entry
|
current = entry
|
||||||
entry.holder.hidden = false
|
entry.holder.hidden = false
|
||||||
document.getElementById('document').dataset.mode = entry.mode
|
document.getElementById('document').dataset.mode = entry.mode
|
||||||
entry.focus()
|
entry.focus()
|
||||||
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback })
|
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback, broke: entry.broke ?? null })
|
||||||
// A newer request owns what is queued now.
|
// A newer request owns what is queued now.
|
||||||
if (turn !== latest) return
|
if (turn !== latest) return
|
||||||
outline()
|
outline()
|
||||||
@@ -176,10 +209,12 @@ async function open(text, want, words, load, folder, place) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Changed on disk: the caret and scroll stay near. */
|
/** Changed on disk: the caret and scroll stay near. */
|
||||||
async function refresh(text) {
|
async function refresh(text, load) {
|
||||||
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
|
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
pending = null
|
pending = null
|
||||||
|
generation = load
|
||||||
|
version = 0
|
||||||
times = [text]
|
times = [text]
|
||||||
at = 0
|
at = 0
|
||||||
await show(text, current?.mode ?? 'visual')
|
await show(text, current?.mode ?? 'visual')
|
||||||
@@ -215,6 +250,7 @@ window.addEventListener('scroll', placed, { passive: true })
|
|||||||
/** Another mode for the same text; edits are sent first. */
|
/** Another mode for the same text; edits are sent first. */
|
||||||
function mode(next) {
|
function mode(next) {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
|
if (broken !== null) return show(broken, next)
|
||||||
asked = next
|
asked = next
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -243,7 +279,8 @@ function reveal(place) {
|
|||||||
}
|
}
|
||||||
const shown =
|
const shown =
|
||||||
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query)
|
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query)
|
||||||
if (shown === false) console.warn(`ely: nothing at ${JSON.stringify(place)}`)
|
// The app logs a line number, never the query.
|
||||||
|
if (shown === false) tell({ kind: 'missed', generation, line: place.line ?? null })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The app's find box hears where the search stands. */
|
/** The app's find box hears where the search stands. */
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ async function draw(tree) {
|
|||||||
try {
|
try {
|
||||||
const svg = await paint(node.value, 'paper')
|
const svg = await paint(node.value, 'paper')
|
||||||
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
|
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Lines only: Mermaid's message quotes the document.
|
// Lines only: Mermaid's message quotes the document.
|
||||||
console.warn(`ely: a diagram did not draw: ${error.name}`)
|
|
||||||
failed.push(node.position.start.line)
|
failed.push(node.position.start.line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { search } from 'prosemirror-search'
|
|||||||
import { style } from './style.js'
|
import { style } from './style.js'
|
||||||
|
|
||||||
/** An editor over `markdown`; null when blocks cannot map. */
|
/** An editor over `markdown`; null when blocks cannot map. */
|
||||||
export async function visual(root, markdown, editable, texts, changed, edge) {
|
export async function visual(root, markdown, editable, texts, changed, edge, linked) {
|
||||||
const owner = diagrams()
|
const owner = diagrams()
|
||||||
let write = () => ({ text: markdown, spans: [] })
|
let write = () => ({ text: markdown, spans: [] })
|
||||||
// Setting up raw blocks is no edit.
|
// Setting up raw blocks is no edit.
|
||||||
@@ -71,6 +71,29 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
// Preview: a click opens. Editor: Cmd (Ctrl elsewhere).
|
||||||
|
const mac = navigator.platform.startsWith('Mac')
|
||||||
|
const links = $prose(
|
||||||
|
() =>
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
click: (view, event) => {
|
||||||
|
const anchor = event.target.closest?.('a')
|
||||||
|
if (!anchor || !view.dom.contains(anchor)) return false
|
||||||
|
event.preventDefault()
|
||||||
|
if (editable && !(mac ? event.metaKey : event.ctrlKey)) return true
|
||||||
|
// The DOM href is sanitized; the mark is not.
|
||||||
|
const node = view.state.doc.nodeAt(view.posAtDOM(anchor, 0))
|
||||||
|
const mark = node?.marks.find((mark) => mark.type.name === 'link')
|
||||||
|
if (!mark) throw new Error('ely: a link without its mark')
|
||||||
|
linked(mark.attrs.href)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
// Past their history, undo and redo reach the page.
|
// Past their history, undo and redo reach the page.
|
||||||
const ends = $prose(() =>
|
const ends = $prose(() =>
|
||||||
keymap({
|
keymap({
|
||||||
@@ -103,6 +126,7 @@ export async function visual(root, markdown, editable, texts, changed, edge) {
|
|||||||
.use(watch)
|
.use(watch)
|
||||||
.use(current)
|
.use(current)
|
||||||
.use(ends)
|
.use(ends)
|
||||||
|
.use(links)
|
||||||
.use($prose(() => search()))
|
.use($prose(() => search()))
|
||||||
.create()
|
.create()
|
||||||
const editor = await made.catch((error) => {
|
const editor = await made.catch((error) => {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
build = "../../packaging/windows/icon.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ely-files.workspace = true
|
ely-files.workspace = true
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, ca
|
|||||||
use crate::content::{Aim, Tool};
|
use crate::content::{Aim, Tool};
|
||||||
use crate::layout::Placed;
|
use crate::layout::Placed;
|
||||||
use crate::pages::{rect_on, to_view};
|
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.
|
/// How far a click may move, in window pixels.
|
||||||
pub(crate) const CLICK: f32 = 3.0;
|
pub(crate) const CLICK: f32 = 3.0;
|
||||||
@@ -77,31 +77,49 @@ impl PdfView {
|
|||||||
if self.reading_notes {
|
if self.reading_notes {
|
||||||
return;
|
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() {
|
if wanted.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let asked = wanted.clone();
|
||||||
self.reading_notes = true;
|
self.reading_notes = true;
|
||||||
let (doc, version) = (self.doc, self.version);
|
let (doc, version) = (self.doc, self.version);
|
||||||
self.off(
|
self.off(
|
||||||
|
// Page by page: one bad page spares the rest.
|
||||||
move |worker| {
|
move |worker| {
|
||||||
(wanted.into_iter())
|
let read = (wanted.into_iter())
|
||||||
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?)))
|
.map(|page| (page, worker.marks(doc, page).and_then(|marks| Ok((marks, worker.links(doc, page)?)))))
|
||||||
.collect::<std::io::Result<Vec<_>>>()
|
.collect::<Vec<_>>();
|
||||||
|
Ok(read)
|
||||||
},
|
},
|
||||||
move |view, read, cx| {
|
move |view, read: std::io::Result<Vec<_>>, cx| {
|
||||||
view.reading_notes = false;
|
view.reading_notes = false;
|
||||||
if view.version != version {
|
if view.version != version {
|
||||||
return cx.notify();
|
return cx.notify();
|
||||||
}
|
}
|
||||||
match read {
|
// No worker at all: every page asked failed.
|
||||||
Ok(read) => {
|
let read = read.unwrap_or_else(|error| {
|
||||||
for (page, marks, links) in read {
|
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.notes.insert(page, marks);
|
||||||
view.links.insert(page, links);
|
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();
|
cx.notify();
|
||||||
},
|
},
|
||||||
@@ -111,7 +129,7 @@ impl PdfView {
|
|||||||
|
|
||||||
/// Reads every field name in the form, once an edit.
|
/// Reads every field name in the form, once an edit.
|
||||||
pub fn ask_names(&mut self, cx: &mut Context<Self>) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
self.reading_names = true;
|
self.reading_names = true;
|
||||||
@@ -125,7 +143,10 @@ impl PdfView {
|
|||||||
}
|
}
|
||||||
match read {
|
match read {
|
||||||
Ok(names) => view.form_names = Some(names),
|
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();
|
cx.notify();
|
||||||
},
|
},
|
||||||
@@ -254,11 +275,11 @@ impl PdfView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let author = ely_ui::settings::current(cx).author.clone();
|
let author = ely_ui::settings::current(cx).author.clone();
|
||||||
self.settled_edit(what, cx, move |worker, doc| {
|
self.edit_at(what, move |worker, doc| {
|
||||||
let name = ely_platform::user::named(&author)?;
|
let name = ely_platform::user::named(&author)?;
|
||||||
annots.iter_mut().for_each(|annot| annot.author = name.clone());
|
annots.iter_mut().for_each(|annot| annot.author = name.clone());
|
||||||
worker.annotate(doc, &annots)
|
worker.annotate(doc, &annots)
|
||||||
});
|
}, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A press with a drawing or form tool.
|
/// A press with a drawing or form tool.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::document::{RemoveAttachment, SaveAttachment};
|
use crate::document::{RemoveAttachment, SaveAttachment};
|
||||||
use crate::view::PdfView;
|
use crate::view::{PdfView, Unread};
|
||||||
use crate::viewer::Viewer;
|
use crate::viewer::Viewer;
|
||||||
|
|
||||||
/// A size as people read it.
|
/// A size as people read it.
|
||||||
@@ -30,7 +30,7 @@ pub fn size(bytes: u64) -> String {
|
|||||||
impl PdfView {
|
impl PdfView {
|
||||||
/// Reads the attachments once an edit.
|
/// Reads the attachments once an edit.
|
||||||
pub fn ask_files(&mut self, cx: &mut Context<Self>) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
self.reading_files = true;
|
self.reading_files = true;
|
||||||
@@ -44,7 +44,10 @@ impl PdfView {
|
|||||||
}
|
}
|
||||||
match read {
|
match read {
|
||||||
Ok(files) => view.files = Some(files),
|
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();
|
cx.notify();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, di
|
|||||||
|
|
||||||
use crate::layout::Placed;
|
use crate::layout::Placed;
|
||||||
use crate::pages::rect_on;
|
use crate::pages::rect_on;
|
||||||
use crate::view::PdfView;
|
use crate::view::{PdfView, Unread};
|
||||||
|
|
||||||
/// What a press on a page does.
|
/// What a press on a page does.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
@@ -139,7 +139,9 @@ impl PdfView {
|
|||||||
if !self.tool.edits() || self.reading_layer {
|
if !self.tool.edits() || self.reading_layer {
|
||||||
return;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
self.reading_layer = true;
|
self.reading_layer = true;
|
||||||
@@ -156,7 +158,10 @@ impl PdfView {
|
|||||||
Ok(layer) => {
|
Ok(layer) => {
|
||||||
view.layers.insert(page, 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();
|
cx.notify();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ pub struct Queued {
|
|||||||
doc: Doc,
|
doc: Doc,
|
||||||
/// The edit may add, drop, move or reshape pages.
|
/// The edit may add, drop, move or reshape pages.
|
||||||
pages: bool,
|
pages: bool,
|
||||||
|
/// Aimed at a point: runs on the state it meets.
|
||||||
|
follow: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The document as an edit left it.
|
/// The document as an edit left it.
|
||||||
@@ -41,6 +43,23 @@ impl PdfView {
|
|||||||
self.queue_edit(what, None, false, op, cx);
|
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.
|
/// Queues an edit to the pages themselves.
|
||||||
pub fn edit_pages(
|
pub fn edit_pages(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -62,7 +81,7 @@ impl PdfView {
|
|||||||
) {
|
) {
|
||||||
log::info!("pdf: queues {what}");
|
log::info!("pdf: queues {what}");
|
||||||
// Aimed at what is shown now; later states refuse it.
|
// 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);
|
self.next_edit(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +89,12 @@ impl PdfView {
|
|||||||
if self.editing {
|
if self.editing {
|
||||||
return;
|
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;
|
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(
|
self.off(
|
||||||
move |worker| {
|
move |worker| {
|
||||||
let mark = op(worker, doc)?;
|
let mark = op(worker, doc)?;
|
||||||
@@ -83,7 +104,7 @@ impl PdfView {
|
|||||||
Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? })
|
Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? })
|
||||||
},
|
},
|
||||||
move |view, fresh, cx| {
|
move |view, fresh, cx| {
|
||||||
view.editing = false;
|
(view.editing, view.moving) = (false, false);
|
||||||
match fresh {
|
match fresh {
|
||||||
Ok(fresh) => {
|
Ok(fresh) => {
|
||||||
view.refresh(fresh, pages, cx);
|
view.refresh(fresh, pages, cx);
|
||||||
@@ -128,6 +149,7 @@ impl PdfView {
|
|||||||
}
|
}
|
||||||
self.marks = fresh.marks;
|
self.marks = fresh.marks;
|
||||||
self.failed.clear();
|
self.failed.clear();
|
||||||
|
self.unread.clear();
|
||||||
self.text.clear();
|
self.text.clear();
|
||||||
self.asking_text.clear();
|
self.asking_text.clear();
|
||||||
self.thumbs_failed.clear();
|
self.thumbs_failed.clear();
|
||||||
@@ -151,8 +173,10 @@ impl PdfView {
|
|||||||
/// The file now holds history `mark`.
|
/// The file now holds history `mark`.
|
||||||
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
|
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
|
||||||
self.saved = mark.to_string();
|
self.saved = mark.to_string();
|
||||||
|
self.saves += 1;
|
||||||
// The file changed; read it again.
|
// The file changed; read it again.
|
||||||
self.signatures = None;
|
self.signatures = None;
|
||||||
|
self.unread.retain(|unread| !matches!(unread, crate::view::Unread::Signatures(_)));
|
||||||
cx.emit(Reported);
|
cx.emit(Reported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,12 @@ use gpui::Context;
|
|||||||
|
|
||||||
use crate::view::{Failed, PdfView};
|
use crate::view::{Failed, PdfView};
|
||||||
|
|
||||||
/// Addresses that may leave the document.
|
|
||||||
pub fn outward(uri: &str) -> bool {
|
|
||||||
["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A target as typed: a page number or an address.
|
/// A target as typed: a page number or an address.
|
||||||
pub fn parse(typed: &str, pages: usize) -> Option<Target> {
|
pub fn parse(typed: &str, pages: usize) -> Option<Target> {
|
||||||
let typed = typed.trim();
|
let typed = typed.trim();
|
||||||
match typed.parse::<usize>() {
|
match typed.parse::<usize>() {
|
||||||
Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)),
|
Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)),
|
||||||
Err(_) => outward(typed).then(|| Target::Web(typed.to_string())),
|
Err(_) => ely_office::outward(typed).then(|| Target::Web(typed.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,12 +30,12 @@ impl PdfView {
|
|||||||
log::info!("pdf: follows a link to page {}", page + 1);
|
log::info!("pdf: follows a link to page {}", page + 1);
|
||||||
self.show(page, link.y, cx)
|
self.show(page, link.y, cx)
|
||||||
}
|
}
|
||||||
None if outward(&link.uri) => {
|
None if ely_office::outward(&link.uri) => {
|
||||||
log::info!("pdf: opens a link outside");
|
log::info!("pdf: opens a link outside");
|
||||||
cx.open_url(&link.uri)
|
cx.open_url(&link.uri)
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let text = crate::viewer::Viewer::text(cx, "pdf.link.refused", &[]);
|
let text = crate::viewer::Viewer::text(cx, "office.link.refused", &[]);
|
||||||
cx.emit(Failed(text.to_string()))
|
cx.emit(Failed(text.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Release builds open no console on Windows.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod annotate;
|
mod annotate;
|
||||||
mod ask;
|
mod ask;
|
||||||
mod attach;
|
mod attach;
|
||||||
|
|||||||
@@ -165,10 +165,12 @@ impl Viewer {
|
|||||||
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
|
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
|
||||||
return self.toast(summary, Severity::Info, cx);
|
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)
|
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.
|
/// The OCR dialog: the ask, then the progress.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ impl Viewer {
|
|||||||
id,
|
id,
|
||||||
move |worker, doc| worker.get()?.print_copy(*doc, &out),
|
move |worker, doc| worker.get()?.print_copy(*doc, &out),
|
||||||
move |viewer, done, _, cx| match done {
|
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),
|
Err(error) => viewer.failed(&error, cx),
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
@@ -203,6 +203,8 @@ impl Viewer {
|
|||||||
Err(error) => return self.failed(&error, cx),
|
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());
|
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();
|
let opened = target.clone();
|
||||||
log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" });
|
log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" });
|
||||||
self.read_in(
|
self.read_in(
|
||||||
@@ -214,7 +216,7 @@ impl Viewer {
|
|||||||
}
|
}
|
||||||
let bytes = std::fs::read(&work)?;
|
let bytes = std::fs::read(&work)?;
|
||||||
std::fs::remove_file(&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)?;
|
ely_files::export(&target, path.as_deref(), &bytes, revision)?;
|
||||||
Ok((before, bytes.len() as u64))
|
Ok((before, bytes.len() as u64))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ impl Viewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn protect_rows(&self, on: bool, user: &Entity<TextInput>, owner: &Entity<TextInput>, allow: u32, this: &Entity<Self>, cx: &mut Context<Self>) -> AnyElement {
|
fn protect_rows(&self, on: bool, user: &Entity<TextInput>, owner: &Entity<TextInput>, allow: u32, this: &Entity<Self>, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let set = |change: Box<dyn Fn(&mut bool, &mut u32, bool)>| {
|
type Change = Box<dyn Fn(&mut bool, &mut u32, bool)>;
|
||||||
|
let set = |change: Change| {
|
||||||
let this = this.clone();
|
let this = this.clone();
|
||||||
move |value: bool, _: &mut Window, cx: &mut App| {
|
move |value: bool, _: &mut Window, cx: &mut App| {
|
||||||
this.update(cx, |viewer, cx| {
|
this.update(cx, |viewer, cx| {
|
||||||
|
|||||||
@@ -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 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::redact::beside;
|
||||||
use crate::view::PdfView;
|
use crate::view::{PdfView, Unread};
|
||||||
use crate::viewer::Viewer;
|
use crate::viewer::Viewer;
|
||||||
|
|
||||||
/// A signature field and the system's word on its certificate.
|
/// A signature field and the system's word on its certificate.
|
||||||
@@ -47,26 +47,34 @@ pub struct Signer {
|
|||||||
impl PdfView {
|
impl PdfView {
|
||||||
/// Reads and checks the signatures of the file at `path`.
|
/// Reads and checks the signatures of the file at `path`.
|
||||||
pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
self.reading_signatures = true;
|
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(
|
self.off(
|
||||||
move |worker| {
|
move |worker| {
|
||||||
let fields = worker.signatures(doc, &path)?;
|
let fields = worker.signatures(doc, &path)?;
|
||||||
Ok(fields.into_iter().map(|field| {
|
Ok(Iterator::collect::<Vec<_>>(fields.into_iter().map(|field| {
|
||||||
let chain = field.signed.as_ref().map(|signed| signed.certs.clone()).filter(|certs| !certs.is_empty());
|
let chain = field.signed.as_ref().map(|signed| signed.certs.clone()).filter(|certs| !certs.is_empty());
|
||||||
let trust = chain.map(|chain| trust::check(&chain).map_err(|error| error.to_string()));
|
let trust = chain.map(|chain| trust::check(&chain).map_err(|error| error.to_string()));
|
||||||
Checked { field, trust }
|
Checked { field, trust }
|
||||||
}))
|
})))
|
||||||
.map(Iterator::collect::<Vec<_>>)
|
|
||||||
},
|
},
|
||||||
move |view, read: io::Result<Vec<Checked>>, cx| {
|
move |view, read: io::Result<Vec<Checked>>, cx| {
|
||||||
view.reading_signatures = false;
|
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 {
|
match read {
|
||||||
Ok(read) => view.signatures = Some((asked, 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();
|
cx.notify();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mod notes;
|
|||||||
mod organize;
|
mod organize;
|
||||||
mod output;
|
mod output;
|
||||||
mod protect;
|
mod protect;
|
||||||
|
mod reads;
|
||||||
mod removal;
|
mod removal;
|
||||||
mod sign;
|
mod sign;
|
||||||
mod structure;
|
mod structure;
|
||||||
@@ -71,6 +72,12 @@ pub(crate) fn windowed<'a>(
|
|||||||
ely_ui::i18n::install(cx);
|
ely_ui::i18n::install(cx);
|
||||||
});
|
});
|
||||||
cx.update(crate::keys::bind);
|
cx.update(crate::keys::bind);
|
||||||
|
cx.update(|cx| {
|
||||||
|
// As document_root does: menus before the window.
|
||||||
|
let menus = crate::keys::menus(cx);
|
||||||
|
let file = &ely_ui::commands::FILE;
|
||||||
|
cx.set_menus(ely_ui::commands::menus(ely_platform::AppId::Pdf, file, true, menus, cx));
|
||||||
|
});
|
||||||
let held = match worker {
|
let held = match worker {
|
||||||
true => Mupdf::with(Worker::spawn().expect("the worker")),
|
true => Mupdf::with(Worker::spawn().expect("the worker")),
|
||||||
false => Mupdf::default(),
|
false => Mupdf::default(),
|
||||||
|
|||||||
@@ -397,3 +397,33 @@ fn a_small_picture_on_text_under_a_page_form_still_aims() {
|
|||||||
click(cx, &view, (140.0, 66.0));
|
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");
|
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");
|
||||||
|
}
|
||||||
@@ -315,3 +315,48 @@ fn a_default_name_avoids_a_field_with_no_widget() {
|
|||||||
send(cx, &viewer, Change::Design(typed, Vec::new()));
|
send(cx, &viewer, Change::Design(typed, Vec::new()));
|
||||||
assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands");
|
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");
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() {
|
|||||||
bytes.truncate(at);
|
bytes.truncate(at);
|
||||||
bytes.extend_from_slice(b"startxref\n9\n%%EOF\n");
|
bytes.extend_from_slice(b"startxref\n9\n%%EOF\n");
|
||||||
std::fs::write(&path, &bytes).expect("writes");
|
std::fs::write(&path, &bytes).expect("writes");
|
||||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
|
let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
|
||||||
let view = shown(cx, &viewer);
|
let view = shown(cx, &viewer);
|
||||||
assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Repaired));
|
assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Repaired));
|
||||||
assert!(cx.update(|_, app| view.read(app).repair.as_ref().is_some_and(|report| !report.is_empty())));
|
assert!(cx.update(|_, app| view.read(app).repair.as_ref().is_some_and(|report| !report.is_empty())));
|
||||||
@@ -158,7 +158,7 @@ fn a_picture_that_breaks_as_it_draws_stops_an_overwrite() {
|
|||||||
let dir = tempfile::tempdir().expect("a temp dir");
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
let mut cx = TestAppContext::single();
|
let mut cx = TestAppContext::single();
|
||||||
let (path, bytes) = broken_picture(dir.path(), 792.0, 600.0);
|
let (path, bytes) = broken_picture(dir.path(), 792.0, 600.0);
|
||||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
|
let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
|
||||||
shown(cx, &viewer);
|
shown(cx, &viewer);
|
||||||
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
|
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
|
||||||
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
|
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
|
||||||
@@ -184,7 +184,7 @@ fn a_picture_never_drawn_is_checked_before_an_overwrite() {
|
|||||||
let path = dir.path().join("long.pdf");
|
let path = dir.path().join("long.pdf");
|
||||||
worker.save(doc, &path).expect("saves");
|
worker.save(doc, &path).expect("saves");
|
||||||
let bytes = std::fs::read(&path).expect("reads");
|
let bytes = std::fs::read(&path).expect("reads");
|
||||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
|
let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
|
||||||
shown(cx, &viewer);
|
shown(cx, &viewer);
|
||||||
for _ in 0..10 {
|
for _ in 0..10 {
|
||||||
super::frames(cx);
|
super::frames(cx);
|
||||||
@@ -195,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));
|
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");
|
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");
|
||||||
|
}
|
||||||
@@ -158,7 +158,7 @@ fn a_signed_file_saved_as_a_copy_reopens_as_the_copy() {
|
|||||||
settle(cx, |app| view.read(app).edited == 1);
|
settle(cx, |app| view.read(app).edited == 1);
|
||||||
let copy = dir.path().join("copy.pdf");
|
let copy = dir.path().join("copy.pdf");
|
||||||
let reopened = save_signed_as(cx, &viewer, &view, ©);
|
let reopened = save_signed_as(cx, &viewer, &view, ©);
|
||||||
let tab = cx.update(|_, app| (viewer.read(app).active_tab().map(|tab| (tab.risk, tab.dirty(app)))));
|
let tab = cx.update(|_, app| viewer.read(app).active_tab().map(|tab| (tab.risk, tab.dirty(app))));
|
||||||
assert_eq!(tab, Some((None, false)), "an unsigned, saved copy");
|
assert_eq!(tab, Some((None, false)), "an unsigned, saved copy");
|
||||||
assert!(!shows_signed(cx, &viewer, &reopened), "the copy holds no signature");
|
assert!(!shows_signed(cx, &viewer, &reopened), "the copy holds no signature");
|
||||||
assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the edit is in the copy");
|
assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the edit is in the copy");
|
||||||
@@ -212,7 +212,7 @@ fn reopening_the_dialog_checks_the_file_as_it_is_now() {
|
|||||||
let dir = tempfile::tempdir().expect("a temp dir");
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
let mut cx = TestAppContext::single();
|
let mut cx = TestAppContext::single();
|
||||||
let path = signed(dir.path());
|
let path = signed(dir.path());
|
||||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
|
let (viewer, cx) = windowed(&mut cx, dir.path(), std::slice::from_ref(&path), true);
|
||||||
let view = shown(cx, &viewer);
|
let view = shown(cx, &viewer);
|
||||||
let digest = |cx: &mut VisualTestContext| {
|
let digest = |cx: &mut VisualTestContext| {
|
||||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx)));
|
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx)));
|
||||||
|
|||||||
@@ -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");
|
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);
|
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.
|
/// The document changed what it shows.
|
||||||
pub struct Reported;
|
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 {
|
pub struct PdfView {
|
||||||
worker: Arc<Mutex<Mupdf>>,
|
worker: Arc<Mutex<Mupdf>>,
|
||||||
pub doc: Doc,
|
pub doc: Doc,
|
||||||
@@ -41,6 +52,8 @@ pub struct PdfView {
|
|||||||
pub painted: f64,
|
pub painted: f64,
|
||||||
/// Tiles the worker could not draw; not asked again.
|
/// Tiles the worker could not draw; not asked again.
|
||||||
pub failed: HashSet<TileKey>,
|
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.
|
/// Old tiles shown under their page until covered.
|
||||||
pub backdrop: Vec<crate::backdrop::Old>,
|
pub backdrop: Vec<crate::backdrop::Old>,
|
||||||
pub text: HashMap<usize, Arc<Vec<Char>>>,
|
pub text: HashMap<usize, Arc<Vec<Char>>>,
|
||||||
@@ -65,9 +78,13 @@ pub struct PdfView {
|
|||||||
/// Edits waiting their turn, and whether one runs.
|
/// Edits waiting their turn, and whether one runs.
|
||||||
pub queue: VecDeque<crate::edits::Queued>,
|
pub queue: VecDeque<crate::edits::Queued>,
|
||||||
pub editing: bool,
|
pub editing: bool,
|
||||||
|
/// The running edit may move pages.
|
||||||
|
pub moving: bool,
|
||||||
/// History marks: now, and at the last load or save.
|
/// History marks: now, and at the last load or save.
|
||||||
pub mark: String,
|
pub mark: String,
|
||||||
pub saved: 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.
|
/// Page pictures for the side panel, by page and darkness.
|
||||||
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
|
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
|
||||||
pub thumbs_failed: HashSet<usize>,
|
pub thumbs_failed: HashSet<usize>,
|
||||||
@@ -171,6 +188,7 @@ impl PdfView {
|
|||||||
retired: Default::default(),
|
retired: Default::default(),
|
||||||
painted: 0.0,
|
painted: 0.0,
|
||||||
failed: HashSet::new(),
|
failed: HashSet::new(),
|
||||||
|
unread: HashSet::new(),
|
||||||
backdrop: Vec::new(),
|
backdrop: Vec::new(),
|
||||||
text: HashMap::new(),
|
text: HashMap::new(),
|
||||||
asking_text: HashSet::new(),
|
asking_text: HashSet::new(),
|
||||||
@@ -186,7 +204,9 @@ impl PdfView {
|
|||||||
edited: 0,
|
edited: 0,
|
||||||
queue: VecDeque::new(),
|
queue: VecDeque::new(),
|
||||||
editing: false,
|
editing: false,
|
||||||
|
moving: false,
|
||||||
saved: loaded.mark.clone(),
|
saved: loaded.mark.clone(),
|
||||||
|
saves: 0,
|
||||||
mark: loaded.mark,
|
mark: loaded.mark,
|
||||||
thumbs: HashMap::new(),
|
thumbs: HashMap::new(),
|
||||||
thumbs_failed: HashSet::new(),
|
thumbs_failed: HashSet::new(),
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ impl Host for Viewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn read_at(&self, tab: u64, cx: &gpui::App) -> Option<ely_pdf_engine::Doc> {
|
fn read_at(&self, tab: u64, cx: &gpui::App) -> Option<ely_pdf_engine::Doc> {
|
||||||
self.tab(tab).map(|tab| tab.view.read(cx).doc.clone())
|
self.tab(tab).map(|tab| tab.view.read(cx).doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn observed(&mut self, id: u64, view: &Entity<PdfView>, cx: &mut Context<Self>) {
|
fn observed(&mut self, id: u64, view: &Entity<PdfView>, cx: &mut Context<Self>) {
|
||||||
|
|||||||
@@ -45,8 +45,11 @@ pub struct Making {
|
|||||||
picture: Option<(PathBuf, f32)>,
|
picture: Option<(PathBuf, f32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Points of each stroke.
|
||||||
|
type Strokes = Vec<Vec<(f32, f32)>>;
|
||||||
|
|
||||||
/// Strokes moved to the corner, the longer side 1.
|
/// Strokes moved to the corner, the longer side 1.
|
||||||
pub fn fitted(strokes: &[Vec<(f32, f32)>]) -> Option<(Vec<Vec<(f32, f32)>>, f32)> {
|
pub fn fitted(strokes: &[Vec<(f32, f32)>]) -> Option<(Strokes, f32)> {
|
||||||
if strokes.iter().all(|stroke| stroke.len() < 2) {
|
if strokes.iter().all(|stroke| stroke.len() < 2) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -92,15 +95,17 @@ impl PdfView {
|
|||||||
rgb: 0,
|
rgb: 0,
|
||||||
bx: [at[0], at[1], self.sizes[page].0 - at[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::Done(mark) => Ok(mark),
|
||||||
Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))),
|
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")),
|
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 } => {
|
Stamp::Picture { path, aspect } => {
|
||||||
let at = placed(aspect, rect, click);
|
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());
|
let (asked, viewer) = (writing.clone(), cx.weak_entity());
|
||||||
view.update(cx, |view, cx| {
|
view.update(cx, |view, cx| {
|
||||||
let doc = view.doc;
|
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 |worker| worker.put_text(doc, page, &asked, PREVIEW),
|
||||||
move |_, answer, cx| {
|
move |_, answer, cx| {
|
||||||
// The viewer edits this view; wait until it is free.
|
// The viewer edits this view; wait until it is free.
|
||||||
@@ -95,7 +96,19 @@ impl Viewer {
|
|||||||
replacing: bool,
|
replacing: bool,
|
||||||
cx: &mut Context<Self>,
|
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 {
|
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)) => {
|
Ok(Wrote::Missing(c)) => {
|
||||||
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
|
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
|
||||||
self.edit_panel.update(cx, |panel, cx| {
|
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)? {
|
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||||
Wrote::Done(mark) => Ok(mark),
|
Wrote::Done(mark) => Ok(mark),
|
||||||
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
|
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")),
|
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
@@ -168,25 +182,27 @@ impl Viewer {
|
|||||||
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
||||||
viewer.on_same(&view, version, cx, |view, 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)
|
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(
|
pub(crate) fn on_same(
|
||||||
&self,
|
&self,
|
||||||
view: &gpui::Entity<crate::view::PdfView>,
|
view: &gpui::Entity<crate::view::PdfView>,
|
||||||
version: u64,
|
version: u64,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
|
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
|
||||||
) {
|
) -> bool {
|
||||||
// Landed or queued, an edit since may move the target.
|
// Landed or queued, an edit since may move the target.
|
||||||
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
|
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
|
||||||
if !open || view.read(cx).version != version || !view.read(cx).settled() {
|
if !open || view.read(cx).version != version || !view.read(cx).settled() {
|
||||||
let text = Self::text(cx, "pdf.object.changed", &[]);
|
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);
|
view.update(cx, step);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks for a picture; it lands mid-page.
|
/// Asks for a picture; it lands mid-page.
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
build = "../../packaging/windows/icon.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ely-files.workspace = true
|
ely-files.workspace = true
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ impl Deck {
|
|||||||
return log::info!("show: a chart dialog for tab {tab} came late");
|
return log::info!("show: a chart dialog for tab {tab} came late");
|
||||||
}
|
}
|
||||||
let editing = Editing::new(tab, chart, window, cx);
|
let editing = Editing::new(tab, chart, window, cx);
|
||||||
deck.dialog = Some(Dialog::Chart(Box::new(editing)));
|
// Refused while another dialog is open; it says so.
|
||||||
cx.notify();
|
let _ = deck.open_dialog(Dialog::Chart(Box::new(editing)), cx);
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.detach_and_log_err(cx);
|
.detach_and_log_err(cx);
|
||||||
|
|||||||
+20
-10
@@ -108,6 +108,21 @@ pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Deck {
|
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.
|
/// Reads every slide and master, then lists what to fix.
|
||||||
pub fn check_show(&mut self, cx: &mut Context<Self>) {
|
pub fn check_show(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(tab) = self.active_tab() else {
|
let Some(tab) = self.active_tab() else {
|
||||||
@@ -128,8 +143,8 @@ impl Deck {
|
|||||||
Ok((check, slides)) if deck.dialog_due(asked, id) => {
|
Ok((check, slides)) if deck.dialog_due(asked, id) => {
|
||||||
let found = findings(&check, &slides, &missing);
|
let found = findings(&check, &slides, &missing);
|
||||||
log::info!("show: the check found {} things", found.len());
|
log::info!("show: the check found {} things", found.len());
|
||||||
deck.dialog = Some(Dialog::Check(found));
|
// Refused while another dialog is open; it says so.
|
||||||
cx.notify();
|
let _ = deck.open_dialog(Dialog::Check(found), cx);
|
||||||
}
|
}
|
||||||
Ok(_) => log::info!("show: the check is no longer due"),
|
Ok(_) => log::info!("show: the check is no longer due"),
|
||||||
Err(error) => deck.engine_failed(&format!("the check: {error}"), cx),
|
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 firsts: Vec<Option<usize>> = found.iter().map(|f| f.slides.first().copied()).collect();
|
||||||
let list = list.on_change(move |keys, _, cx| {
|
let list = list.on_change(move |keys, _, cx| {
|
||||||
let first = keys.first().and_then(|key| key.parse::<usize>().ok()).and_then(|at| firsts[at]);
|
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) = first {
|
||||||
if let (Some(part), Some(tab)) = (first, deck.active_tab()) {
|
this.update(cx, |deck, cx| deck.go_to_finding(part, cx));
|
||||||
let view = tab.view.clone();
|
}
|
||||||
view.update(cx, |view, _| view.show_part(part));
|
|
||||||
deck.dialog = None;
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
card.child(list).into_any_element()
|
card.child(list).into_any_element()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ impl Deck {
|
|||||||
}
|
}
|
||||||
(tab.extra.reading, tab.extra.read) = (true, Some(seen));
|
(tab.extra.reading, tab.extra.read) = (true, Some(seen));
|
||||||
let landed = tab.extra.transition_landed(view.read(cx));
|
let landed = tab.extra.transition_landed(view.read(cx));
|
||||||
|
let sized = tab.extra.size_landed(view.read(cx));
|
||||||
let doc = tab.doc.clone();
|
let doc = tab.doc.clone();
|
||||||
ely_lok::read_off(
|
ely_lok::read_off(
|
||||||
&self.core.worker,
|
&self.core.worker,
|
||||||
@@ -296,12 +297,6 @@ impl Deck {
|
|||||||
{
|
{
|
||||||
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
|
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.slides, tab.extra.design) = (slides, Some(design));
|
||||||
tab.extra.objects = Some(objects);
|
tab.extra.objects = Some(objects);
|
||||||
(tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks);
|
(tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks);
|
||||||
@@ -309,6 +304,7 @@ impl Deck {
|
|||||||
tab.extra.settle_transition(landed);
|
tab.extra.settle_transition(landed);
|
||||||
tab.extra.reads += 1;
|
tab.extra.reads += 1;
|
||||||
tab.extra.size = size;
|
tab.extra.size = size;
|
||||||
|
tab.extra.settle_size(sized);
|
||||||
tab.extra.failed.clear();
|
tab.extra.failed.clear();
|
||||||
deck.paint_next(id, cx);
|
deck.paint_next(id, cx);
|
||||||
deck.read_fonts(id, cx);
|
deck.read_fonts(id, cx);
|
||||||
|
|||||||
@@ -59,9 +59,18 @@ impl Deck {
|
|||||||
log::info!("show: the slides already measure {w} x {h}");
|
log::info!("show: the slides already measure {w} x {h}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tab.extra.size_asked = Some((w, h));
|
let (id, view) = (tab.id, tab.view.clone());
|
||||||
let (w, h) = (w.to_string(), h.to_string());
|
let before = view.read(cx).sent();
|
||||||
self.design_command("slide.size", &[&w, &h, "true"], cx);
|
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.
|
/// Enters or leaves the master view.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! ElyShow's one open dialog.
|
//! 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 gpui::{App, Context, Entity, IntoElement as _};
|
||||||
|
|
||||||
use crate::deck::Deck;
|
use crate::deck::Deck;
|
||||||
@@ -41,6 +42,18 @@ pub(crate) fn edit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Deck {
|
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.
|
/// A read that will open a dialog; earlier ones lapse.
|
||||||
pub fn ask_dialog(&mut self) -> u64 {
|
pub fn ask_dialog(&mut self) -> u64 {
|
||||||
self.dialog_asked += 1;
|
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 {
|
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.
|
/// The open dialog, drawn.
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ pub struct Extra {
|
|||||||
pub size: (i64, i64),
|
pub size: (i64, i64),
|
||||||
/// Undo and redo lists at the last read.
|
/// Undo and redo lists at the last read.
|
||||||
pub history: Option<(Vec<Step>, Vec<Step>)>,
|
pub history: Option<(Vec<Step>, Vec<Step>)>,
|
||||||
/// A size sent, in 1/100 mm, until read back.
|
/// A size sent (1/100 mm) and its input, until read.
|
||||||
pub size_asked: Option<(i64, i64)>,
|
pub size_asked: Option<((i64, i64), u64)>,
|
||||||
/// A transition sent for a slide, until read back.
|
/// A transition sent for a slide, until read back.
|
||||||
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
|
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
|
||||||
/// Pictures by slide name; stale ones show until repainted.
|
/// Pictures by slide name; stale ones show until repainted.
|
||||||
@@ -60,6 +60,22 @@ impl Extra {
|
|||||||
/// The size to show, in 1/100 mm: asked, else read.
|
/// The size to show, in 1/100 mm: asked, else read.
|
||||||
pub fn shown_size(&self) -> (i64, i64) {
|
pub fn shown_size(&self) -> (i64, i64) {
|
||||||
self.size_asked
|
self.size_asked
|
||||||
.unwrap_or_else(|| crate::design::mm100(self.size))
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,8 @@ actions!(
|
|||||||
/// Insert and View; View checks what is on.
|
/// Insert and View; View checks what is on.
|
||||||
pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] {
|
pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] {
|
||||||
let label = |key: &str| Deck::text(cx, key, &[]);
|
let label = |key: &str| Deck::text(cx, key, &[]);
|
||||||
let insert = Menu::new(label("menu.insert")).items([
|
let media = ely_platform::media::BUILT.then(|| MenuItem::action(label("show.insert.media"), InsertMedia));
|
||||||
|
let insert = [
|
||||||
MenuItem::action(label("show.insert.text"), InsertTextBox),
|
MenuItem::action(label("show.insert.text"), InsertTextBox),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::action(label("show.insert.rectangle"), InsertRectangle),
|
MenuItem::action(label("show.insert.rectangle"), InsertRectangle),
|
||||||
@@ -42,12 +43,16 @@ pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2]
|
|||||||
MenuItem::action(label("show.insert.connector"), InsertConnector),
|
MenuItem::action(label("show.insert.connector"), InsertConnector),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::action(label("show.insert.picture"), InsertPicture),
|
MenuItem::action(label("show.insert.picture"), InsertPicture),
|
||||||
MenuItem::action(label("show.insert.media"), InsertMedia),
|
]
|
||||||
|
.into_iter()
|
||||||
|
.chain(media)
|
||||||
|
.chain([
|
||||||
MenuItem::action(label("show.insert.table"), InsertTable),
|
MenuItem::action(label("show.insert.table"), InsertTable),
|
||||||
MenuItem::action(label("show.insert.chart"), InsertChart),
|
MenuItem::action(label("show.insert.chart"), InsertChart),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::action(label("show.insert.remark"), InsertRemark),
|
MenuItem::action(label("show.insert.remark"), InsertRemark),
|
||||||
]);
|
]);
|
||||||
|
let insert = Menu::new(label("menu.insert")).items(insert);
|
||||||
let view = Menu::new(label("menu.view")).items([
|
let view = Menu::new(label("menu.view")).items([
|
||||||
MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks),
|
MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
@@ -150,3 +155,26 @@ impl Deck {
|
|||||||
self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx);
|
self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn insert_media_shows_only_where_it_plays() {
|
||||||
|
let cx = gpui::TestAppContext::single();
|
||||||
|
cx.update(ely_gpui_component::init_for_tests);
|
||||||
|
let (offered, total) = cx.update(|cx| {
|
||||||
|
ely_ui::settings::install_defaults(cx);
|
||||||
|
ely_ui::i18n::install(cx);
|
||||||
|
let media = crate::deck::Deck::text(cx, "show.insert.media", &[]);
|
||||||
|
let [insert, _] = super::menus((false, false, false), cx);
|
||||||
|
let names = insert.items.iter().filter_map(|item| match item {
|
||||||
|
gpui::MenuItem::Action { name, .. } => Some(name.clone()),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let names: Vec<_> = names.collect();
|
||||||
|
(names.contains(&media), names.len())
|
||||||
|
});
|
||||||
|
assert_eq!(offered, ely_platform::media::BUILT);
|
||||||
|
assert!(total >= 11, "the rest stay: {total}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Release builds open no console on Windows.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod chart;
|
mod chart;
|
||||||
mod check;
|
mod check;
|
||||||
mod deck;
|
mod deck;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use ely_lok::{LokView, show::Media};
|
|||||||
use ely_office::prelude::*;
|
use ely_office::prelude::*;
|
||||||
use ely_platform::media::{Player, State};
|
use ely_platform::media::{Player, State};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Styled as _, SurfaceSource,
|
AnyElement, App, Context, Entity, ParentElement as _, Styled as _, SurfaceSource,
|
||||||
TaskExt as _, Window, div,
|
TaskExt as _, Window, div,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,6 +275,7 @@ pub(crate) fn video(
|
|||||||
frame: SurfaceSource,
|
frame: SurfaceSource,
|
||||||
(at, size): (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>),
|
(at, size): (gpui::Point<gpui::Pixels>, gpui::Point<gpui::Pixels>),
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
|
use gpui::IntoElement as _;
|
||||||
gpui::surface(frame)
|
gpui::surface(frame)
|
||||||
.absolute()
|
.absolute()
|
||||||
.left(at.x)
|
.left(at.x)
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ impl Deck {
|
|||||||
match ely_lok::templates("presnt") {
|
match ely_lok::templates("presnt") {
|
||||||
Ok(templates) => {
|
Ok(templates) => {
|
||||||
log::info!("show: {} bundled templates", templates.len());
|
log::info!("show: {} bundled templates", templates.len());
|
||||||
self.dialog = Some(Dialog::New(Choosing { templates }));
|
// Refused while another dialog is open; it says so.
|
||||||
cx.notify();
|
let _ = self.open_dialog(Dialog::New(Choosing { templates }), cx);
|
||||||
}
|
}
|
||||||
Err(error) => self.engine_failed(&error.to_string(), cx),
|
Err(error) => self.engine_failed(&error.to_string(), cx),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ pub struct NotesField {
|
|||||||
field: Option<Entity<TextInput>>,
|
field: Option<Entity<TextInput>>,
|
||||||
shown: Option<(u64, usize, String)>,
|
shown: Option<(u64, usize, String)>,
|
||||||
/// The state the shown slide number was read in.
|
/// 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>,
|
_events: Option<gpui::Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ impl Deck {
|
|||||||
return log::warn!("show: notes for slide {part} have no read state");
|
return log::warn!("show: notes for slide {part} have no read state");
|
||||||
};
|
};
|
||||||
log::info!("show: notes for slide {part} changed");
|
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.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx);
|
||||||
self.force_reread(tab, cx);
|
self.force_reread(tab, cx);
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,9 @@ impl Deck {
|
|||||||
if !typing && self.notes.shown.as_ref() != Some(&read) {
|
if !typing && self.notes.shown.as_ref() != Some(&read) {
|
||||||
field.update(cx, |field, cx| field.set_text(notes.text, cx));
|
field.update(cx, |field, cx| field.set_text(notes.text, cx));
|
||||||
self.notes.shown = Some(read);
|
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)
|
Some(field)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ impl Deck {
|
|||||||
|worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?),
|
|worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?),
|
||||||
move |deck, read, _, cx| match read {
|
move |deck, read, _, cx| match read {
|
||||||
Ok(slides) if deck.dialog_due(asked, id) => {
|
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,
|
tab: id,
|
||||||
print,
|
print,
|
||||||
kind: Kind::Slides,
|
kind: Kind::Slides,
|
||||||
@@ -156,8 +157,8 @@ impl Deck {
|
|||||||
per: 3,
|
per: 3,
|
||||||
current,
|
current,
|
||||||
hidden: slides.iter().map(|slide| slide.hidden).collect(),
|
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"),
|
Ok(_) => log::info!("show: the output dialog is no longer due"),
|
||||||
Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx),
|
Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx),
|
||||||
@@ -185,7 +186,7 @@ impl Deck {
|
|||||||
let path = work.join("print.pdf");
|
let path = work.join("print.pdf");
|
||||||
let printing = path.clone();
|
let printing = path.clone();
|
||||||
return self.write_pdf(&output, path, cx, move |_, cx| {
|
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 {
|
let Some(dir) = ely_office::folder(source) else {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ use super::{Kind, Output, Range};
|
|||||||
use crate::deck::Deck;
|
use crate::deck::Deck;
|
||||||
use crate::dialog::Dialog;
|
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.
|
/// What goes out, which slides, how many a page.
|
||||||
pub fn output_dialog(
|
pub fn output_dialog(
|
||||||
this: gpui::Entity<Deck>,
|
this: gpui::Entity<Deck>,
|
||||||
@@ -79,7 +84,7 @@ pub fn output_dialog(
|
|||||||
let bound = |id: &'static str, value: u32, first: bool| {
|
let bound = |id: &'static str, value: u32, first: bool| {
|
||||||
// A typed number counts before blur commits it.
|
// A typed number counts before blur commits it.
|
||||||
let set = move |this: &gpui::Entity<Deck>, value: f64, cx: &mut gpui::App| {
|
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| {
|
change(this, cx, &|output| {
|
||||||
if let Range::Slides(from, to) = &mut output.range {
|
if let Range::Slides(from, to) = &mut output.range {
|
||||||
*(if first { from } else { to }) = value;
|
*(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)))
|
.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::deck::Deck;
|
||||||
use crate::dialog::Dialog;
|
use crate::dialog::Dialog;
|
||||||
|
|
||||||
/// Each comment as a thread quoted with its slide.
|
/// Threads quoted with their slides; unreadable comments apart.
|
||||||
fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
|
fn threads(remarks: &[Remark], cx: &App) -> (Vec<Thread>, Vec<(String, String)>) {
|
||||||
let authors: Vec<&str> = remarks
|
let authors: Vec<&str> = remarks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|remark| remark.author.as_str())
|
.map(|remark| remark.author.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
remarks
|
let (good, bad): (Vec<_>, Vec<_>) = remarks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|remark| {
|
.map(|remark| {
|
||||||
let at = remark
|
let at = remark
|
||||||
.at
|
.at
|
||||||
.parse::<jiff::civil::DateTime>()
|
.parse::<jiff::civil::DateTime>()
|
||||||
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
|
.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
|
let hue = authors
|
||||||
.iter()
|
.iter()
|
||||||
.position(|name| *name == remark.author)
|
.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)])),
|
quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])),
|
||||||
comments: vec![Comment {
|
comments: vec![Comment {
|
||||||
author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
|
author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
|
||||||
at: at.timestamp(),
|
at,
|
||||||
body: remark.text.clone().into(),
|
body: remark.text.clone().into(),
|
||||||
reactions: Vec::new(),
|
reactions: Vec::new(),
|
||||||
}],
|
}],
|
||||||
resolved: false,
|
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 {
|
impl Deck {
|
||||||
@@ -77,8 +80,8 @@ impl Deck {
|
|||||||
input.set_text(text, cx);
|
input.set_text(text, cx);
|
||||||
input
|
input
|
||||||
});
|
});
|
||||||
self.dialog = Some(Dialog::Remark { tab, edit, field });
|
// Refused while another dialog is open; it says so.
|
||||||
cx.notify();
|
let _ = self.open_dialog(Dialog::Remark { tab, edit, field }, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends the dialog's text: a new comment or an edit.
|
/// Sends the dialog's text: a new comment or an edit.
|
||||||
@@ -166,14 +169,15 @@ impl Deck {
|
|||||||
.active_tab()
|
.active_tab()
|
||||||
.map(|tab| tab.extra.remarks.clone())
|
.map(|tab| tab.extra.remarks.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let threads = match threads(&remarks, cx) {
|
let (threads, bad) = threads(&remarks, cx);
|
||||||
Ok(threads) => threads,
|
for (at, error) in &bad {
|
||||||
Err(error) => {
|
log::error!("show: a bad comment time {at}: {error}");
|
||||||
log::error!("show: {error}");
|
}
|
||||||
let message = InlineMessage::new(Severity::Danger, error);
|
// One line however many: the comments below stay in view.
|
||||||
return div().p_2().child(message).into_any_element();
|
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 active = self.shown_remark();
|
||||||
let act = |id: &'static str,
|
let act = |id: &'static str,
|
||||||
key: &str,
|
key: &str,
|
||||||
@@ -227,6 +231,7 @@ impl Deck {
|
|||||||
.gap_2()
|
.gap_2()
|
||||||
.p_2()
|
.p_2()
|
||||||
.child(head)
|
.child(head)
|
||||||
|
.children(bad)
|
||||||
.child(div().flex_1().min_h_0().child(sidebar))
|
.child(div().flex_1().min_h_0().child(sidebar))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -289,3 +294,32 @@ impl Deck {
|
|||||||
(!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element())
|
(!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.
|
/// Plays a copy from the first or shown slide.
|
||||||
pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) {
|
pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) {
|
||||||
if self.show.is_some() || self.show_starting {
|
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 {
|
let Some(id) = self.core.active else {
|
||||||
return;
|
return;
|
||||||
@@ -180,7 +182,9 @@ impl Deck {
|
|||||||
let Some(first) = first.filter(|_| deck.core.active == Some(id)) else {
|
let Some(first) = first.filter(|_| deck.core.active == Some(id)) else {
|
||||||
discard(worker, doc, path, cx);
|
discard(worker, doc, path, cx);
|
||||||
if deck.core.active != Some(id) {
|
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", &[]);
|
let text = Deck::text(cx, "show.play.none", &[]);
|
||||||
return deck.toast(text, Severity::Warning, cx);
|
return deck.toast(text, Severity::Warning, cx);
|
||||||
@@ -196,11 +200,11 @@ impl Deck {
|
|||||||
};
|
};
|
||||||
match cx.displays().len() {
|
match cx.displays().len() {
|
||||||
1 => deck.open_show(starting, cx),
|
1 => deck.open_show(starting, cx),
|
||||||
_ => {
|
_ => match deck.open_dialog(Dialog::Show(starting), cx) {
|
||||||
deck.show_starting = true;
|
Ok(()) => deck.show_starting = true,
|
||||||
deck.dialog = Some(Dialog::Show(starting));
|
Err(Dialog::Show(refused)) => discard(worker, refused.doc, refused.copy, cx),
|
||||||
cx.notify();
|
Err(_) => unreachable!("the show dialog comes back"),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
@@ -214,6 +218,8 @@ impl Deck {
|
|||||||
let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience))
|
let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience))
|
||||||
else {
|
else {
|
||||||
log::warn!("show: the tab or screen {} is gone", starting.audience);
|
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);
|
return discard(worker, starting.doc, starting.copy, cx);
|
||||||
};
|
};
|
||||||
let presenter = (displays.iter())
|
let presenter = (displays.iter())
|
||||||
@@ -347,4 +353,45 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(super::bounds(area), want);
|
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 {
|
impl Deck {
|
||||||
pub fn ask_table(&mut self, cx: &mut Context<Self>) {
|
pub fn ask_table(&mut self, cx: &mut Context<Self>) {
|
||||||
if let Some(tab) = self.core.active {
|
if let Some(tab) = self.core.active {
|
||||||
self.dialog = Some(Dialog::Table {
|
// Refused while another dialog is open; it says so.
|
||||||
tab,
|
let _ = self.open_dialog(Dialog::Table { tab, columns: 3, rows: 3 }, cx);
|
||||||
columns: 3,
|
|
||||||
rows: 3,
|
|
||||||
});
|
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
|
|||||||
use crate::deck::Deck;
|
use crate::deck::Deck;
|
||||||
|
|
||||||
mod motion;
|
mod motion;
|
||||||
|
mod notes;
|
||||||
mod objects;
|
mod objects;
|
||||||
mod output;
|
mod output;
|
||||||
mod play;
|
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.
|
/// Steps the scheduler until `done` or ten seconds pass.
|
||||||
#[track_caller]
|
#[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);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||||
while std::time::Instant::now() < deadline {
|
while std::time::Instant::now() < deadline {
|
||||||
if !cx.executor().tick() {
|
if !cx.executor().tick() {
|
||||||
@@ -58,7 +59,7 @@ fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
|||||||
panic!("the state never settled");
|
panic!("the state never settled");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(
|
pub(super) fn update(
|
||||||
cx: &mut TestAppContext,
|
cx: &mut TestAppContext,
|
||||||
deck: &Entity<Deck>,
|
deck: &Entity<Deck>,
|
||||||
f: impl FnOnce(&mut Deck, &mut gpui::Context<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(centre)), Some(0));
|
||||||
assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None);
|
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;
|
use crate::dialog::Dialog;
|
||||||
|
|
||||||
/// Runs `f` on the deck inside its window.
|
/// Runs `f` on the deck inside its window.
|
||||||
fn in_window<R>(
|
pub(super) fn in_window<R>(
|
||||||
cx: &mut TestAppContext,
|
cx: &mut TestAppContext,
|
||||||
deck: &Entity<Deck>,
|
deck: &Entity<Deck>,
|
||||||
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
|
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
|
||||||
@@ -25,7 +25,7 @@ fn in_window<R>(
|
|||||||
.expect("the window lives")
|
.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();
|
let text = text.to_string();
|
||||||
cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx)));
|
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]
|
#[test]
|
||||||
fn typed_notes_reach_the_slide() {
|
fn an_open_dialog_is_not_pushed_aside() {
|
||||||
let dir = tempfile::tempdir().expect("a temp dir");
|
let dir = tempfile::tempdir().expect("a temp dir");
|
||||||
let mut cx = TestAppContext::single();
|
let mut cx = TestAppContext::single();
|
||||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||||
eprintln!("SKIP: no LOK build");
|
eprintln!("SKIP: no LOK build");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let notes = |app: &gpui::App| {
|
update(&mut cx, &deck, |deck, cx| deck.ask_table(cx));
|
||||||
deck.read(app)
|
update(&mut cx, &deck, |deck, cx| deck.ask_new(cx));
|
||||||
.active_tab()
|
// The chart dialog opens after a background read.
|
||||||
.and_then(|tab| tab.extra.notes.clone())
|
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
|
||||||
};
|
cx.run_until_parked();
|
||||||
settle(&cx, |app| notes(app).is_some());
|
let kept = cx.update(|app| matches!(deck.read(app).dialog, Some(crate::dialog::Dialog::Table { .. })));
|
||||||
let field = in_window(&mut cx, &deck, |deck, window, cx| {
|
assert!(kept, "the table dialog stays");
|
||||||
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]
|
#[test]
|
||||||
@@ -408,3 +400,64 @@ fn a_zoom_stays_through_later_reports() {
|
|||||||
assert_eq!(view.read(app).zoom(), zoom, "the zoom stays");
|
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:?}");
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ edition.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
|
build = "../../packaging/windows/icon.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
embed-resource.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
encoding_rs.workspace = true
|
encoding_rs.workspace = true
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ impl Book {
|
|||||||
}
|
}
|
||||||
(Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx),
|
(Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx),
|
||||||
(_, Ok(Found::Pivot(Some(pivot)))) => {
|
(_, Ok(Found::Pivot(Some(pivot)))) => {
|
||||||
book.dialog = Some(Dialog::Pivot {
|
book.open_dialog(Dialog::Pivot {
|
||||||
tab: id,
|
tab: id,
|
||||||
source: pivot.source,
|
source: pivot.source,
|
||||||
fields: pivot.fields,
|
fields: pivot.fields,
|
||||||
@@ -120,7 +120,7 @@ impl Book {
|
|||||||
if picked.top.iter().any(String::is_empty) {
|
if picked.top.iter().any(String::is_empty) {
|
||||||
return self.warn_key("table.pivot.headers", cx);
|
return self.warn_key("table.pivot.headers", cx);
|
||||||
}
|
}
|
||||||
self.dialog = Some(Dialog::Pivot {
|
self.open_dialog(Dialog::Pivot {
|
||||||
tab: id,
|
tab: id,
|
||||||
source: picked.data,
|
source: picked.data,
|
||||||
fields: picked.top,
|
fields: picked.top,
|
||||||
@@ -146,7 +146,7 @@ impl Book {
|
|||||||
range: ely_office::chart::field(range, "table.chart.range", window, cx),
|
range: ely_office::chart::field(range, "table.chart.range", window, cx),
|
||||||
look: ely_office::chart::LookFields::new(&look, window, cx),
|
look: ely_office::chart::LookFields::new(&look, window, cx),
|
||||||
};
|
};
|
||||||
self.dialog = Some(Dialog::Chart {
|
self.open_dialog(Dialog::Chart {
|
||||||
tab: id,
|
tab: id,
|
||||||
chart: chart.map(Box::new),
|
chart: chart.map(Box::new),
|
||||||
look: Box::new(look),
|
look: Box::new(look),
|
||||||
|
|||||||
@@ -101,6 +101,17 @@ impl Host for Book {
|
|||||||
self.sync_bars(view, cx);
|
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.
|
/// The window root with a blank workbook.
|
||||||
@@ -408,6 +419,8 @@ fn frozen_index(state: Option<&str>) -> Option<u32> {
|
|||||||
(index > 0).then_some(index)
|
(index > 0).then_some(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Zooming for Book {}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -417,5 +430,3 @@ mod tests {
|
|||||||
assert_eq!(super::frozen_index(None), None);
|
assert_eq!(super::frozen_index(None), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Zooming for Book {}
|
|
||||||
@@ -121,7 +121,7 @@ impl Book {
|
|||||||
|worker, doc| doc.selection_text(worker),
|
|worker, doc| doc.selection_text(worker),
|
||||||
move |book, text, _, cx| match text {
|
move |book, text, _, cx| match text {
|
||||||
Ok(text) if filled(&text) > 1 => {
|
Ok(text) if filled(&text) > 1 => {
|
||||||
book.dialog = Some(Dialog::Merge { tab });
|
book.open_dialog(Dialog::Merge { tab });
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
Ok(_) => book.run_in(id, "cells.merge", &["false"], cx),
|
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);
|
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
|
||||||
};
|
};
|
||||||
let field = cx.new(|cx| TextInput::new(window, 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();
|
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>) {
|
fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) {
|
||||||
log::info!("table: {asked:?} on {}", picked.range);
|
log::info!("table: {asked:?} on {}", picked.range);
|
||||||
let header = picked.header;
|
let header = picked.header;
|
||||||
self.dialog = Some(match asked {
|
self.open_dialog(match asked {
|
||||||
Asked::Sort => {
|
Asked::Sort => {
|
||||||
let (first, ..) = picked.area.expect("a data area");
|
let (first, ..) = picked.area.expect("a data area");
|
||||||
Dialog::Sort {
|
Dialog::Sort {
|
||||||
@@ -314,7 +314,7 @@ impl Book {
|
|||||||
return book.warn_key("table.columns.same", cx);
|
return book.warn_key("table.columns.same", cx);
|
||||||
}
|
}
|
||||||
let other = cx.new(|cx| TextInput::new(window, cx));
|
let other = cx.new(|cx| TextInput::new(window, cx));
|
||||||
book.dialog = Some(Dialog::Columns {
|
book.open_dialog(Dialog::Columns {
|
||||||
tab: id,
|
tab: id,
|
||||||
separators: vec!["9".into()],
|
separators: vec!["9".into()],
|
||||||
other,
|
other,
|
||||||
@@ -394,9 +394,10 @@ impl Book {
|
|||||||
.filter(|entry| entry.selected)
|
.filter(|entry| entry.selected)
|
||||||
.map(|entry| SharedString::from(entry.text.clone()))
|
.map(|entry| SharedString::from(entry.text.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
book.dialog = Some(Dialog::Column {
|
book.open_dialog(Dialog::Column {
|
||||||
tab: id,
|
tab: id,
|
||||||
column,
|
column,
|
||||||
|
row,
|
||||||
entries: filter.entries,
|
entries: filter.entries,
|
||||||
picked,
|
picked,
|
||||||
});
|
});
|
||||||
@@ -413,12 +414,15 @@ impl Book {
|
|||||||
let Some(Dialog::Column {
|
let Some(Dialog::Column {
|
||||||
tab,
|
tab,
|
||||||
column,
|
column,
|
||||||
|
row,
|
||||||
entries,
|
entries,
|
||||||
picked,
|
picked,
|
||||||
}) = self.dialog.take()
|
}) = self.dialog.take()
|
||||||
else {
|
else {
|
||||||
return;
|
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 {
|
match sort {
|
||||||
Some(rising) => {
|
Some(rising) => {
|
||||||
let key = SortKey {
|
let key = SortKey {
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ pub enum Dialog {
|
|||||||
Column {
|
Column {
|
||||||
tab: u64,
|
tab: u64,
|
||||||
column: u32,
|
column: u32,
|
||||||
|
/// The button's row: its cell names the filtered range.
|
||||||
|
row: u32,
|
||||||
entries: Vec<Entry>,
|
entries: Vec<Entry>,
|
||||||
picked: Vec<SharedString>,
|
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 {
|
impl Book {
|
||||||
/// The define dialog's sheets: its own workbook's.
|
/// The define dialog's sheets: its own workbook's.
|
||||||
pub(crate) fn define_scopes(&self) -> Option<Vec<String>> {
|
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 name = cx.new(|cx| TextInput::new(window, cx));
|
||||||
let range = 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));
|
range.update(cx, |range, cx| range.set_text(refers, cx));
|
||||||
book.dialog = Some(Dialog::Define {
|
book.open_dialog(Dialog::Define {
|
||||||
tab: id,
|
tab: id,
|
||||||
name,
|
name,
|
||||||
range,
|
range,
|
||||||
@@ -206,7 +206,7 @@ impl Book {
|
|||||||
let range = cx.new(|cx| TextInput::new(window, cx));
|
let range = cx.new(|cx| TextInput::new(window, cx));
|
||||||
let formula = first.formula.clone();
|
let formula = first.formula.clone();
|
||||||
range.update(cx, |range, cx| range.set_text(formula, cx));
|
range.update(cx, |range, cx| range.set_text(formula, cx));
|
||||||
book.dialog = Some(Dialog::Names {
|
book.open_dialog(Dialog::Names {
|
||||||
tab: id,
|
tab: id,
|
||||||
names,
|
names,
|
||||||
chosen: 0,
|
chosen: 0,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
// Release builds open no console on Windows.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod analysis;
|
mod analysis;
|
||||||
mod bars;
|
mod bars;
|
||||||
mod book;
|
mod book;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl Book {
|
|||||||
let Some(tab) = self.core.active else {
|
let Some(tab) = self.core.active else {
|
||||||
return self.warn_key("command.blocked.no_document", cx);
|
return self.warn_key("command.blocked.no_document", cx);
|
||||||
};
|
};
|
||||||
self.dialog = Some(Dialog::Export {
|
self.open_dialog(Dialog::Export {
|
||||||
tab,
|
tab,
|
||||||
format: Format::Pdf,
|
format: Format::Pdf,
|
||||||
});
|
});
|
||||||
@@ -144,10 +144,8 @@ impl Book {
|
|||||||
tab,
|
tab,
|
||||||
move |worker, doc| doc.export_pdf(worker, &url, &WHOLE),
|
move |worker, doc| doc.export_pdf(worker, &url, &WHOLE),
|
||||||
move |book, written, _, cx| match written {
|
move |book, written, _, cx| match written {
|
||||||
Ok(()) => ely_office::print_later(path, cx, |book, error, cx| {
|
Ok(()) => ely_office::print_later(path, cx),
|
||||||
book.output_failed(&error, cx)
|
Err(error) => book.print_failed(&error.to_string(), cx),
|
||||||
}),
|
|
||||||
Err(error) => book.output_failed(&error.to_string(), cx),
|
|
||||||
},
|
},
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ fn cell_ok(cell: &str) -> bool {
|
|||||||
&& row_number(digits).is_some()
|
&& 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.
|
/// Areas as the kernel takes them, or None.
|
||||||
fn areas(text: &str) -> Option<String> {
|
fn areas(text: &str) -> Option<String> {
|
||||||
let tokens: Vec<&str> = text
|
let tokens: Vec<&str> = text
|
||||||
@@ -90,7 +99,9 @@ fn areas(text: &str) -> Option<String> {
|
|||||||
.filter(|token| !token.is_empty())
|
.filter(|token| !token.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
let ok = |token: &&str| match token.split_once(':') {
|
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),
|
None => cell_ok(token),
|
||||||
};
|
};
|
||||||
tokens.iter().all(ok).then(|| tokens.join(","))
|
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),
|
rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx),
|
||||||
columns: field(sheet::columns_ref(layout.columns), "table.page.columns_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,
|
tab: id,
|
||||||
landscape: layout.landscape,
|
landscape: layout.landscape,
|
||||||
scale: layout.scale,
|
scale: layout.scale,
|
||||||
@@ -228,6 +239,11 @@ mod tests {
|
|||||||
assert_eq!(areas("A1:B"), None);
|
assert_eq!(areas("A1:B"), None);
|
||||||
assert_eq!(areas("XFE1"), None);
|
assert_eq!(areas("XFE1"), None);
|
||||||
assert_eq!(areas("S!A1"), 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("$1:$3", row_number), Some(Some((1, 3))));
|
||||||
assert_eq!(span("2", row_number), Some(Some((2, 2))));
|
assert_eq!(span("2", row_number), Some(Some((2, 2))));
|
||||||
assert_eq!(span("3:1", row_number), None);
|
assert_eq!(span("3:1", row_number), None);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ impl Book {
|
|||||||
confirm: masked("table.protect.confirm", cx),
|
confirm: masked("table.protect.confirm", cx),
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
book.dialog = Some(Dialog::Protect {
|
book.open_dialog(Dialog::Protect {
|
||||||
tab: id,
|
tab: id,
|
||||||
protecting: Box::new(protecting),
|
protecting: Box::new(protecting),
|
||||||
});
|
});
|
||||||
|
|||||||
Loaded 100 of 581 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user