Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,6 @@
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[env]
|
||||
# cef-dll-sys builds against the CEF that scripts/fetch-cef.sh fetched.
|
||||
CEF_PATH = { value = "engines/cef", relative = true }
|
||||
@@ -0,0 +1,77 @@
|
||||
# Builds the patched engines once; release.yml downloads them.
|
||||
name: engines
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 355
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 6G
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Key
|
||||
id: key
|
||||
run: echo "key=$(scripts/engines-key.sh)" >> "$GITHUB_OUTPUT"
|
||||
- name: Already built?
|
||||
id: done
|
||||
run: |
|
||||
if gh release view "engines-${{ steps.key.outputs.key }}" --json assets \
|
||||
--jq '.assets[].name' 2>/dev/null | grep -qx "engines-macos-${{ matrix.arch }}.tar.zst"; then
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Tools
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: brew install make gnu-sed bison flex gettext autoconf automake libtool ccache nasm zstd
|
||||
- name: Compiler cache
|
||||
if: steps.done.outputs.built != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
|
||||
restore-keys: ccache-macos-${{ matrix.arch }}-
|
||||
- name: Upstream sources
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: scripts/fetch-references.sh
|
||||
- name: LibreOffice (patched)
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: scripts/build-libreoffice.sh
|
||||
- name: MuPDF (patched), Git, git-lfs, OCR data, CEF
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: |
|
||||
scripts/build-mupdf.sh
|
||||
scripts/build-git.sh
|
||||
scripts/fetch-git-lfs.sh
|
||||
scripts/fetch-tessdata.sh
|
||||
scripts/fetch-cef.sh
|
||||
- name: Keep the compiler cache, even after a timeout
|
||||
if: always() && steps.done.outputs.built != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
|
||||
- name: Publish
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: |
|
||||
archive=engines-macos-${{ matrix.arch }}.tar.zst
|
||||
tar -C engines -cf - lo-src/instdir mupdf-build mupdf-src git git-src/COPYING git-src/LGPL-2.1 cef tessdata |
|
||||
zstd -19 -T0 -o "$archive"
|
||||
tag=engines-${{ steps.key.outputs.key }}
|
||||
gh release view "$tag" >/dev/null 2>&1 ||
|
||||
gh release create "$tag" --prerelease --title "$tag" --notes "Pinned, patched engines; built by engines.yml."
|
||||
gh release upload "$tag" "$archive" --clobber
|
||||
@@ -0,0 +1,55 @@
|
||||
# On a version tag: macOS apps attached to its release.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Engines
|
||||
run: |
|
||||
brew install zstd
|
||||
tag=engines-$(scripts/engines-key.sh)
|
||||
gh release download "$tag" --pattern "engines-macos-${{ matrix.arch }}.tar.zst" ||
|
||||
{ echo "::error::no $tag for ${{ matrix.arch }}; run the engines workflow first"; exit 1; }
|
||||
mkdir -p engines
|
||||
zstd -dc "engines-macos-${{ matrix.arch }}.tar.zst" | tar -C engines -xf -
|
||||
- name: Upstream sources and ElyMark's page
|
||||
run: |
|
||||
scripts/fetch-references.sh
|
||||
scripts/build-mark-content.sh
|
||||
- name: Tests that need no window
|
||||
run: |
|
||||
tests/scripts/types.sh
|
||||
tests/scripts/icons.sh
|
||||
python3 tests/scripts/comments.py
|
||||
- name: Packages
|
||||
run: packaging/macos/release.sh 1
|
||||
- name: Attach to the release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
version=${GITHUB_REF_NAME}
|
||||
gh release view "$version" >/dev/null 2>&1 || gh release create "$version" --title "Ely Office $version" --notes "Built by release.yml."
|
||||
gh release upload "$version" dist/*.dmg --clobber
|
||||
if [ "${{ matrix.arch }}" = arm64 ]; then
|
||||
gh release upload "$version" dist/ElyOffice-*-source.tar.gz dist/ElyOffice-*-upstream-source.tar.gz --clobber
|
||||
fi
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Build output and downloads
|
||||
/target/
|
||||
/dist/
|
||||
/engines/
|
||||
/reference/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
.ruff_cache/
|
||||
|
||||
# Internal working files, kept local
|
||||
/agents.md
|
||||
/CLAUDE.md
|
||||
/PRD.md
|
||||
/tasks/
|
||||
/.pi/
|
||||
/markdown-sample.md
|
||||
/docs/engineering-risks.md
|
||||
/docs/qa.md
|
||||
/docs/acceptance.md
|
||||
/docs/test-map.md
|
||||
/scripts/review.sh
|
||||
/scripts/test-map.py
|
||||
/tests/scripts/agents_md.py
|
||||
/assets/papirus_icons-master.zip
|
||||
Generated
+9348
File diff suppressed because it is too large.
Load diff
+66
@@ -0,0 +1,66 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["apps/*", "crates/*"]
|
||||
# Upstream sources and their builds keep their own manifests.
|
||||
exclude = ["engines", "reference"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
csv = "1"
|
||||
encoding_rs = "0.8"
|
||||
jiff = "0.2"
|
||||
anyhow = "1"
|
||||
directories = "6"
|
||||
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "69265b4fd4da888e2d8e1268021af4d358e67afb" }
|
||||
env_logger = "0.11"
|
||||
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
|
||||
futures = "0.3"
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
metal = "0.33"
|
||||
notify = "8"
|
||||
quick-xml = "0.41"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSWorkspace", "NSAccessibility", "NSPrintInfo", "NSPrintOperation", "objc2-core-foundation"] }
|
||||
rfd = { version = "0.17", default-features = false }
|
||||
aes-gcm = "0.11"
|
||||
base64 = "0.23"
|
||||
arboard = { version = "3.6", default-features = false, features = ["image-data"] }
|
||||
getrandom = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tempfile = "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"] }
|
||||
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-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] }
|
||||
objc2-core-video = { version = "0.3", default-features = false, features = ["std", "CVBuffer", "CVImageBuffer", "CVPixelBuffer"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFString", "CFCGTypes"] }
|
||||
core-video = "0.5"
|
||||
cef = { git = "https://github.com/tauri-apps/cef-rs", rev = "a712ad9bacd234c050631a95b9745b27d346b7cc", default-features = false }
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6"
|
||||
security-framework = "3.7"
|
||||
objc2-pdf-kit = { version = "0.3", default-features = false, features = ["PDFDocument", "PDFPage", "objc2-app-kit"] }
|
||||
|
||||
ely-files = { path = "crates/ely-files" }
|
||||
ely-ipc = { path = "crates/ely-ipc" }
|
||||
ely-lok = { path = "crates/ely-lok" }
|
||||
ely-office = { path = "crates/ely-office" }
|
||||
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
|
||||
ely-platform = { path = "crates/ely-platform" }
|
||||
ely-web-content = { path = "crates/ely-web-content" }
|
||||
ely-ui = { path = "crates/ely-ui" }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server 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,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
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/>.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Ely Office
|
||||
|
||||
Offline-first desktop apps in Rust and GPUI. Files stay in your folders; no account, no cloud service.
|
||||
|
||||
| App | Does | Engine |
|
||||
| --- | --- | --- |
|
||||
| ElyDoc | Word processing (DOCX, ODT) | LibreOfficeKit |
|
||||
| ElyTable | Spreadsheets (XLSX, ODS, CSV) | LibreOfficeKit |
|
||||
| ElyShow | Presentations (PPTX, ODP) | LibreOfficeKit, system media |
|
||||
| ElyPDF | PDF reading and editing, OCR, signatures | MuPDF, Tesseract |
|
||||
| ElyTree | Git client | Git |
|
||||
| ElyMark | Markdown | Milkdown, CodeMirror in CEF |
|
||||
| ElyMail, ElyAgent | Second release | — |
|
||||
|
||||
## Build (macOS)
|
||||
|
||||
```sh
|
||||
scripts/doctor.sh # toolchain versions
|
||||
scripts/fetch-references.sh # upstream sources at pinned commits
|
||||
scripts/build-libreoffice.sh # patched LibreOffice 26.2.6.3 (about an hour)
|
||||
scripts/build-mupdf.sh # patched MuPDF 1.28.5
|
||||
scripts/build-git.sh && scripts/fetch-git-lfs.sh
|
||||
scripts/fetch-tessdata.sh && scripts/fetch-cef.sh
|
||||
scripts/build-mark-content.sh # ElyMark's page
|
||||
cargo build -p ely-lok --bins -p ely-pdf-engine --bins
|
||||
cargo run -p ely-doc # or ely-table, ely-show, ely-pdf, ely-tree, ely-mark
|
||||
```
|
||||
|
||||
Packages: `packaging/macos/release.sh` writes each app's `.app` and `.dmg`, the source archive and the upstream source archive (patched MuPDF, Git) to `dist/`. Linux and Windows recipes are in `packaging/linux` and `packaging/windows`; they have not been run on those systems.
|
||||
|
||||
## Tests
|
||||
|
||||
`cargo test --workspace`, the window scripts in `tests/scripts` (macOS, Accessibility permission), and the benchmark `tests/perf/bench.py`.
|
||||
|
||||
## Documents
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `docs/help.md` | Help: opening and saving, recovery, offline limits, repositories |
|
||||
| `docs/compatibility.md` | Versioned compatibility list |
|
||||
| `docs/accessibility.md` | Accessibility and language review |
|
||||
| `docs/perf/` | Benchmark records |
|
||||
| `docs/licenses.md` | Licences and sources |
|
||||
|
||||
## Licence
|
||||
|
||||
AGPL-3.0-or-later (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ely-agent"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,5 @@
|
||||
use ely_platform::AppId;
|
||||
|
||||
fn main() {
|
||||
ely_ui::run(AppId::Agent, ely_ui::blank);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "ely-doc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
csv.workspace = true
|
||||
anyhow.workspace = true
|
||||
directories.workspace = true
|
||||
ely-files.workspace = true
|
||||
ely-gpui-component.workspace = true
|
||||
ely-lok = { workspace = true, features = ["view"] }
|
||||
ely-office.workspace = true
|
||||
gpui.workspace = true
|
||||
jiff.workspace = true
|
||||
log.workspace = true
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely-ui = { workspace = true, features = ["test-support"] }
|
||||
env_logger.workspace = true
|
||||
image.workspace = true
|
||||
tempfile.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,136 @@
|
||||
//! New, and ElyDoc's own dialogs (DOC-01).
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::Button,
|
||||
documents::{Template as Pick, TemplatePicker},
|
||||
forms::TextInput,
|
||||
overlays::Dialog as Card,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::commands::New;
|
||||
use gpui::{AnyElement, App, Context, Entity, IntoElement, ParentElement as _, Window};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use crate::files::TEMPLATES;
|
||||
use crate::refs::Ask;
|
||||
|
||||
pub enum Dialog {
|
||||
Templates,
|
||||
Table {
|
||||
columns: f64,
|
||||
rows: f64,
|
||||
},
|
||||
Section,
|
||||
Ask {
|
||||
ask: Ask,
|
||||
field: Entity<TextInput>,
|
||||
},
|
||||
Link {
|
||||
text: Entity<TextInput>,
|
||||
url: Entity<TextInput>,
|
||||
},
|
||||
Reference {
|
||||
marks: Vec<String>,
|
||||
mark: String,
|
||||
show: &'static str,
|
||||
},
|
||||
Output {
|
||||
tab: u64,
|
||||
print: bool,
|
||||
all: bool,
|
||||
pdf: ely_lok::Pdf,
|
||||
},
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
pub fn on_new(&mut self, _: &New, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dialog = Some(Dialog::Templates);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The open dialog, if any.
|
||||
pub fn dialog_element(&mut self, _: &mut Window, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let this = cx.entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |docs, cx| {
|
||||
docs.dialog = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let card = match self.dialog.as_ref()? {
|
||||
Dialog::Templates => templates(this, dismiss, cx),
|
||||
Dialog::Section => {
|
||||
return Some(crate::insert::section_dialog(this, dismiss, cx).into_any_element());
|
||||
}
|
||||
Dialog::Ask { ask, field } => {
|
||||
let prompt = crate::refs::ask_dialog(this, ask, field, dismiss, cx);
|
||||
return Some(prompt.into_any_element());
|
||||
}
|
||||
Dialog::Link { text, url } => crate::refs::link_dialog(this, (text, url), dismiss, cx),
|
||||
Dialog::Reference { marks, mark, show } => {
|
||||
crate::refs::reference_dialog(this, (marks, mark, show), dismiss, cx)
|
||||
}
|
||||
Dialog::Output {
|
||||
tab,
|
||||
print,
|
||||
all,
|
||||
pdf,
|
||||
} => {
|
||||
let last = self
|
||||
.tab(*tab)
|
||||
.expect("a modal keeps its tab")
|
||||
.view
|
||||
.read(cx)
|
||||
.pages()
|
||||
.len();
|
||||
let last = u32::try_from(last).expect("a page count");
|
||||
crate::output::output_dialog(this, (*print, *all, *pdf, last), dismiss, cx)
|
||||
}
|
||||
Dialog::Table { columns, rows } => {
|
||||
crate::insert::table_dialog(this, (*columns, *rows), dismiss, cx)
|
||||
}
|
||||
};
|
||||
Some(card.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// The New dialog: bundled templates and a local file (DOC-01).
|
||||
fn templates(
|
||||
this: Entity<Docs>,
|
||||
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &mut Context<Docs>,
|
||||
) -> Card {
|
||||
let picks: Vec<Pick> = TEMPLATES
|
||||
.iter()
|
||||
.map(|template| Pick {
|
||||
name: Docs::text(cx, &format!("doc.template.{}", template.key()), &[]),
|
||||
icon: "▤".into(),
|
||||
category: Docs::text(cx, "doc.template.category", &[]),
|
||||
description: Docs::text(cx, &format!("doc.template.{}.about", template.key()), &[]),
|
||||
markdown: template.preview(cx).into(),
|
||||
})
|
||||
.collect();
|
||||
let used = this.clone();
|
||||
let picker = TemplatePicker::new("doc-templates", Rc::new(picks)).on_use(move |ix, _, cx| {
|
||||
used.update(cx, |docs, cx| {
|
||||
docs.dialog = None;
|
||||
docs.new_from(TEMPLATES[ix], cx);
|
||||
})
|
||||
});
|
||||
let label = Docs::text(cx, "doc.new.from_file", &[]);
|
||||
Card::new("doc-new", Docs::text(cx, "doc.new.title", &[]), dismiss)
|
||||
.child(picker)
|
||||
.action(move |close| {
|
||||
Button::new("doc-new-file", label).on_click(move |_, window, cx| {
|
||||
close(window, cx);
|
||||
this.update(cx, |docs, cx| {
|
||||
docs.pick(false, cx, |docs, path, cx| docs.new_from_file(&path, cx))
|
||||
});
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Tabs, page, status line and side panel.
|
||||
|
||||
use ely_gpui_component::{
|
||||
feedback::{Banner, EmptyState},
|
||||
primitives::{IconName, Severity},
|
||||
shell::{StatusBar, StatusBarItem},
|
||||
};
|
||||
use ely_lok::LokView;
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, AnyView, Context, Entity, IntoElement, ParentElement as _, Styled as _, div,
|
||||
};
|
||||
|
||||
use ely_office::prelude::*;
|
||||
|
||||
use crate::docs::{Docs, Side};
|
||||
|
||||
impl Docs {
|
||||
/// The save state, and the kernel's word count.
|
||||
pub(crate) fn status(&self, cx: &mut Context<Self>) -> StatusBar {
|
||||
let state = self.save_state(cx);
|
||||
let status = StatusBar::new().left(StatusBarItem::new("doc-state").label(state));
|
||||
let zoom = self.zoom_control(cx);
|
||||
let Some(counts) = self.active_tab().and_then(|tab| tab.extra.counts) else {
|
||||
return status.when_some(zoom, |status, zoom| status.right(zoom));
|
||||
};
|
||||
let (words, characters) = (counts.words.to_string(), counts.characters.to_string());
|
||||
let label = match counts.selected_words {
|
||||
0 => Self::text(
|
||||
cx,
|
||||
"doc.count.words",
|
||||
&[("words", &words), ("characters", &characters)],
|
||||
),
|
||||
selected => Self::text(
|
||||
cx,
|
||||
"doc.count.selected",
|
||||
&[("selected", &selected.to_string()), ("words", &words)],
|
||||
),
|
||||
};
|
||||
status.right(StatusBarItem::new("doc-count").label(label)).when_some(zoom, |status, zoom| status.right(zoom))
|
||||
}
|
||||
|
||||
pub(crate) fn body(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if let Some(tab) = self.active_tab() {
|
||||
let view = div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(tab.view.clone())
|
||||
.children(self.find_box(cx))
|
||||
.children(self.spelling_menu(cx))
|
||||
.into_any_element();
|
||||
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
|
||||
let fonts = tab.view.read(cx).missing_fonts().join(", ");
|
||||
let fonts = (!fonts.is_empty()).then(|| {
|
||||
let warning = Self::text(cx, "doc.fonts.missing", &[("fonts", &fonts)]);
|
||||
Banner::new("doc-fonts", Severity::Warning, warning)
|
||||
});
|
||||
if risk.is_none() && fonts.is_none() {
|
||||
return view;
|
||||
}
|
||||
return div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(risk)
|
||||
.children(fonts)
|
||||
.child(div().flex_1().min_h_0().child(view))
|
||||
.into_any_element();
|
||||
}
|
||||
let title = match self.core.loading.first() {
|
||||
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
|
||||
None => Self::text(cx, "command.blocked.no_document", &[]),
|
||||
};
|
||||
EmptyState::new("doc-empty", IconName::FileText, title).into_any_element()
|
||||
}
|
||||
|
||||
/// The navigator column, while shown.
|
||||
pub(crate) fn left_view(&mut self, cx: &mut Context<Self>) -> Option<AnyView> {
|
||||
let shown = self
|
||||
.active_tab()
|
||||
.map(|tab| (tab.view.clone(), tab.doc.clone()));
|
||||
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
|
||||
self.navigator
|
||||
.update(cx, |navigator, cx| navigator.show(tab, cx));
|
||||
shown.filter(|_| self.navigating)?;
|
||||
Some(self.navigator.clone().into())
|
||||
}
|
||||
|
||||
/// The right column for the active tab.
|
||||
pub(crate) fn side_view(&mut self, cx: &mut Context<Self>) -> Option<AnyView> {
|
||||
let shown = self
|
||||
.active_tab()
|
||||
.map(|tab| (tab.view.clone(), tab.doc.clone()));
|
||||
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
|
||||
self.props.update(cx, |props, cx| props.show(tab, cx));
|
||||
self.review.update(cx, |review, cx| review.show(tab, cx));
|
||||
shown.as_ref()?;
|
||||
Some(match self.side {
|
||||
Side::Props => self.props.clone().into(),
|
||||
Side::Review => self.review.clone().into(),
|
||||
Side::Merge => self.merge.clone().into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the counts, again when the kernel's count moves.
|
||||
pub(crate) fn count(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
let state = view
|
||||
.read(cx)
|
||||
.state(".uno:StateWordCount")
|
||||
.map(str::to_string);
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
// The kernel reports its count only once the status moves.
|
||||
let asked = tab.extra.counted.as_ref() == Some(&state);
|
||||
if tab.extra.counting || asked {
|
||||
return;
|
||||
}
|
||||
let doc = tab.doc.clone();
|
||||
// Asked now: a failure waits for the next change.
|
||||
let extra = &mut self.tab_mut(id).expect("the tab is open").extra;
|
||||
(extra.counting, extra.counted) = (true, Some(state));
|
||||
ely_lok::read_off(
|
||||
&self.core.worker,
|
||||
&doc,
|
||||
|worker, doc| {
|
||||
let json = worker.values(doc, ".uno:WordCount")?;
|
||||
ely_lok::review::counts(&json)
|
||||
},
|
||||
move |docs: &mut Self, read, cx| {
|
||||
let Some(tab) = docs.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
tab.extra.counting = false;
|
||||
match read {
|
||||
Ok(counts) => tab.extra.counts = Some(counts),
|
||||
Err(error) => docs.engine_failed(&error.to_string(), cx),
|
||||
}
|
||||
cx.notify();
|
||||
// The status may have moved meanwhile.
|
||||
let view = docs.tab(id).map(|tab| tab.view.clone());
|
||||
if let Some(view) = view {
|
||||
docs.count(id, &view, cx);
|
||||
}
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Compares the active document with an older file (DOC-13).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use gpui::{Context, Window, actions};
|
||||
|
||||
use ely_office::Source;
|
||||
use ely_office::files::{self, file_name};
|
||||
use ely_office::prelude::*;
|
||||
|
||||
use crate::docs::{Docs, Side};
|
||||
|
||||
actions!(doc, [CompareWith]);
|
||||
|
||||
impl Docs {
|
||||
pub fn on_compare(&mut self, _: &CompareWith, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.core.active else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
self.pick(false, cx, move |docs, older, cx| {
|
||||
docs.compare(id, older, cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// A new tab holds this version, marked against `older`.
|
||||
pub(crate) fn compare(&mut self, id: u64, older: PathBuf, cx: &mut Context<Self>) {
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |docs, _, cx| docs.compare(id, older, cx));
|
||||
}
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let name = tab.name.clone();
|
||||
// The copy's tab and folder, reserved now.
|
||||
let into = self.core.next;
|
||||
self.core.next += 1;
|
||||
let dir = match files::work_dir(&self.core.work, into) {
|
||||
Ok(dir) => dir,
|
||||
Err(error) => return self.failed_open(&name, &error, cx),
|
||||
};
|
||||
let copy = dir.join("compared.odt");
|
||||
let url = ely_lok::file_url(©);
|
||||
self.read_in(
|
||||
id,
|
||||
move |worker, doc| doc.save_as(worker, &url, "odt"),
|
||||
move |docs, saved, _, cx| match saved {
|
||||
Ok(()) => docs.compare_copy((id, into), name, copy, older, cx),
|
||||
Err(error) => docs.failed_open(&name, &error, cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Opens the saved copy and marks it against `older`.
|
||||
fn compare_copy(
|
||||
&mut self,
|
||||
(id, into): (u64, u64),
|
||||
name: gpui::SharedString,
|
||||
copy: PathBuf,
|
||||
older: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::info!("doc: comparing tab {id} with {}", file_name(&older));
|
||||
self.load_into(into, Source::Copy(copy), cx);
|
||||
let url = ely_lok::file_url(&older);
|
||||
self.core.waiting.push((
|
||||
into,
|
||||
Box::new(move |docs, _, cx| {
|
||||
let Some(tab) = docs.tab_mut(into) else {
|
||||
return;
|
||||
};
|
||||
tab.name = Docs::text(cx, "doc.compare.name", &[("name", &name)]);
|
||||
let sent = tab
|
||||
.view
|
||||
.update(cx, |view, _| view.run("doc.compare", &[&url, "true"]));
|
||||
if let Err(error) = sent {
|
||||
let text =
|
||||
Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return docs.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
docs.side = Side::Review;
|
||||
cx.notify();
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! ElyDoc's window: document tabs over one LOK worker (§8.1).
|
||||
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_lok::{LokError, LokView, Worker, review::Counts};
|
||||
use ely_office::{Core, Kind, prelude::*};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::{Root, Shell, commands};
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::actions::Dialog;
|
||||
use crate::files::Template;
|
||||
use crate::props::Props;
|
||||
use crate::review::Review;
|
||||
|
||||
/// What ElyDoc keeps per tab.
|
||||
#[derive(Default)]
|
||||
pub struct Extra {
|
||||
/// The kernel's counts, read when its word count moves.
|
||||
pub counts: Option<Counts>,
|
||||
/// The word count state last asked about.
|
||||
pub counted: Option<Option<String>>,
|
||||
/// A count is on its way.
|
||||
pub counting: bool,
|
||||
}
|
||||
|
||||
pub struct Docs {
|
||||
pub core: Core<Docs>,
|
||||
pub dialog: Option<Dialog>,
|
||||
pub props: Entity<Props>,
|
||||
pub review: Entity<Review>,
|
||||
/// What the right column shows.
|
||||
pub side: Side,
|
||||
pub offer: Option<crate::spell::Offer>,
|
||||
pub merge: Entity<crate::merge::Merge>,
|
||||
pub navigator: Entity<crate::navigate::Navigator>,
|
||||
/// The left column shows the navigator.
|
||||
pub navigating: bool,
|
||||
/// Where jumps left from, latest last.
|
||||
pub back: Vec<(u64, ely_lok::Rect)>,
|
||||
/// Last jump: tab, input, prior cursor moves, target.
|
||||
pub going: Option<(u64, u64, u64, ely_lok::Rect)>,
|
||||
}
|
||||
|
||||
/// The right column's panels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Side {
|
||||
Props,
|
||||
Review,
|
||||
Merge,
|
||||
}
|
||||
|
||||
impl Host for Docs {
|
||||
type Extra = Extra;
|
||||
type Engine = ely_office::Lok;
|
||||
const KIND: Kind = Kind {
|
||||
app: AppId::Doc,
|
||||
kept: &["docx", "odt", "doc", "rtf", "fodt", "txt"],
|
||||
save_as: &["docx", "odt"],
|
||||
macros: Some(&["docm", "dotm"]),
|
||||
};
|
||||
|
||||
fn core(&self) -> &Core<Self> {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core<Self> {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
/// Tab stops come with the events: the view's state.
|
||||
fn read_at(&self, tab: u64, cx: &gpui::App) -> Option<ely_lok::Doc> {
|
||||
self.tab(tab).map(|tab| tab.view.read(cx).read_at())
|
||||
}
|
||||
|
||||
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
self.count(id, view, cx);
|
||||
self.found(id, view, cx);
|
||||
self.offer_spelling(id, view, cx);
|
||||
}
|
||||
|
||||
fn search_leaves(&mut self, cx: &mut Context<Self>) {
|
||||
self.mark_place(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// The window root with a blank document.
|
||||
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
ely_lok::init(cx);
|
||||
let (worker, work) = match ely_office::start(app, cx) {
|
||||
Ok(started) => started,
|
||||
Err(error) => ely_platform::startup::fail(app, &format!("{error}")),
|
||||
};
|
||||
let docs = cx.new(|cx| {
|
||||
let mut host = Docs::new(worker, work, cx);
|
||||
// Recovery points, crash return, autosave (§6.2, §6.3).
|
||||
ely_office::Recovering::start_recovery(&mut host, cx);
|
||||
<Docs as ely_office::Autosaving>::start_autosave(cx);
|
||||
host
|
||||
});
|
||||
cx.bind_keys([
|
||||
gpui::KeyBinding::new(
|
||||
"secondary-f",
|
||||
ely_office::FindReplace,
|
||||
Some(commands::CONTEXT),
|
||||
),
|
||||
gpui::KeyBinding::new(
|
||||
"secondary-[",
|
||||
crate::navigate::GoBack,
|
||||
Some(commands::CONTEXT),
|
||||
),
|
||||
]);
|
||||
ely_office::bind_zoom_keys(cx);
|
||||
let menus = vec![
|
||||
gpui::Menu::new(ely_ui::i18n::text(cx, "menu.view", &[])).items(ely_office::zoom_items(false, cx)),
|
||||
crate::insert::menu(cx),
|
||||
crate::review::menu(cx),
|
||||
crate::merge::menu(cx),
|
||||
];
|
||||
let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
|
||||
docs.update(cx, |docs, cx| {
|
||||
docs.core.root = root.downgrade();
|
||||
docs.core.window = Some(window.window_handle());
|
||||
window.focus(&docs.core.focus, cx);
|
||||
});
|
||||
let launched = ely_ui::launch_files(cx);
|
||||
docs.update(cx, |docs, cx| match launched.is_empty() {
|
||||
true => docs.new_from(Template::Blank, cx),
|
||||
false => launched.into_iter().for_each(|path| docs.open_file(path, cx)),
|
||||
});
|
||||
let opening = docs.clone();
|
||||
ely_ui::on_open(cx, move |path, cx| {
|
||||
opening.update(cx, |docs, cx| docs.open_file(path, cx))
|
||||
});
|
||||
let closing = docs.clone();
|
||||
window.on_window_should_close(cx, move |window, cx| {
|
||||
closing.update(cx, |docs, cx| docs.should_close(window, cx))
|
||||
});
|
||||
root
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
pub fn new(worker: Arc<Mutex<Worker>>, work: PathBuf, cx: &mut Context<Self>) -> Self {
|
||||
let props = cx.new(|_| Props::new(Arc::clone(&worker)));
|
||||
let review = cx.new(|_| Review::new(Arc::clone(&worker)));
|
||||
let weak = cx.weak_entity();
|
||||
let merge = cx.new(|_| crate::merge::Merge::new(weak.clone()));
|
||||
let navigator = cx.new(|_| crate::navigate::Navigator::new(weak, Arc::clone(&worker)));
|
||||
let failed = |docs: &mut Docs, error: &LokError, cx: &mut Context<Docs>| {
|
||||
docs.engine_failed(&error.0, cx)
|
||||
};
|
||||
cx.subscribe(&props, move |docs, _, error, cx| failed(docs, error, cx))
|
||||
.detach();
|
||||
cx.subscribe(&review, move |docs, _, error, cx| failed(docs, error, cx))
|
||||
.detach();
|
||||
cx.subscribe(&navigator, move |docs, _, error, cx| {
|
||||
failed(docs, error, cx)
|
||||
})
|
||||
.detach();
|
||||
Docs {
|
||||
core: Core::new(worker, work, cx),
|
||||
dialog: None,
|
||||
props,
|
||||
review,
|
||||
side: Side::Props,
|
||||
offer: None,
|
||||
merge,
|
||||
navigator,
|
||||
navigating: true,
|
||||
back: Vec::new(),
|
||||
going: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
|
||||
ely_ui::i18n::text(cx, key, args)
|
||||
}
|
||||
|
||||
/// A new untitled document from a bundled template (DOC-01).
|
||||
pub fn new_from(&mut self, template: Template, cx: &mut Context<Self>) {
|
||||
let name = format!("{}.fodt", template.key());
|
||||
self.new_from_text(&name, &template.fodt(cx), cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Docs {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let title = self
|
||||
.active_tab()
|
||||
.map_or_else(|| AppId::Doc.name().into(), |tab| tab.name.clone());
|
||||
let side = self.side_view(cx);
|
||||
let shell = Shell::new(title, &self.core.focus)
|
||||
.tabs(self.tab_strip(cx))
|
||||
.left(self.left_view(cx))
|
||||
.right(side)
|
||||
.status(self.status(cx))
|
||||
.child(self.body(cx));
|
||||
let root = div()
|
||||
.size_full()
|
||||
.key_context(commands::CONTEXT)
|
||||
.track_focus(&self.core.focus)
|
||||
.on_action(cx.listener(Self::on_new))
|
||||
.on_action(cx.listener(Self::on_open))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.dialog_element(window, cx));
|
||||
Self::bind_zoom(crate::insert::bind(root, cx), cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Zooming for Docs {}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The bundled templates (DOC-01).
|
||||
|
||||
use gpui::App;
|
||||
|
||||
/// The three bundled templates (DOC-01).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Template {
|
||||
Blank,
|
||||
Report,
|
||||
Letter,
|
||||
}
|
||||
|
||||
pub const TEMPLATES: [Template; 3] = [Template::Blank, Template::Report, Template::Letter];
|
||||
|
||||
impl Template {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Template::Blank => "blank",
|
||||
Template::Report => "report",
|
||||
Template::Letter => "letter",
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraphs, as (heading level, text); 0 is body.
|
||||
fn lines(self, cx: &App) -> Vec<(u8, String)> {
|
||||
let text = |key: &str| ely_ui::i18n::text(cx, key, &[]).to_string();
|
||||
match self {
|
||||
Template::Blank => vec![(0, String::new())],
|
||||
Template::Report => vec![
|
||||
(9, text("doc.report.title")),
|
||||
(1, text("doc.report.summary")),
|
||||
(0, text("doc.body")),
|
||||
(1, text("doc.report.details")),
|
||||
(0, text("doc.body")),
|
||||
],
|
||||
Template::Letter => vec![
|
||||
(0, text("doc.letter.sender")),
|
||||
(0, text("doc.letter.date")),
|
||||
(0, String::new()),
|
||||
(0, text("doc.letter.recipient")),
|
||||
(0, String::new()),
|
||||
(0, text("doc.letter.greeting")),
|
||||
(0, text("doc.body")),
|
||||
(0, String::new()),
|
||||
(0, text("doc.letter.closing")),
|
||||
(0, text("doc.letter.sender")),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat ODF text; level 9 is the Title style.
|
||||
pub fn fodt(self, cx: &App) -> String {
|
||||
let body: String = self
|
||||
.lines(cx)
|
||||
.into_iter()
|
||||
.map(|(level, text)| {
|
||||
let text = escape(&text);
|
||||
match level {
|
||||
0 => format!("<text:p>{text}</text:p>"),
|
||||
9 => format!("<text:p text:style-name=\"Title\">{text}</text:p>"),
|
||||
level => format!("<text:h text:outline-level=\"{level}\">{text}</text:h>"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" office:version=\"1.3\" office:mimetype=\"application/vnd.oasis.opendocument.text\"><office:body><office:text>{body}</office:text></office:body></office:document>\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// The template as the picker's markdown preview.
|
||||
pub fn preview(self, cx: &App) -> String {
|
||||
self.lines(cx)
|
||||
.into_iter()
|
||||
.map(|(level, text)| match level {
|
||||
0 => text,
|
||||
9 => format!("# {text}"),
|
||||
level => format!("{} {text}", "#".repeat(usize::from(level) + 1)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// XML-escapes template text.
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn template_text_is_escaped() {
|
||||
assert_eq!(escape("a<b & c>"), "a<b & c>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! The Insert menu and its dialog (DOC-05 to 08).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
forms::NumberInput,
|
||||
overlays::{ConfirmDialog, Dialog as Card},
|
||||
primitives::Severity,
|
||||
};
|
||||
use gpui::{
|
||||
App, Context, Div, Entity, InteractiveElement as _, Menu, MenuItem, ParentElement as _,
|
||||
Styled as _, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::actions::Dialog;
|
||||
use crate::docs::Docs;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
actions!(
|
||||
doc,
|
||||
[
|
||||
InsertTable,
|
||||
InsertImage,
|
||||
InsertPageBreak,
|
||||
InsertSectionBreak,
|
||||
InsertPageNumber,
|
||||
InsertPageCount
|
||||
]
|
||||
);
|
||||
|
||||
/// ElyDoc's Insert menu, after File.
|
||||
pub fn menu(cx: &App) -> Menu {
|
||||
let label = |key: &str| Docs::text(cx, key, &[]);
|
||||
let mut items = vec![
|
||||
MenuItem::action(label("doc.insert.table"), InsertTable),
|
||||
MenuItem::action(label("doc.insert.image"), InsertImage),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("doc.insert.page_break"), InsertPageBreak),
|
||||
MenuItem::action(label("doc.insert.section_break"), InsertSectionBreak),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("doc.insert.page_number"), InsertPageNumber),
|
||||
MenuItem::action(label("doc.insert.page_count"), InsertPageCount),
|
||||
];
|
||||
items.extend(crate::refs::items(cx));
|
||||
Menu::new(label("menu.insert")).items(items)
|
||||
}
|
||||
|
||||
/// Binds the Insert and Review actions on the window root.
|
||||
pub fn bind(root: Div, cx: &mut Context<Docs>) -> Div {
|
||||
root.on_action(cx.listener(Docs::on_insert_table))
|
||||
.on_action(cx.listener(Docs::on_insert_image))
|
||||
.on_action(cx.listener(Docs::on_page_break))
|
||||
.on_action(cx.listener(Docs::on_section_break))
|
||||
.on_action(cx.listener(Docs::on_page_number))
|
||||
.on_action(cx.listener(Docs::on_page_count))
|
||||
.on_action(cx.listener(Docs::on_contents))
|
||||
.on_action(cx.listener(Docs::on_update))
|
||||
.on_action(cx.listener(Docs::on_footnote))
|
||||
.on_action(cx.listener(Docs::on_endnote))
|
||||
.on_action(cx.listener(Docs::on_bookmark))
|
||||
.on_action(cx.listener(Docs::on_link))
|
||||
.on_action(cx.listener(Docs::on_reference))
|
||||
.on_action(cx.listener(Docs::on_caption))
|
||||
.on_action(cx.listener(Docs::on_formula))
|
||||
.on_action(cx.listener(Docs::on_comment))
|
||||
.on_action(cx.listener(Docs::on_track))
|
||||
.on_action(cx.listener(Docs::on_accept_all))
|
||||
.on_action(cx.listener(Docs::on_reject_all))
|
||||
.on_action(cx.listener(Docs::on_show_review))
|
||||
.on_action(cx.listener(Docs::on_find))
|
||||
.on_action(cx.listener(Docs::on_compare))
|
||||
.on_action(cx.listener(Docs::on_merge))
|
||||
.on_action(cx.listener(Docs::on_export))
|
||||
.on_action(cx.listener(Docs::on_navigator))
|
||||
.on_action(cx.listener(Docs::on_back))
|
||||
.on_action(cx.listener(Docs::on_print))
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Runs a command on the active document.
|
||||
pub(crate) fn run_active(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
log::info!("doc: runs {id}");
|
||||
let sent = tab.view.update(cx, |view, _| view.run(id, values));
|
||||
if let Err(error) = sent {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_insert_table(&mut self, _: &InsertTable, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dialog = Some(Dialog::Table {
|
||||
columns: 2.0,
|
||||
rows: 2.0,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn on_insert_image(&mut self, _: &InsertImage, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.pick(false, cx, |docs, path, cx| {
|
||||
let url = ely_lok::file_url(&path);
|
||||
docs.run_active("image.insert", &[&url], cx)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn on_page_break(&mut self, _: &InsertPageBreak, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("page.break", &[], cx);
|
||||
}
|
||||
|
||||
/// Asks first: a new section clears the undo history.
|
||||
pub fn on_section_break(
|
||||
&mut self,
|
||||
_: &InsertSectionBreak,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.dialog = Some(Dialog::Section);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Copies this page's style, then breaks into it.
|
||||
pub fn insert_section(&mut self, cx: &mut Context<Self>) {
|
||||
self.dialog = None;
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
let listed = self
|
||||
.core
|
||||
.worker
|
||||
.lock()
|
||||
.expect("the worker lock")
|
||||
.values(&tab.doc, ".uno:StyleApply")
|
||||
.and_then(|json| ely_lok::commands::styles(&json, "8"));
|
||||
let names = match listed {
|
||||
Ok(names) => names,
|
||||
Err(error) => {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
let name = (2..)
|
||||
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
|
||||
.find(|name| !names.contains(name))
|
||||
.expect("a free section name");
|
||||
log::info!("doc: a new section");
|
||||
self.run_active("style.new", &[&name, "8"], cx);
|
||||
self.run_active("page.section", &["3", &name], 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>) {
|
||||
self.run_active("field.page", &[], cx);
|
||||
}
|
||||
|
||||
pub fn on_page_count(&mut self, _: &InsertPageCount, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("field.pages", &[], cx);
|
||||
}
|
||||
|
||||
/// Inserts the dialog's table and closes it.
|
||||
pub fn insert_table(&mut self, columns: f64, rows: f64, cx: &mut Context<Self>) {
|
||||
self.dialog = None;
|
||||
let (columns, rows) = (columns.round().to_string(), rows.round().to_string());
|
||||
self.run_active("table.insert", &[&columns, &rows], cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// The section break's warning: undo clears.
|
||||
pub fn section_dialog(
|
||||
this: Entity<Docs>,
|
||||
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> ConfirmDialog {
|
||||
ConfirmDialog::new(
|
||||
"doc-insert-section",
|
||||
Docs::text(cx, "doc.section.title", &[]),
|
||||
Docs::text(cx, "doc.section.detail", &[]),
|
||||
dismiss,
|
||||
)
|
||||
.confirm(Docs::text(cx, "doc.insert.insert", &[]))
|
||||
.on_confirm(move |_, cx| this.update(cx, |docs, cx| docs.insert_section(cx)))
|
||||
}
|
||||
|
||||
/// The Insert Table dialog: columns and rows.
|
||||
pub fn table_dialog(
|
||||
this: Entity<Docs>,
|
||||
(columns, rows): (f64, f64),
|
||||
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> Card {
|
||||
let field = |id: &'static str, value: f64, max: f64, tall: bool| {
|
||||
let this = this.clone();
|
||||
let input = NumberInput::new(id, value)
|
||||
.range(1.0, max)
|
||||
.on_change(move |value, _, cx| {
|
||||
this.update(cx, |docs, cx| {
|
||||
if let Some(Dialog::Table { columns, rows }) = &mut docs.dialog {
|
||||
*if tall { rows } else { columns } = value;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
div().w_full().max_w_32().child(input)
|
||||
};
|
||||
let fields = DescriptionList::new()
|
||||
.item(
|
||||
Docs::text(cx, "doc.insert.columns", &[]),
|
||||
field("doc-table-columns", columns, 99.0, false),
|
||||
)
|
||||
.item(
|
||||
Docs::text(cx, "doc.insert.rows", &[]),
|
||||
field("doc-table-rows", rows, 999.0, true),
|
||||
);
|
||||
let (label, cancel) = (
|
||||
Docs::text(cx, "doc.insert.insert", &[]),
|
||||
Docs::text(cx, "office.cancel", &[]),
|
||||
);
|
||||
Card::new(
|
||||
"doc-insert-table",
|
||||
Docs::text(cx, "doc.insert.table_title", &[]),
|
||||
dismiss,
|
||||
)
|
||||
.child(fields)
|
||||
.action(move |close| {
|
||||
Button::new("doc-insert-table-cancel", cancel)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})
|
||||
.action(move |_| {
|
||||
Button::new("doc-insert-table-go", label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |docs, cx| docs.insert_table(columns, rows, cx))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
mod actions;
|
||||
mod chrome;
|
||||
mod compare;
|
||||
mod docs;
|
||||
mod files;
|
||||
mod insert;
|
||||
mod merge;
|
||||
mod navigate;
|
||||
mod output;
|
||||
mod props;
|
||||
mod refs;
|
||||
mod review;
|
||||
mod spell;
|
||||
|
||||
use ely_platform::AppId;
|
||||
|
||||
fn main() {
|
||||
ely_ui::run(AppId::Doc, docs::open);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Mail merge from a CSV (DOC-14); never sends mail.
|
||||
|
||||
mod run;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
data_display::{PropertyGrid, PropertyGroup},
|
||||
forms::NumberInput,
|
||||
layout::ScrollArea,
|
||||
primitives::Severity,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use gpui::{
|
||||
Context, IntoElement, ParentElement as _, Render, Styled as _, WeakEntity, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::docs::{Docs, Side};
|
||||
use ely_office::prelude::*;
|
||||
pub use run::{Table, marker, read_csv};
|
||||
|
||||
actions!(doc, [MailMerge]);
|
||||
|
||||
/// ElyDoc's Tools menu.
|
||||
pub fn menu(cx: &gpui::App) -> gpui::Menu {
|
||||
let label = |key: &str| Docs::text(cx, key, &[]);
|
||||
gpui::Menu::new(label("menu.tools")).items([
|
||||
gpui::MenuItem::action(label("office.find.open"), ely_office::FindReplace),
|
||||
gpui::MenuItem::action(label("doc.merge.open"), MailMerge),
|
||||
gpui::MenuItem::separator(),
|
||||
gpui::MenuItem::action(label("doc.nav.open"), crate::navigate::ToggleNavigator),
|
||||
gpui::MenuItem::action(label("doc.nav.back"), crate::navigate::GoBack),
|
||||
])
|
||||
}
|
||||
|
||||
/// The merge panel: the data, the record shown, the format.
|
||||
pub struct Merge {
|
||||
docs: WeakEntity<Docs>,
|
||||
pub table: Option<Table>,
|
||||
/// The record to preview, from 1.
|
||||
record: usize,
|
||||
pdf: bool,
|
||||
}
|
||||
|
||||
impl Merge {
|
||||
pub fn new(docs: WeakEntity<Docs>) -> Self {
|
||||
Merge {
|
||||
docs,
|
||||
table: None,
|
||||
record: 1,
|
||||
pdf: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
pub fn on_merge(&mut self, _: &MailMerge, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.side = Side::Merge;
|
||||
cx.notify();
|
||||
if self.merge.read(cx).table.is_none() {
|
||||
self.choose_csv(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
|
||||
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
|
||||
Ok(table) => {
|
||||
log::info!("doc: merge data has {} rows", table.rows.len());
|
||||
docs.merge.update(cx, |merge, cx| {
|
||||
merge.table = Some(table);
|
||||
merge.record = 1;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let name = path.display().to_string();
|
||||
let error = error.to_string();
|
||||
let text = Docs::text(
|
||||
cx,
|
||||
"doc.merge.unreadable",
|
||||
&[("name", &name), ("error", &error)],
|
||||
);
|
||||
docs.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The tab a merge starts from: the active one.
|
||||
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
|
||||
if self.core.active.is_none() {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
self.core.active
|
||||
}
|
||||
|
||||
/// Types a field's marker at the cursor.
|
||||
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
tab.view.update(cx, |view, _| view.insert(&marker(header)));
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Merge {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (docs, this) = (self.docs.clone(), cx.entity());
|
||||
let text = |key: &str| Docs::text(cx, key, &[]);
|
||||
let choose = {
|
||||
let docs = docs.clone();
|
||||
Button::new("doc-merge-choose", text("doc.merge.choose"))
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
docs.update(cx, |docs, cx| docs.choose_csv(cx))
|
||||
.expect("the documents live");
|
||||
})
|
||||
};
|
||||
let Some(table) = self.table.clone() else {
|
||||
let group =
|
||||
PropertyGroup::new(text("doc.merge.title")).row(text("doc.merge.data"), choose);
|
||||
return PropertyGrid::new("doc-merge")
|
||||
.group(group)
|
||||
.into_any_element();
|
||||
};
|
||||
let fields = table
|
||||
.headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(at, header)| {
|
||||
let (docs, header) = (docs.clone(), header.clone());
|
||||
Button::new(("doc-merge-field", at), header.clone())
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, _, cx| {
|
||||
docs.update(cx, |docs, cx| docs.insert_field(&header, cx))
|
||||
.expect("the documents live");
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let record = {
|
||||
let this = this.clone();
|
||||
NumberInput::new("doc-merge-record", self.record as f64)
|
||||
.range(1.0, table.rows.len() as f64)
|
||||
.on_commit(move |value, _, cx| {
|
||||
this.update(cx, |merge, cx| {
|
||||
merge.record = value.round() as usize;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
};
|
||||
let preview = {
|
||||
let (docs, table, at) = (docs.clone(), table.clone(), self.record - 1);
|
||||
Button::new("doc-merge-preview", text("doc.merge.preview"))
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
docs.update(cx, |docs, cx| {
|
||||
if let Some(source) = docs.source(cx) {
|
||||
docs.preview_record(source, &table, at, cx)
|
||||
}
|
||||
})
|
||||
.expect("the documents live");
|
||||
})
|
||||
};
|
||||
let format = {
|
||||
let this = this.clone();
|
||||
SegmentedControl::new("doc-merge-format", if self.pdf { "pdf" } else { "docx" })
|
||||
.size(ControlSize::Sm)
|
||||
.segment("docx", "DOCX", None)
|
||||
.segment("pdf", "PDF", None)
|
||||
.on_change(move |value, _, cx| {
|
||||
this.update(cx, |merge, cx| {
|
||||
merge.pdf = value.as_ref() == "pdf";
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
};
|
||||
let generate = {
|
||||
let (pdf, table) = (self.pdf, table.clone());
|
||||
Button::new("doc-merge-generate", text("doc.merge.generate"))
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| {
|
||||
let table = table.clone();
|
||||
docs.update(cx, |docs, cx| {
|
||||
let Some(source) = docs.source(cx) else {
|
||||
return;
|
||||
};
|
||||
docs.pick(true, cx, move |docs, folder, cx| {
|
||||
docs.generate(source, table, pdf, folder, cx)
|
||||
})
|
||||
})
|
||||
.expect("the documents live");
|
||||
})
|
||||
};
|
||||
let row = || div().flex().flex_wrap().items_center().gap_1();
|
||||
let group = PropertyGroup::new(table.name.clone())
|
||||
.row(text("doc.merge.data"), choose)
|
||||
.row(text("doc.merge.fields"), row().children(fields))
|
||||
.row(
|
||||
text("doc.merge.record"),
|
||||
row().child(div().w_full().max_w_24().child(record)).child(preview),
|
||||
)
|
||||
.row(
|
||||
text("doc.merge.output"),
|
||||
row().child(format).child(generate),
|
||||
);
|
||||
ScrollArea::new("doc-merge-scroll").vertical()
|
||||
.size_full()
|
||||
.child(PropertyGrid::new("doc-merge").group(group))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Mail merge work: the CSV, filled copies, previews and files.
|
||||
|
||||
use std::{
|
||||
collections::hash_map::RandomState,
|
||||
hash::BuildHasher,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_lok::{Doc, Worker};
|
||||
use gpui::{AppContext as _, Context, SharedString, TaskExt as _};
|
||||
|
||||
use ely_office::files::{self, file_name};
|
||||
use ely_office::prelude::*;
|
||||
use ely_office::{Source, in_window};
|
||||
|
||||
use crate::docs::Docs;
|
||||
|
||||
/// A CSV: its name, column names and rows.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Table {
|
||||
pub name: SharedString,
|
||||
pub headers: Vec<String>,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 CSV whose first row names the columns.
|
||||
pub fn read_csv(path: &Path) -> io::Result<Table> {
|
||||
let mut reader = csv::Reader::from_path(path).map_err(io::Error::other)?;
|
||||
let headers: Vec<String> = reader
|
||||
.headers()
|
||||
.map_err(io::Error::other)?
|
||||
.iter()
|
||||
.map(|header| header.trim().to_string())
|
||||
.collect();
|
||||
if let Some(at) = headers.iter().position(String::is_empty) {
|
||||
return Err(io::Error::other(format!("column {} has no name", at + 1)));
|
||||
}
|
||||
// A name with a guillemet would blur the marker bounds.
|
||||
if let Some(header) = headers.iter().find(|header| header.contains(['«', '»'])) {
|
||||
return Err(io::Error::other(format!("column {header} holds « or »")));
|
||||
}
|
||||
for (at, header) in headers.iter().enumerate() {
|
||||
if headers[..at].contains(header) {
|
||||
return Err(io::Error::other(format!("two columns are named {header}")));
|
||||
}
|
||||
}
|
||||
let rows = reader
|
||||
.records()
|
||||
.map(|record| record.map(|record| record.iter().map(str::to_string).collect()))
|
||||
.collect::<Result<Vec<Vec<String>>, _>>()
|
||||
.map_err(io::Error::other)?;
|
||||
if rows.is_empty() {
|
||||
return Err(io::Error::other("the file has no rows"));
|
||||
}
|
||||
Ok(Table {
|
||||
name: file_name(path),
|
||||
headers,
|
||||
rows,
|
||||
})
|
||||
}
|
||||
|
||||
/// The marker a field leaves in the text.
|
||||
pub fn marker(header: &str) -> String {
|
||||
format!("«{header}»")
|
||||
}
|
||||
|
||||
/// Replaces every field marker in `doc` with `row`'s values.
|
||||
fn fill(worker: &mut Worker, doc: &Doc, headers: &[String], row: &[String]) -> io::Result<()> {
|
||||
// Markers become tokens first, so values are never rescanned.
|
||||
let nonce = BuildHasher::hash_one(&RandomState::new(), 0);
|
||||
let token = |at: usize| format!("\u{e000}{nonce:x}-{at}\u{e001}");
|
||||
let mut replace = |from: &str, to: &str| {
|
||||
worker.run(
|
||||
doc,
|
||||
"find.replace",
|
||||
&[from, "false", "3", "0", "0", "1", to],
|
||||
)
|
||||
};
|
||||
for (at, header) in headers.iter().enumerate() {
|
||||
replace(&marker(header), &token(at))?;
|
||||
}
|
||||
for (at, value) in row.iter().enumerate() {
|
||||
replace(&token(at), value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copies `from` to `to`, never over an existing file.
|
||||
fn publish(from: &Path, to: &Path) -> io::Result<()> {
|
||||
let mut target = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(to)?;
|
||||
io::copy(&mut std::fs::File::open(from)?, &mut target)?;
|
||||
target.sync_all()
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Saves tab `source` as the template copy, in the background.
|
||||
fn template(
|
||||
&mut self,
|
||||
source: u64,
|
||||
into: u64,
|
||||
cx: &mut Context<Self>,
|
||||
then: impl FnOnce(&mut Self, SharedString, PathBuf, &mut Context<Self>) + 'static,
|
||||
) {
|
||||
let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else {
|
||||
return self.merge_failed(&io::Error::other("the source document closed"), cx);
|
||||
};
|
||||
let copy = match files::work_dir(&self.core.work, into) {
|
||||
Ok(dir) => dir.join("merge.odt"),
|
||||
Err(error) => return self.merge_failed(&error, cx),
|
||||
};
|
||||
let url = ely_lok::file_url(©);
|
||||
self.read_in(
|
||||
source,
|
||||
move |worker, doc| doc.save_as(worker, &url, "odt"),
|
||||
move |docs, saved, _, cx| match saved {
|
||||
Ok(()) => then(docs, name, copy, cx),
|
||||
Err(error) => docs.merge_failed(&error, cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn merge_failed(&self, error: &io::Error, cx: &mut Context<Self>) {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// Opens record `at` filled in, as a new tab.
|
||||
pub(crate) fn preview_record(
|
||||
&mut self,
|
||||
source: u64,
|
||||
table: &Table,
|
||||
at: usize,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.settled(source, cx) {
|
||||
let table = table.clone();
|
||||
return self.when_settled(source, move |docs, _, cx| {
|
||||
docs.preview_record(source, &table, at, cx)
|
||||
});
|
||||
}
|
||||
let (headers, row) = (table.headers.clone(), table.rows[at].clone());
|
||||
// The preview's tab and folder, reserved now.
|
||||
let into = self.core.next;
|
||||
self.core.next += 1;
|
||||
self.template(source, into, cx, move |docs, name, copy, cx| {
|
||||
docs.preview_copy((into, at), name, copy, (headers, row), cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens the template copy and fills it with one row.
|
||||
fn preview_copy(
|
||||
&mut self,
|
||||
(into, at): (u64, usize),
|
||||
name: SharedString,
|
||||
copy: PathBuf,
|
||||
(headers, row): (Vec<String>, Vec<String>),
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::info!("doc: previewing merge record {}", at + 1);
|
||||
self.load_into(into, Source::Copy(copy), cx);
|
||||
self.core.waiting.push((
|
||||
into,
|
||||
Box::new(move |docs, _, cx| {
|
||||
let Some(tab) = docs.tab_mut(into) else {
|
||||
return;
|
||||
};
|
||||
let n = (at + 1).to_string();
|
||||
tab.name = Docs::text(cx, "doc.merge.preview_name", &[("name", &name), ("n", &n)]);
|
||||
cx.notify();
|
||||
docs.read_in(
|
||||
into,
|
||||
move |worker, doc| fill(worker, doc, &headers, &row),
|
||||
|docs, filled, _, cx| {
|
||||
if let Err(error) = filled {
|
||||
docs.merge_failed(&error, cx);
|
||||
}
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
/// Writes one file per row into `folder`; refuses to overwrite.
|
||||
pub(crate) fn generate(
|
||||
&mut self,
|
||||
source: u64,
|
||||
table: Table,
|
||||
pdf: bool,
|
||||
folder: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.settled(source, cx) {
|
||||
return self.when_settled(source, move |docs, _, cx| {
|
||||
docs.generate(source, table, pdf, folder, cx)
|
||||
});
|
||||
}
|
||||
let Some(name) = self.tab(source).map(|tab| tab.name.clone()) else {
|
||||
return self.merge_failed(&io::Error::other("the source document closed"), cx);
|
||||
};
|
||||
let stem = Path::new(name.as_ref()).file_stem().map_or_else(
|
||||
|| name.to_string(),
|
||||
|stem| stem.to_string_lossy().into_owned(),
|
||||
);
|
||||
let extension = if pdf { "pdf" } else { "docx" };
|
||||
let targets: Vec<PathBuf> = (1..=table.rows.len())
|
||||
.map(|n| folder.join(format!("{stem} {n}.{extension}")))
|
||||
.collect();
|
||||
let taken = targets
|
||||
.iter()
|
||||
.find(|target| target.exists())
|
||||
.map(|target| file_name(target));
|
||||
if let Some(taken) = taken {
|
||||
let text = Docs::text(cx, "doc.merge.exists", &[("name", &taken)]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
// Each run keeps its own template and staging folder.
|
||||
let into = self.core.next;
|
||||
self.core.next += 1;
|
||||
self.template(source, into, cx, move |docs, _, template, cx| {
|
||||
docs.write_records(template, (table, targets, extension, folder), cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// Writes each row's file from the template copy.
|
||||
fn write_records(
|
||||
&mut self,
|
||||
template: PathBuf,
|
||||
(table, targets, extension, folder): (Table, Vec<PathBuf>, &'static str, PathBuf),
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::info!("doc: merging {} records", targets.len());
|
||||
let worker = Arc::clone(&self.core.worker);
|
||||
cx.spawn(async move |this, cx| {
|
||||
let written = cx
|
||||
.background_spawn(async move {
|
||||
for (n, (row, target)) in table.rows.iter().zip(&targets).enumerate() {
|
||||
// One record at a time: open documents keep their turns.
|
||||
let mut worker = worker.lock().expect("the worker lock");
|
||||
let staged = template.with_file_name(format!("record.{extension}"));
|
||||
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
|
||||
fill(&mut worker, &doc, &table.headers, row)?;
|
||||
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?;
|
||||
worker.close_doc(doc)?;
|
||||
publish(&staged, target)
|
||||
});
|
||||
made.map_err(|error| (n + 1, error))?;
|
||||
}
|
||||
Ok(targets.len())
|
||||
})
|
||||
.await;
|
||||
in_window(&this, cx, |docs, _, cx| {
|
||||
let folder = folder.display().to_string();
|
||||
let text = match written {
|
||||
Ok(count) => {
|
||||
let count = count.to_string();
|
||||
let text =
|
||||
Docs::text(cx, "doc.merge.done", &[("n", &count), ("folder", &folder)]);
|
||||
return docs.toast(text, Severity::Success, cx);
|
||||
}
|
||||
Err((n, error)) => Docs::text(
|
||||
cx,
|
||||
"doc.merge.failed",
|
||||
&[("n", &n.to_string()), ("error", &error.to_string())],
|
||||
),
|
||||
};
|
||||
docs.toast(text, Severity::Danger, cx);
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_csv_reads_or_says_why_not() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("people.csv");
|
||||
std::fs::write(&path, "\u{feff}Name,City\nAda,\"London, UK\"\nLin,上海\n").expect("writes");
|
||||
let table = read_csv(&path).expect("reads");
|
||||
assert_eq!(table.headers, ["Name", "City"]);
|
||||
assert_eq!(table.rows[0], ["Ada", "London, UK"]);
|
||||
assert_eq!(table.rows[1][1], "上海");
|
||||
for (body, why) in [
|
||||
("Name,\nAda,x\n", "no name"),
|
||||
("Name,Name\na,b\n", "two columns"),
|
||||
("Name,«Name»\na,b\n", "holds « or »"),
|
||||
("Name,City\n", "no rows"),
|
||||
("Name,City\nAda\n", "found record"),
|
||||
] {
|
||||
std::fs::write(&path, body).expect("writes");
|
||||
let error = read_csv(&path).expect_err(body).to_string();
|
||||
assert!(error.contains(why), "{body}: {error}");
|
||||
}
|
||||
std::fs::write(&path, b"Name\n\xff\xfe\n").expect("writes");
|
||||
assert!(read_csv(&path).is_err(), "bytes that are not UTF-8 fail");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
use crate::tests::{
|
||||
blank, commit, docs, settle, settled, text_of, type_into, with_window, written,
|
||||
};
|
||||
|
||||
fn tabs(app: &gpui::App, docs: &Entity<Docs>) -> usize {
|
||||
docs.read(app).core.tabs.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_fill_previews_and_files() {
|
||||
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 source_id = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Dear ");
|
||||
let csv = dir.path().join("people.csv");
|
||||
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
type_into(&mut cx, &docs, " of ");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
|
||||
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.preview_record(source_id, &table, 1, cx)
|
||||
})
|
||||
});
|
||||
let name = cx.update(|app| {
|
||||
Docs::text(
|
||||
app,
|
||||
"doc.merge.preview_name",
|
||||
&[("name", &source), ("n", "2")],
|
||||
)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
tabs(app, &docs) == 2
|
||||
&& docs.read(app).core.tabs[1].name == name
|
||||
&& docs.read(app).core.tabs[1].view.read(app).settled()
|
||||
});
|
||||
let preview = dir.path().join("preview.odt");
|
||||
let id = cx.update(|app| docs.read(app).core.tabs[1].id);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, preview.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &preview, None);
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &preview).contains("Dear «City» of 上海"),
|
||||
"the preview takes queued input and keeps values literal"
|
||||
);
|
||||
cx.update(|app| docs.update(app, |docs, _| docs.core.active = Some(docs.core.tabs[0].id)));
|
||||
let out = dir.path().join("out");
|
||||
std::fs::create_dir(&out).expect("the folder");
|
||||
let target = |n: usize, extension: &str| out.join(format!("{source} {n}.{extension}"));
|
||||
std::fs::write(target(2, "docx"), "mine").expect("a taken name");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(source_id, table.clone(), false, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(target(2, "docx")).expect("reads"),
|
||||
"mine",
|
||||
"a taken name stops the merge"
|
||||
);
|
||||
assert!(!target(1, "docx").exists(), "nothing is written");
|
||||
std::fs::remove_file(target(2, "docx")).expect("frees the name");
|
||||
let staged = cx.update(|app| {
|
||||
let docs = docs.read(app);
|
||||
docs.core
|
||||
.work
|
||||
.join(format!("doc-{}", docs.core.next))
|
||||
.join("record.pdf")
|
||||
});
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(source_id, table.clone(), true, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
std::fs::write(target(1, "pdf"), "EXTERNAL").expect("a file arrives late");
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
settle(&cx, |_| staged.exists() && worker.try_lock().is_ok());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(target(1, "pdf")).expect("reads"),
|
||||
"EXTERNAL",
|
||||
"a file that arrives late stays"
|
||||
);
|
||||
assert!(
|
||||
!target(2, "pdf").exists(),
|
||||
"the merge stops at the taken name"
|
||||
);
|
||||
std::fs::remove_file(target(1, "pdf")).expect("frees the name");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(source_id, table, true, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
settle(&cx, |_| target(2, "pdf").exists());
|
||||
let first = std::fs::read(target(1, "pdf")).expect("the pdf reads");
|
||||
assert!(first.starts_with(b"%PDF"), "a PDF is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_opens_a_marked_copy_and_keeps_both_files() {
|
||||
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 new line");
|
||||
settled(&cx, &docs);
|
||||
let older = dir.path().join("older.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, older.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &older, None);
|
||||
commit(&mut cx, &docs, " grows");
|
||||
let before = std::fs::read(&older).expect("the older file reads");
|
||||
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.compare(id, older.clone(), cx)));
|
||||
settle(&cx, |app| {
|
||||
tabs(app, &docs) == 2 && docs.read(app).side == crate::docs::Side::Review
|
||||
});
|
||||
let review = cx.update(|app| docs.read(app).review.clone());
|
||||
let tab = cx.update(|app| docs.read(app).core.tabs[1].view.clone());
|
||||
cx.update(|app| {
|
||||
let doc = docs.read(app).core.tabs[1].doc.clone();
|
||||
review.update(app, |review, cx| review.show(Some((&tab, &doc)), cx))
|
||||
});
|
||||
settle(&cx, |app| !review.read(app).changes.is_empty());
|
||||
let name = cx.update(|app| docs.read(app).core.tabs[1].name.to_string());
|
||||
assert!(name.contains(&source), "the copy names its source: {name}");
|
||||
let copy = dir.path().join("copy.odt");
|
||||
let into = cx.update(|app| docs.read(app).core.tabs[1].id);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(into, copy.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, ©, None);
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, ©).contains(" grows"),
|
||||
"the copy takes the queued input"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&older).expect("reads"),
|
||||
before,
|
||||
"the older file stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
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 other = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceB ");
|
||||
let source = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceA ");
|
||||
let csv = dir.path().join("one.csv");
|
||||
std::fs::write(&csv, "Name\nAda\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.insert_field("Name", cx);
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
docs.core.active = Some(other);
|
||||
})
|
||||
});
|
||||
let name = cx.update(|app| {
|
||||
let source = docs.read(app).tab(source).expect("a tab").name.clone();
|
||||
Docs::text(
|
||||
app,
|
||||
"doc.merge.preview_name",
|
||||
&[("name", &source), ("n", "1")],
|
||||
)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
let docs = docs.read(app);
|
||||
// A filled preview is a modified copy.
|
||||
docs.core.tabs.len() == 3 && docs.core.tabs[2].name == name && docs.core.tabs[2].modified
|
||||
});
|
||||
let preview = dir.path().join("preview.odt");
|
||||
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, preview.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &preview, None);
|
||||
let text = text_of(&mut cx, &docs, &preview);
|
||||
assert!(text.contains("SourceA Ada"), "the preview keeps its source");
|
||||
let stem = cx.update(|app| docs.read(app).tab(source).expect("a tab").name.to_string());
|
||||
let out = dir.path().join("out");
|
||||
std::fs::create_dir(&out).expect("the folder");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.core.active = Some(other);
|
||||
docs.generate(source, table, false, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
let target = out.join(format!("{stem} 1.docx"));
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
settle(&cx, |_| target.exists() && worker.try_lock().is_ok());
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &target).contains("SourceA Ada"),
|
||||
"the file keeps its source"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_generations_keep_their_own_templates() {
|
||||
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 mut sources = Vec::new();
|
||||
for text in ["SourceA ", "SourceB "] {
|
||||
sources.push(blank(&mut cx, &docs));
|
||||
type_into(&mut cx, &docs, text);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
settled(&cx, &docs);
|
||||
}
|
||||
let csv = dir.path().join("one.csv");
|
||||
std::fs::write(&csv, "Name\nAda\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
let outs: Vec<_> = (0..2).map(|n| dir.path().join(format!("out{n}"))).collect();
|
||||
for (source, out) in sources.iter().zip(&outs) {
|
||||
std::fs::create_dir(out).expect("the folder");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.generate(*source, table.clone(), false, out.clone(), cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
let names: Vec<String> = cx.update(|app| {
|
||||
let docs = docs.read(app);
|
||||
sources
|
||||
.iter()
|
||||
.map(|id| docs.tab(*id).expect("a tab").name.to_string())
|
||||
.collect()
|
||||
});
|
||||
let targets: Vec<_> = outs
|
||||
.iter()
|
||||
.zip(&names)
|
||||
.map(|(out, name)| out.join(format!("{name} 1.docx")))
|
||||
.collect();
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
settle(&cx, |_| {
|
||||
targets.iter().all(|target| target.exists()) && worker.try_lock().is_ok()
|
||||
});
|
||||
for (target, want) in targets.iter().zip(["SourceA Ada", "SourceB Ada"]) {
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, target).contains(want),
|
||||
"{want} lands"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Outline, pages and bookmarks beside the page (DOC-16).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
layout::ScrollArea,
|
||||
lists::{List, ListItem, Outline},
|
||||
primitives::{IconName, Severity},
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_lok::{
|
||||
Doc, LokError, LokView, Rect, Reported, Worker,
|
||||
review::{Bookmark, Heading},
|
||||
};
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, IntoElement, ParentElement as _, Render, SharedString,
|
||||
Styled as _, Subscription, WeakEntity, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
actions!(doc, [ToggleNavigator, GoBack]);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Pane {
|
||||
Outline,
|
||||
Pages,
|
||||
Marks,
|
||||
}
|
||||
|
||||
struct Shown {
|
||||
view: Entity<LokView>,
|
||||
doc: Doc,
|
||||
/// Edits and cursor at the last read.
|
||||
read: Option<(u64, Option<Rect>)>,
|
||||
/// A read is on its way.
|
||||
reading: bool,
|
||||
_observe: Subscription,
|
||||
}
|
||||
|
||||
pub struct Navigator {
|
||||
docs: WeakEntity<Docs>,
|
||||
worker: Arc<Mutex<Worker>>,
|
||||
shown: Option<Shown>,
|
||||
outline: Vec<Heading>,
|
||||
marks: Vec<Bookmark>,
|
||||
pane: Pane,
|
||||
}
|
||||
|
||||
impl EventEmitter<LokError> for Navigator {}
|
||||
|
||||
impl Navigator {
|
||||
pub fn new(docs: WeakEntity<Docs>, worker: Arc<Mutex<Worker>>) -> Self {
|
||||
Navigator {
|
||||
docs,
|
||||
worker,
|
||||
shown: None,
|
||||
outline: Vec::new(),
|
||||
marks: Vec::new(),
|
||||
pane: Pane::Outline,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows the active tab; reads the lists after edits.
|
||||
pub fn show(&mut self, tab: Option<(&Entity<LokView>, &Doc)>, cx: &mut Context<Self>) {
|
||||
let id = tab.map(|(view, _)| view.entity_id());
|
||||
if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id {
|
||||
return;
|
||||
}
|
||||
self.shown = None;
|
||||
self.outline.clear();
|
||||
self.marks.clear();
|
||||
cx.notify();
|
||||
let Some((view, doc)) = tab else {
|
||||
return;
|
||||
};
|
||||
let observe = cx.subscribe(view, |navigator, _, _: &Reported, cx| navigator.refresh(cx));
|
||||
self.shown = Some(Shown {
|
||||
view: view.clone(),
|
||||
doc: doc.clone(),
|
||||
read: None,
|
||||
reading: false,
|
||||
_observe: observe,
|
||||
});
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// Rereads the lists after edits or moves.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(shown) = self.shown.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let view = shown.view.read(cx);
|
||||
let seen = Some((view.edited(), view.cursor()));
|
||||
if shown.reading || shown.read == seen {
|
||||
return;
|
||||
}
|
||||
(shown.read, shown.reading) = (seen, true);
|
||||
let reader = shown.view.entity_id();
|
||||
ely_lok::read_off(
|
||||
&self.worker,
|
||||
&shown.doc,
|
||||
|worker, doc| {
|
||||
let json = worker.values(doc, ".uno:Outline")?;
|
||||
let outline = ely_lok::review::outline(&json)?;
|
||||
let json = worker.values(doc, ".uno:Bookmarks")?;
|
||||
Ok((outline, ely_lok::review::bookmarks(&json)?))
|
||||
},
|
||||
move |navigator: &mut Self, read, cx| {
|
||||
let Some(shown) = navigator.shown.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if shown.view.entity_id() != reader {
|
||||
return;
|
||||
}
|
||||
shown.reading = false;
|
||||
navigator.take_lists(read, cx);
|
||||
navigator.refresh(cx);
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn take_lists(
|
||||
&mut self,
|
||||
read: std::io::Result<(Vec<Heading>, Vec<Bookmark>)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match read {
|
||||
Ok((outline, marks)) => {
|
||||
let (placed, hidden): (Vec<_>, Vec<_>) =
|
||||
marks.into_iter().partition(|mark| mark.rect.is_some());
|
||||
log::info!(
|
||||
"doc: {} headings, {} bookmarks, {} not laid out",
|
||||
outline.len(),
|
||||
placed.len(),
|
||||
hidden.len()
|
||||
);
|
||||
self.outline = outline;
|
||||
self.marks = placed;
|
||||
}
|
||||
Err(error) => cx.emit(LokError(format!(
|
||||
"the navigator lists are unreadable: {error}"
|
||||
))),
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn jump(docs: &WeakEntity<Docs>, to: Rect, cx: &mut App) {
|
||||
docs.update(cx, |docs, cx| docs.jump(to, cx))
|
||||
.expect("the documents live");
|
||||
}
|
||||
|
||||
/// The page that holds the cursor.
|
||||
fn current_page(&self, pages: &[Rect], cx: &App) -> Option<usize> {
|
||||
let cursor = self.shown.as_ref()?.view.read(cx).cursor()?;
|
||||
pages.iter().rposition(|page| page.1 <= cursor.1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Goes to `to`; Back returns to where the cursor was.
|
||||
pub(crate) fn jump(&mut self, to: Rect, cx: &mut Context<Self>) {
|
||||
self.mark_place(cx);
|
||||
self.go(to, cx);
|
||||
}
|
||||
|
||||
/// Remembers the place of the active tab for Back.
|
||||
pub(crate) fn mark_place(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let view = tab.view.read(cx);
|
||||
let Some(cursor) = view.cursor() else {
|
||||
return;
|
||||
};
|
||||
// A jump the cursor has not caught up with.
|
||||
let place = match self.going {
|
||||
Some((id, seq, moves, to))
|
||||
if id == tab.id && (!view.reached(seq) || view.moves() == moves) =>
|
||||
{
|
||||
to
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
self.back.push((tab.id, place));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Puts the active tab's cursor at `to`.
|
||||
fn go(&mut self, to: Rect, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let (id, view) = (tab.id, tab.view.clone());
|
||||
let moves = view.read(cx).moves();
|
||||
let seq = view.update(cx, |view, cx| {
|
||||
view.reveal(to, cx);
|
||||
view.sent()
|
||||
});
|
||||
self.going = Some((id, seq, moves, to));
|
||||
}
|
||||
|
||||
pub fn on_back(&mut self, _: &GoBack, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some((id, place)) = self.back.pop() else {
|
||||
return;
|
||||
};
|
||||
if self.tab(id).is_none() {
|
||||
let text = Docs::text(cx, "doc.nav.gone", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
log::info!("doc: back to tab {id}");
|
||||
self.core.active = Some(id);
|
||||
self.go(place, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn on_navigator(&mut self, _: &ToggleNavigator, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.navigating = !self.navigating;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Navigator {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let this = cx.entity();
|
||||
let text = |key: &str| Docs::text(cx, key, &[]);
|
||||
let docs = self.docs.clone();
|
||||
let back = docs
|
||||
.upgrade()
|
||||
.is_some_and(|docs| !docs.read(cx).back.is_empty());
|
||||
let head = Button::new("doc-nav-back", text("doc.nav.back"))
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.icon(IconName::ArrowLeft)
|
||||
.disabled(!back)
|
||||
.on_click(move |_, window, cx| window.dispatch_action(Box::new(GoBack), cx));
|
||||
let pane = SegmentedControl::new(
|
||||
"doc-nav-pane",
|
||||
match self.pane {
|
||||
Pane::Outline => "outline",
|
||||
Pane::Pages => "pages",
|
||||
Pane::Marks => "marks",
|
||||
},
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("outline", text("doc.nav.outline"), Some(IconName::List))
|
||||
.segment("pages", text("doc.nav.pages"), Some(IconName::FileText))
|
||||
.segment("marks", text("doc.nav.marks"), Some(IconName::Bookmark))
|
||||
.on_change({
|
||||
let this = this.clone();
|
||||
move |value, _, cx| {
|
||||
this.update(cx, |navigator, cx| {
|
||||
navigator.pane = match value.as_ref() {
|
||||
"pages" => Pane::Pages,
|
||||
"marks" => Pane::Marks,
|
||||
_ => Pane::Outline,
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
});
|
||||
let body = match self.pane {
|
||||
Pane::Outline => {
|
||||
let mut outline = Outline::new("doc-nav-outline");
|
||||
for (at, heading) in self.outline.iter().enumerate() {
|
||||
let level = usize::try_from(heading.level).expect("a level") + 1;
|
||||
outline = outline.item(at.to_string(), heading.text.clone(), level);
|
||||
}
|
||||
if let Some(at) = self.outline.iter().position(|heading| heading.current) {
|
||||
outline = outline.current(at.to_string());
|
||||
}
|
||||
let this = this.clone();
|
||||
outline
|
||||
.on_select(move |key, _, cx| {
|
||||
let at: usize = key.parse().expect("an outline index");
|
||||
let (to, docs) = {
|
||||
let navigator = this.read(cx);
|
||||
(navigator.outline[at].rect, navigator.docs.clone())
|
||||
};
|
||||
Navigator::jump(&docs, to, cx);
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
Pane::Pages => {
|
||||
let pages = self
|
||||
.shown
|
||||
.as_ref()
|
||||
.map(|shown| shown.view.read(cx).pages().to_vec())
|
||||
.unwrap_or_default();
|
||||
let current = self.current_page(&pages, cx);
|
||||
let rows = pages.iter().enumerate().map(|(at, &(x, y, _, _))| {
|
||||
let docs = docs.clone();
|
||||
let label: SharedString =
|
||||
Docs::text(cx, "doc.nav.page", &[("n", &(at + 1).to_string())]);
|
||||
ListItem::new(("doc-nav-page", at), label)
|
||||
.selected(current == Some(at))
|
||||
.on_click(move |_, _, cx| Navigator::jump(&docs, (x, y, 0, 0), cx))
|
||||
});
|
||||
List::new().children(rows).into_any_element()
|
||||
}
|
||||
Pane::Marks => {
|
||||
let rows = self.marks.iter().enumerate().map(|(at, mark)| {
|
||||
let docs = docs.clone();
|
||||
let to = mark.rect.expect("listed marks are placed");
|
||||
ListItem::new(("doc-nav-mark", at), mark.name.clone())
|
||||
.on_click(move |_, _, cx| Navigator::jump(&docs, to, cx))
|
||||
});
|
||||
List::new().children(rows).into_any_element()
|
||||
}
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.child(head)
|
||||
.child(pane)
|
||||
.child(
|
||||
ScrollArea::new("doc-nav-scroll").vertical()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(body),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,254 @@
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
use crate::tests::{docs, settle, settled, text_of, with_window, written};
|
||||
use ely_office::Dialog;
|
||||
|
||||
/// Three pages, two headings, a font the system lacks.
|
||||
const LONG: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
|
||||
<office:font-face-decls><style:font-face style:name="Nowhere Sans" svg:font-family="'Nowhere Sans'"/></office:font-face-decls>
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:break-before="page"/></style:style>
|
||||
<style:style style:name="T1" style:family="text"><style:text-properties style:font-name="Nowhere Sans"/></style:style>
|
||||
</office:automatic-styles>
|
||||
<office:body><office:text>
|
||||
<text:h text:outline-level="1">First part</text:h><text:p>alpha <text:span text:style-name="T1">odd</text:span></text:p>
|
||||
<text:h text:style-name="P1" text:outline-level="1">Second part</text:h><text:p>bravo</text:p>
|
||||
<text:p text:style-name="P1">charlie</text:p>
|
||||
</office:text></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
/// Two headings side by side in two columns.
|
||||
const COLUMNS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="P1" style:family="paragraph"><style:paragraph-properties fo:break-before="column"/></style:style>
|
||||
<style:page-layout style:name="pm1"><style:page-layout-properties><style:columns fo:column-count="2" fo:column-gap="0.5cm"/></style:page-layout-properties></style:page-layout>
|
||||
</office:automatic-styles>
|
||||
<office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles>
|
||||
<office:body><office:text>
|
||||
<text:h text:outline-level="1">First part</text:h><text:p>alpha</text:p>
|
||||
<text:h text:style-name="P1" text:outline-level="1">Second part</text:h><text:p>bravo</text:p>
|
||||
</office:text></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
fn open_long(cx: &mut TestAppContext, docs: &Entity<Docs>, dir: &std::path::Path) -> u64 {
|
||||
open_text(cx, docs, &dir.join("long.fodt"), LONG)
|
||||
}
|
||||
|
||||
fn open_text(
|
||||
cx: &mut TestAppContext,
|
||||
docs: &Entity<Docs>,
|
||||
path: &std::path::Path,
|
||||
text: &str,
|
||||
) -> u64 {
|
||||
let path = path.to_path_buf();
|
||||
std::fs::write(&path, text).expect("the file writes");
|
||||
with_window(cx, docs, |docs, _, cx| docs.open_file(path.clone(), cx));
|
||||
settle(cx, |app| docs.read(app).core.tabs.len() == 1);
|
||||
settled(cx, docs);
|
||||
cx.update(|app| docs.read(app).core.tabs[0].id)
|
||||
}
|
||||
|
||||
fn cursor_y(app: &gpui::App, docs: &Entity<Docs>) -> Option<i32> {
|
||||
let tab = docs.read(app).active_tab()?;
|
||||
tab.view.read(app).cursor().map(|cursor| cursor.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headings_and_pages_jump_and_back_returns() {
|
||||
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;
|
||||
};
|
||||
open_long(&mut cx, &docs, dir.path());
|
||||
settle(&cx, |app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).missing_fonts() == ["Nowhere Sans"]
|
||||
&& tab.view.read(app).cursor().is_some()
|
||||
});
|
||||
let navigator = cx.update(|app| docs.read(app).navigator.clone());
|
||||
cx.update(|app| {
|
||||
let (view, doc) = {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
(tab.view.clone(), tab.doc.clone())
|
||||
};
|
||||
navigator.update(app, |navigator, cx| navigator.show(Some((&view, &doc)), cx));
|
||||
});
|
||||
settle(&cx, |app| navigator.read(app).outline.len() == 2);
|
||||
let current = |app: &gpui::App| {
|
||||
let navigator = navigator.read(app);
|
||||
navigator.outline.iter().position(|heading| heading.current)
|
||||
};
|
||||
settle(&cx, |app| current(app) == Some(0));
|
||||
let start = cx.update(|app| cursor_y(app, &docs)).expect("a cursor");
|
||||
let (first, second) = cx.update(|app| {
|
||||
let outline = &navigator.read(app).outline;
|
||||
(outline[0].rect, outline[1].rect)
|
||||
});
|
||||
let third = cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).pages()[2]
|
||||
});
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.jump(second, cx);
|
||||
docs.jump((third.0, third.1, 0, 0), cx);
|
||||
})
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
cursor_y(app, &docs).is_some_and(|y| y >= third.1)
|
||||
});
|
||||
assert_eq!(
|
||||
cx.update(|app| docs.read(app).back.last().map(|place| place.1)),
|
||||
Some(second),
|
||||
"a jump in flight is the place"
|
||||
);
|
||||
settle(&cx, |app| current(app) == Some(1));
|
||||
let between =
|
||||
|app: &gpui::App| cursor_y(app, &docs).is_some_and(|y| y >= second.1 && y < third.1);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_back(&GoBack, window, cx)
|
||||
});
|
||||
settle(&cx, between);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.jump(first, cx)));
|
||||
settle(&cx, |app| current(app) == Some(0));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_back(&GoBack, window, cx)
|
||||
});
|
||||
settle(&cx, between);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_back(&GoBack, window, cx)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
cursor_y(app, &docs).is_some_and(|y| y <= start + 300)
|
||||
});
|
||||
let home = cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).cursor().expect("a cursor")
|
||||
});
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.jump(second, cx)));
|
||||
settle(&cx, between);
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
cx.update(|app| view.update(app, |view, cx| view.reveal(home, cx)));
|
||||
settle(&cx, |app| {
|
||||
cursor_y(app, &docs).is_some_and(|y| y <= start + 300)
|
||||
});
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.jump((third.0, third.1, 0, 0), cx)));
|
||||
settle(&cx, |app| {
|
||||
cursor_y(app, &docs).is_some_and(|y| y >= third.1)
|
||||
});
|
||||
assert_eq!(
|
||||
cx.update(|app| docs.read(app).back.last().map(|place| place.1)),
|
||||
Some(home),
|
||||
"a landed jump is no longer the place"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_current_heading_follows_the_cursor_across_columns() {
|
||||
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;
|
||||
};
|
||||
open_text(&mut cx, &docs, &dir.path().join("columns.fodt"), COLUMNS);
|
||||
let navigator = cx.update(|app| docs.read(app).navigator.clone());
|
||||
cx.update(|app| {
|
||||
let (view, doc) = {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
(tab.view.clone(), tab.doc.clone())
|
||||
};
|
||||
navigator.update(app, |navigator, cx| navigator.show(Some((&view, &doc)), cx));
|
||||
});
|
||||
let current = |app: &gpui::App| {
|
||||
let navigator = navigator.read(app);
|
||||
navigator.outline.iter().position(|heading| heading.current)
|
||||
};
|
||||
settle(&cx, |app| current(app) == Some(0));
|
||||
let (first, second) = cx.update(|app| {
|
||||
let outline = &navigator.read(app).outline;
|
||||
(outline[0].rect, outline[1].rect)
|
||||
});
|
||||
assert_eq!(first.1, second.1, "the headings sit side by side");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.jump(second, cx)));
|
||||
settle(&cx, |app| current(app) == Some(1));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.jump(first, cx)));
|
||||
settle(&cx, |app| current(app) == Some(0));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pdfs_export_a_range_and_locked_files_ask() {
|
||||
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 = open_long(&mut cx, &docs, dir.path());
|
||||
let pdf = dir.path().join("two.pdf");
|
||||
let only_two = ely_lok::Pdf {
|
||||
pages: Some((2, 2)),
|
||||
comments: false,
|
||||
changes: true,
|
||||
handouts: 0,
|
||||
};
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.export_to(id, pdf.clone(), only_two, cx)
|
||||
})
|
||||
});
|
||||
written(&cx, &pdf, None);
|
||||
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");
|
||||
std::fs::hard_link(&source, &link).expect("the hard link");
|
||||
for target in [&source, &alias, &link] {
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.export_to(id, target.clone(), ely_lok::Pdf::default(), cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&source).expect("the source reads"),
|
||||
LONG,
|
||||
"an open source is never replaced"
|
||||
);
|
||||
let locked = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates/ely-lok/src/client/tests/locked.odt");
|
||||
let copy = dir.path().join("locked.odt");
|
||||
std::fs::copy(&locked, ©).expect("the fixture copies");
|
||||
let asked = |app: &gpui::App, wrong: bool| matches!(&docs.read(app).core.dialog, Some(Dialog::Password { wrong: was, .. }) if *was == wrong);
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.open_file(copy.clone(), cx)
|
||||
});
|
||||
settle(&cx, |app| asked(app, false));
|
||||
// As submitting does: the prompt closes, then the load.
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.core.dialog = None;
|
||||
docs.open_with(copy.clone(), "wrong".into(), cx)
|
||||
});
|
||||
settle(&cx, |app| asked(app, true));
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.core.dialog = None;
|
||||
docs.open_with(copy.clone(), "secret".into(), cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.tabs.len() == 2);
|
||||
let out = dir.path().join("opened.odt");
|
||||
let opened = cx.update(|app| docs.read(app).core.tabs[1].id);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(opened, out.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &out, None);
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &out).contains("Teh cat"),
|
||||
"the locked text opens"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Export to PDF and print (DOC-15).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
data_display::DescriptionList,
|
||||
forms::{NumberInput, Switch},
|
||||
overlays::Dialog as Card,
|
||||
primitives::Severity,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_lok::Pdf;
|
||||
use ely_ui::commands::{Export, Print};
|
||||
use gpui::{App, Context, Entity, ParentElement as _, Styled as _, TaskExt as _, Window, div};
|
||||
|
||||
use crate::actions::Dialog;
|
||||
use ely_office::in_window;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
use crate::docs::Docs;
|
||||
|
||||
impl Docs {
|
||||
pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.ask_output(false, cx);
|
||||
}
|
||||
|
||||
pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.ask_output(true, cx);
|
||||
}
|
||||
|
||||
fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
let last = u32::try_from(tab.view.read(cx).pages().len()).expect("a page count");
|
||||
self.dialog = Some(Dialog::Output {
|
||||
tab: tab.id,
|
||||
print,
|
||||
all: true,
|
||||
pdf: Pdf {
|
||||
pages: Some((1, last)),
|
||||
comments: false,
|
||||
changes: true,
|
||||
handouts: 0,
|
||||
},
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Writes the asked PDF, then saves or prints it.
|
||||
pub(crate) fn output(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Dialog::Output {
|
||||
tab,
|
||||
print,
|
||||
all,
|
||||
pdf,
|
||||
}) = self.dialog.take()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
cx.notify();
|
||||
let pdf = Pdf {
|
||||
pages: pdf.pages.filter(|_| !all),
|
||||
..pdf
|
||||
};
|
||||
if !self.settled(tab, cx) {
|
||||
self.dialog = Some(Dialog::Output {
|
||||
tab,
|
||||
print,
|
||||
all,
|
||||
pdf,
|
||||
});
|
||||
return self.when_settled(tab, |docs, _, cx| docs.output(cx));
|
||||
}
|
||||
let Some(source) = self.tab(tab) else {
|
||||
return;
|
||||
};
|
||||
if print {
|
||||
let path = source.work.join("print.pdf");
|
||||
let printing = path.clone();
|
||||
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
|
||||
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
|
||||
docs.output_failed(&error, cx)
|
||||
}),
|
||||
Err(error) => docs.output_failed(&error.to_string(), cx),
|
||||
});
|
||||
}
|
||||
let Some(dir) = ely_office::folder(source) else {
|
||||
return self.panel_failed("no documents folder", cx);
|
||||
};
|
||||
let stem = source
|
||||
.name
|
||||
.rsplit_once('.')
|
||||
.map_or(source.name.as_ref(), |(stem, _)| stem);
|
||||
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.pdf")));
|
||||
cx.spawn(async move |this, cx| {
|
||||
let chosen = match chosen.await {
|
||||
Ok(Ok(path)) => Ok(path),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
};
|
||||
in_window(&this, cx, |docs, _, cx| match chosen {
|
||||
Ok(Some(path)) => docs.export_to(tab, path, pdf, cx),
|
||||
Ok(None) => log::info!("doc: the export panel was cancelled"),
|
||||
Err(error) => docs.panel_failed(&error, cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn export_to(&mut self, tab: u64, path: PathBuf, pdf: Pdf, cx: &mut Context<Self>) {
|
||||
match self.holder(&path) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => {
|
||||
log::info!("doc: the export target is an open document");
|
||||
let name = ely_office::files::file_name(&path);
|
||||
let text = Docs::text(cx, "doc.output.open", &[("name", &name)]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
Err(error) => return self.output_failed(&error.to_string(), cx),
|
||||
}
|
||||
let (name, opened) = (ely_office::files::file_name(&path), path.clone());
|
||||
self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
|
||||
Ok(()) => {
|
||||
let text = Docs::text(cx, "doc.output.exported", &[("name", &name)]);
|
||||
docs.toast_output(text, opened.clone(), cx);
|
||||
}
|
||||
Err(error) => docs.output_failed(&error.to_string(), cx),
|
||||
});
|
||||
}
|
||||
|
||||
/// Writes the PDF in the background; `then` takes the outcome.
|
||||
fn write_pdf(
|
||||
&mut self,
|
||||
tab: u64,
|
||||
path: PathBuf,
|
||||
pdf: Pdf,
|
||||
cx: &mut Context<Self>,
|
||||
then: impl FnOnce(&mut Self, std::io::Result<()>, &mut Context<Self>) + 'static,
|
||||
) {
|
||||
let Some(name) = self.tab(tab).map(|tab| tab.name.clone()) else {
|
||||
return self.output_failed("the document closed", cx);
|
||||
};
|
||||
log::info!("doc: writing a PDF of {name} ({pdf:?})");
|
||||
let url = ely_lok::file_url(&path);
|
||||
self.read_in(
|
||||
tab,
|
||||
move |worker, doc| doc.export_pdf(worker, &url, &pdf),
|
||||
move |docs, written, _, cx| then(docs, written, cx),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn output_failed(&self, error: &str, cx: &mut Context<Self>) {
|
||||
let text = Docs::text(cx, "doc.output.failed", &[("error", error)]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits the open output dialog.
|
||||
fn change(this: &Entity<Docs>, cx: &mut App, edit: impl FnOnce(&mut bool, &mut Pdf)) {
|
||||
this.update(cx, |docs, cx| {
|
||||
if let Some(Dialog::Output { all, pdf, .. }) = &mut docs.dialog {
|
||||
edit(all, pdf);
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
|
||||
/// The dialog before a PDF goes out: pages, comments, changes.
|
||||
pub fn output_dialog(
|
||||
this: Entity<Docs>,
|
||||
(print, all, pdf, last): (bool, bool, Pdf, u32),
|
||||
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> Card {
|
||||
let text = |key: &str| Docs::text(cx, key, &[]);
|
||||
let (from, to) = pdf.pages.unwrap_or((1, last));
|
||||
let pages = {
|
||||
let this = this.clone();
|
||||
SegmentedControl::new("doc-output-pages", if all { "all" } else { "range" })
|
||||
.size(ControlSize::Sm)
|
||||
.segment("all", text("doc.output.all"), None)
|
||||
.segment("range", text("doc.output.range"), None)
|
||||
.on_change(move |value, _, cx| {
|
||||
change(&this, cx, |all, _| *all = value.as_ref() == "all")
|
||||
})
|
||||
};
|
||||
let bound = |id: &'static str, value: u32, first: bool| {
|
||||
let this = this.clone();
|
||||
NumberInput::new(id, f64::from(value))
|
||||
.range(1.0, f64::from(last))
|
||||
.on_commit(move |value, _, cx| {
|
||||
let value = value.round() as u32;
|
||||
change(&this, cx, |_, pdf| {
|
||||
let (from, to) = pdf.pages.unwrap_or((1, value));
|
||||
pdf.pages = Some(if first {
|
||||
(value, to.max(value))
|
||||
} else {
|
||||
(from.min(value), value)
|
||||
});
|
||||
})
|
||||
})
|
||||
};
|
||||
let range = (!all).then(|| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(div().w_full().max_w_24().child(bound("doc-output-from", from, true)))
|
||||
.child("–")
|
||||
.child(div().w_full().max_w_24().child(bound("doc-output-to", to, false)))
|
||||
});
|
||||
let toggle = |id: &'static str, key: &str, on: bool, set: fn(&mut Pdf, bool)| {
|
||||
let this = this.clone();
|
||||
Switch::new(id, on)
|
||||
.label(text(key))
|
||||
.on_change(move |on, _, cx| change(&this, cx, |_, pdf| set(pdf, on)))
|
||||
};
|
||||
let fields = DescriptionList::new()
|
||||
.item(
|
||||
text("doc.output.pages"),
|
||||
div().flex().flex_col().gap_2().child(pages).children(range),
|
||||
)
|
||||
.item(
|
||||
text("doc.output.marks"),
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(toggle(
|
||||
"doc-output-comments",
|
||||
"doc.output.comments",
|
||||
pdf.comments,
|
||||
|pdf, on| pdf.comments = on,
|
||||
))
|
||||
.child(toggle(
|
||||
"doc-output-changes",
|
||||
"doc.output.changes",
|
||||
pdf.changes,
|
||||
|pdf, on| pdf.changes = on,
|
||||
)),
|
||||
);
|
||||
let (title, go) = match print {
|
||||
true => ("doc.output.print_title", "doc.output.print"),
|
||||
false => ("doc.output.export_title", "doc.output.export"),
|
||||
};
|
||||
let label = text(go);
|
||||
Card::new("doc-output", text(title), dismiss)
|
||||
.child(fields)
|
||||
.action(ely_office::cancel(cx))
|
||||
.action(move |_| {
|
||||
Button::new("doc-output-go", label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.output(cx)))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! The properties panel (DOC-02 to 08).
|
||||
|
||||
mod groups;
|
||||
#[cfg(test)]
|
||||
mod layout_tests;
|
||||
mod object;
|
||||
mod page;
|
||||
mod pairs;
|
||||
mod paragraph;
|
||||
mod sheet;
|
||||
mod styles;
|
||||
mod table;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
data_display::PropertyGrid, forms::TextInput, layout::ScrollArea, overlays::PromptDialog,
|
||||
};
|
||||
use ely_lok::{Doc, LokError, LokView, Reported, Worker};
|
||||
use ely_office::panel::Panel as _;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, IntoElement, ParentElement as _, Render, SharedString,
|
||||
Styled as _, Subscription, Window, div,
|
||||
};
|
||||
|
||||
use crate::docs::Docs;
|
||||
|
||||
/// The active document the panel edits.
|
||||
struct Shown {
|
||||
view: Entity<LokView>,
|
||||
doc: Doc,
|
||||
paragraph: Vec<SharedString>,
|
||||
character: Vec<SharedString>,
|
||||
pages: Vec<SharedString>,
|
||||
_observe: Subscription,
|
||||
_errors: Subscription,
|
||||
}
|
||||
|
||||
pub struct Props {
|
||||
worker: Arc<Mutex<Worker>>,
|
||||
shown: Option<Shown>,
|
||||
fonts: Vec<SharedString>,
|
||||
/// Re-read the styles once input lands.
|
||||
reload: bool,
|
||||
/// The family a new style goes to: "1" or "2".
|
||||
naming: Option<&'static str>,
|
||||
tab_at: f64,
|
||||
/// Two-value edits awaiting the kernel's echo.
|
||||
pending: HashMap<&'static str, pairs::Sent>,
|
||||
/// Page edits awaiting the kernel's echo.
|
||||
sheet_sent: Option<sheet::Sent>,
|
||||
}
|
||||
|
||||
impl EventEmitter<LokError> for Props {}
|
||||
|
||||
impl Props {
|
||||
pub fn new(worker: Arc<Mutex<Worker>>) -> Self {
|
||||
Props {
|
||||
worker,
|
||||
shown: None,
|
||||
fonts: Vec::new(),
|
||||
reload: false,
|
||||
naming: None,
|
||||
tab_at: 1.0,
|
||||
pending: HashMap::new(),
|
||||
sheet_sent: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows the active tab; reads the kernel lists on change.
|
||||
pub fn show(&mut self, tab: Option<(&Entity<LokView>, &Doc)>, cx: &mut Context<Self>) {
|
||||
let id = tab.map(|(view, _)| view.entity_id());
|
||||
if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id {
|
||||
return;
|
||||
}
|
||||
self.shown = None;
|
||||
self.naming = None;
|
||||
self.pending.clear();
|
||||
self.sheet_sent = None;
|
||||
let Some((view, doc)) = tab else {
|
||||
return;
|
||||
};
|
||||
let observe = cx.subscribe(view, |props, view, _: &Reported, cx| {
|
||||
if props.reload && view.read(cx).settled() {
|
||||
props.reload = false;
|
||||
props.read_styles(cx);
|
||||
}
|
||||
props.track_pairs(cx);
|
||||
props.track_sheet(cx);
|
||||
cx.notify();
|
||||
});
|
||||
let errors = cx.subscribe(view, |props, _, _: &LokError, _| {
|
||||
props.pending.clear();
|
||||
props.sheet_sent = None;
|
||||
});
|
||||
self.shown = Some(Shown {
|
||||
view: view.clone(),
|
||||
doc: doc.clone(),
|
||||
paragraph: Vec::new(),
|
||||
character: Vec::new(),
|
||||
pages: Vec::new(),
|
||||
_observe: observe,
|
||||
_errors: errors,
|
||||
});
|
||||
if self.fonts.is_empty() {
|
||||
self.read_fonts(doc, cx);
|
||||
}
|
||||
self.read_styles(cx);
|
||||
}
|
||||
|
||||
fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) {
|
||||
let read = self
|
||||
.worker
|
||||
.lock()
|
||||
.expect("the worker lock")
|
||||
.values(doc, ".uno:CharFontName")
|
||||
.and_then(|json| ely_lok::commands::fonts(&json));
|
||||
match read {
|
||||
Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(),
|
||||
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_styles(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(shown) = self.shown.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let read = self
|
||||
.worker
|
||||
.lock()
|
||||
.expect("the worker lock")
|
||||
.values(&shown.doc, ".uno:StyleApply")
|
||||
.and_then(|json| {
|
||||
let list = |family| -> std::io::Result<Vec<SharedString>> {
|
||||
let names = ely_lok::commands::styles(&json, family)?;
|
||||
Ok(names.into_iter().map(Into::into).collect())
|
||||
};
|
||||
Ok((list("2")?, list("1")?, list("8")?))
|
||||
});
|
||||
match read {
|
||||
Ok((paragraph, character, pages)) => {
|
||||
shown.paragraph = paragraph;
|
||||
shown.character = character;
|
||||
shown.pages = pages;
|
||||
}
|
||||
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads the style lists once queued input lands.
|
||||
pub fn refresh_lists(&mut self) {
|
||||
self.reload = true;
|
||||
}
|
||||
|
||||
/// Sends one table command to the shown document.
|
||||
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
|
||||
let Some(shown) = &self.shown else {
|
||||
return;
|
||||
};
|
||||
log::info!("doc: panel runs {id}");
|
||||
// Numbers here, like tab stops, are as the view shows.
|
||||
let sent = shown.view.update(cx, |view, _| view.run_at(&view.read_at(), id, values));
|
||||
if let Err(error) = sent {
|
||||
cx.emit(LokError(format!("{id}: {error}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// A style from the selection; lists reload after.
|
||||
fn create_style(&mut self, name: &str, family: &'static str, cx: &mut Context<Self>) {
|
||||
self.naming = None;
|
||||
self.reload = true;
|
||||
self.run("style.new", &[name, family], cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The new-style prompt, while open.
|
||||
fn prompt(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<PromptDialog> {
|
||||
let (family, shown) = (self.naming?, self.shown.as_ref()?);
|
||||
let field = window.use_keyed_state("doc-style-name", cx, |window, cx| {
|
||||
TextInput::new(window, cx)
|
||||
});
|
||||
let taken = match family {
|
||||
"1" => shown.character.clone(),
|
||||
_ => shown.paragraph.clone(),
|
||||
};
|
||||
let title = match family {
|
||||
"1" => "doc.style.new_character",
|
||||
_ => "doc.style.new_paragraph",
|
||||
};
|
||||
let this = cx.entity();
|
||||
let close = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |props, cx| {
|
||||
props.naming = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let (empty, exists) = (
|
||||
Docs::text(cx, "doc.style.empty", &[]),
|
||||
Docs::text(cx, "doc.style.taken", &[]),
|
||||
);
|
||||
Some(
|
||||
PromptDialog::new("doc-style-new", Docs::text(cx, title, &[]), &field, close)
|
||||
.label(Docs::text(cx, "doc.style.name", &[]))
|
||||
.submit(Docs::text(cx, "doc.style.create", &[]))
|
||||
.check(move |text| {
|
||||
let name = text.trim();
|
||||
if name.is_empty() {
|
||||
return Err(empty.clone());
|
||||
}
|
||||
if taken.iter().any(|style| style == name) {
|
||||
return Err(exists.clone());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_submit(move |text, _, cx| {
|
||||
let name = text.trim().to_string();
|
||||
this.update(cx, |props, cx| props.create_style(&name, family, cx))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Props {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let graphic = self
|
||||
.shown
|
||||
.as_ref()
|
||||
.is_some_and(|shown| shown.view.read(cx).graphic().is_some());
|
||||
let grid = PropertyGrid::new("doc-props");
|
||||
let grid = if graphic {
|
||||
grid.group(self.object_group(cx))
|
||||
} else {
|
||||
let grid = grid
|
||||
.group(self.text_group(window, cx))
|
||||
.group(self.paragraph_group(cx))
|
||||
.group(self.style_group(cx))
|
||||
.group(self.page_group(cx));
|
||||
match self.state(".uno:DeleteTable", cx).as_deref() {
|
||||
Some("enabled") => grid.group(self.table_group(cx)),
|
||||
_ => grid,
|
||||
}
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
.child(ScrollArea::new("doc-props-scroll").vertical().size_full().child(grid))
|
||||
.children(self.prompt(window, cx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! The panel's text group and its kernel hookup.
|
||||
|
||||
use ely_gpui_component::{data_display::PropertyGroup, primitives::IconName};
|
||||
use ely_office::panel::{Panel, color_state, font_box, palette, row, size_menu, toggle};
|
||||
use gpui::{App, Context, SharedString, Window};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
|
||||
/// The swatch's label without a color.
|
||||
const AUTOMATIC: &str = "office.props.automatic";
|
||||
|
||||
impl Panel for Props {
|
||||
fn state(&self, uno: &str, cx: &App) -> Option<SharedString> {
|
||||
let view = self.shown.as_ref()?.view.read(cx);
|
||||
view.state(uno)
|
||||
.map(|state| SharedString::from(state.to_string()))
|
||||
}
|
||||
|
||||
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
|
||||
Props::run(self, id, values, cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Props {
|
||||
pub(super) fn text_group(&self, window: &mut Window, cx: &mut Context<Self>) -> PropertyGroup {
|
||||
let why = "doc.why.selection";
|
||||
let effects = row([
|
||||
toggle(
|
||||
self,
|
||||
"format.bold",
|
||||
(Some(IconName::Bold), ""),
|
||||
("doc.props.bold", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"format.italic",
|
||||
(Some(IconName::Italic), ""),
|
||||
("doc.props.italic", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"format.underline",
|
||||
(Some(IconName::Underline), ""),
|
||||
("doc.props.underline", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"format.strikeout",
|
||||
(Some(IconName::Strikethrough), ""),
|
||||
("doc.props.strikeout", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"format.superscript",
|
||||
(None, "x²"),
|
||||
("doc.props.superscript", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"format.subscript",
|
||||
(None, "x₂"),
|
||||
("doc.props.subscript", why),
|
||||
cx,
|
||||
),
|
||||
]);
|
||||
let this = cx.entity();
|
||||
let color = color_state(self, ".uno:FontColor", cx);
|
||||
let highlight = color_state(self, ".uno:CharBackColor", cx);
|
||||
let font = font_box(self, "doc-props-font", &self.fonts, window, cx);
|
||||
let tip = |key: &str| Docs::text(cx, key, &[]);
|
||||
PropertyGroup::new(tip("doc.props.text"))
|
||||
.row(tip("doc.props.font"), font)
|
||||
.row(tip("doc.props.size"), size_menu(self, "doc-props-size", cx))
|
||||
.row(tip("doc.props.effects"), effects)
|
||||
.row(
|
||||
tip("doc.props.color"),
|
||||
palette(&this, "format.color", (color, AUTOMATIC), None, cx),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.highlight"),
|
||||
palette(
|
||||
&this,
|
||||
"format.highlight",
|
||||
(highlight, AUTOMATIC),
|
||||
Some(("doc.props.no_highlight", "format.highlight", "-1")),
|
||||
cx,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
use super::Props;
|
||||
use super::tests::panel;
|
||||
use crate::docs::Docs;
|
||||
use crate::insert::InsertPageNumber;
|
||||
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
|
||||
use ely_office::panel::Panel as _;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
fn state(cx: &TestAppContext, props: &Entity<Props>, uno: &str) -> Option<String> {
|
||||
cx.update(|app| {
|
||||
props
|
||||
.read(app)
|
||||
.state(uno, app)
|
||||
.map(|state| state.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_turns_resizes_and_keeps_quick_margins() {
|
||||
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, "Page");
|
||||
let props = panel(&mut cx, &docs);
|
||||
settle(&cx, |app| props.read(app).sheet(app).is_some());
|
||||
cx.update(|app| props.update(app, |props, cx| props.set_orientation(true, cx)));
|
||||
settle(&cx, |app| {
|
||||
let props = props.read(app);
|
||||
let size = props.state(".uno:AttributePageSize", app);
|
||||
let size = size.as_deref().and_then(super::sheet::size_of);
|
||||
props
|
||||
.state(".uno:AttributePage", app)
|
||||
.is_some_and(|page| page.starts_with("true,"))
|
||||
&& size.is_some_and(|(w, h)| w > h)
|
||||
});
|
||||
cx.update(|app| props.update(app, |props, cx| props.set_paper("A5", cx)));
|
||||
settle(&cx, |app| {
|
||||
let size = props.read(app).state(".uno:AttributePageSize", app);
|
||||
size.as_deref()
|
||||
.and_then(super::sheet::size_of)
|
||||
.is_some_and(|(w, h)| (w - 21000).abs() <= 2 && (h - 14800).abs() <= 2)
|
||||
});
|
||||
let margins = (".uno:PageLRMargin", "page.margins_lr");
|
||||
let twips = |cm: f64| (cm * 1440.0 / 2.54).round() as i32;
|
||||
cx.update(|app| {
|
||||
props.update(app, |props, cx| {
|
||||
props.set_pair(margins, false, twips(2.0), cx);
|
||||
props.set_pair(margins, true, twips(3.0), cx);
|
||||
})
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
props.read(app).state(".uno:PageLRMargin", app).as_deref() == Some("2000,3000")
|
||||
});
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("page.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
assert!(
|
||||
text.contains("style:print-orientation=\"landscape\""),
|
||||
"landscape lands"
|
||||
);
|
||||
assert!(
|
||||
text.contains("fo:page-width=\"8.2681in\""),
|
||||
"A5 across lands"
|
||||
);
|
||||
assert!(
|
||||
text.contains("fo:margin-left=\"0.7874in\" fo:margin-right=\"1.1811in\""),
|
||||
"margins land"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_actions_and_object_size_land() {
|
||||
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, "Insert");
|
||||
let props = panel(&mut cx, &docs);
|
||||
settle(&cx, |app| {
|
||||
props.read(app).state(".uno:PageStyleName", app).is_some()
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_page_number(&InsertPageNumber, window, cx);
|
||||
docs.insert_section(cx);
|
||||
docs.insert_table(3.0, 2.0, cx);
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
props.read(app).state(".uno:DeleteTable", app).as_deref() == Some("enabled")
|
||||
});
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(300, 200, image::Rgba([0, 0, 255, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.expect("the png encodes");
|
||||
let image = dir.path().join("blue.png");
|
||||
std::fs::write(&image, png).expect("the png writes");
|
||||
let url = ely_lok::file_url(&image);
|
||||
cx.update(|app| props.update(app, |props, cx| props.run("image.insert", &[&url], cx)));
|
||||
settle(&cx, |app| {
|
||||
props
|
||||
.read(app)
|
||||
.state(".uno:Size", app)
|
||||
.is_some_and(|s| s.contains(" x "))
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.active_tab()
|
||||
.expect("a tab")
|
||||
.view
|
||||
.read(app)
|
||||
.graphic()
|
||||
.is_some()
|
||||
});
|
||||
let before = state(&cx, &props, ".uno:Size").expect("a size");
|
||||
let (w0, h0) = super::sheet::size_of(&before).expect("parsed");
|
||||
cx.update(|app| props.update(app, |props, cx| props.set_extent(false, 2.0, cx)));
|
||||
settle(&cx, |app| {
|
||||
let size = props.read(app).state(".uno:Size", app);
|
||||
size.as_deref()
|
||||
.and_then(super::sheet::size_of)
|
||||
.is_some_and(|(w, _)| (w - 2000).abs() <= 3)
|
||||
});
|
||||
let (w, h) =
|
||||
super::sheet::size_of(&state(&cx, &props, ".uno:Size").expect("a size")).expect("parsed");
|
||||
let ratio = (f64::from(w) / f64::from(h)) / (f64::from(w0) / f64::from(h0));
|
||||
assert!(
|
||||
(ratio - 1.0).abs() < 0.01,
|
||||
"the ratio holds: {w}x{h} from {w0}x{h0}"
|
||||
);
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("insert.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
for mark in [
|
||||
"<text:page-number",
|
||||
"<table:table ",
|
||||
"<draw:frame",
|
||||
"style:master-page-name=",
|
||||
] {
|
||||
assert!(text.contains(mark), "{mark} lands");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_footers_and_tall_images_hold_up() {
|
||||
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, "First");
|
||||
let props = panel(&mut cx, &docs);
|
||||
settle(&cx, |app| {
|
||||
props.read(app).state(".uno:PageStyleName", app).is_some()
|
||||
});
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_section(cx)));
|
||||
let name = cx.update(|app| Docs::text(app, "doc.section.name", &[("n", "2")]));
|
||||
settle(&cx, |app| {
|
||||
props.read(app).state(".uno:PageStyleName", app).as_deref() == Some(&*name)
|
||||
});
|
||||
type_into(&mut cx, &docs, "Second");
|
||||
for (command, values) in [
|
||||
("page.columns", vec!["2"]),
|
||||
("footer.on", vec!["true"]),
|
||||
("footer.layout", vec!["2"]),
|
||||
] {
|
||||
cx.update(|app| props.update(app, |props, cx| props.run(command, &values, cx)));
|
||||
}
|
||||
settle(&cx, |app| {
|
||||
props
|
||||
.read(app)
|
||||
.state(".uno:SwPageFooterLayout", app)
|
||||
.as_deref()
|
||||
== Some("2")
|
||||
});
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(20, 400, image::Rgba([0, 0, 255, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.expect("the png encodes");
|
||||
let image = dir.path().join("tall.png");
|
||||
std::fs::write(&image, png).expect("the png writes");
|
||||
let url = ely_lok::file_url(&image);
|
||||
cx.update(|app| props.update(app, |props, cx| props.run("image.insert", &[&url], cx)));
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.active_tab()
|
||||
.expect("a tab")
|
||||
.view
|
||||
.read(app)
|
||||
.graphic()
|
||||
.is_some()
|
||||
});
|
||||
// A person takes this long to reach the panel.
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
cx.update(|app| {
|
||||
props.update(app, |props, cx| {
|
||||
props.run("image.size", &["58", "5670"], cx)
|
||||
})
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
let size = props.read(app).state(".uno:Size", app);
|
||||
size.as_deref()
|
||||
.and_then(super::sheet::size_of)
|
||||
.is_some_and(|(_, h)| (h - 10001).abs() <= 3)
|
||||
});
|
||||
cx.update(|app| props.update(app, |props, cx| props.set_extent(true, 10.1, cx)));
|
||||
settle(&cx, |app| {
|
||||
let size = props.read(app).state(".uno:Size", app);
|
||||
size.as_deref()
|
||||
.and_then(super::sheet::size_of)
|
||||
.is_some_and(|(_, h)| (h - 10100).abs() <= 3)
|
||||
});
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("sections.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
assert_eq!(
|
||||
text.matches("fo:column-count=\"2\"").count(),
|
||||
1,
|
||||
"only the second section has columns"
|
||||
);
|
||||
assert!(
|
||||
text.matches("style:master-page-name=").count() >= 1,
|
||||
"a section starts"
|
||||
);
|
||||
assert!(
|
||||
text.contains("<style:footer-left>"),
|
||||
"the footer differs on even pages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_page_edits_footers_and_new_sections_show_up() {
|
||||
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);
|
||||
type_into(&mut cx, &docs, "Page");
|
||||
let props = panel(&mut cx, &docs);
|
||||
settle(&cx, |app| props.read(app).sheet(app).is_some());
|
||||
cx.update(|app| {
|
||||
props.update(app, |props, cx| {
|
||||
props.set_paper("A5", cx);
|
||||
props.set_orientation(true, cx);
|
||||
})
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
let props = props.read(app);
|
||||
let size = props.state(".uno:AttributePageSize", app);
|
||||
let size = size.as_deref().and_then(super::sheet::size_of);
|
||||
props
|
||||
.state(".uno:AttributePage", app)
|
||||
.is_some_and(|page| page.starts_with("true,"))
|
||||
&& size.is_some_and(|(w, h)| (w - 21000).abs() <= 2 && (h - 14800).abs() <= 2)
|
||||
});
|
||||
cx.update(|app| props.update(app, |props, cx| props.run("footer.on", &["true"], cx)));
|
||||
settle(&cx, |app| {
|
||||
props
|
||||
.read(app)
|
||||
.state(".uno:SwPageFooterLayout", app)
|
||||
.as_deref()
|
||||
== Some("0")
|
||||
});
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_section(cx)));
|
||||
let name = cx.update(|app| Docs::text(app, "doc.section.name", &[("n", "2")]));
|
||||
settle(&cx, |app| {
|
||||
let props = props.read(app);
|
||||
props
|
||||
.shown
|
||||
.as_ref()
|
||||
.is_some_and(|shown| shown.pages.contains(&name))
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! The object group: size, crop, replace, anchor, wrap, order (DOC-08).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::Button, data_display::PropertyGroup, forms::NumberInput, theme::ControlSize,
|
||||
};
|
||||
use ely_office::panel::{Panel as _, button, muted, row, toggle};
|
||||
use gpui::{AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div};
|
||||
|
||||
use super::Props;
|
||||
use super::sheet::size_of;
|
||||
use crate::docs::Docs;
|
||||
|
||||
/// 1/100 mm in whole twips, rounded as the kernel rounds.
|
||||
fn twips_of(mm100: f64) -> i64 {
|
||||
((mm100 * 72.0 + 63.0) / 127.0).floor() as i64
|
||||
}
|
||||
|
||||
impl Props {
|
||||
pub(super) fn object_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
|
||||
let tip = |key: &str| Docs::text(cx, key, &[]);
|
||||
let why = "doc.why.selection";
|
||||
let anchors = row([
|
||||
toggle(
|
||||
self,
|
||||
"image.anchor_page",
|
||||
(None, &tip("doc.props.to_page")),
|
||||
("", "doc.why.anchor"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.anchor_paragraph",
|
||||
(None, &tip("doc.props.to_paragraph")),
|
||||
("", "doc.why.anchor"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.anchor_char",
|
||||
(None, &tip("doc.props.to_char")),
|
||||
("", "doc.why.anchor"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.anchor_as_char",
|
||||
(None, &tip("doc.props.as_char")),
|
||||
("", "doc.why.anchor"),
|
||||
cx,
|
||||
),
|
||||
]);
|
||||
let wraps = row([
|
||||
toggle(
|
||||
self,
|
||||
"image.wrap_off",
|
||||
(None, &tip("doc.props.wrap_none")),
|
||||
("", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.wrap_on",
|
||||
(None, &tip("doc.props.wrap_both")),
|
||||
("", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.wrap_ideal",
|
||||
(None, &tip("doc.props.wrap_ideal")),
|
||||
("", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.wrap_left",
|
||||
(None, &tip("doc.props.wrap_left")),
|
||||
("", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.wrap_right",
|
||||
(None, &tip("doc.props.wrap_right")),
|
||||
("", why),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"image.wrap_through",
|
||||
(None, &tip("doc.props.wrap_through")),
|
||||
("", why),
|
||||
cx,
|
||||
),
|
||||
]);
|
||||
let order = row([
|
||||
button(self, "image.front", "doc.props.front", why, cx),
|
||||
button(self, "image.forward", "doc.props.forward", why, cx),
|
||||
button(self, "image.backward", "doc.props.backward", why, cx),
|
||||
button(self, "image.back", "doc.props.back", why, cx),
|
||||
]);
|
||||
let replace = Button::new("doc-props-replace", tip("doc.props.replace"))
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(crate::insert::InsertImage), cx)
|
||||
});
|
||||
PropertyGroup::new(tip("doc.props.object"))
|
||||
.row(tip("doc.props.width"), self.extent(false, cx))
|
||||
.row(tip("doc.props.height"), self.extent(true, cx))
|
||||
.row(
|
||||
tip("doc.props.image"),
|
||||
row([
|
||||
button(self, "image.crop", "doc.props.crop", why, cx),
|
||||
replace.into_any_element(),
|
||||
]),
|
||||
)
|
||||
.row(tip("doc.props.anchor"), anchors)
|
||||
.row(tip("doc.props.wrap"), wraps)
|
||||
.row(tip("doc.props.order"), order)
|
||||
}
|
||||
|
||||
/// Sizes the image from one side in cm.
|
||||
pub(super) fn set_extent(&mut self, tall: bool, cm: f64, cx: &mut Context<Self>) {
|
||||
let Some((width, height)) = self.state(".uno:Size", cx).as_deref().and_then(size_of) else {
|
||||
return;
|
||||
};
|
||||
let mine = if tall { height } else { width };
|
||||
let scale = cm * 1000.0 / f64::from(mine);
|
||||
let (w, h) = (f64::from(width) * scale, f64::from(height) * scale);
|
||||
let unchanged =
|
||||
twips_of(w) == twips_of(f64::from(width)) && twips_of(h) == twips_of(f64::from(height));
|
||||
if unchanged {
|
||||
return;
|
||||
}
|
||||
let twips = [twips_of(w).to_string(), twips_of(h).to_string()];
|
||||
self.run("image.size", &[&twips[0], &twips[1]], cx);
|
||||
}
|
||||
|
||||
/// Width or height in cm, ratio kept.
|
||||
fn extent(&self, tall: bool, cx: &Context<Self>) -> AnyElement {
|
||||
let Some((width, height)) = self.state(".uno:Size", cx).as_deref().and_then(size_of) else {
|
||||
return muted("—".into(), cx);
|
||||
};
|
||||
if width <= 0 || height <= 0 {
|
||||
return muted("—".into(), cx);
|
||||
}
|
||||
let this = cx.entity();
|
||||
let mine = if tall { height } else { width };
|
||||
let id = SharedString::from(format!("doc-props-extent-{tall}"));
|
||||
let field = NumberInput::new(id, f64::from(mine) / 1000.0)
|
||||
.range(0.1, 600.0)
|
||||
.step(0.1)
|
||||
.precision(2)
|
||||
.size(ControlSize::Sm)
|
||||
.on_commit(move |cm, _, cx| {
|
||||
this.update(cx, |props, cx| props.set_extent(tall, cm, cx))
|
||||
});
|
||||
div().w_full().max_w_32().child(field).into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! The page group: paper, margins, columns, sections, headers (DOC-05, 06).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{ToggleButton, ToggleItem},
|
||||
data_display::PropertyGroup,
|
||||
forms::{Choice, NumberInput, Select},
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::panel::{Panel as _, known, muted, row, send};
|
||||
use gpui::{AnyElement, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div};
|
||||
|
||||
use super::Props;
|
||||
use super::sheet::Sheet;
|
||||
#[cfg(test)]
|
||||
use super::sheet::{page_of, size_of};
|
||||
use crate::docs::Docs;
|
||||
|
||||
/// Paper sizes offered, portrait, in 1/100 mm.
|
||||
const PAPERS: [(&str, i32, i32); 6] = [
|
||||
("A3", 29_700, 42_000),
|
||||
("A4", 21_000, 29_700),
|
||||
("A5", 14_800, 21_000),
|
||||
("B5", 17_600, 25_000),
|
||||
("Letter", 21_590, 27_940),
|
||||
("Legal", 21_590, 35_560),
|
||||
];
|
||||
|
||||
const LEFT_RIGHT: (&str, &str) = (".uno:PageLRMargin", "page.margins_lr");
|
||||
|
||||
/// A header or footer: switch and layout.
|
||||
struct Part {
|
||||
on: (&'static str, &'static str),
|
||||
layout: (&'static str, &'static str),
|
||||
why: &'static str,
|
||||
}
|
||||
|
||||
const HEADER: Part = Part {
|
||||
on: ("header.on", ".uno:SwPageHeaderItem"),
|
||||
layout: ("header.layout", ".uno:SwPageHeaderLayout"),
|
||||
why: "doc.why.header",
|
||||
};
|
||||
|
||||
const FOOTER: Part = Part {
|
||||
on: ("footer.on", ".uno:SwPageFooterItem"),
|
||||
layout: ("footer.layout", ".uno:SwPageFooterLayout"),
|
||||
why: "doc.why.footer",
|
||||
};
|
||||
const TOP_BOTTOM: (&str, &str) = (".uno:PageULMargin", "page.margins_ul");
|
||||
|
||||
/// The offered paper within the kernel's rounding of `size`.
|
||||
fn paper_of((width, height): (i32, i32)) -> Option<&'static str> {
|
||||
let (short, long) = (width.min(height), width.max(height));
|
||||
PAPERS
|
||||
.iter()
|
||||
.find(|(_, w, h)| (short - w).abs() <= 2 && (long - h).abs() <= 2)
|
||||
.map(|(name, ..)| *name)
|
||||
}
|
||||
|
||||
impl Props {
|
||||
pub(super) fn page_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
|
||||
let tip = |key: &str| Docs::text(cx, key, &[]);
|
||||
PropertyGroup::new(tip("doc.props.page"))
|
||||
.row(tip("doc.props.paper"), self.paper(cx))
|
||||
.row(tip("doc.props.orientation"), self.orientation(cx))
|
||||
.row(tip("doc.props.top"), self.margin(TOP_BOTTOM, false, cx))
|
||||
.row(tip("doc.props.bottom"), self.margin(TOP_BOTTOM, true, cx))
|
||||
.row(
|
||||
tip("doc.props.margin_left"),
|
||||
self.margin(LEFT_RIGHT, false, cx),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.margin_right"),
|
||||
self.margin(LEFT_RIGHT, true, cx),
|
||||
)
|
||||
.row(tip("doc.props.columns"), self.columns(cx))
|
||||
.row(tip("doc.props.section"), self.section(cx))
|
||||
.row(tip("doc.props.header"), self.part(HEADER, cx))
|
||||
.row(tip("doc.props.footer"), self.part(FOOTER, cx))
|
||||
}
|
||||
|
||||
fn paper(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let Some(Sheet { size, .. }) = self.sheet(cx) else {
|
||||
return muted("—".into(), cx);
|
||||
};
|
||||
let this = cx.entity();
|
||||
let choices = PAPERS.iter().map(|(name, ..)| Choice::new(*name, *name));
|
||||
let cm = |mm100: i32| format!("{:.2}", f64::from(mm100) / 1000.0);
|
||||
let custom = Docs::text(
|
||||
cx,
|
||||
"doc.props.size_cm",
|
||||
&[("w", &cm(size.0)), ("h", &cm(size.1))],
|
||||
);
|
||||
let mut menu = Select::new("doc-props-paper", choices)
|
||||
.size(ControlSize::Sm)
|
||||
.placeholder(custom)
|
||||
.on_change(move |name, _, cx| this.update(cx, |props, cx| props.set_paper(name, cx)));
|
||||
if let Some(name) = paper_of(size) {
|
||||
menu = menu.selected(name);
|
||||
}
|
||||
menu.into_any_element()
|
||||
}
|
||||
|
||||
fn orientation(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let Some(Sheet { landscape, .. }) = self.sheet(cx) else {
|
||||
return muted("—".into(), cx);
|
||||
};
|
||||
let face = |wide: bool| {
|
||||
let this = cx.entity();
|
||||
let key = if wide {
|
||||
"doc.props.landscape"
|
||||
} else {
|
||||
"doc.props.portrait"
|
||||
};
|
||||
let item = ToggleItem::new(key).label(Docs::text(cx, key, &[]));
|
||||
ToggleButton::new(SharedString::from(key), item, landscape == wide)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |_, _, cx| {
|
||||
this.update(cx, |props, cx| props.set_orientation(wide, cx))
|
||||
})
|
||||
.into_any_element()
|
||||
};
|
||||
row([face(false), face(true)])
|
||||
}
|
||||
|
||||
/// An offered paper, in the page's orientation.
|
||||
pub(super) fn set_paper(&mut self, name: &str, cx: &mut Context<Self>) {
|
||||
let Some(sheet) = self.sheet(cx) else {
|
||||
return;
|
||||
};
|
||||
let (_, w, h) = PAPERS
|
||||
.iter()
|
||||
.find(|(paper, ..)| *paper == name)
|
||||
.expect("an offered paper");
|
||||
let size = if sheet.landscape { (*h, *w) } else { (*w, *h) };
|
||||
self.send_sheet(Sheet { size, ..sheet }, cx);
|
||||
}
|
||||
|
||||
/// Turns the page, size and flag together.
|
||||
pub(super) fn set_orientation(&mut self, wide: bool, cx: &mut Context<Self>) {
|
||||
let Some(sheet) = self.sheet(cx) else {
|
||||
return;
|
||||
};
|
||||
let (width, height) = sheet.size;
|
||||
let (short, long) = (width.min(height), width.max(height));
|
||||
let size = if wide { (long, short) } else { (short, long) };
|
||||
self.send_sheet(
|
||||
Sheet {
|
||||
size,
|
||||
landscape: wide,
|
||||
..sheet
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// One page margin in cm.
|
||||
fn margin(
|
||||
&self,
|
||||
pair: (&'static str, &'static str),
|
||||
second: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
if self.state(pair.0, cx).as_deref() == Some(ely_lok::MIXED) {
|
||||
return muted(Docs::text(cx, "office.props.mixed", &[]), cx);
|
||||
}
|
||||
let Some((first, other)) = self.pair(pair.0, cx) else {
|
||||
return muted("—".into(), cx);
|
||||
};
|
||||
let this = cx.entity();
|
||||
let mine = if second { other } else { first };
|
||||
let id = SharedString::from(format!("doc-props-{}-{second}", pair.1));
|
||||
let field = NumberInput::new(id, f64::from(mine) / 1000.0)
|
||||
.range(0.0, 99.99)
|
||||
.step(0.1)
|
||||
.precision(2)
|
||||
.size(ControlSize::Sm)
|
||||
.on_commit(move |cm, _, cx| {
|
||||
let twips = (cm * 1440.0 / 2.54).round() as i32;
|
||||
this.update(cx, |props, cx| props.set_pair(pair, second, twips, cx))
|
||||
});
|
||||
div().w_full().max_w_32().child(field).into_any_element()
|
||||
}
|
||||
|
||||
fn columns(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let current = self.state(".uno:PageColumnType", cx);
|
||||
row((1..=3).map(|count: i32| {
|
||||
let this = cx.entity();
|
||||
let label = count.to_string();
|
||||
let on = current.as_deref() == Some(label.as_str());
|
||||
ToggleButton::new(
|
||||
SharedString::from(format!("doc-props-columns-{count}")),
|
||||
ToggleItem::new(label.clone()).label(label),
|
||||
on,
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |_, _, cx| send(&this, "page.columns", &[&count.to_string()], cx))
|
||||
.into_any_element()
|
||||
}))
|
||||
}
|
||||
|
||||
/// The current section's page style.
|
||||
fn section(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let names = self
|
||||
.shown
|
||||
.as_ref()
|
||||
.map(|shown| shown.pages.clone())
|
||||
.unwrap_or_default();
|
||||
let current = self
|
||||
.state(".uno:PageStyleName", cx)
|
||||
.filter(|name| known(name));
|
||||
let this = cx.entity();
|
||||
let empty = names.is_empty();
|
||||
let mut menu = Select::new(
|
||||
"doc-props-section",
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| Choice::new(name.clone(), name)),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(empty)
|
||||
.placeholder(current.clone().unwrap_or_default())
|
||||
.on_change(move |name, _, cx| {
|
||||
send(&this, "format.style", &[name.as_ref(), "PageStyles"], cx)
|
||||
});
|
||||
if let Some(name) = current {
|
||||
menu = menu.selected(name);
|
||||
}
|
||||
menu.into_any_element()
|
||||
}
|
||||
|
||||
/// On or off, then first and odd pages.
|
||||
fn part(&self, part: Part, cx: &Context<Self>) -> AnyElement {
|
||||
let (on_command, on_uno) = part.on;
|
||||
let (layout_command, layout_uno) = part.layout;
|
||||
let on = self.state(on_uno, cx).as_deref() == Some("true");
|
||||
let switch = {
|
||||
let this = cx.entity();
|
||||
let key = if on { "doc.props.on" } else { "doc.props.off" };
|
||||
let item = ToggleItem::new(on_command).label(Docs::text(cx, key, &[]));
|
||||
ToggleButton::new(SharedString::from(on_command), item, on)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |turn_on, _, cx| {
|
||||
send(&this, on_command, &[&turn_on.to_string()], cx)
|
||||
})
|
||||
.into_any_element()
|
||||
};
|
||||
let current = self
|
||||
.state(layout_uno, cx)
|
||||
.and_then(|state| state.parse::<i32>().ok());
|
||||
let face = |bit: i32, key: &'static str| {
|
||||
let this = cx.entity();
|
||||
let mut item = ToggleItem::new(key).label(Docs::text(cx, key, &[]));
|
||||
if current.is_none() {
|
||||
item = item.tooltip(Docs::text(cx, part.why, &[]));
|
||||
}
|
||||
let value = current.unwrap_or(0);
|
||||
let id = SharedString::from(format!("{layout_command}-{bit}"));
|
||||
ToggleButton::new(id, item, value & bit != 0)
|
||||
.disabled(current.is_none())
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |_, _, cx| {
|
||||
send(&this, layout_command, &[&(value ^ bit).to_string()], cx)
|
||||
})
|
||||
.into_any_element()
|
||||
};
|
||||
row([
|
||||
switch,
|
||||
face(1, "doc.props.first_page"),
|
||||
face(2, "doc.props.odd_even"),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_states_read_and_papers_match_either_way() {
|
||||
assert_eq!(size_of("21590 x 27940"), Some((21590, 27940)));
|
||||
assert_eq!(page_of("true,0,4"), Some((true, 0, 4)));
|
||||
assert_eq!(
|
||||
paper_of((29700, 21001)),
|
||||
Some("A4"),
|
||||
"landscape A4 after rounding"
|
||||
);
|
||||
assert_eq!(paper_of((21590, 27940)), Some("Letter"));
|
||||
assert_eq!(paper_of((10000, 10000)), None);
|
||||
assert_eq!(size_of("garbage"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Two-value kernel states edited one side at a time.
|
||||
|
||||
use ely_office::panel::Panel as _;
|
||||
use gpui::{App, Context};
|
||||
|
||||
use super::Props;
|
||||
|
||||
/// Pairs sent ahead of the kernel's echo.
|
||||
pub(super) struct Sent {
|
||||
/// The state when the first pair left.
|
||||
before: Option<String>,
|
||||
pairs: Vec<(i32, i32)>,
|
||||
}
|
||||
|
||||
/// Whole twips in 1/100 mm, rounded as the kernel does.
|
||||
pub(super) fn mm100_of_twips(twips: i32) -> i32 {
|
||||
(twips * 127 + 36) / 72
|
||||
}
|
||||
|
||||
/// A "first,second" state.
|
||||
fn parse(text: &str) -> Option<(i32, i32)> {
|
||||
let (first, second) = text.split_once(',')?;
|
||||
Some((first.parse().ok()?, second.parse().ok()?))
|
||||
}
|
||||
|
||||
impl Props {
|
||||
/// Edits build on the last sent pair, else the kernel's.
|
||||
pub(super) fn pair(&self, uno: &'static str, cx: &App) -> Option<(i32, i32)> {
|
||||
match self.pending.get(uno) {
|
||||
Some(sent) => sent.pairs.last().copied(),
|
||||
None => self.state(uno, cx).as_deref().and_then(parse),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets one side in twips; the other stays.
|
||||
pub(super) fn set_pair(
|
||||
&mut self,
|
||||
(uno, command): (&'static str, &'static str),
|
||||
second: bool,
|
||||
twips: i32,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((first, other)) = self.pair(uno, cx) else {
|
||||
return;
|
||||
};
|
||||
let value = mm100_of_twips(twips);
|
||||
let next = if second {
|
||||
(first, value)
|
||||
} else {
|
||||
(value, other)
|
||||
};
|
||||
if next == (first, other) {
|
||||
return;
|
||||
}
|
||||
let before = self.state(uno, cx).map(|state| state.to_string());
|
||||
self.pending
|
||||
.entry(uno)
|
||||
.or_insert(Sent {
|
||||
before,
|
||||
pairs: Vec::new(),
|
||||
})
|
||||
.pairs
|
||||
.push(next);
|
||||
let values = [next.0.to_string(), next.1.to_string()];
|
||||
self.run(command, &[&values[0], &values[1]], cx);
|
||||
}
|
||||
|
||||
/// Drops sent pairs the kernel has answered.
|
||||
pub(super) fn track_pairs(&mut self, cx: &App) {
|
||||
let reported: Vec<(&'static str, Option<String>)> = self
|
||||
.pending
|
||||
.keys()
|
||||
.map(|&uno| (uno, self.state(uno, cx).map(|state| state.to_string())))
|
||||
.collect();
|
||||
for (uno, reported) in reported {
|
||||
let sent = self.pending.get_mut(uno).expect("listed above");
|
||||
if reported == sent.before {
|
||||
continue;
|
||||
}
|
||||
let at = reported
|
||||
.as_deref()
|
||||
.and_then(parse)
|
||||
.and_then(|seen| sent.pairs.iter().position(|pair| *pair == seen));
|
||||
match at {
|
||||
Some(at) if at + 1 < sent.pairs.len() => {
|
||||
sent.pairs.drain(..=at);
|
||||
sent.before = reported;
|
||||
}
|
||||
_ => {
|
||||
self.pending.remove(uno);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! The paragraph group: alignment, spacing, lists, tab stops (DOC-03).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::Button, data_display::PropertyGroup, forms::NumberInput, primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::panel::{Panel as _, button, muted, row, send, toggle};
|
||||
use gpui::{AnyElement, Context, IntoElement, ParentElement as _, Styled as _, div};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
|
||||
/// The kernel's spacing state: "upper,lower" in 1/100 mm.
|
||||
pub(super) const SPACING: &str = ".uno:ULSpacing";
|
||||
|
||||
/// Twips in one centimeter.
|
||||
const TWIPS_PER_CM: f64 = 567.0;
|
||||
|
||||
impl Props {
|
||||
pub(super) fn paragraph_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
|
||||
let tip = |key: &str| Docs::text(cx, key, &[]);
|
||||
let align = row([
|
||||
toggle(
|
||||
self,
|
||||
"paragraph.left",
|
||||
(Some(IconName::AlignLeft), ""),
|
||||
("doc.props.left", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"paragraph.center",
|
||||
(Some(IconName::AlignCenter), ""),
|
||||
("doc.props.center", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"paragraph.right",
|
||||
(Some(IconName::AlignRight), ""),
|
||||
("doc.props.right", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"paragraph.justify",
|
||||
(Some(IconName::AlignJustify), ""),
|
||||
("doc.props.justify", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
]);
|
||||
let line = row([
|
||||
toggle(
|
||||
self,
|
||||
"paragraph.spacing_1",
|
||||
(None, "1"),
|
||||
("", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"paragraph.spacing_15",
|
||||
(None, "1.5"),
|
||||
("", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"paragraph.spacing_2",
|
||||
(None, "2"),
|
||||
("", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
]);
|
||||
let lists = row([
|
||||
toggle(
|
||||
self,
|
||||
"list.bullets",
|
||||
(Some(IconName::List), ""),
|
||||
("doc.props.bullets", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
toggle(
|
||||
self,
|
||||
"list.numbering",
|
||||
(Some(IconName::ListOrdered), ""),
|
||||
("doc.props.numbering", "doc.why.selection"),
|
||||
cx,
|
||||
),
|
||||
button(
|
||||
self,
|
||||
"list.higher",
|
||||
"doc.props.promote",
|
||||
"doc.why.selection",
|
||||
cx,
|
||||
),
|
||||
button(
|
||||
self,
|
||||
"list.deeper",
|
||||
"doc.props.demote",
|
||||
"doc.why.selection",
|
||||
cx,
|
||||
),
|
||||
]);
|
||||
PropertyGroup::new(tip("doc.props.paragraph"))
|
||||
.row(tip("doc.props.align"), align)
|
||||
.row(
|
||||
tip("doc.props.indent"),
|
||||
row([
|
||||
button(
|
||||
self,
|
||||
"paragraph.outdent",
|
||||
"doc.props.less",
|
||||
"doc.why.selection",
|
||||
cx,
|
||||
),
|
||||
button(
|
||||
self,
|
||||
"paragraph.indent",
|
||||
"doc.props.more",
|
||||
"doc.why.selection",
|
||||
cx,
|
||||
),
|
||||
]),
|
||||
)
|
||||
.row(tip("doc.props.line"), line)
|
||||
.row(tip("doc.props.above"), self.spacing(false, cx))
|
||||
.row(tip("doc.props.below"), self.spacing(true, cx))
|
||||
.row(tip("doc.props.lists"), lists)
|
||||
.row(tip("doc.props.tabs"), self.tabs(cx))
|
||||
}
|
||||
|
||||
/// Space above or below, in points.
|
||||
fn spacing(&self, below: bool, cx: &Context<Self>) -> AnyElement {
|
||||
if self.state(SPACING, cx).as_deref() == Some(ely_lok::MIXED) {
|
||||
return muted(Docs::text(cx, "office.props.mixed", &[]), cx);
|
||||
}
|
||||
let Some((upper, lower)) = self.pair(SPACING, cx) else {
|
||||
return muted("—".into(), cx);
|
||||
};
|
||||
let this = cx.entity();
|
||||
let (id, mine) = match below {
|
||||
true => ("doc-props-below", lower),
|
||||
false => ("doc-props-above", upper),
|
||||
};
|
||||
let field = NumberInput::new(id, f64::from(mine) * 72.0 / 2540.0)
|
||||
.range(0.0, 283.0)
|
||||
.step(1.0)
|
||||
.precision(1)
|
||||
.size(ControlSize::Sm)
|
||||
.on_commit(move |points, _, cx| {
|
||||
let twips = (points * 20.0).round() as i32;
|
||||
this.update(cx, |props, cx| {
|
||||
props.set_pair((SPACING, "paragraph.spacing"), below, twips, cx)
|
||||
})
|
||||
});
|
||||
div().w_full().max_w_32().child(field).into_any_element()
|
||||
}
|
||||
|
||||
/// The paragraph's stops, each removable, and an adder in cm.
|
||||
fn tabs(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
let stops = self
|
||||
.shown
|
||||
.as_ref()
|
||||
.map(|shown| shown.view.read(cx).tabs().to_vec())
|
||||
.unwrap_or_default();
|
||||
let chips: Vec<AnyElement> = stops
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(at, mm100)| {
|
||||
let this = this.clone();
|
||||
let cm = format!("{:.2}", f64::from(mm100) / 1000.0);
|
||||
let label = Docs::text(cx, "doc.props.cm", &[("n", &cm)]);
|
||||
Button::new(("doc-props-tab", at), label)
|
||||
.size(ControlSize::Sm)
|
||||
.trailing_icon(IconName::X)
|
||||
.on_click(move |_, _, cx| {
|
||||
send(&this, "paragraph.tab", &[&at.to_string(), "0", "true"], cx)
|
||||
})
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
let at = NumberInput::new("doc-props-tab-at", self.tab_at)
|
||||
.range(0.0, 55.0)
|
||||
.step(0.25)
|
||||
.precision(2)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change({
|
||||
let this = this.clone();
|
||||
move |value, _, cx| this.update(cx, |props, _| props.tab_at = value)
|
||||
});
|
||||
let add = Button::new(
|
||||
"doc-props-tab-add",
|
||||
Docs::text(cx, "doc.props.add_tab", &[]),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
let twips = this.read(cx).tab_at * TWIPS_PER_CM;
|
||||
let twips = (twips.round() as i64).to_string();
|
||||
send(&this, "paragraph.tab", &["-1", &twips, "false"], cx)
|
||||
});
|
||||
let at = div().w_full().max_w_32().child(at).into_any_element();
|
||||
row(chips.into_iter().chain([at, add.into_any_element()]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The page as reported, and page edits not yet echoed.
|
||||
|
||||
use ely_office::panel::Panel as _;
|
||||
use gpui::{App, Context};
|
||||
|
||||
use super::Props;
|
||||
|
||||
const SIZE: &str = ".uno:AttributePageSize";
|
||||
const PAGE: &str = ".uno:AttributePage";
|
||||
|
||||
/// The page: size in 1/100 mm, orientation, layout, number type.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Sheet {
|
||||
pub(super) size: (i32, i32),
|
||||
pub(super) landscape: bool,
|
||||
pub(super) layout: i32,
|
||||
pub(super) numtype: i32,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
/// The same page, within the kernel's twips rounding.
|
||||
fn matches(&self, other: &Sheet) -> bool {
|
||||
let near = |a: i32, b: i32| (a - b).abs() <= 2;
|
||||
(self.landscape, self.layout, self.numtype)
|
||||
== (other.landscape, other.layout, other.numtype)
|
||||
&& near(self.size.0, other.size.0)
|
||||
&& near(self.size.1, other.size.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Page edits sent ahead of the kernel's echo.
|
||||
pub(super) struct Sent {
|
||||
before: (Option<String>, Option<String>),
|
||||
sheets: Vec<Sheet>,
|
||||
}
|
||||
|
||||
/// A "width x height" state in 1/100 mm.
|
||||
pub(super) fn size_of(text: &str) -> Option<(i32, i32)> {
|
||||
let (width, height) = text.split_once(" x ")?;
|
||||
Some((width.trim().parse().ok()?, height.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
/// The page item: landscape, layout, number type.
|
||||
pub(super) fn page_of(text: &str) -> Option<(bool, i32, i32)> {
|
||||
let mut parts = text.split(',');
|
||||
let landscape = parts.next()?.parse().ok()?;
|
||||
Some((
|
||||
landscape,
|
||||
parts.next()?.parse().ok()?,
|
||||
parts.next()?.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
impl Props {
|
||||
fn reported(&self, cx: &App) -> (Option<String>, Option<String>) {
|
||||
let read = |uno| self.state(uno, cx).map(|state| state.to_string());
|
||||
(read(SIZE), read(PAGE))
|
||||
}
|
||||
|
||||
fn reported_sheet(&self, cx: &App) -> Option<Sheet> {
|
||||
let (size, page) = self.reported(cx);
|
||||
let (landscape, layout, numtype) = page_of(&page?)?;
|
||||
Some(Sheet {
|
||||
size: size_of(&size?)?,
|
||||
landscape,
|
||||
layout,
|
||||
numtype,
|
||||
})
|
||||
}
|
||||
|
||||
/// Edits build on the last sent page, else the kernel's.
|
||||
pub(super) fn sheet(&self, cx: &App) -> Option<Sheet> {
|
||||
match &self.sheet_sent {
|
||||
Some(sent) => sent.sheets.last().copied(),
|
||||
None => self.reported_sheet(cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `next`; one command, so one undo step.
|
||||
pub(super) fn send_sheet(&mut self, next: Sheet, cx: &mut Context<Self>) {
|
||||
let Some(now) = self.sheet(cx) else {
|
||||
return;
|
||||
};
|
||||
if next == now {
|
||||
return;
|
||||
}
|
||||
let before = self.reported(cx);
|
||||
self.sheet_sent
|
||||
.get_or_insert(Sent {
|
||||
before,
|
||||
sheets: Vec::new(),
|
||||
})
|
||||
.sheets
|
||||
.push(next);
|
||||
let (w, h) = (next.size.0.to_string(), next.size.1.to_string());
|
||||
let item = (next.landscape, next.layout, next.numtype);
|
||||
if item == (now.landscape, now.layout, now.numtype) {
|
||||
return self.run("page.size", &[&w, &h], cx);
|
||||
}
|
||||
let page = [
|
||||
next.landscape.to_string(),
|
||||
next.layout.to_string(),
|
||||
next.numtype.to_string(),
|
||||
];
|
||||
self.run(
|
||||
"page.orientation",
|
||||
&[&w, &h, &page[0], &page[1], &page[2]],
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Drops sent pages the kernel has answered.
|
||||
pub(super) fn track_sheet(&mut self, cx: &App) {
|
||||
let reported = self.reported(cx);
|
||||
let seen = self.reported_sheet(cx);
|
||||
let Some(sent) = &mut self.sheet_sent else {
|
||||
return;
|
||||
};
|
||||
if reported == sent.before {
|
||||
return;
|
||||
}
|
||||
let at = seen.and_then(|seen| sent.sheets.iter().position(|sheet| sheet.matches(&seen)));
|
||||
match at {
|
||||
Some(at) if at + 1 < sent.sheets.len() => {
|
||||
sent.sheets.drain(..=at);
|
||||
sent.before = reported;
|
||||
}
|
||||
_ => self.sheet_sent = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The style group: apply, create, update (DOC-04).
|
||||
|
||||
use ely_gpui_component::primitives::Tooltip;
|
||||
use ely_gpui_component::{
|
||||
buttons::Button,
|
||||
data_display::PropertyGroup,
|
||||
forms::{Choice, Select},
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::panel::{Panel as _, known, row, send};
|
||||
use gpui::{
|
||||
AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, SharedString,
|
||||
StatefulInteractiveElement as _, div,
|
||||
};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
|
||||
impl Props {
|
||||
pub(super) fn style_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
|
||||
let tip = |key: &str| Docs::text(cx, key, &[]);
|
||||
let (paragraphs, characters) = self
|
||||
.shown
|
||||
.as_ref()
|
||||
.map(|shown| (shown.paragraph.clone(), shown.character.clone()))
|
||||
.unwrap_or_default();
|
||||
let current = self.state(".uno:StyleApply", cx);
|
||||
let paragraph = self.style_menu(
|
||||
"doc-props-paragraph",
|
||||
paragraphs,
|
||||
current.clone(),
|
||||
"ParagraphStyles",
|
||||
cx,
|
||||
);
|
||||
let character_style = self.state(".uno:CharStyle", cx);
|
||||
let character = self.style_menu(
|
||||
"doc-props-character",
|
||||
characters,
|
||||
character_style.clone(),
|
||||
"CharacterStyles",
|
||||
cx,
|
||||
);
|
||||
PropertyGroup::new(tip("doc.props.style"))
|
||||
.row(
|
||||
tip("doc.props.paragraph"),
|
||||
row([
|
||||
paragraph,
|
||||
self.new_style("2", cx),
|
||||
self.update_style("2", current, cx),
|
||||
]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.character"),
|
||||
row([
|
||||
character,
|
||||
self.new_style("1", cx),
|
||||
self.update_style("1", character_style, cx),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
/// A style list; unlisted or mixed names show as placeholder.
|
||||
fn style_menu(
|
||||
&self,
|
||||
id: &'static str,
|
||||
names: Vec<SharedString>,
|
||||
current: Option<SharedString>,
|
||||
family: &'static str,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
let empty = names.is_empty();
|
||||
let placeholder = match current.as_deref() {
|
||||
Some(ely_lok::MIXED) => Docs::text(cx, "office.props.mixed", &[]),
|
||||
Some(name) if known(name) => name.to_string().into(),
|
||||
_ => Docs::text(cx, "doc.props.choose", &[]),
|
||||
};
|
||||
let mut menu = Select::new(
|
||||
id,
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| Choice::new(name.clone(), name)),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(empty)
|
||||
.placeholder(placeholder)
|
||||
.on_change(move |name, _, cx| {
|
||||
this.update(cx, |props, cx| {
|
||||
props.run("format.style", &[name.as_ref(), family], cx)
|
||||
})
|
||||
});
|
||||
if let Some(name) = current.filter(|name| known(name)) {
|
||||
menu = menu.selected(name);
|
||||
}
|
||||
menu.into_any_element()
|
||||
}
|
||||
|
||||
fn new_style(&self, family: &'static str, cx: &Context<Self>) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
Button::new(
|
||||
SharedString::from(format!("doc-props-new-style-{family}")),
|
||||
Docs::text(cx, "doc.props.new_style", &[]),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |props, cx| {
|
||||
props.naming = Some(family);
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn update_style(
|
||||
&self,
|
||||
family: &'static str,
|
||||
name: Option<SharedString>,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
let id = format!("doc-props-update-style-{family}");
|
||||
let name = name.filter(|name| known(name));
|
||||
let button = Button::new(
|
||||
SharedString::from(id.clone()),
|
||||
Docs::text(cx, "doc.props.update_style", &[]),
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.disabled(name.is_none())
|
||||
.on_click({
|
||||
let name = name.clone();
|
||||
move |_, _, cx| {
|
||||
if let Some(name) = &name {
|
||||
send(&this, "style.update", &[name.as_ref(), family], cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
if name.is_some() {
|
||||
return button.into_any_element();
|
||||
}
|
||||
div()
|
||||
.id(SharedString::from(format!("{id}-why")))
|
||||
.child(button)
|
||||
.tooltip(Tooltip::text(Docs::text(cx, "doc.props.no_style", &[])))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! The table group: rows, columns, cells, borders, flow (DOC-07).
|
||||
|
||||
use ely_gpui_component::{buttons::Button, data_display::PropertyGroup, theme::ControlSize};
|
||||
use ely_office::panel::{button, color_state, palette, row, send, toggle};
|
||||
use gpui::{AnyElement, Context, IntoElement, SharedString};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
|
||||
impl Props {
|
||||
pub(super) fn table_group(&self, cx: &mut Context<Self>) -> PropertyGroup {
|
||||
let tip = |key: &str| Docs::text(cx, key, &[]);
|
||||
let why = "doc.why.selection";
|
||||
PropertyGroup::new(tip("doc.props.table"))
|
||||
.row(
|
||||
tip("doc.props.rows"),
|
||||
row([
|
||||
button(self, "table.row_above", "doc.props.above_row", why, cx),
|
||||
button(self, "table.row_below", "doc.props.below_row", why, cx),
|
||||
button(self, "table.delete_rows", "doc.props.delete", why, cx),
|
||||
]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.columns"),
|
||||
row([
|
||||
button(self, "table.column_left", "doc.props.left_column", why, cx),
|
||||
button(
|
||||
self,
|
||||
"table.column_right",
|
||||
"doc.props.right_column",
|
||||
why,
|
||||
cx,
|
||||
),
|
||||
button(self, "table.delete_columns", "doc.props.delete", why, cx),
|
||||
]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.cells"),
|
||||
row([
|
||||
button(self, "table.merge", "doc.props.merge", "doc.why.merge", cx),
|
||||
self.steps(
|
||||
"doc-props-split-columns",
|
||||
"doc.props.split_columns",
|
||||
&[("table.split", &["2", "false"])],
|
||||
cx,
|
||||
),
|
||||
self.steps(
|
||||
"doc-props-split-rows",
|
||||
"doc.props.split_rows",
|
||||
&[("table.split", &["2", "true"])],
|
||||
cx,
|
||||
),
|
||||
]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.cell_size"),
|
||||
row([
|
||||
self.steps(
|
||||
"doc-props-even-columns",
|
||||
"doc.props.even_columns",
|
||||
&[("table.select", &[]), ("table.even_columns", &[])],
|
||||
cx,
|
||||
),
|
||||
self.steps(
|
||||
"doc-props-even-rows",
|
||||
"doc.props.even_rows",
|
||||
&[("table.select", &[]), ("table.even_rows", &[])],
|
||||
cx,
|
||||
),
|
||||
self.steps(
|
||||
"doc-props-fit-rows",
|
||||
"doc.props.fit_rows",
|
||||
&[("table.select", &[]), ("table.optimal_height", &[])],
|
||||
cx,
|
||||
),
|
||||
]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.borders"),
|
||||
row([
|
||||
button(self, "table.borders_all", "doc.props.borders_all", why, cx),
|
||||
button(
|
||||
self,
|
||||
"table.borders_none",
|
||||
"doc.props.borders_none",
|
||||
why,
|
||||
cx,
|
||||
),
|
||||
]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.shading"),
|
||||
palette(
|
||||
&cx.entity(),
|
||||
"table.shading",
|
||||
(color_state(self, ".uno:TableCellBackgroundColor", cx), "office.props.automatic"),
|
||||
None,
|
||||
cx,
|
||||
),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.heading"),
|
||||
row([toggle(
|
||||
self,
|
||||
"table.repeat_heading",
|
||||
(None, &tip("doc.props.repeat")),
|
||||
("", "doc.why.heading"),
|
||||
cx,
|
||||
)]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.row_break"),
|
||||
row([
|
||||
self.steps(
|
||||
"doc-props-row-breaks",
|
||||
"doc.props.may_break",
|
||||
&[("table.row_split", &["true"])],
|
||||
cx,
|
||||
),
|
||||
self.steps(
|
||||
"doc-props-row-whole",
|
||||
"doc.props.keep_whole",
|
||||
&[("table.row_split", &["false"])],
|
||||
cx,
|
||||
),
|
||||
]),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.table"),
|
||||
row([button(
|
||||
self,
|
||||
"table.delete",
|
||||
"doc.props.delete_table",
|
||||
why,
|
||||
cx,
|
||||
)]),
|
||||
)
|
||||
}
|
||||
|
||||
/// A button that runs `commands` in order.
|
||||
fn steps(
|
||||
&self,
|
||||
id: &'static str,
|
||||
key: &str,
|
||||
commands: &'static [(&'static str, &'static [&'static str])],
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
Button::new(SharedString::from(id), Docs::text(cx, key, &[]))
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
for (command, values) in commands {
|
||||
send(&this, command, values, cx);
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use ely_lok::LokError;
|
||||
use ely_office::panel::Panel as _;
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// The panel, shown on the active tab.
|
||||
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> {
|
||||
cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
let (view, doc) = (tab.view.clone(), tab.doc.clone());
|
||||
let props = docs.read(app).props.clone();
|
||||
props.update(app, |props, cx| props.show(Some((&view, &doc)), cx));
|
||||
props
|
||||
})
|
||||
}
|
||||
|
||||
fn run(cx: &mut TestAppContext, props: &Entity<Props>, id: &str, values: &[&str]) {
|
||||
cx.update(|app| props.update(app, |props, cx| props.run(id, values, cx)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_formats_text_paragraphs_and_styles() {
|
||||
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, "Ely panel");
|
||||
let props = panel(&mut cx, &docs);
|
||||
cx.update(|app| {
|
||||
let props = props.read(app);
|
||||
let shown = props.shown.as_ref().expect("shown");
|
||||
assert!(!props.fonts.is_empty(), "the kernel lists fonts");
|
||||
assert!(shown.paragraph.iter().any(|name| name == "Heading 2"));
|
||||
assert!(shown.character.iter().any(|name| name == "Emphasis"));
|
||||
});
|
||||
for (command, values) in [
|
||||
("edit.select_all", vec![]),
|
||||
("format.style", vec!["Heading 2", "ParagraphStyles"]),
|
||||
("format.color", vec!["16711680"]),
|
||||
("format.highlight", vec!["65535"]),
|
||||
("format.superscript", vec![]),
|
||||
("paragraph.tab", vec!["-1", "1134", "false"]),
|
||||
("list.bullets", vec![]),
|
||||
] {
|
||||
run(&mut cx, &props, command, &values);
|
||||
}
|
||||
cx.update(|app| props.update(app, |props, cx| props.create_style("Ely Note", "2", cx)));
|
||||
settle(&cx, |app| {
|
||||
let props = props.read(app);
|
||||
let shown = props.shown.as_ref().expect("shown");
|
||||
!props.reload && shown.paragraph.iter().any(|name| name == "Ely Note")
|
||||
});
|
||||
run(&mut cx, &props, "paragraph.right", &[]);
|
||||
run(&mut cx, &props, "style.update", &["Ely Note", "2"]);
|
||||
run(
|
||||
&mut cx,
|
||||
&props,
|
||||
"format.style",
|
||||
&["Emphasis", "CharacterStyles"],
|
||||
);
|
||||
settle(&cx, |app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
let view = tab.view.read(app);
|
||||
view.settled() && view.tabs().len() == 1 && view.state(".uno:FontColor") == Some("16711680")
|
||||
});
|
||||
// The shown stop, removed by its number.
|
||||
cx.update(|app| ely_office::panel::send(&props, "paragraph.tab", &["0", "0", "true"], app));
|
||||
settle(&cx, |app| {
|
||||
let view = docs.read(app).active_tab().expect("a tab").view.read(app);
|
||||
view.settled() && view.tabs().is_empty()
|
||||
});
|
||||
run(&mut cx, &props, "paragraph.tab", &["-1", "1134", "false"]);
|
||||
let target = dir.path().join("panel.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, target.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &target, None);
|
||||
let text = text_of(&mut cx, &docs, &target);
|
||||
for mark in [
|
||||
"fo:color=\"#ff0000\"",
|
||||
"fo:background-color=\"#00ffff\"",
|
||||
"style:text-position=\"super",
|
||||
"style:tab-stop",
|
||||
"<text:list",
|
||||
"style:display-name=\"Ely Note\" style:family=\"paragraph\" style:parent-style-name=\"Heading_20_2\"",
|
||||
"style:parent-style-name=\"Ely_20_Note\"",
|
||||
"text:style-name=\"Emphasis\"",
|
||||
] {
|
||||
assert!(text.contains(mark), "{mark} lands");
|
||||
}
|
||||
let note = text
|
||||
.split("style:display-name=\"Ely Note\"")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("</style:style>").next())
|
||||
.expect("the style is written");
|
||||
assert!(
|
||||
note.contains("fo:text-align=\"right\""),
|
||||
"the update reached the style"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values_report_and_the_panel_follows_the_tab() {
|
||||
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);
|
||||
let props = panel(&mut cx, &docs);
|
||||
let errors = Rc::new(RefCell::new(Vec::new()));
|
||||
cx.update(|app| {
|
||||
let errors = errors.clone();
|
||||
app.subscribe(&props, move |_, error: &LokError, _| {
|
||||
errors.borrow_mut().push(error.0.clone())
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
run(&mut cx, &props, "format.size", &["0"]);
|
||||
assert_eq!(errors.borrow().len(), 1, "an out-of-range size reports");
|
||||
assert!(errors.borrow()[0].starts_with("format.size"));
|
||||
let first = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id());
|
||||
blank(&mut cx, &docs);
|
||||
let props = panel(&mut cx, &docs);
|
||||
let second = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.entity_id());
|
||||
assert_ne!(first, second);
|
||||
cx.update(|app| {
|
||||
let shown = props
|
||||
.read(app)
|
||||
.shown
|
||||
.as_ref()
|
||||
.map(|shown| shown.view.entity_id());
|
||||
assert_eq!(shown, Some(second), "the panel moves to the new tab");
|
||||
props.update(app, |props, cx| props.show(None, cx));
|
||||
assert!(props.read(app).shown.is_none(), "no tab, no panel");
|
||||
});
|
||||
}
|
||||
|
||||
/// Posts `keys` (LOK key codes) straight to the active document.
|
||||
fn keys(cx: &mut TestAppContext, docs: &Entity<Docs>, keys: &[i32]) {
|
||||
cx.update(|app| {
|
||||
let docs = docs.read(app);
|
||||
let doc = docs.active_tab().expect("a tab").doc.clone();
|
||||
let mut worker = docs.core.worker.lock().expect("the worker lock");
|
||||
for &code in keys {
|
||||
worker.post_key(&doc, 0, code).expect("the key posts");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A text style's definition in a flat ODF file.
|
||||
fn text_style<'a>(text: &'a str, name: &str) -> &'a str {
|
||||
let head = format!("style:name=\"{name}\"");
|
||||
text.split(head.as_str())
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("</style:style>").next())
|
||||
.unwrap_or_else(|| panic!("{name} is defined"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_follows_the_character_style_under_the_cursor() {
|
||||
const HOME: i32 = 1028;
|
||||
const END: i32 = 1029;
|
||||
const LEFT: i32 = 1026;
|
||||
const RIGHT: i32 = 1027;
|
||||
const SHIFT: i32 = 0x1000;
|
||||
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, "Alpha Beta");
|
||||
let props = panel(&mut cx, &docs);
|
||||
run(&mut cx, &props, "edit.select_all", &[]);
|
||||
run(
|
||||
&mut cx,
|
||||
&props,
|
||||
"format.style",
|
||||
&["Strong Emphasis", "CharacterStyles"],
|
||||
);
|
||||
settled(&cx, &docs);
|
||||
keys(
|
||||
&mut cx,
|
||||
&docs,
|
||||
&[
|
||||
HOME,
|
||||
RIGHT | SHIFT,
|
||||
RIGHT | SHIFT,
|
||||
RIGHT | SHIFT,
|
||||
RIGHT | SHIFT,
|
||||
RIGHT | SHIFT,
|
||||
],
|
||||
);
|
||||
run(
|
||||
&mut cx,
|
||||
&props,
|
||||
"format.style",
|
||||
&["Emphasis", "CharacterStyles"],
|
||||
);
|
||||
settled(&cx, &docs);
|
||||
keys(
|
||||
&mut cx,
|
||||
&docs,
|
||||
&[END, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT, LEFT | SHIFT],
|
||||
);
|
||||
let current = |app: &gpui::App| props.read(app).state(".uno:CharStyle", app);
|
||||
settle(&cx, |app| {
|
||||
current(app).as_deref() == Some("Strong Emphasis")
|
||||
});
|
||||
cx.update(|app| {
|
||||
props.update(app, |props, cx| props.show(None, cx));
|
||||
assert_eq!(current(app), None, "a hidden panel shows nothing");
|
||||
});
|
||||
let props = panel(&mut cx, &docs);
|
||||
let target = cx.update(|app| props.read(app).state(".uno:CharStyle", app));
|
||||
assert_eq!(
|
||||
target.as_deref(),
|
||||
Some("Strong Emphasis"),
|
||||
"shown again, still synced"
|
||||
);
|
||||
run(&mut cx, &props, "format.color", &["16711680"]);
|
||||
let target = target.expect("a reported style");
|
||||
run(&mut cx, &props, "style.update", &[&target, "1"]);
|
||||
settled(&cx, &docs);
|
||||
run(&mut cx, &props, "edit.select_all", &[]);
|
||||
settle(&cx, |app| current(app).as_deref() == Some(ely_lok::MIXED));
|
||||
let mixed = cx.update(|app| current(app)).expect("reported");
|
||||
assert!(
|
||||
!ely_office::panel::known(&mixed),
|
||||
"a mixed selection has no update target"
|
||||
);
|
||||
let path = dir.path().join("styles.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
assert!(text_style(&text, "Strong_20_Emphasis").contains("fo:color=\"#ff0000\""));
|
||||
assert!(
|
||||
!text_style(&text, "Emphasis").contains("#ff0000"),
|
||||
"Emphasis stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_spacing_edits_keep_each_other() {
|
||||
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, "Spacing");
|
||||
let props = panel(&mut cx, &docs);
|
||||
let spacing = |app: &gpui::App| props.read(app).state(super::paragraph::SPACING, app);
|
||||
settle(&cx, |app| spacing(app).is_some());
|
||||
let set = |cx: &mut TestAppContext, below: bool, points: f64| {
|
||||
let twips = (points * 20.0).round() as i32;
|
||||
let pair = (super::paragraph::SPACING, "paragraph.spacing");
|
||||
cx.update(|app| props.update(app, |props, cx| props.set_pair(pair, below, twips, cx)))
|
||||
};
|
||||
set(&mut cx, false, 6.0);
|
||||
set(&mut cx, true, 10.0);
|
||||
settle(&cx, |app| spacing(app).as_deref() == Some("212,353"));
|
||||
set(&mut cx, false, 8.0);
|
||||
set(&mut cx, true, 12.0);
|
||||
settle(&cx, |app| spacing(app).as_deref() == Some("282,423"));
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("spacing.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
assert!(
|
||||
text.contains("fo:margin-top=\"0.111in\" fo:margin-bottom=\"0.1665in\""),
|
||||
"both quick edits land"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! References, formulas and comments (DOC-09, 10, 11).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
forms::{Choice, Input, Select, TextInput},
|
||||
overlays::{Dialog as Card, PromptDialog},
|
||||
primitives::Severity,
|
||||
};
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, MenuItem, ParentElement as _, SharedString, Styled as _,
|
||||
Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::actions::Dialog;
|
||||
use crate::docs::Docs;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
actions!(
|
||||
doc,
|
||||
[
|
||||
InsertContents,
|
||||
UpdateContents,
|
||||
InsertFootnote,
|
||||
InsertEndnote,
|
||||
InsertBookmark,
|
||||
InsertLink,
|
||||
InsertReference,
|
||||
InsertCaption,
|
||||
InsertFormula,
|
||||
InsertComment
|
||||
]
|
||||
);
|
||||
|
||||
/// What a one-line prompt asks for.
|
||||
#[derive(Clone)]
|
||||
pub enum Ask {
|
||||
Bookmark(Vec<String>),
|
||||
/// The caption's category, by program name.
|
||||
Caption(&'static str),
|
||||
/// True when it replaces the selected formula.
|
||||
Formula(bool),
|
||||
Comment,
|
||||
}
|
||||
|
||||
/// The reference formats offered, by kernel number.
|
||||
const SHOWN: [(&str, &str); 4] = [
|
||||
("2", "doc.reference.text"),
|
||||
("0", "doc.reference.page"),
|
||||
("1", "doc.reference.chapter"),
|
||||
("3", "doc.reference.direction"),
|
||||
];
|
||||
|
||||
/// The Insert menu's reference entries.
|
||||
pub fn items(cx: &App) -> Vec<MenuItem> {
|
||||
let label = |key: &str| Docs::text(cx, key, &[]);
|
||||
vec![
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("doc.insert.contents"), InsertContents),
|
||||
MenuItem::action(label("doc.insert.update_contents"), UpdateContents),
|
||||
MenuItem::action(label("doc.insert.footnote"), InsertFootnote),
|
||||
MenuItem::action(label("doc.insert.endnote"), InsertEndnote),
|
||||
MenuItem::action(label("doc.insert.bookmark"), InsertBookmark),
|
||||
MenuItem::action(label("doc.insert.link"), InsertLink),
|
||||
MenuItem::action(label("doc.insert.reference"), InsertReference),
|
||||
MenuItem::action(label("doc.insert.caption"), InsertCaption),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("doc.insert.formula"), InsertFormula),
|
||||
MenuItem::action(label("doc.insert.comment"), InsertComment),
|
||||
]
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// A kernel list for the active document; failures toast.
|
||||
fn read(&self, command: &str, cx: &mut Context<Self>) -> Option<String> {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
return None;
|
||||
};
|
||||
let read = self
|
||||
.core
|
||||
.worker
|
||||
.lock()
|
||||
.expect("the worker lock")
|
||||
.values(&tab.doc, command);
|
||||
read.map_err(|error| {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn bookmarks(&self, cx: &mut Context<Self>) -> Option<Vec<String>> {
|
||||
let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?);
|
||||
listed
|
||||
.map(|marks| marks.into_iter().map(|mark| mark.name).collect())
|
||||
.map_err(|error| {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let field = cx.new(|cx| TextInput::new(window, cx));
|
||||
field.update(cx, |field, cx| field.set_text(text, cx));
|
||||
self.dialog = Some(Dialog::Ask { ask, field });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn on_contents(&mut self, _: &InsertContents, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let title = Docs::text(cx, "doc.contents.title", &[]);
|
||||
self.run_active("index.insert", &[&title], cx);
|
||||
}
|
||||
|
||||
pub fn on_update(&mut self, _: &UpdateContents, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("index.update", &[], cx);
|
||||
}
|
||||
|
||||
pub fn on_footnote(&mut self, _: &InsertFootnote, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("note.footnote", &[], cx);
|
||||
}
|
||||
|
||||
pub fn on_endnote(&mut self, _: &InsertEndnote, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("note.endnote", &[], cx);
|
||||
}
|
||||
|
||||
pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(taken) = self.bookmarks(cx) {
|
||||
self.ask(Ask::Bookmark(taken), "", window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let text = cx.new(|cx| TextInput::new(window, cx));
|
||||
let url = cx.new(|cx| TextInput::new(window, cx));
|
||||
cx.observe(&url, |_, _, cx| cx.notify()).detach();
|
||||
self.dialog = Some(Dialog::Link { text, url });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(marks) = self.bookmarks(cx) else {
|
||||
return;
|
||||
};
|
||||
let Some(mark) = marks.first().cloned() else {
|
||||
let text = Docs::text(cx, "doc.reference.none", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
self.dialog = Some(Dialog::Reference {
|
||||
marks,
|
||||
mark,
|
||||
show: "2",
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
let view = tab.view.read(cx);
|
||||
let category = if view.graphic().is_some() {
|
||||
"Figure"
|
||||
} else if view.state(".uno:DeleteTable") == Some("enabled") {
|
||||
"Table"
|
||||
} else {
|
||||
let text = Docs::text(cx, "doc.caption.none", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
self.ask(Ask::Caption(category), "", window, cx);
|
||||
}
|
||||
|
||||
pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(json) = self.read(".uno:Formula", cx) else {
|
||||
return;
|
||||
};
|
||||
match ely_lok::review::formula(&json) {
|
||||
Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx),
|
||||
Ok(None) => self.ask(Ask::Formula(false), "", window, cx),
|
||||
Err(error) => {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.ask(Ask::Comment, "", window, cx);
|
||||
}
|
||||
|
||||
/// Sends the hyperlink dialog.
|
||||
pub(crate) fn insert_link(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Dialog::Link { text, url }) = self.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
let (words, address) = (
|
||||
text.read(cx).text().trim().to_string(),
|
||||
url.read(cx).text().trim().to_string(),
|
||||
);
|
||||
// No text shows the address.
|
||||
let shown = if words.is_empty() { &address } else { &words };
|
||||
self.run_active("link.insert", &[shown, &address], cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sends the cross-reference dialog.
|
||||
pub(crate) fn insert_reference(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Dialog::Reference { mark, show, .. }) = self.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
self.run_active("reference.insert", &["13", "2", &mark, show], cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sends a prompt's answer and closes it.
|
||||
pub(crate) fn answer(&mut self, ask: &Ask, text: &str, cx: &mut Context<Self>) {
|
||||
self.dialog = None;
|
||||
match ask {
|
||||
Ask::Bookmark(_) => self.run_active("bookmark.insert", &[text], cx),
|
||||
Ask::Caption(category) => self.run_active("caption.insert", &[category, text], cx),
|
||||
Ask::Formula(_) => self.run_active("formula.insert", &[text], cx),
|
||||
Ask::Comment => {
|
||||
self.side = crate::docs::Side::Review;
|
||||
self.run_active("comment.insert", &[text], cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-line prompt for `ask`.
|
||||
pub fn ask_dialog(
|
||||
this: Entity<Docs>,
|
||||
ask: &Ask,
|
||||
field: &Entity<TextInput>,
|
||||
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> PromptDialog {
|
||||
let text = |key: &str| Docs::text(cx, key, &[]);
|
||||
let (title, label) = match ask {
|
||||
Ask::Bookmark(_) => ("doc.bookmark.title", "doc.bookmark.name"),
|
||||
Ask::Caption(_) => ("doc.caption.title", "doc.caption.text"),
|
||||
Ask::Formula(true) => ("doc.formula.edit", "doc.formula.text"),
|
||||
Ask::Formula(false) => ("doc.formula.title", "doc.formula.text"),
|
||||
Ask::Comment => ("doc.comment.title", "doc.comment.text"),
|
||||
};
|
||||
let submit = match ask {
|
||||
Ask::Formula(true) => text("doc.formula.apply"),
|
||||
_ => text("doc.insert.insert"),
|
||||
};
|
||||
let reasons: Vec<(&str, SharedString)> = [
|
||||
"doc.bookmark.empty",
|
||||
"doc.bookmark.taken",
|
||||
"doc.caption.empty",
|
||||
"doc.formula.empty",
|
||||
"doc.comment.empty",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|key| (key, text(key)))
|
||||
.collect();
|
||||
let (asked, ask) = (ask.clone(), ask.clone());
|
||||
PromptDialog::new("doc-ask", text(title), field, dismiss)
|
||||
.label(text(label))
|
||||
.submit(submit)
|
||||
.check(move |value| {
|
||||
refuse(&asked, value.trim()).map_err(|key| {
|
||||
let (_, why) = reasons
|
||||
.iter()
|
||||
.find(|(known, _)| *known == key)
|
||||
.expect("a reason");
|
||||
why.clone()
|
||||
})
|
||||
})
|
||||
.on_submit(move |value, _, cx| {
|
||||
let value = value.trim().to_string();
|
||||
this.update(cx, |docs, cx| docs.answer(&ask, &value, cx))
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a prompt's answer cannot go, by message key.
|
||||
fn refuse(ask: &Ask, value: &str) -> Result<(), &'static str> {
|
||||
if let Ask::Bookmark(taken) = ask
|
||||
&& taken.iter().any(|name| name == value)
|
||||
{
|
||||
return Err("doc.bookmark.taken");
|
||||
}
|
||||
if !value.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(match ask {
|
||||
Ask::Bookmark(_) => "doc.bookmark.empty",
|
||||
Ask::Caption(_) => "doc.caption.empty",
|
||||
Ask::Formula(_) => "doc.formula.empty",
|
||||
Ask::Comment => "doc.comment.empty",
|
||||
})
|
||||
}
|
||||
|
||||
/// The hyperlink dialog: text and address.
|
||||
pub fn link_dialog(
|
||||
this: Entity<Docs>,
|
||||
(text, url): (&Entity<TextInput>, &Entity<TextInput>),
|
||||
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> Card {
|
||||
let fields = DescriptionList::new()
|
||||
.item(
|
||||
Docs::text(cx, "doc.link.text", &[]),
|
||||
div().w_full().max_w_64().child(Input::new(text)),
|
||||
)
|
||||
.item(
|
||||
Docs::text(cx, "doc.link.url", &[]),
|
||||
div().w_full().max_w_64().child(Input::new(url)),
|
||||
);
|
||||
let label = Docs::text(cx, "doc.insert.insert", &[]);
|
||||
let empty = url.read(cx).text().trim().is_empty();
|
||||
Card::new(
|
||||
"doc-insert-link",
|
||||
Docs::text(cx, "doc.link.title", &[]),
|
||||
dismiss,
|
||||
)
|
||||
.child(fields)
|
||||
.action(ely_office::cancel(cx))
|
||||
.action(move |_| {
|
||||
Button::new("doc-insert-link-go", label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.disabled(empty)
|
||||
.on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.insert_link(cx)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The cross-reference dialog: which bookmark, shown how.
|
||||
pub fn reference_dialog(
|
||||
this: Entity<Docs>,
|
||||
(marks, mark, show): (&[String], &str, &'static str),
|
||||
dismiss: impl Fn(&mut Window, &mut App) + 'static,
|
||||
cx: &App,
|
||||
) -> Card {
|
||||
let pick = {
|
||||
let this = this.clone();
|
||||
let choices = marks
|
||||
.iter()
|
||||
.map(|name| Choice::new(name.clone(), name.clone()));
|
||||
Select::new("doc-reference-mark", choices)
|
||||
.selected(mark.to_string())
|
||||
.on_change(move |name, _, cx| {
|
||||
this.update(cx, |docs, cx| {
|
||||
if let Some(Dialog::Reference { mark, .. }) = &mut docs.dialog {
|
||||
*mark = name.to_string();
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
};
|
||||
let how = {
|
||||
let this = this.clone();
|
||||
let choices = SHOWN
|
||||
.iter()
|
||||
.map(|(value, key)| Choice::new(*value, Docs::text(cx, key, &[])));
|
||||
Select::new("doc-reference-show", choices)
|
||||
.selected(show)
|
||||
.on_change(move |value, _, cx| {
|
||||
let Some((value, _)) = SHOWN.iter().find(|(shown, _)| *shown == value.as_ref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
this.update(cx, |docs, cx| {
|
||||
if let Some(Dialog::Reference { show, .. }) = &mut docs.dialog {
|
||||
*show = value;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
};
|
||||
let fields = DescriptionList::new()
|
||||
.item(
|
||||
Docs::text(cx, "doc.reference.target", &[]),
|
||||
div().w_full().max_w_64().child(pick),
|
||||
)
|
||||
.item(
|
||||
Docs::text(cx, "doc.reference.show", &[]),
|
||||
div().w_full().max_w_64().child(how),
|
||||
);
|
||||
let label = Docs::text(cx, "doc.insert.insert", &[]);
|
||||
Card::new(
|
||||
"doc-insert-reference",
|
||||
Docs::text(cx, "doc.reference.title", &[]),
|
||||
dismiss,
|
||||
)
|
||||
.child(fields)
|
||||
.action(ely_office::cancel(cx))
|
||||
.action(move |_| {
|
||||
Button::new("doc-insert-reference-go", label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| this.update(cx, |docs, cx| docs.insert_reference(cx)))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,190 @@
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
|
||||
|
||||
/// The open prompt's request and field.
|
||||
fn asked(cx: &TestAppContext, docs: &Entity<Docs>) -> (Ask, Entity<TextInput>) {
|
||||
cx.update(|app| match &docs.read(app).dialog {
|
||||
Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()),
|
||||
_ => panic!("a prompt is open"),
|
||||
})
|
||||
}
|
||||
|
||||
fn answer(cx: &mut TestAppContext, docs: &Entity<Docs>, text: &str) {
|
||||
let (ask, _) = asked(cx, docs);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.answer(&ask, text, cx)));
|
||||
settled(cx, docs);
|
||||
}
|
||||
|
||||
fn graphic(app: &gpui::App, docs: &Entity<Docs>) -> bool {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).graphic().is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_go_in_through_their_dialogs() {
|
||||
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, "Start");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_reference(&InsertReference, window, cx)
|
||||
});
|
||||
assert!(
|
||||
cx.update(|app| docs.read(app).dialog.is_none()),
|
||||
"no bookmark, no cross-reference dialog"
|
||||
);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_bookmark(&InsertBookmark, window, cx)
|
||||
});
|
||||
answer(&mut cx, &docs, "Here");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_bookmark(&InsertBookmark, window, cx)
|
||||
});
|
||||
let (ask, _) = asked(&cx, &docs);
|
||||
assert!(
|
||||
matches!(&ask, Ask::Bookmark(taken) if taken == &["Here"]),
|
||||
"the prompt knows the taken names"
|
||||
);
|
||||
cx.update(|app| docs.update(app, |docs, _| docs.dialog = None));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_reference(&InsertReference, window, cx)
|
||||
});
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx)));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_link(&InsertLink, window, cx)
|
||||
});
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
let Some(Dialog::Link { url, .. }) = &docs.dialog else {
|
||||
panic!("the link dialog is open");
|
||||
};
|
||||
url.update(cx, |url, cx| url.set_text("https://example.org/", cx));
|
||||
docs.insert_link(cx);
|
||||
})
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_footnote(&InsertFootnote, window, cx);
|
||||
});
|
||||
type_into(&mut cx, &docs, "Foot");
|
||||
cx.update(|app| {
|
||||
let view = docs.read(app).active_tab().expect("a tab").view.clone();
|
||||
view.update(app, |view, cx| view.reveal((1500, 1500, 0, 0), cx));
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_contents(&InsertContents, window, cx);
|
||||
docs.on_update(&UpdateContents, window, cx);
|
||||
});
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("refs.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
for mark in [
|
||||
"<text:bookmark text:name=\"Here\"",
|
||||
"<text:bookmark-ref ",
|
||||
"xlink:href=\"https://example.org/\"",
|
||||
">https://example.org/<",
|
||||
"<text:note ",
|
||||
"Foot",
|
||||
"<text:table-of-content ",
|
||||
] {
|
||||
assert!(text.contains(mark), "{mark} lands");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_edit_in_place_and_take_captions() {
|
||||
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, "Ratio");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_caption(&InsertCaption, window, cx)
|
||||
});
|
||||
assert!(
|
||||
cx.update(|app| docs.read(app).dialog.is_none()),
|
||||
"nothing to caption"
|
||||
);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_formula(&InsertFormula, window, cx)
|
||||
});
|
||||
assert!(matches!(asked(&cx, &docs).0, Ask::Formula(false)));
|
||||
answer(&mut cx, &docs, "a over b");
|
||||
settle(&cx, |app| graphic(app, &docs));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_formula(&InsertFormula, window, cx)
|
||||
});
|
||||
let (ask, field) = asked(&cx, &docs);
|
||||
assert!(matches!(ask, Ask::Formula(true)), "the formula edits");
|
||||
assert_eq!(
|
||||
cx.update(|app| field.read(app).text().to_string()),
|
||||
"a over b"
|
||||
);
|
||||
answer(&mut cx, &docs, "c over d");
|
||||
settle(&cx, |app| graphic(app, &docs));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_caption(&InsertCaption, window, cx)
|
||||
});
|
||||
assert!(matches!(asked(&cx, &docs).0, Ask::Caption("Figure")));
|
||||
answer(&mut cx, &docs, "ratio");
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("formula.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
assert_eq!(text.matches("<draw:object").count(), 1, "the edit replaces");
|
||||
for mark in ["c over d", ": ratio", "text:sequence"] {
|
||||
assert!(text.contains(mark), "{mark} lands");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompts_refuse_empty_and_taken_answers() {
|
||||
let taken = Ask::Bookmark(vec!["Here".into()]);
|
||||
assert_eq!(refuse(&taken, "Here"), Err("doc.bookmark.taken"));
|
||||
assert_eq!(refuse(&taken, ""), Err("doc.bookmark.empty"));
|
||||
assert_eq!(refuse(&taken, "There"), Ok(()));
|
||||
assert_eq!(
|
||||
refuse(&Ask::Caption("Figure"), ""),
|
||||
Err("doc.caption.empty")
|
||||
);
|
||||
assert_eq!(refuse(&Ask::Formula(true), ""), Err("doc.formula.empty"));
|
||||
assert_eq!(refuse(&Ask::Comment, "Note"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_formula_prefills_whole() {
|
||||
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);
|
||||
type_into(&mut cx, &docs, "Long");
|
||||
let long = format!("{}1", "x + ".repeat(300));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_formula(&InsertFormula, window, cx)
|
||||
});
|
||||
answer(&mut cx, &docs, &long);
|
||||
settle(&cx, |app| graphic(app, &docs));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_formula(&InsertFormula, window, cx)
|
||||
});
|
||||
let (_, field) = asked(&cx, &docs);
|
||||
assert_eq!(cx.update(|app| field.read(app).text().to_string()), long);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Comments and tracked changes beside the page (DOC-11).
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
|
||||
collab::{Comment, CommentSidebar, Peer, Thread},
|
||||
feedback::EmptyState,
|
||||
forms::{Switch, TextInput},
|
||||
layout::ScrollArea,
|
||||
lists::{List, ListItem},
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_lok::{
|
||||
Doc, LokError, LokView, Reported, Worker,
|
||||
review::{Change, Note},
|
||||
};
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Subscription, Window, actions, div,
|
||||
};
|
||||
|
||||
use crate::docs::{Docs, Side};
|
||||
|
||||
actions!(doc, [TrackChanges, AcceptAll, RejectAll, ShowReview]);
|
||||
|
||||
/// ElyDoc's Review menu.
|
||||
pub fn menu(cx: &App) -> Menu {
|
||||
let label = |key: &str| Docs::text(cx, key, &[]);
|
||||
Menu::new(label("menu.review")).items([
|
||||
MenuItem::action(label("doc.insert.comment"), crate::refs::InsertComment),
|
||||
MenuItem::action(label("doc.review.panel"), ShowReview),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("doc.review.track"), TrackChanges),
|
||||
MenuItem::action(label("doc.review.accept_all"), AcceptAll),
|
||||
MenuItem::action(label("doc.review.reject_all"), RejectAll),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("doc.compare.open"), crate::compare::CompareWith),
|
||||
])
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
pub fn on_track(&mut self, _: &TrackChanges, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("track.toggle", &[], cx);
|
||||
}
|
||||
|
||||
pub fn on_accept_all(&mut self, _: &AcceptAll, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("track.accept_all", &[], cx);
|
||||
}
|
||||
|
||||
pub fn on_reject_all(&mut self, _: &RejectAll, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.run_active("track.reject_all", &[], cx);
|
||||
}
|
||||
|
||||
pub fn on_show_review(&mut self, _: &ShowReview, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.side = match self.side {
|
||||
Side::Review => Side::Props,
|
||||
_ => Side::Review,
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Pane {
|
||||
Comments,
|
||||
Changes,
|
||||
}
|
||||
|
||||
struct Shown {
|
||||
view: Entity<LokView>,
|
||||
doc: Doc,
|
||||
/// The view's review count at the last read.
|
||||
read: Option<u64>,
|
||||
/// A read is on its way.
|
||||
reading: bool,
|
||||
_observe: Subscription,
|
||||
}
|
||||
|
||||
pub struct Review {
|
||||
worker: Arc<Mutex<Worker>>,
|
||||
shown: Option<Shown>,
|
||||
notes: Vec<Note>,
|
||||
threads: Vec<Thread>,
|
||||
pub(crate) changes: Vec<Change>,
|
||||
active: Option<SharedString>,
|
||||
pane: Pane,
|
||||
}
|
||||
|
||||
impl EventEmitter<LokError> for Review {}
|
||||
|
||||
impl Review {
|
||||
pub fn new(worker: Arc<Mutex<Worker>>) -> Self {
|
||||
Review {
|
||||
worker,
|
||||
shown: None,
|
||||
notes: Vec::new(),
|
||||
threads: Vec::new(),
|
||||
changes: Vec::new(),
|
||||
active: None,
|
||||
pane: Pane::Comments,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows the active tab; reads the lists when they change.
|
||||
pub fn show(&mut self, tab: Option<(&Entity<LokView>, &Doc)>, cx: &mut Context<Self>) {
|
||||
let id = tab.map(|(view, _)| view.entity_id());
|
||||
if self.shown.as_ref().map(|shown| shown.view.entity_id()) == id {
|
||||
return;
|
||||
}
|
||||
// Another document's lists must not stay actionable.
|
||||
self.shown = None;
|
||||
self.active = None;
|
||||
self.notes.clear();
|
||||
self.threads.clear();
|
||||
self.changes.clear();
|
||||
cx.notify();
|
||||
let Some((view, doc)) = tab else {
|
||||
return;
|
||||
};
|
||||
let observe = cx.subscribe(view, |review, _, _: &Reported, cx| review.refresh(cx));
|
||||
self.shown = Some(Shown {
|
||||
view: view.clone(),
|
||||
doc: doc.clone(),
|
||||
read: None,
|
||||
reading: false,
|
||||
_observe: observe,
|
||||
});
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// Rereads the lists after review events.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(shown) = self.shown.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let count = shown.view.read(cx).review();
|
||||
if shown.reading || shown.read == Some(count) {
|
||||
return;
|
||||
}
|
||||
(shown.read, shown.reading) = (Some(count), true);
|
||||
let reader = shown.view.entity_id();
|
||||
ely_lok::read_off(
|
||||
&self.worker,
|
||||
&shown.doc,
|
||||
|worker, doc| {
|
||||
let json = worker.values(doc, ".uno:ViewAnnotations")?;
|
||||
let notes = ely_lok::review::notes(&json)?;
|
||||
let json = worker.values(doc, ".uno:AcceptTrackedChanges")?;
|
||||
Ok((notes, ely_lok::review::changes(&json)?))
|
||||
},
|
||||
move |review: &mut Self, read, cx| {
|
||||
let Some(shown) = review.shown.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if shown.view.entity_id() != reader {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
Ok((notes, threads, 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!(
|
||||
"the review lists are unreadable: {error}"
|
||||
))),
|
||||
}
|
||||
cx.notify();
|
||||
review.refresh(cx);
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn run(&mut self, id: &str, values: &[&str], cx: &mut Context<Self>) {
|
||||
let Some(shown) = &self.shown else {
|
||||
return;
|
||||
};
|
||||
log::info!("doc: review runs {id}");
|
||||
if let Err(error) = shown.view.update(cx, |view, _| view.run(id, values)) {
|
||||
cx.emit(LokError(format!("{id}: {error}")));
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal(&mut self, anchor: ely_lok::Rect, cx: &mut Context<Self>) {
|
||||
if let Some(shown) = &self.shown {
|
||||
shown.view.update(cx, |view, cx| view.reveal(anchor, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks a thread and goes to its words.
|
||||
fn select(&mut self, key: &SharedString, cx: &mut Context<Self>) {
|
||||
self.active = Some(key.clone());
|
||||
if let Some(note) = self.notes.iter().find(|note| note.id == key.as_ref()) {
|
||||
self.reveal(note.anchor, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Answers the picked thread.
|
||||
fn reply(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
if let Some(key) = self.active.clone() {
|
||||
self.run("comment.reply", &[&key, text], cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn changes_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.changes.is_empty() {
|
||||
let none = Docs::text(cx, "doc.review.none", &[]);
|
||||
return EmptyState::new("doc-review-none", IconName::Check, none).into_any_element();
|
||||
}
|
||||
let this = cx.entity();
|
||||
let (accept, reject) = (
|
||||
Docs::text(cx, "doc.review.accept", &[]),
|
||||
Docs::text(cx, "doc.review.reject", &[]),
|
||||
);
|
||||
let rows = self.changes.iter().map(|change| {
|
||||
let index = change.index.to_string();
|
||||
let decide = |id: &'static str, icon, tip: &SharedString| {
|
||||
let (this, index) = (this.clone(), index.clone());
|
||||
IconButton::new((id, change.index as usize), icon)
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip(tip.clone())
|
||||
.on_click(move |_, _, cx| {
|
||||
cx.stop_propagation();
|
||||
this.update(cx, |review, cx| review.run(id, &[&index], cx))
|
||||
})
|
||||
};
|
||||
let actions = div()
|
||||
.flex()
|
||||
.gap_1()
|
||||
.child(decide("track.accept", IconName::Check, &accept))
|
||||
.child(decide("track.reject", IconName::X, &reject));
|
||||
let mut item = ListItem::new(
|
||||
("doc-review-change", change.index as usize),
|
||||
change.description.clone(),
|
||||
)
|
||||
.description(change.author.clone())
|
||||
.trailing(actions);
|
||||
if let Some(anchor) = change.anchor {
|
||||
let this = this.clone();
|
||||
item = item.on_click(move |_, _, cx| {
|
||||
this.update(cx, |review, cx| review.reveal(anchor, cx))
|
||||
});
|
||||
}
|
||||
item
|
||||
});
|
||||
List::new().divided().children(rows).into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Review {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let this = cx.entity();
|
||||
let tracking = self.shown.as_ref().and_then(|shown| {
|
||||
shown
|
||||
.view
|
||||
.read(cx)
|
||||
.state(".uno:TrackChanges")
|
||||
.map(|on| on == "true")
|
||||
});
|
||||
let all = |id: &'static str, key: &str| {
|
||||
let this = this.clone();
|
||||
Button::new(id, Docs::text(cx, key, &[]))
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.disabled(self.changes.is_empty())
|
||||
.on_click(move |_, _, cx| this.update(cx, |review, cx| review.run(id, &[], cx)))
|
||||
};
|
||||
let head = div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
Switch::new("doc-review-track", tracking == Some(true))
|
||||
.disabled(tracking.is_none())
|
||||
.label(Docs::text(cx, "doc.review.track", &[]))
|
||||
.on_change({
|
||||
let this = this.clone();
|
||||
move |_, _, cx| {
|
||||
this.update(cx, |review, cx| review.run("track.toggle", &[], cx))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(all("track.accept_all", "doc.review.accept_all"))
|
||||
.child(all("track.reject_all", "doc.review.reject_all"));
|
||||
let roots = self.threads.len().to_string();
|
||||
let pane = SegmentedControl::new(
|
||||
"doc-review-pane",
|
||||
match self.pane {
|
||||
Pane::Comments => "comments",
|
||||
Pane::Changes => "changes",
|
||||
},
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.segment(
|
||||
"comments",
|
||||
Docs::text(cx, "doc.review.comments", &[("n", &roots)]),
|
||||
None,
|
||||
)
|
||||
.segment(
|
||||
"changes",
|
||||
Docs::text(
|
||||
cx,
|
||||
"doc.review.changes",
|
||||
&[("n", &self.changes.len().to_string())],
|
||||
),
|
||||
None,
|
||||
)
|
||||
.on_change({
|
||||
let this = this.clone();
|
||||
move |value, _, cx| {
|
||||
this.update(cx, |review, cx| {
|
||||
review.pane = match value.as_ref() {
|
||||
"changes" => Pane::Changes,
|
||||
_ => Pane::Comments,
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
});
|
||||
let body = match self.pane {
|
||||
Pane::Changes => ScrollArea::new("doc-review-scroll").vertical()
|
||||
.size_full()
|
||||
.child(self.changes_list(cx))
|
||||
.into_any_element(),
|
||||
Pane::Comments => {
|
||||
let field = window.use_keyed_state("doc-review-reply", cx, |window, cx| {
|
||||
TextInput::new(window, cx)
|
||||
});
|
||||
let (pick, send) = (this.clone(), this.clone());
|
||||
CommentSidebar::new("doc-review-comments", self.threads.clone())
|
||||
.active(self.active.clone(), move |key, _, cx| {
|
||||
pick.update(cx, |review, cx| review.select(key, cx))
|
||||
})
|
||||
.reply(&field.clone(), move |_, cx| {
|
||||
let text = field.read(cx).text().trim().to_string();
|
||||
send.update(cx, |review, cx| review.reply(&text, cx));
|
||||
field.update(cx, |field, cx| field.set_text("", cx));
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.child(head)
|
||||
.child(pane)
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
/// The notes as threads under their first comment.
|
||||
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
|
||||
let parents: HashMap<&str, &str> = notes
|
||||
.iter()
|
||||
.map(|note| (note.id.as_str(), note.parent.as_str()))
|
||||
.collect();
|
||||
let root = |id: &'_ str| -> String {
|
||||
let mut id = id;
|
||||
while let Some(parent) = parents.get(id).filter(|parent| **parent != "0") {
|
||||
id = parent;
|
||||
}
|
||||
id.to_string()
|
||||
};
|
||||
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
|
||||
let mut threads: Vec<Thread> = Vec::new();
|
||||
for note in notes {
|
||||
let at = note
|
||||
.at
|
||||
.parse::<jiff::civil::DateTime>()
|
||||
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
|
||||
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?;
|
||||
let hue = authors
|
||||
.iter()
|
||||
.position(|name| *name == note.author)
|
||||
.expect("the author is listed");
|
||||
let comment = Comment {
|
||||
author: Peer::new(note.author.clone(), note.author.clone(), hue),
|
||||
at: at.timestamp(),
|
||||
body: note.text.clone().into(),
|
||||
reactions: Vec::new(),
|
||||
};
|
||||
let key: SharedString = root(¬e.id).into();
|
||||
match threads.iter_mut().find(|thread| thread.key == key) {
|
||||
Some(thread) => thread.comments.push(comment),
|
||||
None => threads.push(Thread {
|
||||
key,
|
||||
quote: None,
|
||||
comments: vec![comment],
|
||||
resolved: note.resolved,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(threads)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,84 @@
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
use super::*;
|
||||
use crate::actions::Dialog;
|
||||
use crate::refs::InsertComment;
|
||||
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// The review panel on the active tab.
|
||||
fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Review> {
|
||||
cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
let (view, doc) = (tab.view.clone(), tab.doc.clone());
|
||||
let review = docs.read(app).review.clone();
|
||||
review.update(app, |review, cx| review.show(Some((&view, &doc)), cx));
|
||||
review
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_thread_and_changes_accept_or_reject() {
|
||||
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, "Hello");
|
||||
let review = panel(&mut cx, &docs);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_comment(&InsertComment, window, cx);
|
||||
let Some(Dialog::Ask { ask, .. }) = &docs.dialog else {
|
||||
panic!("the comment prompt is open");
|
||||
};
|
||||
let ask = ask.clone();
|
||||
docs.answer(&ask, "First note", cx);
|
||||
});
|
||||
assert_eq!(
|
||||
cx.update(|app| docs.read(app).side),
|
||||
Side::Review,
|
||||
"the panel shows"
|
||||
);
|
||||
settle(&cx, |app| review.read(app).threads.len() == 1);
|
||||
let key = cx.update(|app| review.read(app).threads[0].key.clone());
|
||||
cx.update(|app| review.update(app, |review, cx| review.select(&key, cx)));
|
||||
cx.update(|app| review.update(app, |review, cx| review.reply("An answer", cx)));
|
||||
settle(&cx, |app| {
|
||||
review
|
||||
.read(app)
|
||||
.threads
|
||||
.first()
|
||||
.is_some_and(|thread| thread.comments.len() == 2)
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_track(&TrackChanges, window, cx)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).state(".uno:TrackChanges") == Some("true")
|
||||
});
|
||||
type_into(&mut cx, &docs, " kept");
|
||||
settle(&cx, |app| review.read(app).changes.len() == 1);
|
||||
let index = cx.update(|app| review.read(app).changes[0].index.to_string());
|
||||
cx.update(|app| review.update(app, |review, cx| review.run("track.accept", &[&index], cx)));
|
||||
settle(&cx, |app| review.read(app).changes.is_empty());
|
||||
type_into(&mut cx, &docs, " gone");
|
||||
settle(&cx, |app| review.read(app).changes.len() == 1);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_reject_all(&RejectAll, window, cx)
|
||||
});
|
||||
settle(&cx, |app| review.read(app).changes.is_empty());
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("review.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
for mark in ["First note", "An answer", " kept"] {
|
||||
assert!(text.contains(mark), "{mark} lands");
|
||||
}
|
||||
assert!(!text.contains("gone"), "the rejected words go");
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Spelling suggestions from the kernel's context menu (DOC-12).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::LokView;
|
||||
use gpui::{Context, Entity, IntoElement, Pixels, Point};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// A misspelled word's menu, asked for at a point.
|
||||
pub struct Offer {
|
||||
tab: u64,
|
||||
pub(crate) suggestions: Vec<(String, String)>,
|
||||
/// Bumps for each menu, so each opens once.
|
||||
request: (u64, Point<Pixels>),
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Turns the kernel's context menu into a spelling offer.
|
||||
pub(crate) fn offer_spelling(
|
||||
&mut self,
|
||||
id: u64,
|
||||
view: &Entity<LokView>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
|
||||
return;
|
||||
};
|
||||
let spelling = match ely_lok::review::spelling(&json) {
|
||||
Ok(spelling) => spelling,
|
||||
Err(error) => {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
let Some(spelling) = spelling else {
|
||||
log::info!("doc: the kernel menu has no spelling");
|
||||
return;
|
||||
};
|
||||
let Some(caret) = view.read(cx).cursor_bounds() else {
|
||||
log::warn!("doc: a spelling menu without a caret");
|
||||
return;
|
||||
};
|
||||
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
|
||||
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
|
||||
self.offer = Some(Offer {
|
||||
tab: id,
|
||||
suggestions: spelling.suggestions,
|
||||
request: (number, caret.bottom_left()),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The spelling menu's host, for the active tab.
|
||||
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
let offer = self
|
||||
.offer
|
||||
.as_ref()
|
||||
.filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
let this = cx.entity();
|
||||
let mut menu = Menu::new();
|
||||
for (label, rule) in &offer.suggestions {
|
||||
let (this, rule) = (this.clone(), rule.clone());
|
||||
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
|
||||
}));
|
||||
}
|
||||
if offer.suggestions.is_empty() {
|
||||
let none = Docs::text(cx, "doc.spell.none", &[]);
|
||||
menu = menu.item(MenuItem::new(none).disabled(true));
|
||||
}
|
||||
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
|
||||
let menu = menu
|
||||
.separator()
|
||||
.item(MenuItem::new(ignore).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| {
|
||||
docs.run_active("spell.ignore_all", &["Spelling"], cx)
|
||||
})
|
||||
}));
|
||||
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use gpui::{AppContext as _, Entity, TestAppContext, Window};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use crate::files::Template;
|
||||
use ely_office::prelude::*;
|
||||
use ely_office::{After, Dialog};
|
||||
|
||||
struct Blank;
|
||||
|
||||
impl gpui::Render for Blank {
|
||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
gpui::Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// A Docs over a fresh worker, if LOK exists.
|
||||
pub(crate) fn docs(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Docs>> {
|
||||
// A failing test prints its log.
|
||||
let _ = env_logger::builder()
|
||||
.is_test(true)
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.try_init();
|
||||
let install = ely_lok::install_dir().ok()?;
|
||||
let worker = ely_lok::Worker::spawn(&install, &dir.join("profile")).expect("the worker");
|
||||
let work = dir.join("work");
|
||||
std::fs::create_dir_all(&work).expect("the work dir");
|
||||
cx.update(|app| {
|
||||
ely_ui::settings::install_defaults(app);
|
||||
ely_ui::i18n::install(app);
|
||||
let worker = Arc::new(Mutex::new(worker));
|
||||
Some(app.new(|cx| Docs::new(worker, work, cx)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Steps the scheduler until `done` or ten seconds pass.
|
||||
#[track_caller]
|
||||
pub(crate) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !cx.executor().tick() {
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_millis(20));
|
||||
}
|
||||
if cx.update(|app| done(app)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the state never settled");
|
||||
}
|
||||
|
||||
/// Waits until `path` holds bytes other than `before`.
|
||||
#[track_caller]
|
||||
pub(crate) fn written(cx: &TestAppContext, path: &Path, before: Option<&[u8]>) {
|
||||
settle(cx, |_| {
|
||||
std::fs::read(path).is_ok_and(|bytes| Some(bytes.as_slice()) != before)
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs `call` with a window, as actions get one.
|
||||
pub(crate) fn with_window(
|
||||
cx: &mut TestAppContext,
|
||||
docs: &Entity<Docs>,
|
||||
call: impl FnOnce(&mut Docs, &mut Window, &mut gpui::Context<Docs>),
|
||||
) {
|
||||
let window = cx.add_window(|_, _| Blank);
|
||||
cx.update_window(window.into(), |_, window, app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.core.window = Some(window.window_handle());
|
||||
call(docs, window, cx)
|
||||
})
|
||||
})
|
||||
.expect("the window lives");
|
||||
}
|
||||
|
||||
pub(crate) fn type_into(cx: &mut TestAppContext, docs: &Entity<Docs>, text: &str) {
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
let window = cx.add_window(|_, _| Blank);
|
||||
cx.update_window(window.into(), |_, window, app| {
|
||||
let mut ime = gpui::ElementInputHandler::new(gpui::Bounds::default(), view.clone());
|
||||
gpui::InputHandler::replace_text_in_range(&mut ime, None, text, window, app);
|
||||
})
|
||||
.expect("the window lives");
|
||||
settle(cx, |app| {
|
||||
docs.read(app)
|
||||
.active_tab()
|
||||
.is_some_and(|tab| tab.dirty(app) && tab.view.read(app).settled())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_saves_as_docx_and_comes_back_clean() {
|
||||
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;
|
||||
};
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.new_from(Template::Report, cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.active.is_some());
|
||||
let (id, dirty) = cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
(tab.id, tab.dirty(app))
|
||||
});
|
||||
assert!(!dirty, "a fresh template is clean");
|
||||
type_into(&mut cx, &docs, "Ely");
|
||||
let target = dir.path().join("Report.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, target.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &target, None);
|
||||
let bytes = std::fs::read(&target).expect("the docx exists");
|
||||
assert!(bytes.starts_with(b"PK"), "a docx zip");
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.active_tab()
|
||||
.is_some_and(|tab| !tab.dirty(app))
|
||||
});
|
||||
cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
assert_eq!(tab.name.as_ref(), "Report.docx");
|
||||
assert!(tab.registry.is_some(), "the saved file joins the registry");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_keeps_the_format_and_save_as_converts_to_a_new_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 odt = dir.path().join("Notes.odt");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.new_from(Template::Blank, cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.active.is_some());
|
||||
let id = cx.update(|app| docs.read(app).core.active.expect("a tab"));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, odt.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &odt, None);
|
||||
type_into(&mut cx, &docs, "more");
|
||||
let first = std::fs::read(&odt).expect("the first odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.save(id, None, window, cx)
|
||||
});
|
||||
written(&cx, &odt, Some(&first));
|
||||
let odf = std::fs::read(&odt).expect("the odt exists");
|
||||
let mime = b"application/vnd.oasis.opendocument.text";
|
||||
assert!(
|
||||
odf.windows(mime.len()).any(|w| w == mime),
|
||||
"the save kept ODT"
|
||||
);
|
||||
let docx = dir.path().join("Notes.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, docx.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &docx, None);
|
||||
assert!(std::fs::read(&docx).expect("the docx").starts_with(b"PK"));
|
||||
assert_eq!(
|
||||
std::fs::read(&odt).expect("the odt stays"),
|
||||
odf,
|
||||
"the source is untouched"
|
||||
);
|
||||
let pdf = dir.path().join("Notes.pdf");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, pdf.clone(), None, window, cx)
|
||||
});
|
||||
assert!(!pdf.exists(), "Save As writes only DOCX or ODT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_template_opens_untitled_and_stays_untouched() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let template = dir.path().join("memo.fodt");
|
||||
let fodt = cx.update(|app| Template::Letter.fodt(app));
|
||||
std::fs::write(&template, &fodt).expect("the template writes");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.new_from_file(&template, cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.active.is_some());
|
||||
type_into(&mut cx, &docs, "x");
|
||||
let id = cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
assert!(tab.session.path().is_none(), "a template opens untitled");
|
||||
tab.id
|
||||
});
|
||||
let out = dir.path().join("memo.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, out.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &out, None);
|
||||
assert!(out.exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&template).expect("the template"),
|
||||
fodt,
|
||||
"the original stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_file_gets_focus_and_unsaved_closes_ask() {
|
||||
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 file = dir.path().join("open.fodt");
|
||||
let fodt = cx.update(|app| Template::Report.fodt(app));
|
||||
std::fs::write(&file, fodt).expect("the file writes");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.open_file(file.clone(), cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.tabs.len() == 1);
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.open_file(file.clone(), cx)
|
||||
});
|
||||
for _ in 0..40 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
assert_eq!(
|
||||
cx.update(|app| docs.read(app).core.tabs.len()),
|
||||
1,
|
||||
"the same file focuses"
|
||||
);
|
||||
type_into(&mut cx, &docs, "edit");
|
||||
let id = cx.update(|app| docs.read(app).core.active.expect("a tab"));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.request_close(id, After::Tab, window, cx)
|
||||
});
|
||||
cx.update(|app| {
|
||||
let asking =
|
||||
matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { tab, .. }) if tab == id);
|
||||
assert!(asking, "a dirty close asks");
|
||||
});
|
||||
assert!(
|
||||
cx.update(|app| docs.read(app).core.tabs.len()) == 1,
|
||||
"nothing closed yet"
|
||||
);
|
||||
}
|
||||
|
||||
/// The text the kernel reads back from `path`.
|
||||
pub(crate) fn text_of(cx: &mut TestAppContext, docs: &Entity<Docs>, path: &Path) -> String {
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
let mut worker = worker.lock().expect("the worker lock");
|
||||
let doc = worker
|
||||
.load(&ely_lok::file_url(path))
|
||||
.expect("the saved file loads");
|
||||
let flat = path.with_extension("check.fodt");
|
||||
doc.save_as(&mut worker, &ely_lok::file_url(&flat), "fodt")
|
||||
.expect("flattens");
|
||||
worker.close_doc(doc).expect("closes");
|
||||
std::fs::read_to_string(&flat).expect("reads")
|
||||
}
|
||||
|
||||
/// Waits for the active tab's queued input to land.
|
||||
pub(crate) fn settled(cx: &TestAppContext, docs: &Entity<Docs>) {
|
||||
settle(cx, |app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).settled()
|
||||
});
|
||||
}
|
||||
|
||||
/// Opens the blank template and returns its tab id.
|
||||
pub(crate) fn blank(cx: &mut TestAppContext, docs: &Entity<Docs>) -> u64 {
|
||||
let before = cx.update(|app| docs.read(app).core.tabs.len());
|
||||
with_window(cx, docs, |docs, _, cx| docs.new_from(Template::Blank, cx));
|
||||
settle(cx, |app| docs.read(app).core.tabs.len() > before);
|
||||
cx.update(|app| docs.read(app).core.tabs.last().expect("a tab").id)
|
||||
}
|
||||
|
||||
/// Commits `text` through the view without waiting.
|
||||
pub(crate) fn commit(cx: &mut TestAppContext, docs: &Entity<Docs>, text: &str) {
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
let window = cx.add_window(|_, _| Blank);
|
||||
cx.update_window(window.into(), |_, window, app| {
|
||||
let mut ime = gpui::ElementInputHandler::new(gpui::Bounds::default(), view.clone());
|
||||
gpui::InputHandler::replace_text_in_range(&mut ime, None, text, window, app);
|
||||
})
|
||||
.expect("the window lives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_input_reaches_the_save_and_the_close_prompt() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let id = blank(&mut cx, &docs);
|
||||
commit(&mut cx, &docs, "PendingText");
|
||||
let out = dir.path().join("pending.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, out.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &out, None);
|
||||
settle(&cx, |_| out.exists());
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &out).contains("PendingText"),
|
||||
"the queued text saved"
|
||||
);
|
||||
commit(&mut cx, &docs, "More");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.request_close(id, After::Tab, window, cx)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. }))
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macro_file_opens_in_risk_mode_and_saves_only_as_a_copy() {
|
||||
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 macro_doc = dir.path().join("macro.fodt");
|
||||
let body = cx.update(|app| Template::Report.fodt(app))
|
||||
.replace(
|
||||
"office:version=",
|
||||
"xmlns:script=\"urn:oasis:names:tc:opendocument:xmlns:script:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=",
|
||||
)
|
||||
.replace(
|
||||
"<office:body>",
|
||||
"<office:scripts><office:script script:language=\"ooo:Basic\"><ooo:libraries><ooo:library-embedded ooo:name=\"Standard\"><ooo:module ooo:name=\"Module1\"><ooo:source-code>Sub Main
End Sub</ooo:source-code></ooo:module></ooo:library-embedded></ooo:libraries></office:script></office:scripts><office:body>",
|
||||
);
|
||||
std::fs::write(¯o_doc, &body).expect("the macro file writes");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.open_file(macro_doc.clone(), cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.active.is_some());
|
||||
let id = cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
assert!(tab.risk.is_some(), "macros put the tab in risk mode");
|
||||
tab.id
|
||||
});
|
||||
type_into(&mut cx, &docs, "x");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, macro_doc.with_extension("docx"), None, window, cx)
|
||||
});
|
||||
let copy = macro_doc.with_extension("docx");
|
||||
written(&cx, ©, None);
|
||||
assert!(copy.exists(), "the copy writes");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(¯o_doc).expect("source"),
|
||||
body,
|
||||
"the source stays"
|
||||
);
|
||||
cx.update(|app| {
|
||||
assert!(
|
||||
!docs.read(app).active_tab().expect("a tab").risk.is_some(),
|
||||
"the DOCX copy holds no macros"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_alias_never_converts_the_open_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 source = dir.path().join("source.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, source.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &source, None);
|
||||
let before = std::fs::read(&source).expect("the odt");
|
||||
let alias = dir.path().join("alias.docx");
|
||||
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
|
||||
type_into(&mut cx, &docs, "y");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, alias.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &source, Some(&before));
|
||||
let after = std::fs::read(&source).expect("the odt");
|
||||
let has = |bytes: &[u8], mark: &[u8]| bytes.windows(mark.len()).any(|w| w == mark);
|
||||
assert!(
|
||||
has(&after, b"application/vnd.oasis.opendocument.text"),
|
||||
"the file stays ODT"
|
||||
);
|
||||
assert!(
|
||||
!has(&after, b"word/document.xml"),
|
||||
"no DOCX lands in the ODT"
|
||||
);
|
||||
assert_ne!(after, before, "the alias saved in the file's own format");
|
||||
let link = dir.path().join("link.docx");
|
||||
std::fs::hard_link(&source, &link).expect("the hard link");
|
||||
type_into(&mut cx, &docs, "z");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, link.clone(), None, window, cx)
|
||||
});
|
||||
assert_eq!(
|
||||
std::fs::read(&source).expect("the odt"),
|
||||
after,
|
||||
"a hard link never converts the open file"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&link).expect("the link"),
|
||||
after,
|
||||
"the link is refused, not replaced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outside_change_asks_and_keep_mine_overwrites() {
|
||||
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 own = dir.path().join("own.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, own.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &own, None);
|
||||
type_into(&mut cx, &docs, "Mine");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
std::fs::write(&own, "EXTERNAL REVISION").expect("the outside write");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.save(id, None, window, cx)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
matches!(docs.read(app).core.dialog, Some(Dialog::Conflict { .. }))
|
||||
});
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&own).expect("own"),
|
||||
"EXTERNAL REVISION"
|
||||
);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.keep_mine(id, None, window, cx)
|
||||
});
|
||||
written(&cx, &own, Some(b"EXTERNAL REVISION"));
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &own).contains("Mine"),
|
||||
"keep mine wrote the local version"
|
||||
);
|
||||
}
|
||||
|
||||
mod find;
|
||||
mod more;
|
||||
mod overlap;
|
||||
@@ -0,0 +1,240 @@
|
||||
use ely_gpui_component::forms::TextInput;
|
||||
use ely_office::{FindReplace, Finder, prelude::*};
|
||||
use gpui::{AppContext as _, Entity, TestAppContext};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window, written};
|
||||
|
||||
fn finder<R>(cx: &TestAppContext, docs: &Entity<Docs>, read: impl Fn(&Finder) -> R) -> R {
|
||||
cx.update(|app| {
|
||||
read(
|
||||
docs.read(app)
|
||||
.core
|
||||
.finder
|
||||
.as_ref()
|
||||
.expect("the finder is open"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn total(app: &gpui::App, docs: &Entity<Docs>) -> Option<usize> {
|
||||
docs.read(app)
|
||||
.core
|
||||
.finder
|
||||
.as_ref()
|
||||
.map(|finder| finder.all.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_counts_steps_replaces_and_counts_words() {
|
||||
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, "cat Cat cat dog");
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.extra.counts)
|
||||
.is_some_and(|counts| counts.words == 4)
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_find(&FindReplace, window, cx)
|
||||
});
|
||||
let query = finder(&cx, &docs, |finder| finder.query.clone());
|
||||
cx.update(|app| query.update(app, |query, cx| query.set_text("cat", cx)));
|
||||
settle(&cx, |app| total(app, &docs) == Some(3));
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.core.finder.as_mut().expect("open").options.case = true;
|
||||
docs.count_matches(cx);
|
||||
})
|
||||
});
|
||||
settle(&cx, |app| total(app, &docs) == Some(2));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.core
|
||||
.finder
|
||||
.as_ref()
|
||||
.is_some_and(|finder| finder.current == Some(0))
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
let finder = docs.core.finder.as_mut().expect("open");
|
||||
finder.replace = Some(cx.new(|cx| TextInput::new(window, cx)));
|
||||
});
|
||||
let with = finder(&cx, &docs, |finder| {
|
||||
finder.replace.clone().expect("a field")
|
||||
});
|
||||
cx.update(|app| with.update(app, |with, cx| with.set_text("cow", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.replace(false, cx)));
|
||||
settle(&cx, |app| total(app, &docs) == Some(1));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.replace(true, cx)));
|
||||
settle(&cx, |app| total(app, &docs) == Some(0));
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("found.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
assert!(text.contains("cow Cat cow dog"), "the cased matches go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_misspelled_word_offers_suggestions() {
|
||||
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, "Teh end");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_find(&FindReplace, window, cx)
|
||||
});
|
||||
let query = finder(&cx, &docs, |finder| finder.query.clone());
|
||||
cx.update(|app| query.update(app, |query, cx| query.set_text("Teh", cx)));
|
||||
settle(&cx, |app| total(app, &docs) == Some(1));
|
||||
let (x, y, _, h) = finder(&cx, &docs, |finder| finder.all[0]);
|
||||
let (worker, doc) = cx.update(|app| {
|
||||
let docs = docs.read(app);
|
||||
(
|
||||
docs.core.worker.clone(),
|
||||
docs.active_tab().expect("a tab").doc.clone(),
|
||||
)
|
||||
});
|
||||
{
|
||||
let mut worker = worker.lock().expect("the worker lock");
|
||||
for (kind, buttons) in [(0, 1), (1, 1), (0, 4), (1, 4)] {
|
||||
let (x, y, count, modifiers) = (x + 60, y + h / 2, 1, 0);
|
||||
let event = ely_lok::Mouse {
|
||||
kind,
|
||||
x,
|
||||
y,
|
||||
count,
|
||||
buttons,
|
||||
modifiers,
|
||||
};
|
||||
worker.post_mouse(&doc, event).expect("the click posts");
|
||||
}
|
||||
}
|
||||
settle(&cx, |app| docs.read(app).offer.is_some());
|
||||
let rule = cx.update(|app| {
|
||||
let offer = docs.read(app).offer.as_ref().expect("an offer");
|
||||
let (_, rule) = offer
|
||||
.suggestions
|
||||
.iter()
|
||||
.find(|(label, _)| label == "The")
|
||||
.expect("The is offered");
|
||||
rule.clone()
|
||||
});
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.run_active("spell.apply", &[&rule], cx)));
|
||||
settled(&cx, &docs);
|
||||
let path = dir.path().join("spelled.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
assert!(
|
||||
text_of(&mut cx, &docs, &path).contains("The end"),
|
||||
"the suggestion lands"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_mark_the_count_and_steps_scroll_to_far_matches() {
|
||||
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 filler = "<text:p>filler</text:p>".repeat(100);
|
||||
let body = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text><text:p>a b c</text:p>{filler}<text:p>needle</text:p></office:text></office:body></office:document>"#
|
||||
);
|
||||
let path = dir.path().join("long.fodt");
|
||||
std::fs::write(&path, body).expect("the file writes");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.open_file(path.clone(), cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.tabs.len() == 1);
|
||||
settled(&cx, &docs);
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_find(&FindReplace, window, cx)
|
||||
});
|
||||
let query = finder(&cx, &docs, |finder| finder.query.clone());
|
||||
cx.update(|app| query.update(app, |query, cx| query.set_text(" ", cx)));
|
||||
settle(&cx, |app| total(app, &docs) == Some(2));
|
||||
cx.update(|app| query.update(app, |query, cx| query.set_text("needle", cx)));
|
||||
settle(&cx, |app| total(app, &docs) == Some(1));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
|
||||
settle(&cx, |app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).scroll().1 > 20_000
|
||||
});
|
||||
type_into(&mut cx, &docs, "needle needle");
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.core
|
||||
.finder
|
||||
.as_ref()
|
||||
.is_some_and(|finder| finder.stale)
|
||||
});
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
|
||||
settle(&cx, |app| {
|
||||
let finder = docs.read(app).core.finder.as_ref().expect("open");
|
||||
!finder.stale && finder.all.len() == 2
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_asked_during_a_count_waits_for_it() {
|
||||
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);
|
||||
type_into(&mut cx, &docs, "cat cat cat");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.on_find(&FindReplace, window, cx)
|
||||
});
|
||||
let query = finder(&cx, &docs, |finder| finder.query.clone());
|
||||
cx.update(|app| query.update(app, |query, cx| query.set_text("cat", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.step(true, cx)));
|
||||
assert_eq!(
|
||||
finder(&cx, &docs, |finder| finder.queued.len()),
|
||||
2,
|
||||
"both steps wait for the count"
|
||||
);
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.core
|
||||
.finder
|
||||
.as_ref()
|
||||
.is_some_and(|finder| finder.all.len() == 3 && finder.current == Some(1))
|
||||
});
|
||||
cx.update(|app| query.update(app, |query, cx| query.set_text("ca", cx)));
|
||||
cx.update(|app| query.update(app, |query, cx| query.set_text("", cx)));
|
||||
settle(&cx, |app| {
|
||||
docs.read(app)
|
||||
.core
|
||||
.finder
|
||||
.as_ref()
|
||||
.is_some_and(|finder| finder.pending.is_empty())
|
||||
});
|
||||
assert_eq!(
|
||||
finder(&cx, &docs, |finder| finder.all.len()),
|
||||
0,
|
||||
"a cleared query voids the count on its way"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The report template with an embedded Basic library.
|
||||
fn macro_fodt(cx: &mut TestAppContext) -> String {
|
||||
cx.update(|app| Template::Report.fodt(app))
|
||||
.replace(
|
||||
"office:version=",
|
||||
"xmlns:script=\"urn:oasis:names:tc:opendocument:xmlns:script:1.0\" xmlns:ooo=\"http://openoffice.org/2004/office\" office:version=",
|
||||
)
|
||||
.replace(
|
||||
"<office:body>",
|
||||
"<office:scripts><office:script script:language=\"ooo:Basic\"><ooo:libraries><ooo:library-embedded ooo:name=\"Standard\"><ooo:module ooo:name=\"Module1\"><ooo:source-code>Sub Main
End Sub</ooo:source-code></ooo:module></ooo:library-embedded></ooo:libraries></office:script></office:scripts><office:body>",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_macro_checks_read_real_kernel_files() {
|
||||
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 source = dir.path().join("basic.fodt");
|
||||
std::fs::write(&source, macro_fodt(&mut cx)).expect("writes");
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
let mut worker = worker.lock().expect("the worker lock");
|
||||
let doc = worker.load(&ely_lok::file_url(&source)).expect("loads");
|
||||
let (odt, docx) = (dir.path().join("basic.odt"), dir.path().join("plain.docx"));
|
||||
doc.save_as(&mut worker, &ely_lok::file_url(&odt), "odt")
|
||||
.expect("odt");
|
||||
doc.save_as(&mut worker, &ely_lok::file_url(&docx), "docx")
|
||||
.expect("docx");
|
||||
worker.close_doc(doc).expect("closes");
|
||||
assert!(
|
||||
ely_office::files::macro_risk(&Docs::KIND, &odt).expect("odt"),
|
||||
"an ODT with Basic is a risk"
|
||||
);
|
||||
assert!(
|
||||
!ely_office::files::macro_risk(&Docs::KIND, &docx).expect("docx"),
|
||||
"a DOCX drops the Basic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_template_with_macros_opens_in_risk_mode() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let template = dir.path().join("macro-template.fodt");
|
||||
std::fs::write(&template, macro_fodt(&mut cx)).expect("writes");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.new_from_file(&template, cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.active.is_some());
|
||||
cx.update(|app| {
|
||||
assert!(
|
||||
docs.read(app).active_tab().expect("a tab").risk.is_some(),
|
||||
"the copy shows the risk"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_alone_leaves_the_tab_clean() {
|
||||
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);
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
view.update(&mut cx, |view, _| {
|
||||
view.run("edit.select_all", &[]).expect("select all queues")
|
||||
});
|
||||
settle(&cx, |app| view.read(app).settled());
|
||||
for _ in 0..40 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
cx.update(|app| {
|
||||
assert!(
|
||||
!docs.read(app).active_tab().expect("a tab").dirty(app),
|
||||
"selection is no edit"
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mine_waits_and_two_queued_saves_never_crash() {
|
||||
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 mine = dir.path().join("mine.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, mine.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &mine, None);
|
||||
type_into(&mut cx, &docs, "x");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
std::fs::write(&mine, "EXTERNAL").expect("the outside write");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.save(id, None, window, cx)
|
||||
});
|
||||
commit(&mut cx, &docs, "PendingKeepMine");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.keep_mine(id, Some(After::Tab), window, cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.tabs.is_empty());
|
||||
let saved = text_of(&mut cx, &docs, &mine);
|
||||
assert!(
|
||||
saved.contains("PendingKeepMine"),
|
||||
"keep mine saved the queued text: {}",
|
||||
saved[saved.find("<office:text").unwrap_or(0)..].replace("\n", " ")
|
||||
);
|
||||
|
||||
let id = blank(&mut cx, &docs);
|
||||
commit(&mut cx, &docs, "queued");
|
||||
let (one, two) = (dir.path().join("one.docx"), dir.path().join("two.docx"));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, one.clone(), Some(After::Tab), window, cx)
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, two.clone(), None, window, cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).core.tabs.is_empty());
|
||||
for _ in 0..40 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
assert!(
|
||||
one.exists() && !two.exists(),
|
||||
"the closed tab saves no more"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kernel_write_failure_stays_visible() {
|
||||
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, "RetainedText");
|
||||
let work = cx.update(|app| docs.read(app).active_tab().expect("a tab").work.clone());
|
||||
std::fs::remove_dir_all(&work).expect("drops the scratch");
|
||||
std::fs::write(&work, b"not a folder").expect("blocks it");
|
||||
let target = dir.path().join("failure.docx");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, target.clone(), None, window, cx)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
!tab.exporting && tab.session.state() == ely_files::SaveState::SaveFailed
|
||||
});
|
||||
assert!(!target.exists(), "the failure writes nothing");
|
||||
std::fs::remove_file(&work).expect("unblocks");
|
||||
std::fs::create_dir_all(&work).expect("the scratch returns");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, target.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &target, None);
|
||||
assert!(target.exists(), "a retry lands");
|
||||
cx.update(|app| {
|
||||
let state = docs.read(app).active_tab().expect("a tab").session.state();
|
||||
assert_eq!(
|
||||
state,
|
||||
ely_files::SaveState::Saved,
|
||||
"the retry clears the failure"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_just_landed_still_asks_and_selection_does_not() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let id = blank(&mut cx, &docs);
|
||||
commit(&mut cx, &docs, "JustLanded");
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
settle(&cx, |app| view.read(app).settled());
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.request_close(id, After::Tab, window, cx)
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. }))
|
||||
});
|
||||
assert!(
|
||||
cx.update(|app| docs.read(app).tab(id).is_some()),
|
||||
"a landed edit asks before closing"
|
||||
);
|
||||
|
||||
let id = blank(&mut cx, &docs);
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
view.update(&mut cx, |view, _| {
|
||||
view.run("edit.select_all", &[]).expect("select all queues")
|
||||
});
|
||||
settle(&cx, |app| view.read(app).settled());
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.request_close(id, After::Tab, window, cx)
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).tab(id).is_none());
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use ely_office::After;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::*;
|
||||
use crate::merge::read_csv;
|
||||
|
||||
#[test]
|
||||
fn typing_during_a_closing_save_is_never_lost() {
|
||||
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, "BeforeExport ");
|
||||
let path = dir.path().join("closing.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), Some(After::Tab), window, cx)
|
||||
});
|
||||
commit(&mut cx, &docs, "AfterExport");
|
||||
settle(&cx, |app| {
|
||||
let docs = docs.read(app);
|
||||
docs.tab(id).is_none() || matches!(docs.core.dialog, Some(Dialog::Unsaved { .. }))
|
||||
});
|
||||
let closed = cx.update(|app| docs.read(app).tab(id).is_none());
|
||||
let text = text_of(&mut cx, &docs, &path);
|
||||
assert!(text.contains("BeforeExport"), "the export saved");
|
||||
assert!(
|
||||
!closed || text.contains("AfterExport"),
|
||||
"a closed tab lost later typing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_tabs_saving_as_one_path_keep_one_owner() {
|
||||
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 first = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "FirstOwner");
|
||||
let second = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SecondOwner");
|
||||
let same = dir.path().join("same.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(first, same.clone(), None, window, cx);
|
||||
docs.write_as(second, same.clone(), None, window, cx);
|
||||
});
|
||||
written(&cx, &same, None);
|
||||
settle(&cx, |app| {
|
||||
docs.read(app).core.tabs.iter().all(|tab| !tab.exporting)
|
||||
});
|
||||
let owners: Vec<u64> = cx.update(|app| {
|
||||
let docs = docs.read(app);
|
||||
docs.core
|
||||
.tabs
|
||||
.iter()
|
||||
.filter(|tab| {
|
||||
tab.session
|
||||
.path()
|
||||
.is_some_and(|path| ely_files::same_file(path, &same).expect("comparable"))
|
||||
})
|
||||
.map(|tab| tab.id)
|
||||
.collect()
|
||||
});
|
||||
assert_eq!(owners.len(), 1, "one tab holds the file");
|
||||
let text = text_of(&mut cx, &docs, &same);
|
||||
let mark = if owners[0] == first {
|
||||
"FirstOwner"
|
||||
} else {
|
||||
"SecondOwner"
|
||||
};
|
||||
assert!(text.contains(mark), "the file is its owner's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_previews_keep_their_sources() {
|
||||
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 alpha = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceAlpha ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
let beta = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceBeta ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
settled(&cx, &docs);
|
||||
let csv = dir.path().join("two.csv");
|
||||
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
update_doc(&mut cx, &docs, |docs, cx| {
|
||||
docs.preview_record(alpha, &table, 0, cx);
|
||||
docs.preview_record(beta, &table, 1, cx);
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
let docs = docs.read(app);
|
||||
// A filled preview is a modified copy.
|
||||
docs.core.tabs.len() == 4 && docs.core.tabs[2..].iter().all(|tab| tab.modified)
|
||||
});
|
||||
let previews: Vec<u64> = cx.update(|app| {
|
||||
let tabs = &docs.read(app).core.tabs;
|
||||
tabs[2..].iter().map(|tab| tab.id).collect()
|
||||
});
|
||||
let mut texts = Vec::new();
|
||||
for (n, id) in previews.into_iter().enumerate() {
|
||||
let path = dir.path().join(format!("preview{n}.odt"));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
texts.push(text_of(&mut cx, &docs, &path));
|
||||
}
|
||||
assert!(
|
||||
texts.iter().any(|text| text.contains("SourceAlpha Ada")),
|
||||
"{texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|text| text.contains("SourceBeta Grace")),
|
||||
"{texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_clean_tab_never_waits_for_the_worker() {
|
||||
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);
|
||||
settled(&cx, &docs);
|
||||
let worker = cx.update(|app| docs.read(app).core.worker.clone());
|
||||
let (held, hold) = std::sync::mpsc::channel();
|
||||
let holder = std::thread::spawn(move || {
|
||||
let _busy = worker.lock().expect("the worker lock");
|
||||
held.send(()).expect("the test waits");
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
});
|
||||
hold.recv().expect("the lock is held");
|
||||
let started = std::time::Instant::now();
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.request_close(id, After::Tab, window, cx)
|
||||
});
|
||||
let took = started.elapsed();
|
||||
assert!(
|
||||
took < std::time::Duration::from_millis(100),
|
||||
"the close waited {took:?}"
|
||||
);
|
||||
holder.join().expect("the holder ends");
|
||||
settle(&cx, |app| docs.read(app).tab(id).is_none());
|
||||
}
|
||||
|
||||
fn update_doc(
|
||||
cx: &mut TestAppContext,
|
||||
docs: &Entity<Docs>,
|
||||
f: impl FnOnce(&mut Docs, &mut gpui::Context<Docs>),
|
||||
) {
|
||||
cx.update(|app| docs.update(app, f));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ely-mail"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,5 @@
|
||||
use ely_platform::AppId;
|
||||
|
||||
fn main() {
|
||||
ely_ui::run(AppId::Mail, ely_ui::blank);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "ely-mark"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
ely-files.workspace = true
|
||||
ely-gpui-component.workspace = true
|
||||
ely-office.workspace = true
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
ely-web-content.workspace = true
|
||||
gpui.workspace = true
|
||||
log.workspace = true
|
||||
base64.workspace = true
|
||||
futures.workspace = true
|
||||
jiff.workspace = true
|
||||
notify.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,433 @@
|
||||
//! The built page, live: each package renders offline.
|
||||
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use ely_gpui_component::theme::{ActiveTheme as _, Mode, Theme};
|
||||
use ely_platform::AppId;
|
||||
use ely_web_content::{Event, WebView};
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
/// What the page holds, each block scrolled in and mounted.
|
||||
const CHECK: &str = r#"
|
||||
(async () => {
|
||||
const view = document.querySelector('#document > div:not([hidden])') ?? document;
|
||||
const settle = () => new Promise((done) => setTimeout(done, 700));
|
||||
const seen = (node) => {
|
||||
if (!node || !node.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
|
||||
node.scrollIntoView({ block: 'nearest' });
|
||||
const box = node.getBoundingClientRect();
|
||||
if (box.width < 1 || box.height < 1) return false;
|
||||
const top = document.elementFromPoint(box.x + box.width / 2, box.y + Math.min(box.height / 2, 8));
|
||||
return Boolean(top && (node.contains(top) || top.contains(node)));
|
||||
};
|
||||
const blocks = [];
|
||||
for (const block of view.querySelectorAll('.milkdown-code-block')) {
|
||||
block.scrollIntoView({ block: 'center' });
|
||||
await settle();
|
||||
const diagram = block.querySelector('.ely-diagram svg');
|
||||
const shape = diagram?.querySelector('.node rect, .node polygon');
|
||||
const box = diagram?.getBoundingClientRect();
|
||||
blocks.push({
|
||||
language: block.querySelector('.language-button')?.textContent.trim() ?? '',
|
||||
editors: block.querySelectorAll('.cm-editor').length,
|
||||
editable: block.querySelector('.cm-content')?.contentEditable ?? 'none',
|
||||
code: seen(block.querySelector('.cm-line')),
|
||||
math: seen(block.querySelector('.preview .katex')),
|
||||
diagram: box ? [Math.round(box.width), Math.round(box.height)] : [0, 0],
|
||||
drawn: seen(diagram),
|
||||
fill: shape ? getComputedStyle(shape).fill : '',
|
||||
first: window.firstFill ?? '',
|
||||
failure: seen(block.querySelector('.ely-failure')) ? block.querySelector('.ely-failure').textContent : '',
|
||||
});
|
||||
}
|
||||
document.scrollingElement.scrollTop = 0;
|
||||
await settle();
|
||||
const root = document.getElementById('document');
|
||||
window.elyQuery({
|
||||
request: JSON.stringify({
|
||||
step: 'STEP',
|
||||
mode: root.dataset.mode,
|
||||
editable: view.querySelector('.ProseMirror')?.contentEditable ?? 'none',
|
||||
title: view.querySelector('.milkdown h1')?.textContent ?? '',
|
||||
heading: seen(view.querySelector('.milkdown h1')),
|
||||
inline: seen(view.querySelector('.milkdown p .katex')),
|
||||
display: seen(view.querySelector('.milkdown p .katex-display')),
|
||||
tasks: view.querySelectorAll('li[data-item-type="task"]').length,
|
||||
cells: view.querySelectorAll('.milkdown td').length,
|
||||
notes: view.querySelectorAll('[data-type="footnote_definition"]').length,
|
||||
blocks,
|
||||
font: [...document.fonts].some((f) => f.family === 'KaTeX_Main' && f.status === 'loaded'),
|
||||
source: seen(view.querySelector(':scope > .cm-editor .cm-line')),
|
||||
first: view.querySelector('.cm-line')?.textContent ?? '',
|
||||
editors: document.querySelectorAll('#document > div:not([hidden])').length,
|
||||
text: view.textContent.slice(0, 40),
|
||||
bg: getComputedStyle(document.documentElement).getPropertyValue('--ely-bg').trim(),
|
||||
}),
|
||||
onSuccess: () => {},
|
||||
onFailure: () => {},
|
||||
});
|
||||
})();
|
||||
"#;
|
||||
|
||||
const TEXTS: &str = r#"{"searchLanguage":"s","noResult":"n","copy":"c","preview":"p","loading":"l","edit":"e","hide":"h"}"#;
|
||||
|
||||
/// Steps in order; each reports once.
|
||||
const STEPS: [&str; 13] = [
|
||||
"visual", "themed", "preview", "source", "race", "language", "midtheme", "release", "asked",
|
||||
"stale", "owner", "find", "look",
|
||||
];
|
||||
|
||||
/// Empty and broken queries: errors, never CodeMirror's own panel.
|
||||
const FIND: &str = r#"
|
||||
(async () => {
|
||||
const report = {};
|
||||
for (const mode of ['source', 'visual']) {
|
||||
await ely.open('a{b alpha\n', mode, TEXTS, 1, location.href);
|
||||
const empty = { search: '' };
|
||||
ely.find(empty);
|
||||
ely.step({ spec: empty, forward: true });
|
||||
ely.replace({ spec: empty, all: false });
|
||||
for (const search of ['a{', '\\a']) ely.find({ search, regexp: true });
|
||||
ely.unfind();
|
||||
report[mode] = document.querySelectorAll('.cm-panels .cm-search').length;
|
||||
await ely.open('\u{1F600} alpha\n', mode, TEXTS, 1, location.href);
|
||||
ely.find({ search: '(?=.)', regexp: true });
|
||||
ely.unfind();
|
||||
}
|
||||
await new Promise((done) => setTimeout(done, 300));
|
||||
window.elyQuery({ request: JSON.stringify({ step: 'find', panels: report }), onSuccess: () => {}, onFailure: () => {} });
|
||||
})();
|
||||
"#;
|
||||
|
||||
/// The writing view: dim blocks, narrow serif column, centered caret.
|
||||
const LOOK: &str = r#"
|
||||
(async () => {
|
||||
const paragraphs = Array.from({ length: 80 }, (_, n) => `Paragraph ${n + 1} of the look.`)
|
||||
await ely.open(paragraphs.join('\n\n') + '\n', 'visual', TEXTS, 1, location.href)
|
||||
ely.look({ focus: true, typewriter: true, width: 'narrow', font: 'serif', size: 120 })
|
||||
ely.reveal({ line: 159, query: '' })
|
||||
await new Promise((done) => setTimeout(done, 600))
|
||||
const blocks = [...document.querySelectorAll('.milkdown .ProseMirror > *')]
|
||||
const dim = blocks.filter((block) => getComputedStyle(block).opacity === '0.35').length
|
||||
const bright = document.querySelector('.ely-current')
|
||||
const caret = window.getSelection().getRangeAt(0).getBoundingClientRect()
|
||||
const page = document.getElementById('document')
|
||||
const report = {
|
||||
step: 'look',
|
||||
blocks: blocks.length,
|
||||
dim,
|
||||
bright: bright ? getComputedStyle(bright).opacity : 'none',
|
||||
centered: Math.abs(caret.top - window.innerHeight / 2) < window.innerHeight / 4,
|
||||
width: getComputedStyle(page).maxWidth,
|
||||
serif: getComputedStyle(page).fontFamily.includes('serif'),
|
||||
}
|
||||
ely.look({ focus: false, typewriter: false, width: 'medium', font: 'sans', size: 100 })
|
||||
window.elyQuery({ request: JSON.stringify(report), onSuccess: () => {}, onFailure: () => {} })
|
||||
})();
|
||||
"#;
|
||||
|
||||
/// Picks Rust in the first block's list, once mounted.
|
||||
const TO_RUST: &str = r#"
|
||||
(function pick() {
|
||||
const block = document.querySelector('#document > div:not([hidden]) .milkdown-code-block');
|
||||
const button = block?.querySelector('.language-button');
|
||||
if (!button) return requestAnimationFrame(pick);
|
||||
button.click();
|
||||
setTimeout(() => [...block.querySelectorAll('.language-list-item')]
|
||||
.find((item) => item.textContent.trim() === 'Rust').click(), 30);
|
||||
})();
|
||||
"#;
|
||||
|
||||
/// Says when a diagram waits, drawn not yet.
|
||||
const PENDING: &str = r#"
|
||||
(function wait() {
|
||||
if (!document.querySelector('.ely-diagram:empty')) return requestAnimationFrame(wait);
|
||||
new MutationObserver((_, watch) => {
|
||||
const shape = document.querySelector('.ely-diagram svg .node rect');
|
||||
if (!shape) return;
|
||||
window.firstFill = getComputedStyle(shape).fill;
|
||||
watch.disconnect();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
window.elyQuery({ request: JSON.stringify({ kind: 'pending' }), onSuccess: () => {}, onFailure: () => {} });
|
||||
})();
|
||||
"#;
|
||||
|
||||
/// A diagram slow enough to change things mid-draw.
|
||||
fn big() -> String {
|
||||
let mut source = String::from("```mermaid\ngraph TD\n");
|
||||
for at in 0..150 {
|
||||
source += &format!(" N{at} --> N{}\n N{at} --> N{}\n", at + 1, (at * 7) % 151);
|
||||
}
|
||||
serde_json::to_string(&(source + "```\n")).expect("json")
|
||||
}
|
||||
|
||||
/// Short sources, slow draws: blocks mount at once.
|
||||
fn chains(blocks: usize) -> String {
|
||||
let mut source = String::new();
|
||||
for block in 0..blocks {
|
||||
let side = |name: &str| {
|
||||
let nodes: Vec<String> = (0..20).map(|at| format!("{name}{block}x{at}")).collect();
|
||||
nodes.join(" & ")
|
||||
};
|
||||
source += &format!(
|
||||
"```mermaid\ngraph LR\n{} --> {}\n```\n\n",
|
||||
side("A"),
|
||||
side("B")
|
||||
);
|
||||
}
|
||||
serde_json::to_string(&source).expect("json")
|
||||
}
|
||||
|
||||
/// Leaves for source mid-draw; counts long stalls after.
|
||||
const RELEASE: &str = r#"
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
await ely.open(BIG, 'preview', TEXTS, 1, location.href);
|
||||
await sleep(50);
|
||||
await ely.open('x', 'source', TEXTS, 1, location.href);
|
||||
let last = performance.now();
|
||||
let long = 0;
|
||||
const tick = setInterval(() => {
|
||||
const now = performance.now();
|
||||
if (now - last > 500) long += 1;
|
||||
last = now;
|
||||
}, 20);
|
||||
await sleep(7000);
|
||||
clearInterval(tick);
|
||||
window.elyQuery({ request: JSON.stringify({ step: 'release', long }), onSuccess: () => {}, onFailure: () => {} });
|
||||
})();
|
||||
"#;
|
||||
|
||||
fn flip(cx: &mut App) {
|
||||
let mode = match cx.theme().is_dark() {
|
||||
true => Mode::Light,
|
||||
false => Mode::Dark,
|
||||
};
|
||||
Theme::set_mode_now(mode, cx);
|
||||
}
|
||||
|
||||
struct Probe {
|
||||
view: Entity<WebView>,
|
||||
step: usize,
|
||||
/// A fresh page: Mermaid's bundle not yet loaded.
|
||||
fresh: bool,
|
||||
/// Find reports that carried an error, and every total.
|
||||
errors: usize,
|
||||
totals: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
/// Starts the current step; its check follows.
|
||||
fn begin(&mut self, cx: &mut Context<Self>) {
|
||||
let markdown =
|
||||
serde_json::to_string(include_str!("../../../tests/fixtures/mark/extended.md"))
|
||||
.expect("json");
|
||||
let step = STEPS[self.step];
|
||||
match step {
|
||||
"themed" => {
|
||||
flip(cx);
|
||||
self.check(step, 2500, cx);
|
||||
}
|
||||
// Two quick shows: only the last may stay.
|
||||
"race" => {
|
||||
self.view.read(cx).run_js(&format!(
|
||||
"ely.open('# OLD', 'visual', {TEXTS}, 1, location.href); \
|
||||
ely.open('# NEW', 'source', {TEXTS}, 1, location.href)"
|
||||
));
|
||||
self.check(step, 2500, cx);
|
||||
}
|
||||
// Two modes asked at once: the last one shows.
|
||||
"asked" => {
|
||||
self.view.read(cx).run_js(&format!(
|
||||
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
|
||||
.then(() => {{ ely.mode('visual'); ely.mode('preview') }})"
|
||||
));
|
||||
self.check(step, 3000, cx);
|
||||
}
|
||||
// A request while visual's shown is unanswered wins.
|
||||
"stale" => {
|
||||
self.view.read(cx).run_js(&format!(
|
||||
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
|
||||
.then(() => {{ ely.mode('visual'); ely.mode('preview') }})"
|
||||
));
|
||||
self.check(step, 3000, cx);
|
||||
}
|
||||
// An old answer leaves a newer build's request alone.
|
||||
"owner" => {
|
||||
self.view.read(cx).run_js(&format!(
|
||||
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
|
||||
.then(() => ely.mode('visual'))"
|
||||
));
|
||||
self.check(step, 3000, cx);
|
||||
}
|
||||
"find" => self.view.read(cx).run_js(&FIND.replace("TEXTS", TEXTS)),
|
||||
"look" => self.view.read(cx).run_js(&LOOK.replace("TEXTS", TEXTS)),
|
||||
"release" => {
|
||||
let script = RELEASE.replace("BIG", &chains(3)).replace("TEXTS", TEXTS);
|
||||
self.view.read(cx).run_js(&script);
|
||||
}
|
||||
// Changes land while the bundle still loads.
|
||||
"language" | "midtheme" if !self.fresh => {
|
||||
self.fresh = true;
|
||||
self.view.read(cx).run_js("location.reload()");
|
||||
}
|
||||
"language" | "midtheme" => {
|
||||
self.fresh = false;
|
||||
let show = format!("ely.open({}, 'visual', {TEXTS}, 1, location.href)", big());
|
||||
self.view.read(cx).run_js(&show);
|
||||
}
|
||||
mode => self.view.read(cx).run_js(&format!(
|
||||
"ely.open({markdown}, '{mode}', {TEXTS}, 1, location.href)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks after `wait`, when drawing is done.
|
||||
fn check(&self, step: &'static str, wait: u64, cx: &mut Context<Self>) {
|
||||
let view = self.view.clone();
|
||||
cx.spawn(async move |_, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(wait))
|
||||
.await;
|
||||
view.update(cx, |view, _| view.run_js(&CHECK.replace("STEP", step)));
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn heard(
|
||||
&mut self,
|
||||
_: &Entity<WebView>,
|
||||
event: &Event,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match event {
|
||||
Event::Loaded => self.begin(cx),
|
||||
Event::Query(said, reply) => {
|
||||
let read: serde_json::Value = serde_json::from_str(said).expect("json");
|
||||
// Some answers wait; meanwhile newer modes are asked.
|
||||
let (wait, then) = match (STEPS.get(self.step), read["mode"].as_str()) {
|
||||
(Some(&"stale"), Some("visual")) => (300, "ely.mode('source')"),
|
||||
(Some(&"owner"), Some("visual")) => {
|
||||
(300, "ely.mode('preview'); ely.mode('source')")
|
||||
}
|
||||
(Some(&"owner"), Some("preview")) => (600, ""),
|
||||
_ => (0, ""),
|
||||
};
|
||||
if !then.is_empty() {
|
||||
self.view.read(cx).run_js(then);
|
||||
}
|
||||
let reply = reply.clone();
|
||||
cx.spawn(async move |_, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(wait))
|
||||
.await;
|
||||
reply.ok("");
|
||||
})
|
||||
.detach();
|
||||
if read["kind"] == "shown" {
|
||||
println!("mark: shown {}", read["mode"]);
|
||||
let Some(&step) = STEPS.get(self.step) else {
|
||||
return;
|
||||
};
|
||||
// Mid-draw: a new language, or a new theme.
|
||||
match step {
|
||||
"language" => self.view.read(cx).run_js(TO_RUST),
|
||||
"midtheme" => return self.view.read(cx).run_js(PENDING),
|
||||
_ if step == read["mode"] => {}
|
||||
_ => return,
|
||||
}
|
||||
self.check(step, 5000, cx);
|
||||
return;
|
||||
}
|
||||
if read["kind"] == "found" {
|
||||
self.errors += read["error"].is_string() as usize;
|
||||
self.totals.extend(read["total"].as_u64());
|
||||
return;
|
||||
}
|
||||
// Edits report too; the session tests them.
|
||||
if ["changed", "text", "outline", "place"]
|
||||
.contains(&read["kind"].as_str().unwrap_or_default())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if read["kind"] == "pending" {
|
||||
// Its draw takes seconds; flip once it has begun.
|
||||
println!("mark: pending");
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(600))
|
||||
.await;
|
||||
this.update(cx, |probe, cx| {
|
||||
flip(cx);
|
||||
probe.check("midtheme", 6000, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
return;
|
||||
}
|
||||
let mut read = read;
|
||||
if read["step"] == "find" {
|
||||
read["errors"] = self.errors.into();
|
||||
read["totals"] = self.totals.clone().into();
|
||||
}
|
||||
println!("mark: {read}");
|
||||
self.step += 1;
|
||||
if self.step == STEPS.len() {
|
||||
println!("mark: done");
|
||||
return cx.quit();
|
||||
}
|
||||
// The first view stays a while for the window's pixels.
|
||||
let wait = if self.step == 1 { 3000 } else { 0 };
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(wait))
|
||||
.await;
|
||||
this.update(cx, |probe, cx| probe.begin(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Event::Leave(url) => println!("mark: leave {url}"),
|
||||
Event::Printed(path, ok) => println!("mark: printed {} {ok}", path.display()),
|
||||
Event::Failed(why) => {
|
||||
println!("mark: failed {why}");
|
||||
cx.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Probe {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.view.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<ely_ui::Root> {
|
||||
let work = tempfile::tempdir().expect("a temp dir").keep();
|
||||
ely_web_content::start(&work, cx).expect("CEF starts");
|
||||
let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
|
||||
let view = WebView::open(&content.join("index.html"), &content, window, cx).expect("a view");
|
||||
let probe = cx.new(|cx| {
|
||||
cx.subscribe_in(&view, window, Probe::heard).detach();
|
||||
Probe {
|
||||
view,
|
||||
step: 0,
|
||||
fresh: false,
|
||||
errors: 0,
|
||||
totals: Vec::new(),
|
||||
}
|
||||
});
|
||||
ely_ui::root(app, Some(probe.into()), window, cx)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Some(code) = ely_web_content::subprocess() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
ely_ui::run(AppId::Mark, open);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Images dropped or pasted, copied beside the file (MARK-07).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
io::Write as _,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{App, Context, WeakEntity, Window};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::view::{Failed, MarkView};
|
||||
|
||||
/// The folder beside the file that takes its images.
|
||||
pub const FOLDER: &str = "assets";
|
||||
|
||||
const IMAGES: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
|
||||
|
||||
fn image(path: &Path) -> bool {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default();
|
||||
IMAGES.contains(&extension.to_lowercase().as_str())
|
||||
}
|
||||
|
||||
/// A relative link to a file in the folder, escaped.
|
||||
pub fn link(name: &str) -> String {
|
||||
format!("{FOLDER}/{}", crate::paper::segment(name))
|
||||
}
|
||||
|
||||
/// Writes `png` into `dir` under a new name.
|
||||
fn save_png(dir: &Path, png: &[u8]) -> io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(io::Error::other)?
|
||||
.as_millis();
|
||||
let mut n = 0;
|
||||
loop {
|
||||
let name = match n {
|
||||
0 => format!("pasted-{stamp}.png"),
|
||||
n => format!("pasted-{stamp}-{n}.png"),
|
||||
};
|
||||
let path = dir.join(name);
|
||||
match std::fs::File::create_new(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(png)?;
|
||||
file.flush()?;
|
||||
return Ok(path);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => n += 1,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The paste hook: an image on the clipboard is attached.
|
||||
pub fn paste(view: WeakEntity<MarkView>) -> impl Fn(&mut Window, &mut App) -> bool {
|
||||
move |_, cx| match clipboard::read() {
|
||||
Ok(Some(Clip::Png(png))) => {
|
||||
view.update(cx, |view, cx| view.attach(Attach::Png(png), cx))
|
||||
.ok();
|
||||
true
|
||||
}
|
||||
Ok(_) => false,
|
||||
Err(error) => {
|
||||
log::error!("mark: the clipboard did not read: {error:#}");
|
||||
let why = format!("{error:#}");
|
||||
view.update(cx, |_, cx| cx.emit(Failed(why))).ok();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Attach {
|
||||
Files(Vec<PathBuf>),
|
||||
Png(Vec<u8>),
|
||||
}
|
||||
|
||||
impl MarkView {
|
||||
/// Copies into the file's folder, then links at the caret.
|
||||
pub fn attach(&mut self, what: Attach, cx: &mut Context<Self>) {
|
||||
let refused = match (&what, self.attachable, self.mode) {
|
||||
(_, false, _) => Some("mark.attach.untitled"),
|
||||
(_, _, crate::page::Mode::Preview) => Some("mark.attach.preview"),
|
||||
(Attach::Files(files), ..) if !files.iter().all(|file| image(file)) => {
|
||||
Some("mark.attach.images")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = refused {
|
||||
return cx.emit(Failed(text(cx, key, &[]).to_string()));
|
||||
}
|
||||
let folder = self.folder.clone();
|
||||
let dir = folder.join(FOLDER);
|
||||
cx.spawn(async move |view, cx| {
|
||||
let copied = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
match what {
|
||||
Attach::Files(files) => files
|
||||
.iter()
|
||||
.map(|file| ely_files::import_attachment(file, &dir))
|
||||
.collect(),
|
||||
Attach::Png(png) => save_png(&dir, &png).map(|path| vec![path]),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
view.update(cx, |view, cx| view.attached(&folder, copied, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn attached(
|
||||
&mut self,
|
||||
folder: &Path,
|
||||
copied: io::Result<Vec<PathBuf>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let copied = match copied {
|
||||
Ok(copied) => copied,
|
||||
Err(error) => {
|
||||
log::error!("mark: an image did not copy: {error}");
|
||||
return cx.emit(Failed(error.to_string()));
|
||||
}
|
||||
};
|
||||
// Links resolve from the file's folder; it moved meanwhile.
|
||||
if self.folder != folder {
|
||||
log::warn!("mark: the file moved while images copied; no links");
|
||||
let at = folder.join(FOLDER).display().to_string();
|
||||
return cx.emit(Failed(
|
||||
text(cx, "mark.attach.moved", &[("folder", &at)]).to_string(),
|
||||
));
|
||||
}
|
||||
log::info!("mark: attached {} images", copied.len());
|
||||
let images: Vec<_> = copied
|
||||
.iter()
|
||||
.filter_map(|path| {
|
||||
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||
let alt = path.file_stem()?.to_string_lossy().into_owned();
|
||||
Some(json!({ "src": link(&name), "alt": alt }))
|
||||
})
|
||||
.collect();
|
||||
crate::page::call(self.web.read(cx), "insert", &json!(images));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn links_escape_and_names_never_repeat() {
|
||||
assert_eq!(super::link("a b#1.png"), "assets/a%20b%231.png");
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let first = super::save_png(dir.path(), b"x").expect("saves");
|
||||
let second = super::save_png(dir.path(), b"y").expect("saves");
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(std::fs::read(first).expect("reads"), b"x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! ElyMark saves a second after typing stops (§6.2).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_files::{Action, DEBOUNCE, SaveState};
|
||||
use ely_office::{in_window, prelude::*};
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::outside::Outside;
|
||||
|
||||
impl Desk {
|
||||
/// A page change restarts the wait; finished saves report.
|
||||
pub fn autosave(&mut self, id: u64, changed: u64, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
let saving = tab.exporting || tab.session.state() == SaveState::Saving;
|
||||
if let Some(revision) = tab.extra.writing.filter(|_| !saving) {
|
||||
let ok = tab.session.state() != SaveState::SaveFailed;
|
||||
tab.extra.policy.save_finished(revision, ok);
|
||||
tab.extra.writing = None;
|
||||
}
|
||||
if tab.session.path().is_some() {
|
||||
tab.extra.policy.set_has_path();
|
||||
}
|
||||
// A page reload restarts its count: any difference is news.
|
||||
if changed == tab.extra.heard {
|
||||
return;
|
||||
}
|
||||
tab.extra.heard = changed;
|
||||
tab.extra.policy.edit(Instant::now());
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let wait = DEBOUNCE + Duration::from_millis(50);
|
||||
cx.background_executor().timer(wait).await;
|
||||
in_window(&desk, cx, |desk, window, cx| {
|
||||
desk.autosave_due(id, window, cx)
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn autosave_due(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
if !matches!(tab.extra.outside, Outside::Clear) || tab.extra.writing.is_some() {
|
||||
return;
|
||||
}
|
||||
let Action::SaveDue(revision) = tab.extra.policy.tick(Instant::now()) else {
|
||||
return;
|
||||
};
|
||||
if !tab.dirty(cx) {
|
||||
return tab.extra.policy.save_finished(revision, true);
|
||||
}
|
||||
log::info!("mark: autosaves tab {id}");
|
||||
tab.extra.writing = Some(revision);
|
||||
self.save(id, None, window, cx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The disk's version against unsaved text, over their base (§14.4).
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use ely_files::{Fingerprint, Policy, PolicyEngine};
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
git::{self as merging, Region, Take, ThreeWayMerge},
|
||||
overlays::Dialog,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{AnyElement, Context, IntoElement as _, ParentElement as _, Styled as _, Window, div};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::outside::Outside;
|
||||
use crate::text::Source;
|
||||
|
||||
/// A comparison open over tab `id`.
|
||||
pub struct Comparing {
|
||||
pub id: u64,
|
||||
disk: Source,
|
||||
/// The disk as read; a choice checks it first.
|
||||
fingerprint: Fingerprint,
|
||||
regions: Rc<Vec<Region>>,
|
||||
takes: Vec<Option<Take>>,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Mine, the base and the disk's, side by side.
|
||||
pub fn compare(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
// The page's newest text first.
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |desk, _, cx| desk.compare(id, cx));
|
||||
}
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let Outside::Changed(disk, fingerprint) = &tab.extra.outside else {
|
||||
return;
|
||||
};
|
||||
let mine = tab.doc.lock().expect("the text lock").current.clone();
|
||||
let regions = merging::regions(&tab.extra.base, &mine, &disk.text);
|
||||
let conflicts = regions
|
||||
.iter()
|
||||
.filter(|region| region.kind == merging::RegionKind::Conflict)
|
||||
.count();
|
||||
log::info!("mark: compares tab {id}: {conflicts} conflicts");
|
||||
self.comparing = Some(Comparing {
|
||||
id,
|
||||
disk: disk.clone(),
|
||||
fingerprint: *fingerprint,
|
||||
regions: Rc::new(regions),
|
||||
takes: vec![None; conflicts],
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn take(&mut self, conflict: usize, take: Take, cx: &mut Context<Self>) {
|
||||
if let Some(slot) = self
|
||||
.comparing
|
||||
.as_mut()
|
||||
.and_then(|comparing| comparing.takes.get_mut(conflict))
|
||||
{
|
||||
*slot = Some(take);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Settles tab `id`: the disk is the new base.
|
||||
fn settle(&mut self, choice: Choice, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(comparing) = self.comparing.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let id = comparing.id;
|
||||
let now = self
|
||||
.tab(id)
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| Fingerprint::of(path).ok());
|
||||
// The disk moved on: compare again, never write blind.
|
||||
if now != Some(comparing.fingerprint) {
|
||||
log::warn!("mark: tab {id} changed on disk during the comparison");
|
||||
let said = text(cx, "mark.compare.again", &[]);
|
||||
self.toast(said, Severity::Warning, cx);
|
||||
return self.look(id, cx);
|
||||
}
|
||||
let Some(Comparing {
|
||||
id,
|
||||
disk,
|
||||
fingerprint,
|
||||
regions,
|
||||
takes,
|
||||
}) = self.comparing.take()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
log::info!("mark: tab {id} settles with {choice:?}");
|
||||
match choice {
|
||||
Choice::Disk => self.take_disk(id, disk, fingerprint, cx),
|
||||
// The checked save: a later disk change conflicts again.
|
||||
Choice::Mine => {
|
||||
if let Some(tab) = self.tab_mut(id) {
|
||||
tab.session.rebase(fingerprint);
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
}
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
self.save(id, None, window, cx);
|
||||
}
|
||||
Choice::Merged => {
|
||||
let (merged, open) = merging::result(®ions, &takes);
|
||||
if open > 0 {
|
||||
return log::error!("mark: a merge with {open} open conflicts");
|
||||
}
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
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.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compare_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let comparing = self.comparing.as_ref()?;
|
||||
let name = self.tab(comparing.id)?.name.to_string();
|
||||
let this = cx.entity();
|
||||
let labels = [
|
||||
"mark.compare.mine",
|
||||
"mark.compare.base",
|
||||
"mark.compare.disk",
|
||||
]
|
||||
.map(|key| text(cx, key, &[]).to_string());
|
||||
let merge = ThreeWayMerge::new(
|
||||
"mark-compare",
|
||||
[&labels[0], &labels[1], &labels[2]],
|
||||
comparing.regions.clone(),
|
||||
)
|
||||
.takes(comparing.takes.clone())
|
||||
.on_take({
|
||||
let this = this.clone();
|
||||
move |conflict, take, _, cx| this.update(cx, |desk, cx| desk.take(conflict, take, cx))
|
||||
});
|
||||
let open = comparing.takes.iter().filter(|take| take.is_none()).count();
|
||||
let title = text(cx, "mark.compare.title", &[("name", &name)]);
|
||||
let close = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut gpui::App| {
|
||||
this.update(cx, |desk, cx| {
|
||||
desk.comparing = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let mut dialog = Dialog::new("mark-compare-dialog", title, close)
|
||||
.fullscreen()
|
||||
.child(div().flex_1().min_h_0().size_full().child(merge));
|
||||
for (choice, key, variant) in [
|
||||
(
|
||||
Choice::Disk,
|
||||
"mark.compare.use_disk",
|
||||
ButtonVariant::Secondary,
|
||||
),
|
||||
(
|
||||
Choice::Mine,
|
||||
"mark.compare.keep_mine",
|
||||
ButtonVariant::Secondary,
|
||||
),
|
||||
(
|
||||
Choice::Merged,
|
||||
"mark.compare.use_merged",
|
||||
ButtonVariant::Primary,
|
||||
),
|
||||
] {
|
||||
let label = text(cx, key, &[]);
|
||||
let this = this.clone();
|
||||
let disabled = choice == Choice::Merged && open > 0;
|
||||
dialog = dialog.action(move |_| {
|
||||
Button::new(key, label)
|
||||
.variant(variant)
|
||||
.disabled(disabled)
|
||||
.on_click(move |_, window, cx| {
|
||||
this.update(cx, |desk, cx| desk.settle(choice, window, cx))
|
||||
})
|
||||
});
|
||||
}
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// How a comparison ends.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Choice {
|
||||
Disk,
|
||||
Mine,
|
||||
Merged,
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Markdown files in the shared shell (§14).
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
feedback::EmptyState,
|
||||
forms::TextInput,
|
||||
primitives::IconName,
|
||||
shell::{StatusBar, StatusBarItem},
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::{Core, Host, Kind, prelude::*};
|
||||
use ely_platform::{AppId, startup};
|
||||
use ely_ui::{Root, Shell, commands, i18n::text};
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, PathPromptOptions, Render, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::engine::{Mark, Pages};
|
||||
use crate::keys::{
|
||||
CheckDisk, ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, PreviewMode,
|
||||
RowAbove, RowBelow, SourceMode, VisualMode,
|
||||
};
|
||||
use crate::page::Mode;
|
||||
use crate::view::MarkView;
|
||||
|
||||
pub struct Desk {
|
||||
pub core: Core<Desk>,
|
||||
/// The tab being renamed and its name field.
|
||||
pub naming: Option<(u64, Entity<TextInput>)>,
|
||||
pub folder: Option<crate::folder::Folder>,
|
||||
/// Folder listings so far; searches redo on a new one.
|
||||
pub listings: u64,
|
||||
/// Files and folders opened lately, newest first.
|
||||
pub recent: Vec<PathBuf>,
|
||||
pub recent_file: PathBuf,
|
||||
/// A file asked to open at a place.
|
||||
pub going: Option<(PathBuf, serde_json::Value)>,
|
||||
/// The path each tab last put in the recent list.
|
||||
pub remembered: HashMap<u64, PathBuf>,
|
||||
side: Entity<crate::side::Side>,
|
||||
pub finder: Option<crate::finding::Finder>,
|
||||
pub watcher: Option<notify::RecommendedWatcher>,
|
||||
/// The folders the watcher covers.
|
||||
pub watched: Vec<PathBuf>,
|
||||
pub comparing: Option<crate::compare::Comparing>,
|
||||
pub keeping: crate::restore::Keeping,
|
||||
/// The export dialog's choice, while it is open.
|
||||
pub output: Option<crate::export::Output>,
|
||||
pub printing: Option<crate::export::Printing>,
|
||||
pub look: crate::look::Look,
|
||||
pub look_file: PathBuf,
|
||||
/// Git's word on the shown file, and which tab asked.
|
||||
pub tracked: Option<crate::vcs::Tracked>,
|
||||
asked: Option<u64>,
|
||||
/// The shown file's fingerprint when Git was last asked.
|
||||
asked_print: Option<ely_files::Fingerprint>,
|
||||
}
|
||||
|
||||
impl Host for Desk {
|
||||
type Extra = crate::outside::Extra;
|
||||
type Engine = Mark;
|
||||
const KIND: Kind = Kind {
|
||||
app: AppId::Mark,
|
||||
kept: &["md", "markdown"],
|
||||
save_as: &["md"],
|
||||
macros: None,
|
||||
};
|
||||
|
||||
fn core(&self) -> &Core<Self> {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core<Self> {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
/// A quit or close stopped: the kept list follows again.
|
||||
fn close_cancelled(&mut self, cx: &mut Context<Self>) {
|
||||
self.keeping.leaving = false;
|
||||
self.keep(cx);
|
||||
}
|
||||
|
||||
/// An autosave met an outside change: compare, never guess.
|
||||
fn conflicted(&mut self, id: u64, cx: &mut Context<Self>) -> bool {
|
||||
log::info!("mark: a save of tab {id} met an outside change");
|
||||
if let Some(tab) = self.tab_mut(id) {
|
||||
tab.extra.policy.external_modified();
|
||||
}
|
||||
self.look(id, cx);
|
||||
true
|
||||
}
|
||||
|
||||
/// A closed tab's unsaved text is gone for good (§6.2).
|
||||
fn tab_closed(&mut self, tab: &ely_office::Tab<Self::Extra, Mark>, cx: &mut Context<Self>) {
|
||||
let path = tab.session.path().map(std::path::Path::to_path_buf);
|
||||
self.drop_slot(&tab.extra.slot, path, tab.name.to_string(), cx);
|
||||
}
|
||||
|
||||
/// Save As or rename moves where images come from.
|
||||
fn observed(&mut self, id: u64, view: &Entity<MarkView>, cx: &mut Context<Self>) {
|
||||
self.watch_folders(cx);
|
||||
let look = serde_json::to_value(&self.look).expect("a look serializes");
|
||||
view.update(cx, |view, cx| view.set_look(look, cx));
|
||||
let changed = view.read(cx).changed;
|
||||
self.autosave(id, changed, cx);
|
||||
let recheck = self.tab_mut(id).is_some_and(|tab| {
|
||||
if !tab.dirty(cx) {
|
||||
tab.extra.base = tab.doc.lock().expect("the text lock").current.clone();
|
||||
}
|
||||
tab.extra.recheck
|
||||
});
|
||||
if recheck {
|
||||
self.look(id, cx);
|
||||
}
|
||||
if view.read(cx).shown {
|
||||
self.restore_shown(id, cx);
|
||||
}
|
||||
// A save changes what Git sees.
|
||||
let print = self.active_tab().and_then(|tab| tab.session.fingerprint());
|
||||
if print != self.asked_print {
|
||||
self.asked_print = print;
|
||||
self.ask_git(cx);
|
||||
}
|
||||
let path = self
|
||||
.tab(id)
|
||||
.and_then(|tab| tab.session.path().map(PathBuf::from));
|
||||
if let Some(path) = path {
|
||||
view.update(cx, |view, _| view.attachable = true);
|
||||
if let Some(folder) = path.parent() {
|
||||
view.update(cx, |view, cx| view.moved(folder.to_path_buf(), cx));
|
||||
}
|
||||
self.track_recent(id, &path, cx);
|
||||
let real = std::fs::canonicalize(&path).ok();
|
||||
if self
|
||||
.going
|
||||
.as_ref()
|
||||
.is_some_and(|(file, _)| Some(file) == real.as_ref())
|
||||
&& let Some((_, place)) = self.going.take()
|
||||
{
|
||||
view.update(cx, |view, cx| view.reveal(place, cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// The window root; files given at launch open.
|
||||
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let fail = |error: String| -> ! { startup::fail(app, &error) };
|
||||
let data = ely_platform::data_dir(app).unwrap_or_else(|error| fail(format!("{error:#}")));
|
||||
if let Err(error) = ely_web_content::start(&data, cx) {
|
||||
fail(format!("{error:#}"));
|
||||
}
|
||||
let dir = crate::page::content().unwrap_or_else(|error| fail(format!("{error:#}")));
|
||||
let work = ely_office::fresh_work(&data).unwrap_or_else(|error| fail(format!("{error}")));
|
||||
crate::keys::bind(cx);
|
||||
let pages = Arc::new(Mutex::new(Pages { dir }));
|
||||
let recent_file = data.join("recent.json");
|
||||
let look_file = data.join("look.json");
|
||||
// An unreadable look is reported; the default shows.
|
||||
let (look, look_unread) = match crate::look::load(&look_file) {
|
||||
Ok(look) => (look, None),
|
||||
Err(error) => (crate::look::Look::default(), Some(error)),
|
||||
};
|
||||
let (keeping, kept, missing) =
|
||||
crate::restore::Keeping::start(&data).unwrap_or_else(|error| fail(format!("{error}")));
|
||||
// An unreadable list is reported, never overwritten.
|
||||
let (recent, unread) = match ely_files::recent::recent(&recent_file) {
|
||||
Ok(list) => (list, None),
|
||||
Err(error) => (Vec::new(), Some(error)),
|
||||
};
|
||||
let desk = cx.new(|cx| {
|
||||
let this = cx.entity();
|
||||
let side = cx.new(|cx| crate::side::Side::new(&this, window, cx));
|
||||
Desk {
|
||||
core: Core::new(pages, work, cx),
|
||||
naming: None,
|
||||
folder: None,
|
||||
listings: 0,
|
||||
recent,
|
||||
recent_file,
|
||||
going: None,
|
||||
remembered: HashMap::new(),
|
||||
side,
|
||||
finder: None,
|
||||
watcher: None,
|
||||
watched: Vec::new(),
|
||||
comparing: None,
|
||||
keeping,
|
||||
output: None,
|
||||
printing: None,
|
||||
look: look.clone(),
|
||||
look_file,
|
||||
tracked: None,
|
||||
asked: None,
|
||||
asked_print: None,
|
||||
}
|
||||
});
|
||||
let menus = crate::keys::menus(&look, cx);
|
||||
let root = ely_ui::document_root(
|
||||
app,
|
||||
desk.clone().into(),
|
||||
&commands::RENAMING,
|
||||
menus,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let asked = ely_ui::launch_files(cx);
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.core.root = root.downgrade();
|
||||
desk.core.window = Some(window.window_handle());
|
||||
window.focus(&desk.core.focus, cx);
|
||||
// Launch files stand alone; unsaved text still returns.
|
||||
if asked.is_empty() {
|
||||
for path in kept {
|
||||
desk.open_path(path, cx);
|
||||
}
|
||||
}
|
||||
for name in desk.keeping.untitled() {
|
||||
desk.new_from_text(&name, "", cx);
|
||||
}
|
||||
for path in asked {
|
||||
desk.open_path(path, cx);
|
||||
}
|
||||
desk.keep_every(cx);
|
||||
cx.on_release(|desk, _| desk.keeping.close()).detach();
|
||||
// Another shown tab asks Git again.
|
||||
cx.observe_self(|desk, cx| {
|
||||
let shown = desk.active_tab().map(|tab| tab.id);
|
||||
if shown != desk.asked {
|
||||
desk.asked = shown;
|
||||
desk.tracked = None;
|
||||
desk.ask_git(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
cx.on_app_quit(|desk, _| {
|
||||
desk.keeping.close();
|
||||
async {}
|
||||
})
|
||||
.detach();
|
||||
for gone in missing {
|
||||
let said = text(cx, "mark.restore.missing", &[("file", &gone)]);
|
||||
desk.toast(said, ely_gpui_component::primitives::Severity::Warning, cx);
|
||||
}
|
||||
if let Some(error) = look_unread {
|
||||
log::error!("mark: the look did not read: {error}");
|
||||
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
desk.toast(said, ely_gpui_component::primitives::Severity::Danger, cx);
|
||||
}
|
||||
if let Some(error) = unread {
|
||||
log::error!("mark: the recent list did not read: {error}");
|
||||
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
desk.toast(said, ely_gpui_component::primitives::Severity::Danger, cx);
|
||||
}
|
||||
// Back in front: the folder may have changed.
|
||||
cx.observe_window_activation(window, |desk, window, cx| {
|
||||
if window.is_window_active() {
|
||||
desk.relist(cx);
|
||||
desk.ask_git(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
let opening = desk.clone();
|
||||
ely_ui::on_open(cx, move |path, cx| {
|
||||
opening.update(cx, |desk, cx| desk.open_path(path, cx))
|
||||
});
|
||||
let closing = desk.clone();
|
||||
window.on_window_should_close(cx, move |window, cx| {
|
||||
closing.update(cx, |desk, cx| {
|
||||
desk.leave(cx);
|
||||
desk.should_close(window, cx)
|
||||
})
|
||||
});
|
||||
root
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub fn active_view(&self) -> Option<Entity<MarkView>> {
|
||||
self.active_tab().map(|tab| tab.view.clone())
|
||||
}
|
||||
|
||||
/// Asks for files; each opens in its own tab.
|
||||
fn open_picked(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let picked = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: true,
|
||||
multiple: true,
|
||||
prompt: None,
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let picked = match picked.await {
|
||||
Ok(Ok(paths)) => Ok(paths),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
};
|
||||
let done = this.update(cx, |desk, cx| match picked {
|
||||
Ok(Some(paths)) => paths.into_iter().for_each(|path| desk.open_path(path, cx)),
|
||||
Ok(None) => log::info!("mark: the open panel was cancelled"),
|
||||
Err(error) => desk.panel_failed(&error, cx),
|
||||
});
|
||||
if done.is_err() {
|
||||
log::info!("mark: the window closed before the panel");
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// An empty untitled file (MARK-01).
|
||||
fn on_new(&mut self, _: &commands::New, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.new_from_text("untitled.md", "", cx);
|
||||
}
|
||||
|
||||
fn table(&mut self, op: &str, cx: &mut Context<Self>) {
|
||||
if let Some(view) = self.active_view() {
|
||||
view.update(cx, |view, cx| view.table(op, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn switch(&mut self, mode: Mode, cx: &mut Context<Self>) {
|
||||
if let Some(view) = self.active_view() {
|
||||
view.update(cx, |view, cx| view.switch(mode, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self, cx: &mut Context<Self>) -> StatusBar {
|
||||
let state = StatusBarItem::new("mark-state").label(self.save_state(cx));
|
||||
let Some(view) = self.active_view() else {
|
||||
return StatusBar::new().left(state);
|
||||
};
|
||||
let git = self.git_item(cx);
|
||||
let this = cx.entity();
|
||||
let modes = Mode::ALL.into_iter().fold(
|
||||
SegmentedControl::new("mark-mode", view.read(cx).mode.name()).size(ControlSize::Sm),
|
||||
|control, mode| {
|
||||
let icon = match mode {
|
||||
Mode::Visual => IconName::PenLine,
|
||||
Mode::Source => IconName::Code,
|
||||
Mode::Preview => IconName::Eye,
|
||||
};
|
||||
control.segment(mode.name(), crate::keys::label(mode, cx), Some(icon))
|
||||
},
|
||||
);
|
||||
let modes = modes.on_change(move |key, _, cx| {
|
||||
if let Some(mode) = Mode::parse(key) {
|
||||
this.update(cx, |desk, cx| desk.switch(mode, cx));
|
||||
}
|
||||
});
|
||||
let bar = StatusBar::new().left(state);
|
||||
let bar = match git {
|
||||
Some(git) => bar.left(git),
|
||||
None => bar,
|
||||
};
|
||||
bar.right(modes)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Desk {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let tab = self.active_tab();
|
||||
let title = tab.map_or_else(|| AppId::Mark.name().into(), |tab| tab.name.clone());
|
||||
let body = match tab {
|
||||
Some(tab) => div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(self.outside_banner(cx))
|
||||
.children(large_banner(&tab.view, cx))
|
||||
.child(div().flex_1().min_h_0().child(tab.view.clone()))
|
||||
.into_any_element(),
|
||||
None => {
|
||||
let none = text(cx, "command.blocked.no_document", &[]);
|
||||
EmptyState::new("mark-empty", IconName::File, none).into_any_element()
|
||||
}
|
||||
};
|
||||
// Focus mode folds tabs, side and status away.
|
||||
let shell = match self.look.focus {
|
||||
true => Shell::new(title, &self.core.focus),
|
||||
false => Shell::new(title, &self.core.focus)
|
||||
.tabs(self.tab_strip(cx))
|
||||
.left(Some(self.side.clone().into()))
|
||||
.status(self.status(cx)),
|
||||
}
|
||||
.child(body);
|
||||
let root = div()
|
||||
.size_full()
|
||||
.key_context(commands::CONTEXT)
|
||||
.track_focus(&self.core.focus)
|
||||
.on_action(cx.listener(Self::open_picked))
|
||||
.on_action(cx.listener(Self::on_new))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_rename))
|
||||
.on_action(cx.listener(Self::on_find))
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.on_action(cx.listener(|desk, quit, window, cx| {
|
||||
desk.leave(cx);
|
||||
desk.on_quit(quit, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx)))
|
||||
.on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx)))
|
||||
.on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx)))
|
||||
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| desk.table("column-left", cx)))
|
||||
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| desk.table("column-right", cx)))
|
||||
.on_action(cx.listener(|desk, _: &DeleteRow, _, cx| desk.table("delete-row", cx)))
|
||||
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| desk.table("delete-column", cx)))
|
||||
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
|
||||
.on_action(cx.listener(|desk, _: &VisualMode, _, cx| desk.switch(Mode::Visual, cx)))
|
||||
.on_action(cx.listener(|desk, _: &SourceMode, _, cx| desk.switch(Mode::Source, cx)))
|
||||
.on_action(cx.listener(|desk, _: &PreviewMode, _, cx| desk.switch(Mode::Preview, cx)))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.naming_element(cx))
|
||||
.children(self.find_bar(cx))
|
||||
.children(self.compare_dialog(cx))
|
||||
.children(self.export_dialog(cx));
|
||||
Self::look_actions(root, cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// A large file says why it shows as source.
|
||||
fn large_banner(
|
||||
view: &Entity<MarkView>,
|
||||
cx: &mut Context<Desk>,
|
||||
) -> Option<ely_gpui_component::feedback::Banner> {
|
||||
if !view.read(cx).large {
|
||||
return None;
|
||||
}
|
||||
let size = view.read(cx).bytes() as f64 / (1024.0 * 1024.0);
|
||||
let said = text(cx, "mark.large", &[("size", &format!("{size:.1}"))]);
|
||||
Some(ely_gpui_component::feedback::Banner::new(
|
||||
"mark-large",
|
||||
ely_gpui_component::primitives::Severity::Info,
|
||||
said,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Markdown files as the shell's engine (§14.3).
|
||||
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
hash::{DefaultHasher, Hash as _, Hasher as _},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_office::{Engine, Host, Load};
|
||||
use ely_web_content::WebView;
|
||||
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
|
||||
|
||||
use crate::text::Source;
|
||||
use crate::view::{Failed, MarkView, Reported};
|
||||
|
||||
/// The bundled page every tab shows.
|
||||
pub struct Pages {
|
||||
pub dir: PathBuf,
|
||||
}
|
||||
|
||||
/// A file as read, and its text now.
|
||||
pub struct Text {
|
||||
pub source: Source,
|
||||
pub current: String,
|
||||
/// The last write's mark, until history reads it.
|
||||
written: Option<String>,
|
||||
}
|
||||
|
||||
pub type Doc = Arc<Mutex<Text>>;
|
||||
|
||||
/// A load: the file as read, and where it is.
|
||||
pub struct Opened {
|
||||
pub source: Source,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// The history mark: this text's length and hash.
|
||||
pub fn mark(text: &str) -> String {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
format!("{}:{:016x}", text.len(), hasher.finish())
|
||||
}
|
||||
|
||||
pub struct Mark;
|
||||
|
||||
impl Engine for Mark {
|
||||
type Worker = Pages;
|
||||
type Doc = Doc;
|
||||
type View = MarkView;
|
||||
type Loaded = Opened;
|
||||
type Import = Infallible;
|
||||
|
||||
fn load(_: &mut Pages, from: Load<Infallible>) -> io::Result<Opened> {
|
||||
let path = match from {
|
||||
Load::File(path, _) => path,
|
||||
Load::Import(_, never) => match *never {},
|
||||
};
|
||||
// The shell reads PermissionDenied as a locked file.
|
||||
let bytes = std::fs::read(path).map_err(|error| match error.kind() {
|
||||
io::ErrorKind::PermissionDenied => io::Error::other(error),
|
||||
_ => error,
|
||||
})?;
|
||||
Ok(Opened {
|
||||
source: Source::decode(bytes)?,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
fn open(
|
||||
worker: &Arc<Mutex<Pages>>,
|
||||
loaded: Opened,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> io::Result<(Doc, Entity<MarkView>, String)> {
|
||||
let Opened {
|
||||
source: loaded,
|
||||
path,
|
||||
} = loaded;
|
||||
let folder = path
|
||||
.parent()
|
||||
.expect("a file sits in a folder")
|
||||
.to_path_buf();
|
||||
let dir = worker.lock().expect("the pages lock").dir.clone();
|
||||
let web =
|
||||
WebView::open(&dir.join("index.html"), &dir, window, cx).map_err(io::Error::other)?;
|
||||
let mark = mark(&loaded.text);
|
||||
let current = loaded.text.clone();
|
||||
let doc = Arc::new(Mutex::new(Text {
|
||||
source: loaded,
|
||||
current,
|
||||
written: None,
|
||||
}));
|
||||
web.read(cx)
|
||||
.allow_images(&folder)
|
||||
.map_err(io::Error::other)?;
|
||||
let view = cx.new(|cx| MarkView::new(web, Arc::clone(&doc), mark.clone(), folder, cx));
|
||||
Ok((doc, view, mark))
|
||||
}
|
||||
|
||||
/// After a write, what it wrote: later edits stay unsaved.
|
||||
fn history(_: &mut Pages, doc: &Doc) -> io::Result<String> {
|
||||
let mut text = doc.lock().expect("the text lock");
|
||||
Ok(text.written.take().unwrap_or_else(|| mark(&text.current)))
|
||||
}
|
||||
|
||||
fn write(_: &mut Pages, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
|
||||
if !matches!(format, "md" | "markdown") {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
format!("writing .{format}"),
|
||||
));
|
||||
}
|
||||
let mut text = doc.lock().expect("the text lock");
|
||||
std::fs::write(path, text.source.encode(&text.current))?;
|
||||
text.written = Some(mark(&text.current));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close(_: &mut Pages, _: Doc) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retire(view: &Entity<MarkView>, cx: &mut App) -> ely_gpui_component::primitives::Retired {
|
||||
let web = view.read(cx).web.clone();
|
||||
web.update(cx, |web, _| web.retire_all())
|
||||
}
|
||||
|
||||
fn settled(view: &MarkView) -> bool {
|
||||
view.settled()
|
||||
}
|
||||
|
||||
fn edited(view: &MarkView) -> u64 {
|
||||
view.changed
|
||||
}
|
||||
|
||||
fn edit_pending(view: &MarkView) -> bool {
|
||||
!view.settled()
|
||||
}
|
||||
|
||||
fn focus(view: &MarkView) -> FocusHandle {
|
||||
view.focus.clone()
|
||||
}
|
||||
|
||||
fn saved(view: &Entity<MarkView>, mark: &str, cx: &mut App) {
|
||||
view.update(cx, |view, cx| view.saved(mark, cx));
|
||||
}
|
||||
|
||||
fn watch<H: Host<Engine = Self>>(
|
||||
view: &Entity<MarkView>,
|
||||
cx: &mut Context<H>,
|
||||
reported: impl Fn(&mut H, &Entity<MarkView>, bool, &mut Context<H>) + 'static,
|
||||
failed: impl Fn(&mut H, &str, &mut Context<H>) + 'static,
|
||||
) {
|
||||
cx.subscribe(view, move |host, _, error: &Failed, cx| {
|
||||
failed(host, &error.0, cx)
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(view, move |host, view, _: &Reported, cx| {
|
||||
let modified = view.read(cx).modified();
|
||||
reported(host, &view, modified, cx)
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ely_office::Engine as _;
|
||||
|
||||
use super::{Mark, Pages, Text, mark};
|
||||
use crate::text::Source;
|
||||
|
||||
#[test]
|
||||
fn a_save_marks_the_text_it_wrote() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = Source::decode(b"Alpha.\n".to_vec()).expect("reads");
|
||||
let current = source.text.clone();
|
||||
let doc = Arc::new(Mutex::new(Text {
|
||||
source,
|
||||
current,
|
||||
written: None,
|
||||
}));
|
||||
let mut pages = Pages {
|
||||
dir: dir.path().to_path_buf(),
|
||||
};
|
||||
let path = dir.path().join("save.md");
|
||||
Mark::write(&mut pages, &doc, &path, "md").expect("writes");
|
||||
// An edit lands between the write and the history read.
|
||||
doc.lock().expect("the text lock").current = "xAlpha.\n".into();
|
||||
let saved = Mark::history(&mut pages, &doc).expect("a mark");
|
||||
assert_eq!(saved, mark("Alpha.\n"));
|
||||
assert_eq!(
|
||||
Mark::history(&mut pages, &doc).expect("a mark"),
|
||||
mark("xAlpha.\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Export and print: HTML, linked HTML, PDF, text (MARK-11).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
overlays::Dialog,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::{cancel, files::file_name, in_window, prelude::*};
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use ely_web_content::{Event, WebView};
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _,
|
||||
Subscription, Window,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::paper;
|
||||
|
||||
/// What an export writes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Output {
|
||||
Page,
|
||||
Linked,
|
||||
Pdf,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
const ALL: [Output; 4] = [Output::Page, Output::Linked, Output::Pdf, Output::Text];
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Output::Page => "page",
|
||||
Output::Linked => "linked",
|
||||
Output::Pdf => "pdf",
|
||||
Output::Text => "text",
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Output::Page | Output::Linked => "html",
|
||||
Output::Pdf => "pdf",
|
||||
Output::Text => "txt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an export lacks: images, diagrams, formulas.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Lost {
|
||||
images: Vec<String>,
|
||||
diagrams: Vec<u64>,
|
||||
/// Each broken formula's line and KaTeX's message.
|
||||
formulas: Vec<(u64, String)>,
|
||||
}
|
||||
|
||||
impl Lost {
|
||||
fn of(images: Vec<String>, rendered: &Value) -> Lost {
|
||||
let diagrams = rendered["failed"].as_array().into_iter().flatten();
|
||||
let formulas = rendered["formulas"].as_array().into_iter().flatten();
|
||||
Lost {
|
||||
images,
|
||||
diagrams: diagrams.filter_map(Value::as_u64).collect(),
|
||||
formulas: formulas
|
||||
.filter_map(|f| Some((f["line"].as_u64()?, f["error"].as_str()?.to_owned())))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn whole(&self) -> bool {
|
||||
self.images.is_empty() && self.diagrams.is_empty() && self.formulas.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// No export lands on a file a tab holds (FILE-06).
|
||||
fn not_open(desk: &Desk, path: &Path) -> io::Result<()> {
|
||||
match desk.holder(path)? {
|
||||
Some(_) => Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} is open in ElyMark", file_name(path)),
|
||||
)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A page printing to PDF in a view nobody sees.
|
||||
pub struct Printing {
|
||||
_view: Entity<WebView>,
|
||||
_heard: Subscription,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub fn on_export(&mut self, _: &commands::Export, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.active_tab().is_some() {
|
||||
self.output = Some(Output::Page);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_print(&mut self, _: &commands::Print, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.active_tab().map(|tab| tab.id) else {
|
||||
return;
|
||||
};
|
||||
let pdf = self.core.work.join(format!("print-{id}.pdf"));
|
||||
self.render_pdf(id, pdf, None, cx);
|
||||
}
|
||||
|
||||
/// Asks where, renders the whole text, then writes it.
|
||||
fn export_to(&mut self, output: Output, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let id = tab.id;
|
||||
let stem = tab
|
||||
.name
|
||||
.rsplit_once('.')
|
||||
.map_or(tab.name.as_ref(), |(stem, _)| stem)
|
||||
.to_owned();
|
||||
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())));
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let Ok(Ok(Some(path))) = chosen.await else {
|
||||
return log::info!("mark: the export panel closed without a path");
|
||||
};
|
||||
desk.update(cx, |desk, cx| {
|
||||
if let Err(error) = not_open(desk, &path) {
|
||||
return desk.exported(Err(error), cx);
|
||||
}
|
||||
match output {
|
||||
Output::Pdf => desk.render_pdf(
|
||||
id,
|
||||
desk.core.work.join(format!("export-{id}.pdf")),
|
||||
Some(path),
|
||||
cx,
|
||||
),
|
||||
_ => desk.write_output(id, output, path, cx),
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// HTML or text, assembled and written off the main thread.
|
||||
fn write_output(&mut self, id: u64, output: Output, path: PathBuf, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let (view, folder) = (tab.view.clone(), tab.view.read(cx).folder.clone());
|
||||
let open = tab.session.path().map(Path::to_path_buf);
|
||||
let style = self.pages_dir().join("export.css");
|
||||
let kind = if output == Output::Text {
|
||||
"text"
|
||||
} else {
|
||||
"html"
|
||||
};
|
||||
let desk = cx.weak_entity();
|
||||
view.update(cx, |view, cx| {
|
||||
view.render(
|
||||
kind,
|
||||
move |rendered, cx: &mut App| {
|
||||
// Checked again: tabs may have opened meanwhile.
|
||||
let free = desk.update(cx, |desk, _| not_open(desk, &path));
|
||||
let free = free.map_err(io::Error::other).and_then(|free| free);
|
||||
let job = cx.background_spawn(async move {
|
||||
free?;
|
||||
let rendered = rendered.map_err(io::Error::other)?;
|
||||
write(output, &rendered, &folder, &style, &path, open.as_deref())
|
||||
});
|
||||
cx.spawn(async move |cx| {
|
||||
let written = job.await;
|
||||
desk.update(cx, |desk, cx| desk.exported(written, cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn exported(&mut self, written: io::Result<(PathBuf, Lost)>, cx: &mut Context<Self>) {
|
||||
match written {
|
||||
Ok((path, lost)) if lost.whole() => {
|
||||
log::info!("mark: exported {}", path.display());
|
||||
let said = text(cx, "mark.export.done", &[("name", &file_name(&path))]);
|
||||
self.toast_output(said, path, cx);
|
||||
}
|
||||
Ok((path, lost)) => {
|
||||
// Counts only: image names come from the text.
|
||||
log::warn!(
|
||||
"mark: exported {} without {} images and {} diagrams; formulas failed at lines {:?}",
|
||||
path.display(),
|
||||
lost.images.len(),
|
||||
lost.diagrams.len(),
|
||||
lost.formulas.iter().map(|(line, _)| line).collect::<Vec<_>>()
|
||||
);
|
||||
self.warn_lost(&lost, Some(path), cx);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("mark: an export failed: {error}");
|
||||
let said = text(cx, "mark.export.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Says which images, diagrams and formulas an output lacks.
|
||||
fn warn_lost(&mut self, lost: &Lost, output: Option<PathBuf>, cx: &mut Context<Self>) {
|
||||
let mut said = Vec::new();
|
||||
if !lost.images.is_empty() {
|
||||
said.push(text(
|
||||
cx,
|
||||
"mark.export.missing",
|
||||
&[("list", &lost.images.join(", "))],
|
||||
));
|
||||
}
|
||||
if !lost.diagrams.is_empty() {
|
||||
said.push(text(
|
||||
cx,
|
||||
"mark.export.diagrams",
|
||||
&[(
|
||||
"list",
|
||||
&lost
|
||||
.diagrams
|
||||
.iter()
|
||||
.map(u64::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)],
|
||||
));
|
||||
}
|
||||
for (line, error) in &lost.formulas {
|
||||
let line = line.to_string();
|
||||
said.push(text(
|
||||
cx,
|
||||
"mark.export.formula",
|
||||
&[("line", &line), ("error", error)],
|
||||
));
|
||||
}
|
||||
// Names and messages quote the text: no log.
|
||||
let said: gpui::SharedString = said.join(" ").into();
|
||||
match (output, self.core.root.upgrade()) {
|
||||
(Some(path), Some(root)) => root.update(cx, |root, cx| root.toast_output(said, Severity::Warning, path, cx)),
|
||||
_ => self.toast_unlogged(said, Severity::Warning, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn pages_dir(&self) -> PathBuf {
|
||||
self.core.worker.lock().expect("the pages lock").dir.clone()
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let (view, folder, name) = (
|
||||
tab.view.clone(),
|
||||
tab.view.read(cx).folder.clone(),
|
||||
tab.name.to_string(),
|
||||
);
|
||||
let open = tab.session.path().map(Path::to_path_buf);
|
||||
let (style, work) = (self.pages_dir().join("export.css"), self.core.work.clone());
|
||||
let desk = cx.weak_entity();
|
||||
view.update(cx, |view, cx| {
|
||||
view.render(
|
||||
"html",
|
||||
move |rendered, cx: &mut App| {
|
||||
let staged = work.join(format!("print-{id}.html"));
|
||||
let page = rendered.map_err(io::Error::other).and_then(|rendered| {
|
||||
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder);
|
||||
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?);
|
||||
std::fs::write(&staged, paper::page(&name, &style, &html))?;
|
||||
Ok(Lost::of(images, &rendered))
|
||||
});
|
||||
cx.spawn(async move |cx| {
|
||||
in_window(&desk, cx, |desk, window, cx| match page {
|
||||
Ok(lost) => {
|
||||
let job = Job {
|
||||
pdf,
|
||||
to,
|
||||
open,
|
||||
lost,
|
||||
};
|
||||
desk.print_page(&staged, job, window, cx)
|
||||
}
|
||||
Err(error) => desk.exported(Err(error), cx),
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
},
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let root = page
|
||||
.parent()
|
||||
.expect("a staged page sits in a folder")
|
||||
.to_path_buf();
|
||||
let view = match WebView::open(page, &root, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(error) => return self.exported(Err(io::Error::other(error)), cx),
|
||||
};
|
||||
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
|
||||
Event::Loaded => view.read(cx).print_pdf(&job.pdf),
|
||||
Event::Printed(path, ok) => {
|
||||
desk.printing = None;
|
||||
let printed = ok
|
||||
.then(|| path.clone())
|
||||
.ok_or_else(|| io::Error::other("Chromium did not print the page"));
|
||||
desk.printed(printed, job.clone(), cx);
|
||||
}
|
||||
Event::Failed(why) => {
|
||||
desk.printing = None;
|
||||
desk.exported(Err(io::Error::other(why.clone())), cx);
|
||||
}
|
||||
Event::Query(_, reply) => reply.ok(""),
|
||||
Event::Leave(_) => {}
|
||||
});
|
||||
self.printing = Some(Printing {
|
||||
_view: view,
|
||||
_heard: heard,
|
||||
});
|
||||
}
|
||||
|
||||
fn printed(&mut self, pdf: io::Result<PathBuf>, job: Job, cx: &mut Context<Self>) {
|
||||
let pdf = match pdf {
|
||||
Ok(pdf) => pdf,
|
||||
Err(error) => return self.exported(Err(error), cx),
|
||||
};
|
||||
let Some(to) = job.to else {
|
||||
if !job.lost.whole() {
|
||||
self.warn_lost(&job.lost, None, cx);
|
||||
}
|
||||
return ely_office::print_later(pdf, cx, |desk, why, cx| {
|
||||
desk.exported(Err(io::Error::other(why)), cx)
|
||||
});
|
||||
};
|
||||
// Never over the open file, not even through a link.
|
||||
let moved = not_open(self, &to)
|
||||
.and_then(|()| std::fs::read(&pdf))
|
||||
.and_then(|bytes| ely_files::export(&to, job.open.as_deref(), &bytes, 0).map(|_| ()));
|
||||
let _ = std::fs::remove_file(&pdf);
|
||||
self.exported(moved.map(|()| (to, job.lost)), cx);
|
||||
}
|
||||
|
||||
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let chosen = self.output?;
|
||||
let this = cx.entity();
|
||||
let kinds = Output::ALL.into_iter().fold(
|
||||
SegmentedControl::new("mark-export-kind", chosen.key()),
|
||||
|control, output| {
|
||||
control.segment(
|
||||
output.key(),
|
||||
text(cx, &format!("mark.export.{}", output.key()), &[]),
|
||||
None,
|
||||
)
|
||||
},
|
||||
);
|
||||
let kinds = kinds.on_change({
|
||||
let this = this.clone();
|
||||
move |key, _, cx| {
|
||||
let picked = Output::ALL
|
||||
.into_iter()
|
||||
.find(|output| key.as_ref() == output.key());
|
||||
this.update(cx, |desk, cx| {
|
||||
desk.output = picked;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
});
|
||||
let close = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |desk, cx| {
|
||||
desk.output = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let label = text(cx, "mark.export.go", &[]);
|
||||
let dialog = Dialog::new("mark-export", text(cx, "mark.export", &[]), close)
|
||||
.child(kinds)
|
||||
.action(cancel(cx))
|
||||
.action(move |close| {
|
||||
Button::new("mark-export-go", label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, window, cx| {
|
||||
close(window, cx);
|
||||
this.update(cx, |desk, cx| desk.export_to(chosen, cx));
|
||||
})
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF in flight: where printed, where bound, what lacks.
|
||||
#[derive(Clone)]
|
||||
struct Job {
|
||||
pdf: PathBuf,
|
||||
to: Option<PathBuf>,
|
||||
/// The tab's own file, never overwritten.
|
||||
open: Option<PathBuf>,
|
||||
lost: Lost,
|
||||
}
|
||||
|
||||
/// Writes `output` at `path`; and what it left out.
|
||||
fn write(
|
||||
output: Output,
|
||||
rendered: &Value,
|
||||
folder: &Path,
|
||||
style: &Path,
|
||||
path: &Path,
|
||||
open: Option<&Path>,
|
||||
) -> io::Result<(PathBuf, Lost)> {
|
||||
let title = path
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let (bytes, missing) = match output {
|
||||
Output::Text => (
|
||||
rendered["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
Vec::new(),
|
||||
),
|
||||
Output::Page => {
|
||||
let (html, missing) = paper::Marked::read(rendered)?.inline(folder);
|
||||
let style = format!("<style>{}</style>", std::fs::read_to_string(style)?);
|
||||
(paper::page(&title, &style, &html).into_bytes(), missing)
|
||||
}
|
||||
Output::Linked => {
|
||||
let assets = format!("{title}_files");
|
||||
let place = path.with_file_name(&assets);
|
||||
let (html, copies, missing) = paper::Marked::read(rendered)?.linked(folder, &assets);
|
||||
// A folder already there is never written into.
|
||||
std::fs::create_dir(&place)?;
|
||||
let copied = copies
|
||||
.iter()
|
||||
.try_for_each(|(from, name)| std::fs::copy(from, place.join(name)).map(|_| ()))
|
||||
.and_then(|()| std::fs::copy(style, place.join("export.css")).map(|_| ()));
|
||||
if let Err(error) = copied {
|
||||
let _ = std::fs::remove_dir_all(&place);
|
||||
return Err(error);
|
||||
}
|
||||
let style = format!(
|
||||
"<link rel=\"stylesheet\" href=\"{}/export.css\">",
|
||||
paper::segment(&assets)
|
||||
);
|
||||
(paper::page(&title, &style, &html).into_bytes(), missing)
|
||||
}
|
||||
Output::Pdf => unreachable!("PDF prints in a page"),
|
||||
};
|
||||
if let Err(error) = ely_files::export(path, open, &bytes, 0) {
|
||||
if output == Output::Linked {
|
||||
let _ = std::fs::remove_dir_all(path.with_file_name(format!("{title}_files")));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok((path.to_path_buf(), Lost::of(missing, rendered)))
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Find and replace over the shown editor (MARK-06).
|
||||
|
||||
use ely_gpui_component::primitives::raise;
|
||||
use ely_gpui_component::{
|
||||
editor::{FindOptions, FindWidget},
|
||||
forms::{InputEvent, TextInput},
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _,
|
||||
IntoElement as _, ParentElement as _, Styled as _, Subscription, Window, actions, div,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::desk::Desk;
|
||||
|
||||
actions!(mark, [Find]);
|
||||
|
||||
/// Where the shown editor's search stands.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Found {
|
||||
pub current: Option<usize>,
|
||||
pub total: usize,
|
||||
/// A pattern that does not compile.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Finder {
|
||||
query: Entity<TextInput>,
|
||||
with: Entity<TextInput>,
|
||||
options: FindOptions,
|
||||
replacing: bool,
|
||||
/// The tab last searched; another one is searched anew.
|
||||
tab: Option<u64>,
|
||||
_follow: Subscription,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Opens the find box, or takes focus to it.
|
||||
pub fn on_find(&mut self, _: &Find, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(finder) = &self.finder {
|
||||
let focus = finder.query.focus_handle(cx);
|
||||
return window.focus(&focus, cx);
|
||||
}
|
||||
let query = cx.new(|cx| TextInput::new(window, cx));
|
||||
let with = cx.new(|cx| TextInput::new(window, cx));
|
||||
cx.subscribe(&query, |desk, _, event: &InputEvent, cx| match event {
|
||||
InputEvent::Changed => desk.seek(cx),
|
||||
InputEvent::Submit => desk.step(true, cx),
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(&with, |desk, _, event: &InputEvent, cx| match event {
|
||||
InputEvent::Changed => desk.seek(cx),
|
||||
InputEvent::Submit => desk.replace(false, cx),
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
let focus = query.focus_handle(cx);
|
||||
let follow = cx.observe_self(|desk, cx| {
|
||||
let shown = desk.active_tab().map(|tab| tab.id);
|
||||
if desk
|
||||
.finder
|
||||
.as_ref()
|
||||
.is_some_and(|finder| finder.tab != shown)
|
||||
{
|
||||
desk.seek(cx);
|
||||
}
|
||||
});
|
||||
self.finder = Some(Finder {
|
||||
query,
|
||||
with,
|
||||
options: FindOptions::default(),
|
||||
replacing: false,
|
||||
tab: None,
|
||||
_follow: follow,
|
||||
});
|
||||
window.focus(&focus, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The query and options as the page's search spec.
|
||||
fn spec(&self, cx: &Context<Self>) -> Option<serde_json::Value> {
|
||||
let finder = self.finder.as_ref()?;
|
||||
Some(json!({
|
||||
"search": finder.query.read(cx).text(),
|
||||
"replace": finder.with.read(cx).text(),
|
||||
"caseSensitive": finder.options.case,
|
||||
"regexp": finder.options.regex,
|
||||
"wholeWord": finder.options.word,
|
||||
}))
|
||||
}
|
||||
|
||||
fn seek(&mut self, cx: &mut Context<Self>) {
|
||||
let shown = self.active_tab().map(|tab| tab.id);
|
||||
if let Some(finder) = self.finder.as_mut() {
|
||||
finder.tab = shown;
|
||||
}
|
||||
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
|
||||
view.update(cx, |view, cx| view.find(spec, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
|
||||
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
|
||||
view.update(cx, |view, cx| view.step(spec, forward, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&mut self, all: bool, cx: &mut Context<Self>) {
|
||||
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
|
||||
view.update(cx, |view, cx| view.replace(spec, all, cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.finder = None;
|
||||
if let Some(view) = self.active_view() {
|
||||
view.update(cx, |view, cx| view.unfind(cx));
|
||||
let focus = view.read(cx).focus.clone();
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn find_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let finder = self.finder.as_ref()?;
|
||||
let found = self
|
||||
.active_view()
|
||||
.and_then(|view| view.read(cx).found.clone());
|
||||
let (current, total) = found
|
||||
.as_ref()
|
||||
.map_or((None, 0), |found| (found.current, found.total));
|
||||
let this = cx.entity();
|
||||
let widget = FindWidget::new("mark-find", &finder.query, current, total)
|
||||
.options(finder.options)
|
||||
.on_options({
|
||||
let this = this.clone();
|
||||
move |options, _, cx| {
|
||||
this.update(cx, |desk, cx| {
|
||||
if let Some(finder) = desk.finder.as_mut() {
|
||||
finder.options = options;
|
||||
}
|
||||
desk.seek(cx);
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_step({
|
||||
let this = this.clone();
|
||||
move |forward, _, cx| this.update(cx, |desk, cx| desk.step(forward, cx))
|
||||
})
|
||||
.on_replace({
|
||||
let this = this.clone();
|
||||
move |all, _, cx| this.update(cx, |desk, cx| desk.replace(all, cx))
|
||||
})
|
||||
.on_toggle_replace({
|
||||
let this = this.clone();
|
||||
move |open, _, cx| {
|
||||
this.update(cx, |desk, cx| {
|
||||
if let Some(finder) = desk.finder.as_mut() {
|
||||
finder.replacing = open;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
})
|
||||
.on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx)));
|
||||
let widget = match finder.replacing {
|
||||
true => widget.replace(&finder.with),
|
||||
false => widget,
|
||||
};
|
||||
let widget = match found.and_then(|found| found.error) {
|
||||
Some(error) => widget.error(error),
|
||||
None => widget,
|
||||
};
|
||||
// Over the page's top right, as elsewhere.
|
||||
let place = div()
|
||||
.absolute()
|
||||
.top_2()
|
||||
.right_4()
|
||||
.block_mouse_except_scroll()
|
||||
.child(widget);
|
||||
Some(raise("mark-find-layer", place).into_any_element())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! An opened folder's Markdown files, and searching them (MARK-05, MARK-10).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// An opened folder and its Markdown files.
|
||||
pub struct Folder {
|
||||
pub root: PathBuf,
|
||||
pub files: Vec<String>,
|
||||
}
|
||||
|
||||
/// Hits a search keeps; it looks no further.
|
||||
pub const MOST_HITS: usize = 500;
|
||||
|
||||
fn markdown(name: &str) -> bool {
|
||||
let name = name.to_lowercase();
|
||||
name.ends_with(".md") || name.ends_with(".markdown")
|
||||
}
|
||||
|
||||
/// Markdown files under `root`; no links, no dot names.
|
||||
pub fn markdown_under(root: &Path) -> io::Result<Vec<String>> {
|
||||
let mut found = Vec::new();
|
||||
let mut folders = vec![(root.to_path_buf(), String::new())];
|
||||
while let Some((dir, prefix)) = folders.pop() {
|
||||
for entry in std::fs::read_dir(&dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let kind = entry.file_type()?;
|
||||
let path = format!("{prefix}{name}");
|
||||
if kind.is_dir() {
|
||||
folders.push((entry.path(), format!("{path}/")));
|
||||
} else if kind.is_file() && markdown(&name) {
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_by_key(|path| path.to_lowercase());
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// One line that holds the words.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Hit {
|
||||
pub file: PathBuf,
|
||||
/// Counted from one.
|
||||
pub line: usize,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Lines of `text` holding `query`, any case.
|
||||
pub fn lines_with(file: &Path, text: &str, query: &str, hits: &mut Vec<Hit>) {
|
||||
let query = query.to_lowercase();
|
||||
for (at, line) in text.lines().enumerate() {
|
||||
if hits.len() == MOST_HITS {
|
||||
return;
|
||||
}
|
||||
if line.to_lowercase().contains(&query) {
|
||||
let shown: String = line.trim().chars().take(160).collect();
|
||||
hits.push(Hit {
|
||||
file: file.to_path_buf(),
|
||||
line: at + 1,
|
||||
text: shown,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn markdown_files_list_without_hidden_or_other_files() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("notes/deep")).expect("makes");
|
||||
std::fs::create_dir_all(root.join(".git")).expect("makes");
|
||||
for file in [
|
||||
"b.md",
|
||||
"A.markdown",
|
||||
"notes/deep/c.MD",
|
||||
"notes/skip.txt",
|
||||
".git/x.md",
|
||||
".hidden.md",
|
||||
] {
|
||||
std::fs::write(root.join(file), "x").expect("writes");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(root.join("notes"), root.join("loop")).expect("links");
|
||||
let found = super::markdown_under(root).expect("lists");
|
||||
assert_eq!(found, ["A.markdown", "b.md", "notes/deep/c.MD"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_with_the_words_any_case() {
|
||||
let mut hits = Vec::new();
|
||||
super::lines_with(
|
||||
Path::new("a.md"),
|
||||
"# Title\nsome Words here\nnone\nWORDS",
|
||||
"words",
|
||||
&mut hits,
|
||||
);
|
||||
let lines: Vec<_> = hits
|
||||
.iter()
|
||||
.map(|hit| (hit.line, hit.text.as_str()))
|
||||
.collect();
|
||||
assert_eq!(lines, [(2, "some Words here"), (4, "WORDS")]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Modes, menus and keys (MARK-01, MARK-02).
|
||||
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{App, KeyBinding, Menu, MenuItem, actions};
|
||||
|
||||
use crate::page::Mode;
|
||||
|
||||
actions!(
|
||||
mark,
|
||||
[
|
||||
VisualMode,
|
||||
SourceMode,
|
||||
PreviewMode,
|
||||
InsertTable,
|
||||
RowAbove,
|
||||
RowBelow,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
DeleteRow,
|
||||
DeleteColumn,
|
||||
CheckDisk
|
||||
]
|
||||
);
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-1", VisualMode, at),
|
||||
KeyBinding::new("secondary-2", SourceMode, at),
|
||||
KeyBinding::new("secondary-3", PreviewMode, at),
|
||||
KeyBinding::new("secondary-f", crate::finding::Find, at),
|
||||
KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at),
|
||||
KeyBinding::new("secondary-=", crate::look::Larger, at),
|
||||
KeyBinding::new("secondary--", crate::look::Smaller, at),
|
||||
KeyBinding::new("secondary-0", crate::look::ActualSize, at),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The label of `mode`.
|
||||
pub fn label(mode: Mode, cx: &App) -> gpui::SharedString {
|
||||
text(cx, &format!("mark.mode.{}", mode.name()), &[])
|
||||
}
|
||||
|
||||
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
use crate::look::{self, Font, Width};
|
||||
let checked = |key: &str, action: Box<dyn gpui::Action>, checked: bool| MenuItem::Action {
|
||||
name: text(cx, key, &[]),
|
||||
action,
|
||||
os_action: None,
|
||||
checked,
|
||||
disabled: false,
|
||||
};
|
||||
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
|
||||
let table = Menu::new(text(cx, "mark.menu.table", &[])).items([
|
||||
item("mark.table.insert", Box::new(InsertTable)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.row_above", Box::new(RowAbove)),
|
||||
item("mark.table.row_below", Box::new(RowBelow)),
|
||||
item("mark.table.column_left", Box::new(ColumnLeft)),
|
||||
item("mark.table.column_right", Box::new(ColumnRight)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.delete_row", Box::new(DeleteRow)),
|
||||
item("mark.table.delete_column", Box::new(DeleteColumn)),
|
||||
]);
|
||||
vec![
|
||||
table,
|
||||
Menu::new(text(cx, "menu.view", &[])).items([
|
||||
MenuItem::action(label(Mode::Visual, cx), VisualMode),
|
||||
MenuItem::action(label(Mode::Source, cx), SourceMode),
|
||||
MenuItem::action(label(Mode::Preview, cx), PreviewMode),
|
||||
MenuItem::separator(),
|
||||
checked("mark.look.focus", Box::new(look::FocusMode), look.focus),
|
||||
checked(
|
||||
"mark.look.typewriter",
|
||||
Box::new(look::Typewriter),
|
||||
look.typewriter,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
checked(
|
||||
"mark.look.narrow",
|
||||
Box::new(look::Narrow),
|
||||
look.width == Width::Narrow,
|
||||
),
|
||||
checked(
|
||||
"mark.look.medium",
|
||||
Box::new(look::Medium),
|
||||
look.width == Width::Medium,
|
||||
),
|
||||
checked(
|
||||
"mark.look.wide",
|
||||
Box::new(look::Wide),
|
||||
look.width == Width::Wide,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
checked(
|
||||
"mark.look.sans",
|
||||
Box::new(look::Sans),
|
||||
look.font == Font::Sans,
|
||||
),
|
||||
checked(
|
||||
"mark.look.serif",
|
||||
Box::new(look::Serif),
|
||||
look.font == Font::Serif,
|
||||
),
|
||||
checked(
|
||||
"mark.look.mono",
|
||||
Box::new(look::Mono),
|
||||
look.font == Font::Mono,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
item("mark.look.larger", Box::new(look::Larger)),
|
||||
item("mark.look.smaller", Box::new(look::Smaller)),
|
||||
item("mark.look.actual", Box::new(look::ActualSize)),
|
||||
MenuItem::separator(),
|
||||
item("mark.outside.check", Box::new(CheckDisk)),
|
||||
]),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The writing view, kept across launches (MARK-09, MARK-12).
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use gpui::{Context, actions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::desk::Desk;
|
||||
|
||||
actions!(
|
||||
mark,
|
||||
[
|
||||
FocusMode, Typewriter, Narrow, Medium, Wide, Sans, Serif, Mono, Larger, Smaller, ActualSize
|
||||
]
|
||||
);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Width {
|
||||
Narrow,
|
||||
Medium,
|
||||
Wide,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Font {
|
||||
Sans,
|
||||
Serif,
|
||||
Mono,
|
||||
}
|
||||
|
||||
/// How text shows; the Markdown never changes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Look {
|
||||
pub focus: bool,
|
||||
pub typewriter: bool,
|
||||
pub width: Width,
|
||||
pub font: Font,
|
||||
/// Percent of the app's text size.
|
||||
pub size: u32,
|
||||
}
|
||||
|
||||
impl Default for Look {
|
||||
fn default() -> Self {
|
||||
Look {
|
||||
focus: false,
|
||||
typewriter: false,
|
||||
width: Width::Medium,
|
||||
font: Font::Sans,
|
||||
size: 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept look; none kept is the default.
|
||||
pub fn load(file: &Path) -> io::Result<Look> {
|
||||
match std::fs::read(file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Look::default()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sizes step by a tenth, from 70 to 200 percent.
|
||||
const SIZES: (u32, u32, u32) = (70, 200, 10);
|
||||
|
||||
impl Desk {
|
||||
/// Changes the look; pages, file and menus follow.
|
||||
pub fn relook(&mut self, change: impl FnOnce(&mut Look), cx: &mut Context<Self>) {
|
||||
change(&mut self.look);
|
||||
self.look.size = self.look.size.clamp(SIZES.0, SIZES.1);
|
||||
log::info!("mark: the look is now {:?}", self.look);
|
||||
self.show_look(cx);
|
||||
let json = serde_json::to_vec_pretty(&self.look).expect("a look serializes");
|
||||
if let Err(error) = ely_files::save_atomic(&self.look_file, &json, 0) {
|
||||
log::error!("mark: the look did not save: {error}");
|
||||
}
|
||||
let menus = crate::keys::menus(&self.look, cx);
|
||||
ely_ui::set_document_menus(
|
||||
ely_platform::AppId::Mark,
|
||||
&ely_ui::commands::RENAMING,
|
||||
menus,
|
||||
cx,
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Every tab's page takes the look.
|
||||
pub fn show_look(&mut self, cx: &mut Context<Self>) {
|
||||
let look = serde_json::to_value(&self.look).expect("a look serializes");
|
||||
for tab in &self.core.tabs {
|
||||
let look = look.clone();
|
||||
tab.view.update(cx, |view, cx| view.set_look(look, cx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn look_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
use gpui::InteractiveElement as _;
|
||||
let step = SIZES.2;
|
||||
element
|
||||
.on_action(cx.listener(|desk, _: &FocusMode, _, cx| {
|
||||
desk.relook(|look| look.focus = !look.focus, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &Typewriter, _, cx| {
|
||||
desk.relook(|look| look.typewriter = !look.typewriter, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &Narrow, _, cx| {
|
||||
desk.relook(|look| look.width = Width::Narrow, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &Medium, _, cx| {
|
||||
desk.relook(|look| look.width = Width::Medium, cx)
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &Wide, _, cx| {
|
||||
desk.relook(|look| look.width = Width::Wide, cx)
|
||||
}),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &Sans, _, cx| desk.relook(|look| look.font = Font::Sans, cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &Serif, _, cx| {
|
||||
desk.relook(|look| look.font = Font::Serif, cx)
|
||||
}),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &Mono, _, cx| desk.relook(|look| look.font = Font::Mono, cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(move |desk, _: &Larger, _, cx| {
|
||||
desk.relook(|look| look.size += step, cx)
|
||||
}),
|
||||
)
|
||||
.on_action(cx.listener(move |desk, _: &Smaller, _, cx| {
|
||||
desk.relook(|look| look.size = look.size.saturating_sub(step), cx)
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &ActualSize, _, cx| desk.relook(|look| look.size = 100, cx)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_kept_look_reads_back_and_none_is_default() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let file = dir.path().join("look.json");
|
||||
assert_eq!(
|
||||
super::load(&file).expect("none kept"),
|
||||
super::Look::default()
|
||||
);
|
||||
std::fs::write(
|
||||
&file,
|
||||
r#"{"focus":true,"typewriter":false,"width":"wide","font":"serif","size":120}"#,
|
||||
)
|
||||
.expect("a look");
|
||||
let look = super::load(&file).expect("it reads");
|
||||
assert!(look.focus);
|
||||
assert_eq!(
|
||||
(look.width, look.font, look.size),
|
||||
(super::Width::Wide, super::Font::Serif, 120)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
mod attach;
|
||||
mod autosave;
|
||||
mod compare;
|
||||
mod desk;
|
||||
mod engine;
|
||||
mod export;
|
||||
mod finding;
|
||||
mod folder;
|
||||
mod keys;
|
||||
mod look;
|
||||
mod outside;
|
||||
mod page;
|
||||
mod paper;
|
||||
mod places;
|
||||
mod rename;
|
||||
mod restore;
|
||||
mod side;
|
||||
mod text;
|
||||
mod vcs;
|
||||
mod view;
|
||||
|
||||
use ely_platform::AppId;
|
||||
|
||||
fn main() {
|
||||
// CEF runs its helper processes from this same binary.
|
||||
if let Some(code) = ely_web_content::subprocess() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
ely_ui::run(AppId::Mark, desk::open);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Changes made to a file outside ElyMark (MARK-08, §14.4).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_files::{Fingerprint, Policy, PolicyEngine, SaveState};
|
||||
use ely_gpui_component::{feedback::Banner, primitives::Severity};
|
||||
use ely_office::prelude::*;
|
||||
use futures::{StreamExt as _, channel::mpsc};
|
||||
use gpui::Context;
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::text::Source;
|
||||
use ely_ui::i18n::text;
|
||||
|
||||
/// Bursts, such as a save's rename, settle into one look.
|
||||
const SETTLE: Duration = Duration::from_millis(200);
|
||||
/// An atomic replace empties the path this long at most.
|
||||
const GAP: Duration = Duration::from_millis(300);
|
||||
|
||||
/// What happened to a tab's file on disk.
|
||||
#[derive(Default)]
|
||||
pub enum Outside {
|
||||
#[default]
|
||||
Clear,
|
||||
/// Changed under unsaved text: the disk's version, as read.
|
||||
Changed(Source, Fingerprint),
|
||||
Deleted,
|
||||
/// Changes cannot be followed; why.
|
||||
Unread(String),
|
||||
}
|
||||
|
||||
/// What ElyMark keeps per tab.
|
||||
pub struct Extra {
|
||||
pub outside: Outside,
|
||||
/// The text as last loaded or saved: the merge base.
|
||||
pub base: String,
|
||||
pub policy: PolicyEngine,
|
||||
/// The page change autosave last heard.
|
||||
pub heard: u64,
|
||||
/// The policy revision an autosave writes.
|
||||
pub writing: Option<u64>,
|
||||
/// A look put off while a save runs.
|
||||
pub recheck: bool,
|
||||
/// Its recovery folder's name, once given.
|
||||
pub slot: String,
|
||||
/// The last recovery point's mark, and one in flight.
|
||||
pub pointed: Option<String>,
|
||||
pub pointing: bool,
|
||||
/// A kept tab's mode, place and text came back once.
|
||||
pub restored: bool,
|
||||
}
|
||||
|
||||
impl Default for Extra {
|
||||
fn default() -> Self {
|
||||
Extra {
|
||||
outside: Outside::Clear,
|
||||
base: String::new(),
|
||||
policy: PolicyEngine::new(Policy::MarkAutoSave, false),
|
||||
heard: 0,
|
||||
writing: None,
|
||||
recheck: false,
|
||||
slot: String::new(),
|
||||
pointed: None,
|
||||
pointing: false,
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's bytes and the fingerprint they match.
|
||||
fn read_still(path: &Path) -> io::Result<(Fingerprint, Source)> {
|
||||
// A writer mid-way changes the fingerprint; read again.
|
||||
for _ in 0..5 {
|
||||
let before = Fingerprint::of(path)?;
|
||||
let bytes = std::fs::read(path)?;
|
||||
if Fingerprint::of(path)? == before {
|
||||
return Ok((before, Source::decode(bytes)?));
|
||||
}
|
||||
}
|
||||
Err(io::Error::other("the file kept changing while read"))
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Watches every tab's folder; a change looks at the tabs.
|
||||
pub fn watch_folders(&mut self, cx: &mut Context<Self>) {
|
||||
let mut dirs: Vec<PathBuf> = self
|
||||
.core
|
||||
.tabs
|
||||
.iter()
|
||||
.filter_map(|tab| Some(tab.session.path()?.parent()?.to_path_buf()))
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
if dirs == self.watched {
|
||||
return;
|
||||
}
|
||||
self.watcher = None;
|
||||
self.watched = dirs.clone();
|
||||
if dirs.is_empty() {
|
||||
return;
|
||||
}
|
||||
match watch(&dirs, cx) {
|
||||
Ok(watcher) => self.watcher = Some(watcher),
|
||||
Err(error) => self.unwatched(error.to_string(), cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Watching failed: every file says so and offers a look.
|
||||
fn unwatched(&mut self, why: String, cx: &mut Context<Self>) {
|
||||
log::error!("mark: watching files failed: {why}");
|
||||
let ids: Vec<u64> = self.core.tabs.iter().map(|tab| tab.id).collect();
|
||||
for id in ids {
|
||||
self.set_outside(id, Outside::Unread(why.clone()), cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn look_all(&mut self, cx: &mut Context<Self>) {
|
||||
// A write, ours or not, may change what Git sees.
|
||||
self.ask_git(cx);
|
||||
let ids: Vec<u64> = self.core.tabs.iter().map(|tab| tab.id).collect();
|
||||
for id in ids {
|
||||
self.look(id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares tab `id`'s file with its last read or write.
|
||||
pub fn look(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = tab.session.path().map(Path::to_path_buf) else {
|
||||
return;
|
||||
};
|
||||
if tab.exporting || tab.session.state() == SaveState::Saving {
|
||||
tab.extra.recheck = true;
|
||||
return;
|
||||
}
|
||||
tab.extra.recheck = false;
|
||||
let ours = tab.session.fingerprint();
|
||||
match Fingerprint::of(&path) {
|
||||
Ok(now) if Some(now) == ours => {
|
||||
if !matches!(tab.extra.outside, Outside::Changed(..)) {
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
}
|
||||
}
|
||||
Ok(_) => self.read_disk(id, path, cx),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => self.missing(id, path, cx),
|
||||
Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gone: an atomic replace, a rename, or a delete.
|
||||
fn missing(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |desk, cx| {
|
||||
cx.background_executor().timer(GAP).await;
|
||||
desk.update(cx, |desk, cx| desk.still_missing(id, path, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn still_missing(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else {
|
||||
return;
|
||||
};
|
||||
if path.exists() {
|
||||
return self.look(id, cx);
|
||||
}
|
||||
let found = match (tab.registry, path.parent()) {
|
||||
(Some(registry), Some(dir)) => self.core.sessions.renamed_in(registry, dir),
|
||||
_ => Ok(None),
|
||||
};
|
||||
match found {
|
||||
Ok(Some(to)) => {
|
||||
log::info!("mark: {} was renamed outside", path.display());
|
||||
self.follow(id, to, cx);
|
||||
self.look(id, cx);
|
||||
}
|
||||
Ok(None) => {
|
||||
log::warn!("mark: {} was deleted outside", path.display());
|
||||
self.set_outside(id, Outside::Deleted, cx);
|
||||
}
|
||||
Err(error) => self.set_outside(id, Outside::Unread(error.to_string()), cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Changed: a clean tab follows, a dirty one asks.
|
||||
fn read_disk(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
|
||||
let reading = path.clone();
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let read = cx
|
||||
.background_executor()
|
||||
.spawn(async move { read_still(&reading) })
|
||||
.await;
|
||||
desk.update(cx, |desk, cx| desk.disk_read(id, path, read, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn disk_read(
|
||||
&mut self,
|
||||
id: u64,
|
||||
path: PathBuf,
|
||||
read: io::Result<(Fingerprint, Source)>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else {
|
||||
return log::info!("mark: a read for a moved or closed tab was dropped");
|
||||
};
|
||||
let (fingerprint, disk) = match read {
|
||||
Ok(read) => read,
|
||||
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
|
||||
};
|
||||
let current = tab.doc.lock().expect("the text lock").current.clone();
|
||||
let dirty = tab.dirty(cx);
|
||||
log::info!("mark: {} changed outside; unsaved {dirty}", path.display());
|
||||
match (disk.text == current, dirty) {
|
||||
(true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx),
|
||||
(false, true) => {
|
||||
let tab = self.tab_mut(id).expect("open");
|
||||
tab.session.external_modified();
|
||||
tab.extra.policy.external_modified();
|
||||
self.set_outside(id, Outside::Changed(disk, fingerprint), cx);
|
||||
// An open comparison shows the newest disk text.
|
||||
if self
|
||||
.comparing
|
||||
.as_ref()
|
||||
.is_some_and(|comparing| comparing.id == id)
|
||||
{
|
||||
self.compare(id, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The disk's text replaces the tab's; autosave starts afresh.
|
||||
pub fn take_disk(
|
||||
&mut self,
|
||||
id: u64,
|
||||
disk: Source,
|
||||
fingerprint: Fingerprint,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = tab.session.reload_at(fingerprint) {
|
||||
return self.set_outside(id, Outside::Unread(error.to_string()), cx);
|
||||
}
|
||||
tab.extra.base = disk.text.clone();
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
let (view, registry) = (tab.view.clone(), tab.registry);
|
||||
let path = tab.session.path().map(Path::to_path_buf);
|
||||
// An atomic replace is a new file underneath.
|
||||
if let (Some(registry), Some(path)) = (registry, path)
|
||||
&& let Err(error) = self.core.sessions.track(registry, &path)
|
||||
{
|
||||
log::warn!("mark: the replaced file did not register: {error}");
|
||||
}
|
||||
view.update(cx, |view, cx| view.refresh(disk, cx));
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
}
|
||||
|
||||
pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context<Self>) {
|
||||
if let Some(tab) = self.tab_mut(id) {
|
||||
tab.extra.outside = outside;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// The active file's trouble on disk, with its way out.
|
||||
pub fn outside_banner(&self, cx: &mut Context<Self>) -> Option<Banner> {
|
||||
let tab = self.active_tab()?;
|
||||
let (id, name) = (tab.id, tab.name.to_string());
|
||||
let this = cx.entity();
|
||||
let (key, why, action, severity) = match &tab.extra.outside {
|
||||
Outside::Clear => return None,
|
||||
Outside::Changed(..) => (
|
||||
"mark.outside.changed",
|
||||
None,
|
||||
"mark.outside.compare",
|
||||
Severity::Warning,
|
||||
),
|
||||
Outside::Deleted => (
|
||||
"mark.outside.deleted",
|
||||
None,
|
||||
"mark.outside.save_as",
|
||||
Severity::Warning,
|
||||
),
|
||||
Outside::Unread(why) => (
|
||||
"mark.outside.unread",
|
||||
Some(why.clone()),
|
||||
"mark.outside.refresh",
|
||||
Severity::Danger,
|
||||
),
|
||||
};
|
||||
let why = why.unwrap_or_default();
|
||||
let said = text(cx, key, &[("name", &name), ("why", &why)]);
|
||||
let label = text(cx, action, &[]);
|
||||
let banner = Banner::new("mark-outside", severity, said);
|
||||
Some(banner.action(label, move |_, cx| {
|
||||
this.update(cx, |desk, cx| {
|
||||
match desk.tab(id).map(|tab| &tab.extra.outside) {
|
||||
Some(Outside::Changed(..)) => desk.compare(id, cx),
|
||||
Some(Outside::Deleted) => desk.save_as(id, None, cx),
|
||||
_ => desk.look(id, cx),
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches `dirs`; each settled burst looks at the tabs.
|
||||
fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<RecommendedWatcher> {
|
||||
let (sender, mut changes) = mpsc::unbounded();
|
||||
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
|
||||
})?;
|
||||
for dir in dirs {
|
||||
watcher.watch(dir, RecursiveMode::NonRecursive)?;
|
||||
}
|
||||
cx.spawn(async move |desk, cx| {
|
||||
while let Some(first) = changes.next().await {
|
||||
cx.background_executor().timer(SETTLE).await;
|
||||
let mut failed = first.err();
|
||||
while let Ok(more) = changes.try_recv() {
|
||||
failed = more.err().or(failed);
|
||||
}
|
||||
let looked = desk.update(cx, |desk, cx| match failed {
|
||||
Some(why) => desk.unwatched(why, cx),
|
||||
None => desk.look_all(cx),
|
||||
});
|
||||
if looked.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
log::info!("mark: watching {} folders", dirs.len());
|
||||
Ok(watcher)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The bundled page: its folder and its input.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use ely_web_content::WebView;
|
||||
use gpui::App;
|
||||
use serde_json::json;
|
||||
|
||||
/// The app's Resources, else this build's `engines/content`.
|
||||
pub fn content() -> Result<PathBuf> {
|
||||
let bundled = std::env::current_exe()?
|
||||
.parent()
|
||||
.and_then(|dir| Some(dir.parent()?.join("Resources/content")));
|
||||
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
|
||||
for dir in bundled.into_iter().chain([built]) {
|
||||
if dir.join("index.html").is_file() {
|
||||
log::info!("mark: page from {}", dir.display());
|
||||
return Ok(dir);
|
||||
}
|
||||
}
|
||||
bail!("ElyMark's page is not built; run scripts/build-mark-content.sh")
|
||||
}
|
||||
|
||||
/// How the page shows a document (MARK-02).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Visual,
|
||||
Source,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub const ALL: [Mode; 3] = [Mode::Visual, Mode::Source, Mode::Preview];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Visual => "visual",
|
||||
Mode::Source => "source",
|
||||
Mode::Preview => "preview",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(name: &str) -> Option<Mode> {
|
||||
Mode::ALL.into_iter().find(|mode| mode.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `markdown` as `mode` after load `load`, maybe placed.
|
||||
pub fn open(
|
||||
view: &WebView,
|
||||
markdown: &str,
|
||||
(mode, load, place): (Mode, u64, Option<crate::view::Place>),
|
||||
folder: &Path,
|
||||
cx: &App,
|
||||
) {
|
||||
let text = |key| ely_ui::i18n::text(cx, key, &[]).to_string();
|
||||
let texts = json!({
|
||||
"searchLanguage": text("mark.search_language"),
|
||||
"noResult": text("mark.no_result"),
|
||||
"copy": text("mark.copy"),
|
||||
"preview": text("mark.preview"),
|
||||
"loading": text("mark.loading"),
|
||||
"edit": text("mark.edit"),
|
||||
"hide": text("mark.hide"),
|
||||
});
|
||||
// Relative links and images resolve in the document's folder.
|
||||
let base = format!("{}/", ely_web_content::file_url(folder));
|
||||
view.run_js(&format!(
|
||||
"ely.open({}, '{}', {texts}, {load}, {}, {})",
|
||||
json!(markdown),
|
||||
mode.name(),
|
||||
json!(base),
|
||||
json!(place)
|
||||
));
|
||||
}
|
||||
|
||||
/// The same text in another mode; pending edits go first.
|
||||
pub fn switch(view: &WebView, mode: Mode) {
|
||||
view.run_js(&format!("ely.mode('{}')", mode.name()));
|
||||
}
|
||||
|
||||
/// The folder for relative links and images.
|
||||
pub fn folder(view: &WebView, folder: &Path) {
|
||||
let base = format!("{}/", ely_web_content::file_url(folder));
|
||||
view.run_js(&format!("ely.base({})", json!(base)));
|
||||
}
|
||||
|
||||
/// Calls `ely.<name>(argument)` in the page.
|
||||
pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) {
|
||||
view.run_js(&format!("ely.{name}({argument})"));
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Rendered Markdown as files: HTML pages and their images (MARK-11).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
/// A whole HTML page: the body, its title, its stylesheet.
|
||||
pub fn page(title: &str, style: &str, body: &str) -> String {
|
||||
format!(
|
||||
"<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{style}\n</head>\n<body>\n{body}\n</body>\n</html>\n",
|
||||
escape(title)
|
||||
)
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// `%XX` decoded, as links to files are written.
|
||||
fn decoded(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
|
||||
while at < bytes.len() {
|
||||
let hex = bytes
|
||||
.get(at + 1..at + 3)
|
||||
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
|
||||
match (bytes[at], hex) {
|
||||
(b'%', Some(byte)) => (out.push(byte), at += 3),
|
||||
(byte, _) => (out.push(byte), at += 1),
|
||||
};
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// One path segment for a URL: reserved bytes percent-escaped.
|
||||
pub fn segment(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in name.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A file image's path; web links stay as they are.
|
||||
fn local(src: &str, folder: &Path) -> Option<PathBuf> {
|
||||
if src.starts_with("data:") || src.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
if let Some(path) = src.strip_prefix("file://") {
|
||||
return Some(PathBuf::from(decoded(path)));
|
||||
}
|
||||
if src.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(decoded(src.split(['?', '#']).next().unwrap_or(src)));
|
||||
Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
folder.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn kind(path: &Path) -> &'static str {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default();
|
||||
match extension.to_lowercase().as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"bmp" => "image/bmp",
|
||||
"avif" => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's HTML; image `src` values replaced by numbered tokens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Marked {
|
||||
pub html: String,
|
||||
/// The original `src` values, decoded, in order.
|
||||
pub images: Vec<String>,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl Marked {
|
||||
/// From the page's answer; anything missing is an error.
|
||||
pub fn read(rendered: &serde_json::Value) -> std::io::Result<Marked> {
|
||||
let bad = || std::io::Error::other("the page's render lacks its parts");
|
||||
let html = rendered["html"].as_str().ok_or_else(bad)?.to_owned();
|
||||
let token = rendered["token"].as_str().ok_or_else(bad)?.to_owned();
|
||||
let images = rendered["images"].as_array().ok_or_else(bad)?;
|
||||
let images = images
|
||||
.iter()
|
||||
.map(|src| src.as_str().map(str::to_owned).ok_or_else(bad));
|
||||
let images = images.collect::<std::io::Result<_>>()?;
|
||||
Ok(Marked {
|
||||
html,
|
||||
images,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Each mark swapped for `each` of its file; unread listed.
|
||||
fn rewrite(
|
||||
&self,
|
||||
folder: &Path,
|
||||
mut each: impl FnMut(&Path) -> Option<String>,
|
||||
) -> (String, Vec<String>) {
|
||||
let (mut html, mut missing) = (self.html.clone(), Vec::new());
|
||||
for (n, src) in self.images.iter().enumerate() {
|
||||
let new = match local(src, folder) {
|
||||
None => src.clone(),
|
||||
Some(path) => each(&path).unwrap_or_else(|| {
|
||||
missing.push(src.clone());
|
||||
src.clone()
|
||||
}),
|
||||
};
|
||||
// Quoted, so mark 1 never matches inside mark 10.
|
||||
let mark = format!("\"{}{n}\"", self.token);
|
||||
html = html.replacen(&mark, &format!("\"{}\"", escape(&new)), 1);
|
||||
}
|
||||
(html, missing)
|
||||
}
|
||||
|
||||
/// Images as data URLs: one file carries all (self-contained).
|
||||
pub fn inline(&self, folder: &Path) -> (String, Vec<String>) {
|
||||
self.rewrite(folder, |path| {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
Some(format!("data:{};base64,{data}", kind(path)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Images linked into the `assets` folder; and the copies.
|
||||
pub fn linked(
|
||||
&self,
|
||||
folder: &Path,
|
||||
assets: &str,
|
||||
) -> (String, Vec<(PathBuf, String)>, Vec<String>) {
|
||||
let mut copies: Vec<(PathBuf, String)> = Vec::new();
|
||||
let (html, missing) = self.rewrite(folder, |path| {
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
if let Some((_, name)) = copies.iter().find(|(from, _)| from == path) {
|
||||
return Some(format!("{}/{}", segment(assets), segment(name)));
|
||||
}
|
||||
let file = path.file_name()?.to_string_lossy().into_owned();
|
||||
// Same names from different folders stay apart.
|
||||
let name = format!("{}-{file}", copies.len() + 1);
|
||||
let url = format!("{}/{}", segment(assets), segment(&name));
|
||||
copies.push((path.to_path_buf(), name));
|
||||
Some(url)
|
||||
});
|
||||
(html, copies, missing)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// HTML as the page marks it.
|
||||
fn marked(images: &[&str]) -> Marked {
|
||||
let token = "ely-image-t-".to_owned();
|
||||
let html = (0..images.len())
|
||||
.map(|n| format!("<img alt=\"a > b src='x'\" src=\"{token}{n}\">"))
|
||||
.collect();
|
||||
Marked {
|
||||
html,
|
||||
images: images.iter().map(|src| src.to_string()).collect(),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_inline_and_links_stay() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
std::fs::create_dir(dir.path().join("assets")).expect("assets");
|
||||
std::fs::write(dir.path().join("assets/a b.png"), b"png").expect("an image");
|
||||
let page = marked(&["assets/a%20b.png", "https://e.x/y.png", "gone.png"]);
|
||||
let (out, missing) = page.inline(dir.path());
|
||||
assert!(out.contains(r#"src="data:image/png;base64,cG5n""#), "{out}");
|
||||
assert!(out.contains(r#"src="https://e.x/y.png""#));
|
||||
assert!(
|
||||
out.contains(r#"alt="a > b src='x'""#),
|
||||
"alt text stays: {out}"
|
||||
);
|
||||
assert_eq!(missing, vec!["gone.png".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_images_copy_once_under_encoded_names() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
std::fs::write(dir.path().join("a#b.png"), b"1").expect("an image");
|
||||
let mut page = marked(&["a%23b.png"; 11]);
|
||||
page.images[10] = "a%23b.png".into();
|
||||
let (out, copies, missing) = page.linked(dir.path(), "report#1_files");
|
||||
assert_eq!(
|
||||
out.matches("src=\"report%231_files/1-a%23b.png\"").count(),
|
||||
11,
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_lacking_its_parts_is_an_error() {
|
||||
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_escape() {
|
||||
assert!(page("a<b>&", "", "").contains("<title>a<b>&</title>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Folders, files, the recent list, going to a line.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{Context, Window};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::folder::{Folder, markdown_under};
|
||||
|
||||
impl Desk {
|
||||
/// A folder lists beside; a file opens in a tab.
|
||||
pub fn open_path(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
||||
match path.is_dir() {
|
||||
true => self.open_folder(path, cx),
|
||||
false => self.open_file(path, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists `root`'s Markdown files off the window (MARK-01).
|
||||
pub fn open_folder(&mut self, root: PathBuf, cx: &mut Context<Self>) {
|
||||
log::info!("mark: opens folder {}", root.display());
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let listed = root.clone();
|
||||
// The real root: listed files compare with tabs' paths.
|
||||
let files = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let real = std::fs::canonicalize(&listed)?;
|
||||
Ok::<_, std::io::Error>((markdown_under(&real)?, real))
|
||||
})
|
||||
.await;
|
||||
desk.update(cx, |desk, cx| match files {
|
||||
Ok((files, real)) => {
|
||||
desk.remember(&real, cx);
|
||||
desk.folder = Some(Folder { root: real, files });
|
||||
desk.listings += 1;
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("mark: {} did not list: {error}", root.display());
|
||||
let (name, error) = (root.display().to_string(), error.to_string());
|
||||
let said = text(
|
||||
cx,
|
||||
"office.error.open",
|
||||
&[("name", &name), ("error", &error)],
|
||||
);
|
||||
desk.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The open folder again, as the disk has it now.
|
||||
pub fn relist(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(root) = self.folder.as_ref().map(|folder| folder.root.clone()) {
|
||||
self.open_folder(root, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts `path` first in the recent list.
|
||||
pub fn remember(&mut self, path: &std::path::Path, cx: &mut Context<Self>) {
|
||||
// One spelling per file: its real path.
|
||||
let remembered = std::fs::canonicalize(path)
|
||||
.and_then(|path| ely_files::recent::remember(&self.recent_file, &path));
|
||||
match remembered {
|
||||
Ok(list) => self.recent = list,
|
||||
Err(error) => {
|
||||
log::error!("mark: the recent list did not save: {error}");
|
||||
self.toast(
|
||||
text(cx, "office.error.engine", &[("error", &error.to_string())]),
|
||||
Severity::Danger,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab `id` shows `path`; the recent list follows.
|
||||
pub fn track_recent(&mut self, id: u64, path: &std::path::Path, cx: &mut Context<Self>) {
|
||||
let Ok(real) = std::fs::canonicalize(path) else {
|
||||
return log::warn!("mark: {} is gone; the recent list keeps it", path.display());
|
||||
};
|
||||
if self.remembered.get(&id) == Some(&real) {
|
||||
return;
|
||||
}
|
||||
if let Some(old) = self.remembered.insert(id, real)
|
||||
&& !old.exists()
|
||||
{
|
||||
self.forget(&old, cx);
|
||||
}
|
||||
self.remember(path, cx);
|
||||
}
|
||||
|
||||
/// Takes a path that is gone out of the list.
|
||||
pub fn forget(&mut self, path: &std::path::Path, cx: &mut Context<Self>) {
|
||||
match ely_files::recent::forget(&self.recent_file, path) {
|
||||
Ok(()) => self.recent.retain(|known| known != path),
|
||||
Err(error) => {
|
||||
log::error!("mark: the recent list did not save: {error}");
|
||||
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `place` in `tab`, else in `file` once open.
|
||||
pub fn open_at(
|
||||
&mut self,
|
||||
tab: Option<u64>,
|
||||
file: PathBuf,
|
||||
place: serde_json::Value,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let held = tab.or_else(|| self.tab_of(&file));
|
||||
if let Some(id) = held.filter(|id| self.tab(*id).is_some()) {
|
||||
self.core.active = Some(id);
|
||||
let view = self.tab(id).map(|open| open.view.clone());
|
||||
if let Some(view) = view {
|
||||
view.update(cx, |view, cx| view.reveal(place, cx));
|
||||
// After the list's own key handling, which keeps focus.
|
||||
let focus = view.read(cx).focus.clone();
|
||||
window.defer(cx, move |window, cx| window.focus(&focus, cx));
|
||||
}
|
||||
return cx.notify();
|
||||
}
|
||||
// A file gone is reported by the open itself.
|
||||
if let Ok(real) = std::fs::canonicalize(&file) {
|
||||
self.going = Some((real, place));
|
||||
}
|
||||
self.open_file(file, cx);
|
||||
}
|
||||
|
||||
/// The tab showing `path`, by real path.
|
||||
pub fn tab_of(&self, path: &std::path::Path) -> Option<u64> {
|
||||
let real = std::fs::canonicalize(path).ok()?;
|
||||
let held = |tab: &&ely_office::Tab<crate::outside::Extra, crate::engine::Mark>| {
|
||||
tab.session
|
||||
.path()
|
||||
.and_then(|open| std::fs::canonicalize(open).ok())
|
||||
== Some(real.clone())
|
||||
};
|
||||
self.core.tabs.iter().find(held).map(|tab| tab.id)
|
||||
}
|
||||
|
||||
/// Tab `id`'s text as the page last sent it.
|
||||
pub fn text_of(&self, id: u64) -> Option<String> {
|
||||
Some(
|
||||
self.tab(id)?
|
||||
.doc
|
||||
.lock()
|
||||
.expect("the text lock")
|
||||
.current
|
||||
.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Renaming the open file in its folder (MARK-01).
|
||||
|
||||
use std::{io, path::PathBuf};
|
||||
|
||||
use ely_files::SaveState;
|
||||
use ely_gpui_component::{forms::TextInput, overlays::PromptDialog, primitives::Severity};
|
||||
use ely_office::{Host as _, files::file_name, prelude::*};
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{AnyElement, AppContext as _, Context, IntoElement as _, Window};
|
||||
|
||||
use crate::desk::Desk;
|
||||
|
||||
/// The text key saying why `name` is refused.
|
||||
fn refused(name: &str) -> Option<&'static str> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']) {
|
||||
return Some("mark.rename.bad");
|
||||
}
|
||||
let extension = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
(!Desk::KIND.kept.contains(&extension.as_str())).then_some("mark.rename.format")
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Asks a new name for the active file.
|
||||
pub fn on_rename(&mut self, _: &commands::Rename, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let (id, name) = (tab.id, tab.name.clone());
|
||||
let field = cx.new(|cx| {
|
||||
let mut field = TextInput::new(window, cx);
|
||||
field.set_text(name, cx);
|
||||
field
|
||||
});
|
||||
self.naming = Some((id, field));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn naming_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let (id, field) = self.naming.as_ref()?;
|
||||
let (closed, sent, id) = (cx.entity(), cx.entity(), *id);
|
||||
let formats = Desk::KIND.kept.join(", .");
|
||||
let bad = text(cx, "mark.rename.bad", &[]);
|
||||
let format = text(cx, "mark.rename.format", &[("formats", &formats)]);
|
||||
let prompt = PromptDialog::new(
|
||||
"mark-rename",
|
||||
text(cx, "mark.rename", &[]),
|
||||
field,
|
||||
move |_, cx| {
|
||||
closed.update(cx, |desk, cx| {
|
||||
desk.naming = None;
|
||||
cx.notify();
|
||||
})
|
||||
},
|
||||
)
|
||||
.label(text(cx, "mark.rename.name", &[]))
|
||||
.submit(text(cx, "mark.rename.apply", &[]))
|
||||
.check(move |name| match refused(name) {
|
||||
None => Ok(()),
|
||||
Some("mark.rename.bad") => Err(bad.clone()),
|
||||
Some(_) => Err(format.clone()),
|
||||
})
|
||||
.on_submit(move |name, _, cx| {
|
||||
sent.update(cx, |desk, cx| {
|
||||
desk.naming = None;
|
||||
desk.rename(id, name, cx);
|
||||
})
|
||||
});
|
||||
Some(prompt.into_any_element())
|
||||
}
|
||||
|
||||
/// Renames tab `id`'s file; an existing name is refused.
|
||||
fn rename(&mut self, id: u64, name: &str, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let Some(from) = tab.session.path().map(|path| path.to_path_buf()) else {
|
||||
return self.toast(text(cx, "mark.rename.untitled", &[]), Severity::Warning, cx);
|
||||
};
|
||||
if tab.exporting || tab.session.state() == SaveState::Saving {
|
||||
return self.toast(text(cx, "mark.rename.saving", &[]), Severity::Warning, cx);
|
||||
}
|
||||
let to = from.with_file_name(name.trim());
|
||||
let (source, target) = (from.clone(), to.clone());
|
||||
cx.spawn(async move |this, cx| {
|
||||
let done = cx
|
||||
.background_executor()
|
||||
.spawn(async move { ely_files::rename_new(&source, &target) })
|
||||
.await;
|
||||
this.update(cx, |desk, cx| desk.renamed(id, from, to, done, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The tab follows only if it holds the old path.
|
||||
fn renamed(
|
||||
&mut self,
|
||||
id: u64,
|
||||
from: PathBuf,
|
||||
to: PathBuf,
|
||||
done: io::Result<()>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Err(error) = done {
|
||||
log::error!("mark: {} did not rename: {error}", from.display());
|
||||
let (name, error) = (file_name(&from), error.to_string());
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.rename.failed",
|
||||
&[("name", &name), ("error", &error)],
|
||||
);
|
||||
return self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
log::info!("mark: renamed {} to {}", from.display(), to.display());
|
||||
match self.tab(id).and_then(|tab| tab.session.path()) {
|
||||
Some(path) if path == from => self.follow(id, to, cx),
|
||||
// The watcher saw the rename first.
|
||||
Some(path) if path == to => log::info!("mark: the tab already follows"),
|
||||
_ => log::info!("mark: the renamed file's tab is gone"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab `id`'s file now lives at `to`.
|
||||
pub fn follow(&mut self, id: u64, to: PathBuf, cx: &mut Context<Self>) {
|
||||
let Some(registry) = self.tab(id).map(|tab| tab.registry) else {
|
||||
return;
|
||||
};
|
||||
// One session moves: an open in the gap focused it.
|
||||
if let Some(registry) = registry
|
||||
&& let Err(error) = self.core_mut().sessions.track(registry, &to)
|
||||
{
|
||||
log::error!("mark: the renamed file did not register: {error}");
|
||||
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
if let Some(tab) = self.tab_mut(id) {
|
||||
tab.session.renamed(to.clone());
|
||||
tab.name = file_name(&to);
|
||||
}
|
||||
if let Some(view) = self.tab(id).map(|tab| tab.view.clone()) {
|
||||
self.observed(id, &view, cx);
|
||||
}
|
||||
// The tree and folder search see the new name.
|
||||
self.relist(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn names_need_no_slashes_and_a_markdown_end() {
|
||||
for bad in ["", " ", "..", "a/b.md", "a\\b.md"] {
|
||||
assert_eq!(super::refused(bad), Some("mark.rename.bad"), "{bad:?}");
|
||||
}
|
||||
assert_eq!(super::refused("notes.txt"), Some("mark.rename.format"));
|
||||
assert_eq!(super::refused("Notes.MD"), None);
|
||||
assert_eq!(super::refused("a.markdown"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//! The session kept across launches (MARK-12, §6.2, §6.3).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use ely_files::{Crash, Marker, Recovery};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::engine::mark;
|
||||
use crate::page::Mode;
|
||||
use crate::view::Place;
|
||||
|
||||
/// A kept tab: file, mode, place, recovery folder.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Kept {
|
||||
path: Option<PathBuf>,
|
||||
name: String,
|
||||
mode: String,
|
||||
place: Option<Place>,
|
||||
slot: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
struct Saved {
|
||||
tabs: Vec<Kept>,
|
||||
}
|
||||
|
||||
/// A kept tab yet to show; recovered text copied.
|
||||
struct Waiting {
|
||||
kept: Kept,
|
||||
real: Option<PathBuf>,
|
||||
text: Option<(String, SystemTime)>,
|
||||
}
|
||||
|
||||
/// The session's files and what still waits to restore.
|
||||
pub struct Keeping {
|
||||
dir: PathBuf,
|
||||
crash: Option<Crash>,
|
||||
waiting: Vec<Waiting>,
|
||||
written: Option<String>,
|
||||
/// A quit began: the kept list stays as then.
|
||||
pub leaving: bool,
|
||||
/// The list as kept when the quit began.
|
||||
frozen: Vec<Kept>,
|
||||
}
|
||||
|
||||
fn recovery(dir: &Path, slot: &str) -> PathBuf {
|
||||
dir.join("recovery").join(slot)
|
||||
}
|
||||
|
||||
/// The newest point's text in `slot`, after a crash.
|
||||
fn recovered(dir: &Path, slot: &str) -> io::Result<Option<(String, SystemTime)>> {
|
||||
let Some(point) = Recovery::new(recovery(dir, slot))
|
||||
.points()?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = std::fs::read(&point.content)?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
Ok(Some((text, point.at)))
|
||||
}
|
||||
|
||||
impl Keeping {
|
||||
/// Reads the last session; a crash brings its unsaved text.
|
||||
pub fn start(dir: &Path) -> io::Result<(Keeping, Vec<PathBuf>, Vec<String>)> {
|
||||
let marker = Crash::check(dir, ely_files::alive)?;
|
||||
log::info!("mark: the last session ended {marker:?}");
|
||||
let crash = Crash::open(dir, std::process::id())?;
|
||||
let saved: Saved = match std::fs::read(dir.join("session.json")) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other)?,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Saved::default(),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let (mut waiting, mut missing) = (Vec::new(), Vec::new());
|
||||
for kept in saved.tabs {
|
||||
let text = match marker {
|
||||
Marker::Crashed => recovered(dir, &kept.slot)?,
|
||||
Marker::Clean | Marker::Discarded => None,
|
||||
};
|
||||
if let Some(path) = &kept.path {
|
||||
match std::fs::canonicalize(path) {
|
||||
// The file reopens as it is on disk.
|
||||
Ok(real) => waiting.push(Waiting {
|
||||
kept: Kept {
|
||||
slot: String::new(),
|
||||
..kept.clone()
|
||||
},
|
||||
real: Some(real),
|
||||
text: None,
|
||||
}),
|
||||
Err(error) => {
|
||||
log::warn!("mark: a kept file does not open: {error}");
|
||||
missing.push(format!("{}: {error}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recovered text never overwrites a file: it is a copy.
|
||||
if text.is_some() {
|
||||
let kept = Kept { path: None, ..kept };
|
||||
waiting.push(Waiting {
|
||||
kept,
|
||||
real: None,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
if marker != Marker::Crashed {
|
||||
clear(&dir.join("recovery"))?;
|
||||
}
|
||||
let paths = waiting
|
||||
.iter()
|
||||
.filter_map(|waiting| waiting.real.clone())
|
||||
.collect();
|
||||
let keeping = Keeping {
|
||||
dir: dir.to_path_buf(),
|
||||
crash: Some(crash),
|
||||
waiting,
|
||||
written: None,
|
||||
leaving: false,
|
||||
frozen: Vec::new(),
|
||||
};
|
||||
Ok((keeping, paths, missing))
|
||||
}
|
||||
|
||||
/// The untitled tabs to make, with their names.
|
||||
pub fn untitled(&self) -> Vec<String> {
|
||||
let untitled = self
|
||||
.waiting
|
||||
.iter()
|
||||
.filter(|waiting| waiting.kept.path.is_none());
|
||||
untitled.map(|waiting| waiting.kept.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// A clean end: no crash next time, no stale points.
|
||||
pub fn close(&mut self) {
|
||||
let Some(crash) = self.crash.take() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = crash
|
||||
.close()
|
||||
.and_then(|()| clear(&self.dir.join("recovery")))
|
||||
{
|
||||
log::error!("mark: the session did not close cleanly: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes every slot folder in `dir` not in `live`.
|
||||
fn sweep(dir: &Path, live: &[&str]) -> io::Result<()> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
entries => entries?,
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
if !live.contains(&entry.file_name().to_string_lossy().as_ref()) {
|
||||
clear(&entry.path())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(dir: &Path) -> io::Result<()> {
|
||||
match std::fs::remove_dir_all(dir) {
|
||||
Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// A tab just shown takes its kept mode, place, text.
|
||||
pub fn restore_shown(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id).filter(|tab| !tab.extra.restored) else {
|
||||
return;
|
||||
};
|
||||
// Each tab takes one kept entry, on its first show.
|
||||
tab.extra.restored = true;
|
||||
let tab = self.tab(id).expect("open");
|
||||
let real = tab
|
||||
.session
|
||||
.path()
|
||||
.and_then(|path| std::fs::canonicalize(path).ok());
|
||||
let untitled = tab.session.path().is_none();
|
||||
let waiting = &mut self.keeping.waiting;
|
||||
let Some(at) = waiting
|
||||
.iter()
|
||||
.position(|waiting| match (&waiting.real, &real) {
|
||||
(Some(kept), Some(open)) => kept == open,
|
||||
(None, None) => untitled,
|
||||
_ => false,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Waiting {
|
||||
kept, text: back, ..
|
||||
} = waiting.remove(at);
|
||||
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
|
||||
log::info!("mark: restores tab {id} as {}", mode.name());
|
||||
let tab = self.tab_mut(id).expect("open");
|
||||
// A recovered copy keeps writing to the same points.
|
||||
tab.extra.slot = kept.slot.clone();
|
||||
let view = tab.view.clone();
|
||||
if let Some((_, at)) = &back {
|
||||
let at = humantime(*at);
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.restore.recovered",
|
||||
&[("name", &kept.name), ("at", &at)],
|
||||
);
|
||||
self.toast(said, ely_gpui_component::primitives::Severity::Info, cx);
|
||||
}
|
||||
let back = back.map(|(back, _)| back);
|
||||
view.update(cx, |view, cx| view.reopen(mode, kept.place, back, cx));
|
||||
}
|
||||
|
||||
/// Every ten seconds: unsaved text to points, tabs to disk.
|
||||
pub fn keep(&mut self, cx: &mut Context<Self>) {
|
||||
let dir = self.keeping.dir.clone();
|
||||
let started = std::time::UNIX_EPOCH
|
||||
.elapsed()
|
||||
.map_or(0, |since| since.as_millis());
|
||||
let mut kept = Vec::new();
|
||||
for tab in &mut self.core.tabs {
|
||||
if tab.extra.slot.is_empty() {
|
||||
tab.extra.slot = format!("{started}-{}", tab.id);
|
||||
}
|
||||
let view = tab.view.read(cx);
|
||||
kept.push(Kept {
|
||||
path: tab.session.path().map(Path::to_path_buf),
|
||||
name: tab.name.to_string(),
|
||||
mode: view.mode.name().to_owned(),
|
||||
place: view.place,
|
||||
slot: tab.extra.slot.clone(),
|
||||
});
|
||||
let current = tab.doc.lock().expect("the text lock").current.clone();
|
||||
let point = mark(¤t);
|
||||
if !tab.dirty(cx) || tab.extra.pointed.as_ref() == Some(&point) || tab.extra.pointing {
|
||||
continue;
|
||||
}
|
||||
tab.extra.pointing = true;
|
||||
let (id, slot) = (tab.id, recovery(&dir, &tab.extra.slot));
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let folder = slot.clone();
|
||||
let wrote = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let now = SystemTime::now();
|
||||
let limits = ely_platform::storage::limits(ely_platform::AppId::Mark)?;
|
||||
let mut recovery = Recovery::new(folder).with_limits(limits.keep, limits.megabytes * 1024 * 1024);
|
||||
recovery.write_once(current.as_bytes(), None, now)
|
||||
})
|
||||
.await;
|
||||
desk.update(cx, |desk, _| desk.pointed(id, point, wrote, &slot))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
// Closed or discarded tabs' points go (§6.2).
|
||||
let live = kept.iter().map(|kept| kept.slot.as_str());
|
||||
let live: Vec<&str> = live
|
||||
.chain(
|
||||
self.keeping
|
||||
.waiting
|
||||
.iter()
|
||||
.map(|waiting| waiting.kept.slot.as_str()),
|
||||
)
|
||||
.collect();
|
||||
if let Err(error) = sweep(&dir.join("recovery"), &live) {
|
||||
log::error!("mark: old recovery points stay: {error}");
|
||||
}
|
||||
if !self.keeping.leaving {
|
||||
self.keeping.frozen = kept.clone();
|
||||
self.write_session(Saved { tabs: kept });
|
||||
}
|
||||
}
|
||||
|
||||
fn pointed(&mut self, id: u64, point: String, wrote: io::Result<Option<PathBuf>>, slot: &Path) {
|
||||
// The tab closed while it wrote: the point goes too.
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
if let Err(error) = clear(slot) {
|
||||
log::error!("mark: a closed tab's point stays: {error}");
|
||||
}
|
||||
return;
|
||||
};
|
||||
tab.extra.pointing = false;
|
||||
match wrote {
|
||||
Ok(_) => tab.extra.pointed = Some(point),
|
||||
Err(error) => log::error!("mark: a recovery point for tab {id} failed: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_session(&mut self, saved: Saved) {
|
||||
let json = serde_json::to_string_pretty(&saved).expect("the session serializes");
|
||||
if self.keeping.written.as_ref() == Some(&json) {
|
||||
return;
|
||||
}
|
||||
let file = self.keeping.dir.join("session.json");
|
||||
match ely_files::save_atomic(&file, json.as_bytes(), 0) {
|
||||
Ok(_) => self.keeping.written = Some(json),
|
||||
Err(error) => log::error!("mark: the session did not save: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps every ten seconds while the window lives (§6.2).
|
||||
pub fn keep_every(&mut self, cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |desk, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(Recovery::INTERVAL).await;
|
||||
if desk.update(cx, |desk, cx| desk.keep(cx)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Slot `slot` closed: its points go, untitled entries too.
|
||||
pub fn drop_slot(
|
||||
&mut self,
|
||||
slot: &str,
|
||||
path: Option<PathBuf>,
|
||||
name: String,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !slot.is_empty()
|
||||
&& let Err(error) = clear(&recovery(&self.keeping.dir, slot))
|
||||
{
|
||||
log::error!("mark: a closed tab's points stay: {error}");
|
||||
}
|
||||
if !self.keeping.leaving {
|
||||
return self.keep(cx);
|
||||
}
|
||||
// Discarded untitled text goes; a first save keeps its file.
|
||||
match path {
|
||||
None => self.keeping.frozen.retain(|kept| kept.slot != slot),
|
||||
Some(path) => {
|
||||
let saved = self
|
||||
.keeping
|
||||
.frozen
|
||||
.iter_mut()
|
||||
.filter(|kept| kept.slot == slot);
|
||||
for kept in saved {
|
||||
(kept.path, kept.name) = (Some(path.clone()), name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let tabs = self.keeping.frozen.clone();
|
||||
self.write_session(Saved { tabs });
|
||||
}
|
||||
|
||||
/// A quit or close begins: the tabs now stay kept.
|
||||
pub fn leave(&mut self, cx: &mut Context<Self>) {
|
||||
self.keeping.leaving = false;
|
||||
self.keep(cx);
|
||||
self.keeping.leaving = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A recovery time as this machine's clock reads it.
|
||||
fn humantime(at: SystemTime) -> String {
|
||||
match jiff::Timestamp::try_from(at) {
|
||||
Ok(at) => at
|
||||
.to_zoned(jiff::tz::TimeZone::system())
|
||||
.strftime("%Y-%m-%d %H:%M")
|
||||
.to_string(),
|
||||
Err(error) => {
|
||||
log::warn!("mark: a recovery time is out of range: {error}");
|
||||
error.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_place_without_a_column_reads_zero() {
|
||||
let kept: super::Kept = serde_json::from_str(
|
||||
r#"{"path":null,"name":"a.md","mode":"source","place":{"line":3,"scroll":0},"slot":"s"}"#,
|
||||
)
|
||||
.expect("an older place reads");
|
||||
let place = kept.place.expect("a place");
|
||||
assert_eq!((place.line, place.column), (3, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Files, outline and search beside the document (MARK-05, MARK-10).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, SegmentedControl},
|
||||
feedback::EmptyState,
|
||||
forms::{InputEvent, SearchInput, TextInput},
|
||||
lists::{FileTree, ListItem, Outline, SelectableList},
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, WeakEntity, Window, div,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::folder::{Hit, MOST_HITS, lines_with};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Pane {
|
||||
Files,
|
||||
Outline,
|
||||
Search,
|
||||
}
|
||||
|
||||
pub struct Side {
|
||||
desk: WeakEntity<Desk>,
|
||||
pane: Pane,
|
||||
query: Entity<TextInput>,
|
||||
/// Search the whole folder, else the shown file.
|
||||
wide: bool,
|
||||
/// Each hit, with its tab when the file is open.
|
||||
hits: Vec<(Option<u64>, Hit)>,
|
||||
/// Searches started; a late answer is dropped.
|
||||
runs: u64,
|
||||
/// The words searched, and the tab when one file was.
|
||||
searched: String,
|
||||
tab: Option<u64>,
|
||||
/// The folder listing searched.
|
||||
listing: u64,
|
||||
/// Files that could not be read, and why.
|
||||
missed: Vec<(PathBuf, String)>,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn new(desk: &Entity<Desk>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// A new shown file is searched again.
|
||||
cx.observe(desk, |side, desk, cx| {
|
||||
let (active, listing) = (desk.read(cx).core.active, desk.read(cx).listings);
|
||||
let shown = !side.wide && side.tab != active;
|
||||
let listed = side.wide && side.listing != listing;
|
||||
if !side.searched.is_empty() && (shown || listed) {
|
||||
side.search(cx);
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
let query = cx.new(|cx| TextInput::new(window, cx));
|
||||
cx.subscribe(&query, |side, _, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Submit {
|
||||
side.search(cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Side {
|
||||
desk: desk.downgrade(),
|
||||
pane: Pane::Files,
|
||||
query,
|
||||
wide: false,
|
||||
hits: Vec::new(),
|
||||
runs: 0,
|
||||
searched: String::new(),
|
||||
tab: None,
|
||||
listing: 0,
|
||||
missed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines holding the query, off the window.
|
||||
fn search(&mut self, cx: &mut Context<Self>) {
|
||||
let query = self.query.read(cx).text().trim().to_owned();
|
||||
let Some(desk) = self.desk.upgrade() else {
|
||||
return;
|
||||
};
|
||||
self.runs += 1;
|
||||
let run = self.runs;
|
||||
let desk = desk.read(cx);
|
||||
(self.tab, self.listing) = (desk.core.active, desk.listings);
|
||||
self.searched = query.clone();
|
||||
if query.is_empty() {
|
||||
(self.hits, self.missed) = (Vec::new(), Vec::new());
|
||||
return cx.notify();
|
||||
}
|
||||
// Open files search as they are now, saved or not.
|
||||
let open: Vec<(u64, PathBuf, String)> = desk
|
||||
.core
|
||||
.tabs
|
||||
.iter()
|
||||
.filter_map(|tab| {
|
||||
let name = PathBuf::from(tab.name.as_ref());
|
||||
let path = tab.session.path().map_or(name, PathBuf::from);
|
||||
Some((tab.id, path, desk.text_of(tab.id)?))
|
||||
})
|
||||
.collect();
|
||||
// Open files by real path, as the folder lists them.
|
||||
let reals: Vec<(u64, PathBuf)> = open
|
||||
.iter()
|
||||
.filter_map(|(id, path, _)| Some((*id, std::fs::canonicalize(path).ok()?)))
|
||||
.collect();
|
||||
let sources: Vec<(Option<u64>, PathBuf)> = match (self.wide, &desk.folder) {
|
||||
(true, Some(folder)) => folder
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| folder.root.join(file))
|
||||
.map(|path| {
|
||||
let held = reals
|
||||
.iter()
|
||||
.find(|(_, real)| *real == path)
|
||||
.map(|(id, _)| *id);
|
||||
(held, path)
|
||||
})
|
||||
.collect(),
|
||||
_ => desk
|
||||
.active_tab()
|
||||
.map(|tab| (Some(tab.id), PathBuf::new()))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
};
|
||||
log::info!("mark: searches {} files", sources.len());
|
||||
cx.spawn(async move |side, cx| {
|
||||
let hits = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let (mut hits, mut missed) = (Vec::new(), Vec::new());
|
||||
for (tab, path) in sources {
|
||||
let held = open.iter().find(|(id, ..)| Some(*id) == tab);
|
||||
let (path, text) = match held {
|
||||
Some((_, path, text)) => (path.clone(), text.clone()),
|
||||
None => {
|
||||
match std::fs::read(&path).and_then(crate::text::Source::decode) {
|
||||
Ok(source) => (path, source.text),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"mark: {} was not searched: {error}",
|
||||
path.display()
|
||||
);
|
||||
missed.push((path, error.to_string()));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
lines_with(&path, &text, &query, &mut found);
|
||||
hits.extend(found.into_iter().map(|hit| (tab, hit)));
|
||||
if hits.len() >= MOST_HITS {
|
||||
hits.truncate(MOST_HITS);
|
||||
break;
|
||||
}
|
||||
}
|
||||
(hits, missed)
|
||||
})
|
||||
.await;
|
||||
side.update(cx, |side, cx| {
|
||||
if side.runs == run {
|
||||
(side.hits, side.missed) = hits;
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn files(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
||||
let read = desk.read(cx);
|
||||
if let Some(folder) = &read.folder {
|
||||
let (root, opener) = (folder.root.clone(), desk.downgrade());
|
||||
return FileTree::new("mark-files", folder.files.clone())
|
||||
.on_open(move |path, _, cx| {
|
||||
let path = root.join(path.as_ref());
|
||||
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
||||
})
|
||||
.size_full()
|
||||
.into_any_element();
|
||||
}
|
||||
if read.recent.is_empty() {
|
||||
let open = Button::new("mark-open", text(cx, "command.file.open", &[]))
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(commands::Open), cx));
|
||||
return EmptyState::new(
|
||||
"mark-no-recent",
|
||||
IconName::Folder,
|
||||
text(cx, "mark.recent.none", &[]),
|
||||
)
|
||||
.action(open)
|
||||
.into_any_element();
|
||||
}
|
||||
let opener = desk.downgrade();
|
||||
let rows = read.recent.iter().map(|path| {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let key: SharedString = path.to_string_lossy().into_owned().into();
|
||||
let item = ListItem::new(key.clone(), name).description(
|
||||
path.parent()
|
||||
.map(|dir| dir.display().to_string())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
(key, item)
|
||||
});
|
||||
rows.fold(
|
||||
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|
||||
|list, (key, item)| list.row(key, item),
|
||||
)
|
||||
.on_activate(move |key, _, cx| {
|
||||
let path = PathBuf::from(key.as_ref());
|
||||
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn outline(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(view) = desk.read(cx).active_view() else {
|
||||
return EmptyState::new(
|
||||
"mark-no-outline",
|
||||
IconName::File,
|
||||
text(cx, "command.blocked.no_document", &[]),
|
||||
)
|
||||
.into_any_element();
|
||||
};
|
||||
let headings = view.read(cx).outline.clone();
|
||||
if headings.is_empty() {
|
||||
return EmptyState::new(
|
||||
"mark-no-headings",
|
||||
IconName::File,
|
||||
text(cx, "mark.outline.none", &[]),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
let outline = headings.iter().enumerate().fold(
|
||||
Outline::new("mark-outline"),
|
||||
|outline, (at, heading)| {
|
||||
outline.item(at.to_string(), heading.title.clone(), heading.level)
|
||||
},
|
||||
);
|
||||
outline
|
||||
.on_select(move |key, window, cx| {
|
||||
if let Ok(at) = key.parse::<usize>() {
|
||||
view.update(cx, |view, cx| view.reveal(json!({ "heading": at }), cx));
|
||||
// Keys go to the document, at the heading.
|
||||
let focus = view.read(cx).focus.clone();
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn search_pane(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
||||
let this = cx.entity();
|
||||
let scope = SegmentedControl::new("mark-scope", if self.wide { "folder" } else { "file" })
|
||||
.size(ControlSize::Sm)
|
||||
.segment("file", text(cx, "mark.search.file", &[]), Some(IconName::File));
|
||||
let scope = match desk.read(cx).folder {
|
||||
Some(_) => scope.segment("folder", text(cx, "mark.search.folder", &[]), Some(IconName::Folder)),
|
||||
None => scope,
|
||||
};
|
||||
let scope = scope.on_change(move |key, _, cx| {
|
||||
this.update(cx, |side, cx| {
|
||||
side.wide = key == "folder";
|
||||
side.search(cx);
|
||||
})
|
||||
});
|
||||
let opener = desk.downgrade();
|
||||
let (hits, query) = (self.hits.clone(), self.searched.clone());
|
||||
// Files not read come first: the results lack them.
|
||||
let missed = self.missed.iter().enumerate().map(|(at, (path, why))| {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let title = text(cx, "mark.search.missed", &[("name", &name)]);
|
||||
(
|
||||
SharedString::from(format!("missed-{at}")),
|
||||
ListItem::new(format!("missed-{at}"), title).description(why.clone()),
|
||||
)
|
||||
});
|
||||
let rows = self.hits.iter().enumerate().map(|(at, (_, hit))| {
|
||||
let name = hit
|
||||
.file
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let item = ListItem::new(at.to_string(), hit.text.clone())
|
||||
.description(format!("{name}:{}", hit.line));
|
||||
(SharedString::from(at.to_string()), item)
|
||||
});
|
||||
let list = missed
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.chain(rows)
|
||||
.fold(
|
||||
SelectableList::new("mark-hits").label(text(cx, "mark.search", &[])),
|
||||
|list, (key, item)| list.row(key, item),
|
||||
)
|
||||
.on_activate(move |key, window, cx| {
|
||||
let Some((tab, hit)) = key.parse::<usize>().ok().and_then(|at| hits.get(at)) else {
|
||||
return;
|
||||
};
|
||||
let (tab, file, line) = (*tab, hit.file.clone(), hit.line);
|
||||
let place = json!({ "line": line, "query": query });
|
||||
opener
|
||||
.update(cx, |desk, cx| desk.open_at(tab, file, place, window, cx))
|
||||
.ok();
|
||||
});
|
||||
let more = (self.hits.len() == MOST_HITS)
|
||||
.then(|| text(cx, "mark.search.more", &[("count", &MOST_HITS.to_string())]));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.size_full()
|
||||
.child(SearchInput::new("mark-search", &self.query))
|
||||
.child(scope)
|
||||
.child(list)
|
||||
.children(more)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Side {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(desk) = self.desk.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let this = cx.entity();
|
||||
let key = match self.pane {
|
||||
Pane::Files => "files",
|
||||
Pane::Outline => "outline",
|
||||
Pane::Search => "search",
|
||||
};
|
||||
let panes = SegmentedControl::new("mark-pane", key)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("files", text(cx, "mark.pane.files", &[]), Some(IconName::Folder))
|
||||
.segment("outline", text(cx, "mark.pane.outline", &[]), Some(IconName::List))
|
||||
.segment("search", text(cx, "mark.pane.search", &[]), Some(IconName::Search))
|
||||
.on_change(move |key, _, cx| {
|
||||
this.update(cx, |side, cx| {
|
||||
side.pane = match key.as_ref() {
|
||||
"outline" => Pane::Outline,
|
||||
"search" => Pane::Search,
|
||||
_ => Pane::Files,
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
let body = match self.pane {
|
||||
Pane::Files => self.files(&desk, cx),
|
||||
Pane::Outline => self.outline(&desk, cx),
|
||||
Pane::Search => self.search_pane(&desk, cx),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.size_full()
|
||||
.child(panes)
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! File bytes to editor text and back (§14.3, QA-21).
|
||||
|
||||
use std::io;
|
||||
|
||||
const BOM: &[u8] = b"\xEF\xBB\xBF";
|
||||
|
||||
/// A file as read: its bytes, BOM and line ending.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Source {
|
||||
bytes: Vec<u8>,
|
||||
bom: bool,
|
||||
/// More CRLF than lone LF line ends.
|
||||
crlf: bool,
|
||||
/// The editor's text: no BOM, CRLF as LF.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Reads UTF-8, with or without BOM; other bytes fail.
|
||||
pub fn decode(bytes: Vec<u8>) -> io::Result<Source> {
|
||||
if bytes.starts_with(b"\xFF\xFE") || bytes.starts_with(b"\xFE\xFF") {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"UTF-16 text is not read",
|
||||
));
|
||||
}
|
||||
let bom = bytes.starts_with(BOM);
|
||||
let body = &bytes[if bom { BOM.len() } else { 0 }..];
|
||||
let text = std::str::from_utf8(body).map_err(|error| {
|
||||
let at = error.valid_up_to();
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("not UTF-8 at byte {at}"),
|
||||
)
|
||||
})?;
|
||||
let crlfs = text.matches("\r\n").count();
|
||||
let crlf = crlfs > text.matches('\n').count() - crlfs;
|
||||
let text = text.replace("\r\n", "\n");
|
||||
Ok(Source {
|
||||
bytes,
|
||||
bom,
|
||||
crlf,
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
/// The bytes for `text`; unedited text is the file itself.
|
||||
pub fn encode(&self, text: &str) -> Vec<u8> {
|
||||
if text == self.text {
|
||||
return self.bytes.clone();
|
||||
}
|
||||
let text = match self.crlf {
|
||||
true => text.replace('\n', "\r\n"),
|
||||
false => text.to_owned(),
|
||||
};
|
||||
let mut bytes = Vec::with_capacity(text.len() + BOM.len());
|
||||
if self.bom {
|
||||
bytes.extend_from_slice(BOM);
|
||||
}
|
||||
bytes.extend_from_slice(text.as_bytes());
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Source;
|
||||
|
||||
#[test]
|
||||
fn unedited_text_writes_the_same_bytes() {
|
||||
for bytes in [
|
||||
&b"# a\r\n\r\nb\n"[..],
|
||||
b"\xEF\xBB\xBF# a\r\n",
|
||||
b"a\rb\n",
|
||||
b"",
|
||||
b"no end",
|
||||
] {
|
||||
let source = Source::decode(bytes.to_vec()).expect("reads");
|
||||
assert_eq!(source.encode(&source.text.clone()), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_keep_the_bom_and_line_ending() {
|
||||
let source = Source::decode(b"\xEF\xBB\xBF# a\r\n\r\nb\r\n".to_vec()).expect("reads");
|
||||
assert_eq!(source.text, "# a\n\nb\n");
|
||||
assert_eq!(source.encode("# a\n\nc\n"), b"\xEF\xBB\xBF# a\r\n\r\nc\r\n");
|
||||
let plain = Source::decode(b"a\nb\n".to_vec()).expect("reads");
|
||||
assert_eq!(plain.encode("a\nc\n"), b"a\nc\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_line_ends_follow_the_most() {
|
||||
let source = Source::decode(b"a\r\nb\r\nc\n".to_vec()).expect("reads");
|
||||
assert_eq!(source.encode("a\nb\nd\n"), b"a\r\nb\r\nd\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_encodings_fail_with_the_place() {
|
||||
let error = Source::decode(b"ok \xFF".to_vec()).expect_err("not UTF-8");
|
||||
assert!(error.to_string().contains("byte 3"), "{error}");
|
||||
assert!(Source::decode(b"\xFF\xFEa\0".to_vec()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Whether Git sees the file changed; ElyTree does the rest.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use ely_gpui_component::{primitives::Severity, shell::StatusBarItem};
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::Context;
|
||||
|
||||
use crate::desk::Desk;
|
||||
|
||||
/// What Git says of one file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Tracked {
|
||||
/// Not in a repository: nothing to show.
|
||||
Outside,
|
||||
Unchanged,
|
||||
Changed,
|
||||
Untracked,
|
||||
Ignored,
|
||||
Conflicted,
|
||||
/// Git could not answer; why.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// Git's word on `file`: in a repository, then its status.
|
||||
pub fn ask(file: &Path) -> Tracked {
|
||||
match status(file) {
|
||||
Ok(tracked) => tracked,
|
||||
Err(error) => Tracked::Unknown(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(git: &Path, dir: &Path, args: &[&str]) -> io::Result<std::process::Output> {
|
||||
ely_platform::git::command(git, dir, args).output()
|
||||
}
|
||||
|
||||
fn status(file: &Path) -> io::Result<Tracked> {
|
||||
let (git, _) = ely_platform::git::locate()?;
|
||||
let dir = file
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("a file without a folder"))?;
|
||||
let top = run(&git, dir, &["rev-parse", "--show-toplevel"])?;
|
||||
let said = String::from_utf8_lossy(&top.stderr);
|
||||
// Not a repository is an answer; other failures are errors.
|
||||
if !top.status.success() {
|
||||
return match said.contains("not a git repository") {
|
||||
true => Ok(Tracked::Outside),
|
||||
false => Err(io::Error::other(said.trim().to_owned())),
|
||||
};
|
||||
}
|
||||
let name = file
|
||||
.file_name()
|
||||
.ok_or_else(|| io::Error::other("a file without a name"))?;
|
||||
let name = name.to_string_lossy();
|
||||
let said = run(
|
||||
&git,
|
||||
dir,
|
||||
&["status", "--porcelain=v2", "-z", "--ignored", "--", &name],
|
||||
)?;
|
||||
if !said.status.success() {
|
||||
return Err(io::Error::other(
|
||||
String::from_utf8_lossy(&said.stderr).trim().to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(match said.stdout.first() {
|
||||
None => Tracked::Unchanged,
|
||||
Some(b'1' | b'2') => Tracked::Changed,
|
||||
Some(b'u') => Tracked::Conflicted,
|
||||
Some(b'?') => Tracked::Untracked,
|
||||
Some(b'!') => Tracked::Ignored,
|
||||
Some(other) => {
|
||||
return Err(io::Error::other(format!(
|
||||
"git status said {:?}",
|
||||
*other as char
|
||||
)));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Asks Git about the shown file, off the main thread.
|
||||
pub fn ask_git(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
let Some(path) = tab.session.path().map(Path::to_path_buf) else {
|
||||
self.tracked = None;
|
||||
return;
|
||||
};
|
||||
let id = tab.id;
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let asked = path.clone();
|
||||
let tracked = cx
|
||||
.background_executor()
|
||||
.spawn(async move { ask(&asked) })
|
||||
.await;
|
||||
desk.update(cx, |desk, cx| {
|
||||
// Only the shown file's answer counts.
|
||||
let shown = desk.active_tab().filter(|tab| tab.id == id);
|
||||
if shown.and_then(|tab| tab.session.path()) == Some(&path) {
|
||||
log::info!("mark: git says {tracked:?}");
|
||||
desk.tracked = Some(tracked);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The Git status item; pressing shows the file in ElyTree.
|
||||
pub fn git_item(&self, cx: &mut Context<Self>) -> Option<StatusBarItem> {
|
||||
let key = match self.tracked.as_ref()? {
|
||||
Tracked::Outside => return None,
|
||||
Tracked::Unchanged => "mark.git.unchanged",
|
||||
Tracked::Changed => "mark.git.changed",
|
||||
Tracked::Untracked => "mark.git.untracked",
|
||||
Tracked::Ignored => "mark.git.ignored",
|
||||
Tracked::Conflicted => "mark.git.conflicted",
|
||||
Tracked::Unknown(why) => {
|
||||
let label = text(cx, "mark.git.unknown", &[]);
|
||||
return Some(
|
||||
StatusBarItem::new("mark-git")
|
||||
.label(label)
|
||||
.tooltip(why.clone()),
|
||||
);
|
||||
}
|
||||
};
|
||||
// ElyTree takes the file: its repository and its history (§16).
|
||||
let file = self.active_tab()?.session.path()?.to_path_buf();
|
||||
let item = StatusBarItem::new("mark-git")
|
||||
.label(text(cx, key, &[]))
|
||||
.tooltip(text(cx, "mark.git.open", &[]));
|
||||
let this = cx.entity();
|
||||
Some(item.on_click(move |_, _, cx| {
|
||||
if !ely_platform::apps::installed(AppId::Tree) {
|
||||
let said = text(cx, "mark.git.missing", &[]);
|
||||
this.update(cx, |desk, cx| desk.toast(said, Severity::Warning, cx));
|
||||
return;
|
||||
}
|
||||
let (this, file) = (this.clone(), file.clone());
|
||||
cx.spawn(async move |cx| {
|
||||
let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await;
|
||||
if let Err(error) = opened {
|
||||
log::error!("mark: ElyTree did not open: {error}");
|
||||
cx.update(|cx| {
|
||||
let said = text(cx, "mark.git.failed", &[("error", &error.to_string())]);
|
||||
this.update(cx, |desk, cx| desk.toast(said, Severity::Danger, cx));
|
||||
});
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let (git, _) = ely_platform::git::locate().expect("a Git");
|
||||
let done = std::process::Command::new(git)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output();
|
||||
assert!(done.expect("git runs").status.success(), "git {args:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_stay_and_broken_repositories_say_why() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let repo = dir.path().join("repo ");
|
||||
std::fs::create_dir(&repo).expect("the folder");
|
||||
git(&repo, &["init", "-q"]);
|
||||
std::fs::write(repo.join("t.md"), "a").expect("a file");
|
||||
assert_eq!(ask(&repo.join("t.md")), Tracked::Untracked);
|
||||
std::fs::write(repo.join(".git/config"), "[bad\n").expect("a broken config");
|
||||
assert!(matches!(ask(&repo.join("t.md")), Tracked::Unknown(_)));
|
||||
let outside = tempfile::tempdir().expect("a temp dir");
|
||||
std::fs::write(outside.path().join("t.md"), "a").expect("a file");
|
||||
assert_eq!(ask(&outside.path().join("t.md")), Tracked::Outside);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
//! One Markdown file in a page, its text returned.
|
||||
|
||||
use ely_web_content::{Event, Reply, WebView};
|
||||
use gpui::{
|
||||
Context, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable as _,
|
||||
InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::engine::{Doc, mark};
|
||||
use crate::page::Mode;
|
||||
|
||||
/// The page reported a change or new text.
|
||||
pub struct Reported;
|
||||
|
||||
/// One heading of the document's outline.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Heading {
|
||||
pub level: usize,
|
||||
pub title: String,
|
||||
}
|
||||
/// Where the reader was: a file line and the scroll.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Place {
|
||||
pub line: u64,
|
||||
/// Sessions from before the column was kept read zero.
|
||||
#[serde(default)]
|
||||
pub column: u64,
|
||||
pub scroll: f64,
|
||||
}
|
||||
|
||||
/// Text past this many bytes opens as source (PERF-07).
|
||||
pub const LARGE: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// A render's answer: the page's reply, or why not.
|
||||
type Rendered = Box<dyn FnOnce(Result<Value, String>, &mut gpui::App)>;
|
||||
|
||||
/// The page failed; the shell shows why.
|
||||
pub struct Failed(pub String);
|
||||
|
||||
pub struct MarkView {
|
||||
pub(crate) web: Entity<WebView>,
|
||||
doc: Doc,
|
||||
/// The newest change the page announced.
|
||||
pub changed: u64,
|
||||
/// The change whose text `doc` holds.
|
||||
reported: u64,
|
||||
/// The mark at the last load or save.
|
||||
saved: String,
|
||||
/// Page loads so far; a reload restarts its versions.
|
||||
load: u64,
|
||||
pub mode: Mode,
|
||||
/// Headings as the shown editor reads them.
|
||||
pub outline: Vec<Heading>,
|
||||
/// Shown since the last page load.
|
||||
pub shown: bool,
|
||||
/// A place to bring into view once shown.
|
||||
waiting: Option<Value>,
|
||||
/// Where the page's search stands, while one runs.
|
||||
pub found: Option<crate::finding::Found>,
|
||||
/// The file's folder: links and images resolve here.
|
||||
pub(crate) folder: PathBuf,
|
||||
/// A saved file: images can go beside it.
|
||||
pub attachable: bool,
|
||||
/// Past `LARGE`: source alone, every byte still saved (MARK-14).
|
||||
pub large: bool,
|
||||
/// The caret's line and the scroll, as last reported.
|
||||
pub place: Option<Place>,
|
||||
/// The writing view, as the page applies it (MARK-09).
|
||||
look: Value,
|
||||
/// Renders asked of the page, by number.
|
||||
renders: std::collections::HashMap<u64, Rendered>,
|
||||
asked: u64,
|
||||
pub focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl EventEmitter<Reported> for MarkView {}
|
||||
impl EventEmitter<Failed> for MarkView {}
|
||||
|
||||
impl MarkView {
|
||||
pub fn new(
|
||||
web: Entity<WebView>,
|
||||
doc: Doc,
|
||||
saved: String,
|
||||
folder: PathBuf,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let focus = web.read(cx).focus_handle(cx);
|
||||
let large = doc.lock().expect("the text lock").current.len() > LARGE;
|
||||
cx.subscribe(&web, Self::heard).detach();
|
||||
let paste = crate::attach::paste(cx.weak_entity());
|
||||
web.update(cx, |web, _| web.intercept_paste(paste));
|
||||
MarkView {
|
||||
web,
|
||||
doc,
|
||||
changed: 0,
|
||||
reported: 0,
|
||||
saved,
|
||||
load: 0,
|
||||
mode: if large { Mode::Source } else { Mode::Visual },
|
||||
large,
|
||||
outline: Vec::new(),
|
||||
shown: false,
|
||||
waiting: None,
|
||||
found: None,
|
||||
attachable: false,
|
||||
place: None,
|
||||
look: Value::Null,
|
||||
renders: std::collections::HashMap::new(),
|
||||
asked: 0,
|
||||
folder,
|
||||
focus,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every change's text has arrived.
|
||||
pub fn settled(&self) -> bool {
|
||||
self.reported == self.changed
|
||||
}
|
||||
|
||||
/// The text's size now, in bytes.
|
||||
pub fn bytes(&self) -> usize {
|
||||
self.doc.lock().expect("the text lock").current.len()
|
||||
}
|
||||
|
||||
pub fn modified(&self) -> bool {
|
||||
mark(&self.doc.lock().expect("the text lock").current) != self.saved
|
||||
}
|
||||
|
||||
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
|
||||
self.saved = mark.to_owned();
|
||||
cx.emit(Reported);
|
||||
}
|
||||
|
||||
/// The same text in `mode` (MARK-02).
|
||||
pub fn switch(&mut self, mode: Mode, cx: &mut Context<Self>) {
|
||||
if self.large && mode != Mode::Source {
|
||||
log::info!("mark: a large file stays in source");
|
||||
let said = ely_ui::i18n::text(cx, "mark.large.modes", &[]).to_string();
|
||||
return cx.emit(Failed(said));
|
||||
}
|
||||
log::info!("mark: switches to {}", mode.name());
|
||||
self.mode = mode;
|
||||
crate::page::switch(self.web.read(cx), mode);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Searches the page for `spec` (MARK-06).
|
||||
pub fn find(&mut self, spec: Value, cx: &mut Context<Self>) {
|
||||
crate::page::call(self.web.read(cx), "find", &spec);
|
||||
}
|
||||
|
||||
/// Steps under `spec`, the box's own; tabs keep older ones.
|
||||
pub fn step(&mut self, spec: Value, forward: bool, cx: &mut Context<Self>) {
|
||||
let ask = serde_json::json!({ "spec": spec, "forward": forward });
|
||||
crate::page::call(self.web.read(cx), "step", &ask);
|
||||
}
|
||||
|
||||
pub fn replace(&mut self, spec: Value, all: bool, cx: &mut Context<Self>) {
|
||||
let ask = serde_json::json!({ "spec": spec, "all": all });
|
||||
crate::page::call(self.web.read(cx), "replace", &ask);
|
||||
}
|
||||
|
||||
/// A table step: insert, rows and columns (MARK-06).
|
||||
pub fn table(&mut self, op: &str, cx: &mut Context<Self>) {
|
||||
crate::page::call(self.web.read(cx), "table", &Value::from(op));
|
||||
}
|
||||
|
||||
pub fn unfind(&mut self, cx: &mut Context<Self>) {
|
||||
self.found = None;
|
||||
crate::page::call(self.web.read(cx), "unfind", &Value::Null);
|
||||
}
|
||||
|
||||
/// Brings `{"heading": n}` or `{"line": n}` into view.
|
||||
pub fn reveal(&mut self, place: Value, cx: &mut Context<Self>) {
|
||||
match self.shown {
|
||||
true => crate::page::call(self.web.read(cx), "reveal", &place),
|
||||
false => self.waiting = Some(place),
|
||||
}
|
||||
}
|
||||
|
||||
/// The disk's text replaces a clean one; the place stays.
|
||||
pub fn refresh(&mut self, source: crate::text::Source, cx: &mut Context<Self>) {
|
||||
let text = source.text.clone();
|
||||
{
|
||||
let mut doc = self.doc.lock().expect("the text lock");
|
||||
doc.current = text.clone();
|
||||
doc.source = source;
|
||||
}
|
||||
self.saved = mark(&text);
|
||||
(self.changed, self.reported) = (0, 0);
|
||||
crate::page::call(self.web.read(cx), "refresh", &Value::from(text));
|
||||
cx.emit(Reported);
|
||||
}
|
||||
|
||||
/// The writing view's settings, applied now and at each show.
|
||||
pub fn set_look(&mut self, look: Value, cx: &mut Context<Self>) {
|
||||
if look == self.look {
|
||||
return;
|
||||
}
|
||||
if self.shown {
|
||||
crate::page::call(self.web.read(cx), "look", &look);
|
||||
}
|
||||
self.look = look;
|
||||
}
|
||||
|
||||
/// The whole text as `kind`; `done` takes it.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
done: impl FnOnce(Result<Value, String>, &mut gpui::App) + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.asked += 1;
|
||||
self.renders.insert(self.asked, Box::new(done));
|
||||
let ask = serde_json::json!({ "kind": kind, "ask": self.asked });
|
||||
crate::page::call(self.web.read(cx), "render", &ask);
|
||||
}
|
||||
|
||||
/// `text` replaces the editor's, as one undoable edit.
|
||||
pub fn adopt(&mut self, text: String, cx: &mut Context<Self>) {
|
||||
crate::page::call(self.web.read(cx), "adopt", &Value::from(text));
|
||||
}
|
||||
|
||||
/// Shows again as `mode` at `place`; recovered text unsaved.
|
||||
pub fn reopen(
|
||||
&mut self,
|
||||
mode: Mode,
|
||||
place: Option<Place>,
|
||||
text: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut doc = self.doc.lock().expect("the text lock");
|
||||
if let Some(text) = text {
|
||||
doc.current = text;
|
||||
}
|
||||
let current = doc.current.clone();
|
||||
drop(doc);
|
||||
let mode = if self.large { Mode::Source } else { mode };
|
||||
self.mode = mode;
|
||||
let at = (mode, self.load, place);
|
||||
crate::page::open(self.web.read(cx), ¤t, at, &self.folder, cx);
|
||||
cx.emit(Reported);
|
||||
}
|
||||
|
||||
/// The file moved; images and links follow it.
|
||||
pub fn moved(&mut self, folder: PathBuf, cx: &mut Context<Self>) {
|
||||
if folder == self.folder {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.web.read(cx).allow_images(&folder) {
|
||||
log::error!("mark: images cannot follow the file: {error:#}");
|
||||
return cx.emit(Failed(format!("{error:#}")));
|
||||
}
|
||||
crate::page::folder(self.web.read(cx), &folder);
|
||||
self.folder = folder;
|
||||
}
|
||||
|
||||
fn heard(&mut self, web: Entity<WebView>, event: &Event, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
// A reload shows the last text that arrived.
|
||||
Event::Loaded => {
|
||||
self.load += 1;
|
||||
self.shown = false;
|
||||
(self.changed, self.reported) = (0, 0);
|
||||
let text = self.doc.lock().expect("the text lock").current.clone();
|
||||
let at = (self.mode, self.load, self.place);
|
||||
crate::page::open(web.read(cx), &text, at, &self.folder, cx);
|
||||
cx.emit(Reported);
|
||||
}
|
||||
Event::Query(said, reply) => self.query(said, reply, cx),
|
||||
Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"),
|
||||
Event::Failed(why) => cx.emit(Failed(why.clone())),
|
||||
Event::Printed(path, _) => {
|
||||
log::error!("mark: an editor page printed {}", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context<Self>) {
|
||||
let said: Value = match serde_json::from_str(said) {
|
||||
Ok(said) => said,
|
||||
Err(error) => {
|
||||
log::error!("mark: the page sent bad JSON: {error}");
|
||||
return reply.fail("bad JSON");
|
||||
}
|
||||
};
|
||||
if said["generation"]
|
||||
.as_u64()
|
||||
.is_some_and(|load| load != self.load)
|
||||
{
|
||||
log::info!("mark: a report from an earlier page load was dropped");
|
||||
return reply.ok("");
|
||||
}
|
||||
let version = said["version"].as_u64();
|
||||
match (said["kind"].as_str(), version, said["text"].as_str()) {
|
||||
// A fallback shows source; the tab follows.
|
||||
(Some("shown"), ..) => {
|
||||
log::info!(
|
||||
"mark: shown as {}, fallback {}",
|
||||
said["mode"],
|
||||
said["fallback"]
|
||||
);
|
||||
if let Some(mode) = said["mode"].as_str().and_then(Mode::parse) {
|
||||
self.mode = mode;
|
||||
cx.notify();
|
||||
}
|
||||
self.shown = true;
|
||||
if let Some(place) = self.waiting.take() {
|
||||
crate::page::call(self.web.read(cx), "reveal", &place);
|
||||
}
|
||||
if !self.look.is_null() {
|
||||
crate::page::call(self.web.read(cx), "look", &self.look);
|
||||
}
|
||||
cx.emit(Reported);
|
||||
}
|
||||
(Some("outline"), ..) => match headings(&said["items"]) {
|
||||
Some(outline) => {
|
||||
self.outline = outline;
|
||||
cx.notify();
|
||||
}
|
||||
None => {
|
||||
log::error!("mark: the page sent a bad outline");
|
||||
return reply.fail("bad outline");
|
||||
}
|
||||
},
|
||||
(Some("found"), ..) => match found(&said) {
|
||||
Some(found) => {
|
||||
self.found = Some(found);
|
||||
cx.notify();
|
||||
}
|
||||
None => {
|
||||
log::error!("mark: the page sent a bad search state");
|
||||
return reply.fail("bad search state");
|
||||
}
|
||||
},
|
||||
(Some("table"), ..) if said["done"] == false => {
|
||||
let key = match self.mode {
|
||||
Mode::Visual => "mark.table.none",
|
||||
_ => "mark.table.visual",
|
||||
};
|
||||
cx.emit(Failed(ely_ui::i18n::text(cx, key, &[]).to_string()));
|
||||
}
|
||||
(Some("inserted"), ..) if said["done"] == false => {
|
||||
cx.emit(Failed(
|
||||
ely_ui::i18n::text(cx, "mark.attach.preview", &[]).to_string(),
|
||||
));
|
||||
}
|
||||
(Some("table" | "inserted"), ..) => {}
|
||||
(Some("rendered"), ..) => {
|
||||
let ask = said["ask"].as_u64().unwrap_or_default();
|
||||
let Some(done) = self.renders.remove(&ask) else {
|
||||
log::error!("mark: a render nobody asked for came back");
|
||||
return reply.fail("unasked render");
|
||||
};
|
||||
let result = match said["error"].as_str() {
|
||||
Some(error) => Err(error.to_owned()),
|
||||
None => Ok(said.clone()),
|
||||
};
|
||||
done(result, cx);
|
||||
}
|
||||
(Some("place"), ..) => match (
|
||||
said["line"].as_u64(),
|
||||
said["column"].as_u64(),
|
||||
said["scroll"].as_f64(),
|
||||
) {
|
||||
(Some(line), Some(column), Some(scroll)) => {
|
||||
self.place = Some(Place {
|
||||
line,
|
||||
column,
|
||||
scroll,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
log::error!("mark: the page sent a bad place");
|
||||
return reply.fail("bad place");
|
||||
}
|
||||
},
|
||||
(Some("changed"), Some(version), _) => {
|
||||
self.changed = self.changed.max(version);
|
||||
cx.emit(Reported);
|
||||
}
|
||||
// Only the newest text counts.
|
||||
(Some("text"), Some(version), Some(text)) if version >= self.reported => {
|
||||
self.doc.lock().expect("the text lock").current = text.to_owned();
|
||||
self.reported = version;
|
||||
cx.emit(Reported);
|
||||
}
|
||||
(Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"),
|
||||
_ => {
|
||||
log::error!("mark: the page asked something unknown");
|
||||
return reply.fail("unknown request");
|
||||
}
|
||||
}
|
||||
reply.ok("");
|
||||
}
|
||||
}
|
||||
|
||||
/// A search state from the page; anything malformed is None.
|
||||
fn found(said: &Value) -> Option<crate::finding::Found> {
|
||||
if let Some(error) = said["error"].as_str() {
|
||||
let error = Some(error.to_owned());
|
||||
return Some(crate::finding::Found {
|
||||
current: None,
|
||||
total: 0,
|
||||
error,
|
||||
});
|
||||
}
|
||||
let total = said["total"].as_u64()? as usize;
|
||||
let current = match &said["current"] {
|
||||
Value::Null => None,
|
||||
at => Some(at.as_u64()? as usize),
|
||||
};
|
||||
Some(crate::finding::Found {
|
||||
current,
|
||||
total,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Dropped files: images go beside the document.
|
||||
let drop = cx.listener(|view, dropped: &ExternalPaths, _, cx| {
|
||||
view.attach(crate::attach::Attach::Files(dropped.paths().to_vec()), cx)
|
||||
});
|
||||
div().size_full().on_drop(drop).child(self.web.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Builds the page into the folder given; no network.
|
||||
import { build } from 'esbuild'
|
||||
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const out = process.argv[2]
|
||||
if (!out) throw new Error('usage: node build.mjs <output folder>')
|
||||
await rm(out, { recursive: true, force: true })
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const built = await build({
|
||||
entryPoints: ['src/main.js', 'src/diagram.js', 'src/main.css'],
|
||||
outdir: out,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
target: 'chrome140',
|
||||
loader: { '.woff2': 'file', '.woff': 'file', '.ttf': 'file' },
|
||||
assetNames: 'fonts/[name]-[hash]',
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
logLevel: 'warning',
|
||||
})
|
||||
// Exports carry one stylesheet: fonts inline, woff2 alone.
|
||||
const exported = await build({
|
||||
entryPoints: ['src/export.css'],
|
||||
outdir: out,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
loader: { '.woff2': 'dataurl', '.woff': 'empty', '.ttf': 'empty' },
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
logLevel: 'warning',
|
||||
})
|
||||
const sheet = join(out, 'export.css')
|
||||
// The dropped woff and ttf left empty sources.
|
||||
await writeFile(sheet, (await readFile(sheet, 'utf8')).replace(/,url\(\) format\("(?:woff|truetype)"\)/g, ''))
|
||||
await copyFile('index.html', join(out, 'index.html'))
|
||||
|
||||
/** Each bundled package's folder under node_modules. */
|
||||
function packages(inputs) {
|
||||
const found = new Set()
|
||||
for (const input of inputs) {
|
||||
const at = input.lastIndexOf('node_modules/')
|
||||
if (at < 0) continue
|
||||
const parts = input.slice(at + 13).split('/')
|
||||
const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]
|
||||
found.add(input.slice(0, at + 13) + name)
|
||||
}
|
||||
return [...found].sort()
|
||||
}
|
||||
|
||||
/** Name, version and license; the file, else the manifest's. */
|
||||
async function notice(folder) {
|
||||
const manifest = JSON.parse(await readFile(join(folder, 'package.json'), 'utf8'))
|
||||
const head = `${manifest.name} ${manifest.version} (${manifest.license})`
|
||||
const file = (await readdir(folder)).find((name) => /^(licen[cs]e|copying)/i.test(name))
|
||||
if (file) return `${head}\n\n${(await readFile(join(folder, file), 'utf8')).trim()}\n`
|
||||
const author = manifest.author?.name ?? manifest.author
|
||||
if (manifest.license !== 'MIT' || !author) throw new Error(`${manifest.name}: no license file`)
|
||||
console.log(`${manifest.name} ships no license file; MIT from its manifest`)
|
||||
return `${head}\n\nCopyright (c) ${author}\n\n${MIT}\n`
|
||||
}
|
||||
|
||||
const MIT = `Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.`
|
||||
|
||||
const folders = packages([
|
||||
...Object.keys(built.metafile.inputs),
|
||||
...Object.keys(exported.metafile.inputs),
|
||||
])
|
||||
const notices = await Promise.all(folders.map(notice))
|
||||
await writeFile(join(out, 'THIRD-PARTY-NOTICES.txt'), notices.join('\n' + '-'.repeat(72) + '\n\n'))
|
||||
console.log(`built ${folders.length} packages into ${out}`)
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="main.css">
|
||||
<script src="main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="document"></main>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4871
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ely-mark-content",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language-data": "6.5.2",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.13",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@milkdown/kit": "7.22.2",
|
||||
"codemirror": "6.0.2",
|
||||
"hast-util-from-dom": "5.0.1",
|
||||
"hast-util-to-text": "4.0.2",
|
||||
"katex": "0.19.0",
|
||||
"mermaid": "12.1.0",
|
||||
"prosemirror-search": "1.1.1",
|
||||
"rehype-katex": "7.0.1",
|
||||
"rehype-stringify": "10.0.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-rehype": "11.1.2",
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vfile": "6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** One request to the app, as a promise. */
|
||||
export function ask(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.elyQuery({
|
||||
request: JSON.stringify(request),
|
||||
onSuccess: resolve,
|
||||
onFailure: (code, message) => reject(new Error(`${code}: ${message}`)),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Code blocks in CodeMirror; LaTeX and Mermaid preview.
|
||||
import { basicSetup, EditorView } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { indentWithTab } from '@codemirror/commands'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
import { theme } from './theme.js'
|
||||
import { icons } from './icons.js'
|
||||
import { render } from './math.js'
|
||||
|
||||
/** Previews for one editor's code blocks. */
|
||||
function previews(owner) {
|
||||
return (language, source, apply) => {
|
||||
const kind = source.length === 0 ? '' : language.toLowerCase()
|
||||
switch (kind) {
|
||||
case 'latex':
|
||||
return render(source, true, document.createElement('div'))
|
||||
case 'mermaid':
|
||||
return owner.show(source, apply)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Milkdown's code block config; read-only in preview. */
|
||||
export function code(texts, editable, owner) {
|
||||
const locked = editable ? [] : [EditorState.readOnly.of(true), EditorView.editable.of(false)]
|
||||
return (ctx) =>
|
||||
ctx.update(codeBlockConfig.key, (prev) => ({
|
||||
...prev,
|
||||
...icons,
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), theme, ...locked],
|
||||
languages,
|
||||
searchPlaceholder: texts.searchLanguage,
|
||||
noResultText: texts.noResult,
|
||||
copyText: texts.copy,
|
||||
previewLabel: texts.preview,
|
||||
previewLoading: texts.loading,
|
||||
previewToggleButton: (only) => (only ? texts.edit : texts.hide),
|
||||
renderPreview: previews(owner),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Mermaid, loaded on first use; only saved source renders.
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
let count = 0
|
||||
|
||||
/** The theme from the page's Ely tokens. */
|
||||
function variables() {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const token = (name) => style.getPropertyValue(`--ely-${name}`).trim()
|
||||
return {
|
||||
background: token('bg'),
|
||||
primaryColor: token('surface'),
|
||||
primaryTextColor: token('fg'),
|
||||
primaryBorderColor: token('border'),
|
||||
lineColor: token('fg-muted'),
|
||||
textColor: token('fg'),
|
||||
fontFamily: token('font'),
|
||||
}
|
||||
}
|
||||
|
||||
/** `source` as SVG; `paper` draws Mermaid's light theme for export. */
|
||||
window.elyDiagram = async (source, look) => {
|
||||
const paper = look === 'paper'
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
theme: paper ? 'default' : 'base',
|
||||
themeVariables: paper ? {} : variables(),
|
||||
})
|
||||
count += 1
|
||||
const { svg } = await mermaid.render(`ely-diagram-${count}`, source)
|
||||
return svg
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Mermaid previews; each editor owns its records.
|
||||
import { failure } from './failure.js'
|
||||
|
||||
// Beside this script; the page's base is the document's folder.
|
||||
const here = document.currentScript.src
|
||||
|
||||
/** The Mermaid bundle, added once as a classic script. */
|
||||
let loading
|
||||
export function bundle() {
|
||||
loading ??= new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = new URL('diagram.js', here).href
|
||||
script.onload = () => resolve(window.elyDiagram)
|
||||
script.onerror = () => reject(new Error('diagram.js did not load'))
|
||||
document.head.append(script)
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
let queue = Promise.resolve()
|
||||
let count = 0
|
||||
const owners = new Set()
|
||||
|
||||
// New tokens mean a new theme; every owner redraws.
|
||||
new MutationObserver(() => {
|
||||
for (const owner of owners) owner.redraw()
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
|
||||
/** A block's diagram, by its id on the page. */
|
||||
const box = (id, inner) => `<div class="ely-diagram" data-diagram="${id}">${inner}</div>`
|
||||
const placed = (id) => document.querySelector(`[data-diagram="${id}"]`) !== null
|
||||
// After the code block's next render.
|
||||
const settle = () => new Promise((done) => setTimeout(done))
|
||||
|
||||
/** One editor's diagrams; a result lands only on its placeholder. */
|
||||
export function diagrams() {
|
||||
const records = new Map()
|
||||
|
||||
/** Drops diagrams whose placeholder has left the page. */
|
||||
const sweep = () => {
|
||||
for (const [id, record] of records) {
|
||||
if (record.settled && !placed(id)) records.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each draw has a turn; a later draw voids earlier. */
|
||||
const draw = (id, record) => {
|
||||
const turn = ++record.turn
|
||||
const live = () => records.get(id) === record && record.turn === turn && placed(id)
|
||||
queue = queue.then(async () => {
|
||||
await settle()
|
||||
if (!live()) return
|
||||
try {
|
||||
const render = await bundle()
|
||||
if (!live()) return
|
||||
const svg = await render(record.source)
|
||||
await settle()
|
||||
if (live()) record.apply(box(id, svg))
|
||||
} catch (error) {
|
||||
await settle()
|
||||
if (!live()) return
|
||||
records.delete(id)
|
||||
record.apply(failure(record.source, error.message))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const owner = {
|
||||
/** The placeholder for `source`; its diagram follows. */
|
||||
show(source, apply) {
|
||||
sweep()
|
||||
const id = `${++count}`
|
||||
const record = { source, apply, settled: false, turn: 0 }
|
||||
records.set(id, record)
|
||||
settle().then(() => (record.settled = true))
|
||||
draw(id, record)
|
||||
return box(id, '')
|
||||
},
|
||||
redraw() {
|
||||
sweep()
|
||||
for (const [id, record] of records) draw(id, record)
|
||||
},
|
||||
release() {
|
||||
records.clear()
|
||||
owners.delete(owner)
|
||||
},
|
||||
}
|
||||
owners.add(owner)
|
||||
return owner
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Myers' diff over two lists.
|
||||
|
||||
/** Equal pairs between `a` and `b`, in order. */
|
||||
export function common(a, b, same) {
|
||||
const n = a.length
|
||||
const m = b.length
|
||||
const max = n + m
|
||||
const reach = new Map([[1, 0]])
|
||||
const trace = []
|
||||
for (let d = 0; d <= max; d++) {
|
||||
trace.push(new Map(reach))
|
||||
for (let k = -d; k <= d; k += 2) {
|
||||
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
|
||||
let x = down ? reach.get(k + 1) : reach.get(k - 1) + 1
|
||||
let y = x - k
|
||||
while (x < n && y < m && same(a[x], b[y])) {
|
||||
x += 1
|
||||
y += 1
|
||||
}
|
||||
reach.set(k, x)
|
||||
if (x >= n && y >= m) return walk(trace, n, m)
|
||||
}
|
||||
}
|
||||
throw new Error('diff did not end')
|
||||
}
|
||||
|
||||
/** Back through the trace: each equal pair, in order. */
|
||||
function walk(trace, n, m) {
|
||||
const pairs = []
|
||||
let x = n
|
||||
let y = m
|
||||
for (let d = trace.length - 1; d > 0; d--) {
|
||||
const reach = trace[d]
|
||||
const k = x - y
|
||||
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
|
||||
const from = down ? k + 1 : k - 1
|
||||
const fromX = reach.get(from)
|
||||
const fromY = fromX - from
|
||||
while (x > fromX + (down ? 0 : 1) && y > fromY + (down ? 1 : 0)) {
|
||||
x -= 1
|
||||
y -= 1
|
||||
pairs.push([x, y])
|
||||
}
|
||||
x = fromX
|
||||
y = fromY
|
||||
}
|
||||
while (x > 0 && y > 0) {
|
||||
x -= 1
|
||||
y -= 1
|
||||
pairs.push([x, y])
|
||||
}
|
||||
return pairs.reverse()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* Exports: KaTeX with its fonts inline, on light paper. */
|
||||
@import 'katex/dist/katex.min.css';
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 auto;
|
||||
max-width: 48rem;
|
||||
padding: 2rem 1.5rem;
|
||||
background: #fff;
|
||||
color: #1f2328;
|
||||
font: 16px / 1.6 -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0969da;
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
border-left: 3px solid #d0d7de;
|
||||
color: #59636e;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
img,
|
||||
figure.diagram svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure.diagram {
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
table,
|
||||
figure {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** The source as saved, and why it did not render. */
|
||||
export function failure(source, message, at) {
|
||||
const box = document.createElement('div')
|
||||
box.className = 'ely-failure'
|
||||
const why = document.createElement('p')
|
||||
why.textContent = at === undefined ? message : `${message} (${at})`
|
||||
const code = document.createElement('pre')
|
||||
code.textContent = source
|
||||
box.append(why, code)
|
||||
return box
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Find and replace in either editor (MARK-06).
|
||||
import {
|
||||
SearchQuery as TextQuery,
|
||||
findNext as nextText,
|
||||
findPrevious as previousText,
|
||||
replaceAll as allText,
|
||||
replaceNext as oneText,
|
||||
setSearchQuery,
|
||||
} from '@codemirror/search'
|
||||
import {
|
||||
SearchQuery as DocQuery,
|
||||
findNext as nextDoc,
|
||||
findPrev as previousDoc,
|
||||
replaceAll as allDoc,
|
||||
replaceNext as oneDoc,
|
||||
setSearchState,
|
||||
} from 'prosemirror-search'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
|
||||
/** Why a pattern fails under the editors' `gmu` flags. */
|
||||
function broken(spec) {
|
||||
if (!spec.regexp || !spec.search) return null
|
||||
try {
|
||||
new RegExp(spec.search, 'gmu')
|
||||
return null
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two specs find the same hits. */
|
||||
export function sameQuery(a, b) {
|
||||
return ['search', 'caseSensitive', 'regexp', 'wholeWord'].every((key) => a[key] === b[key])
|
||||
}
|
||||
|
||||
/** One character's width at `pos`: two for a surrogate pair. */
|
||||
function width(doc, pos) {
|
||||
const next = doc.textBetween(pos, Math.min(pos + 2, doc.content.size), '\n', '\n')
|
||||
return next.codePointAt(0) > 0xffff ? 2 : 1
|
||||
}
|
||||
|
||||
/** Where the selection stands among `ranges`. */
|
||||
function standing(ranges, from, to) {
|
||||
const at = ranges.findIndex((range) => range.from === from && range.to === to)
|
||||
return { current: at < 0 ? null : at, total: ranges.length }
|
||||
}
|
||||
|
||||
/** Find in Milkdown's view; `prosemirror-search` is installed there. */
|
||||
export function docFinder(view) {
|
||||
let spec = { search: '' }
|
||||
const query = () => new DocQuery(spec)
|
||||
const ranges = () => {
|
||||
const found = []
|
||||
const asked = query()
|
||||
if (!asked.valid) return found
|
||||
const { doc } = view.state
|
||||
for (let at = 0; ;) {
|
||||
const hit = asked.findNext(view.state, at)
|
||||
// Mid-pair, a Unicode pattern answers before `at`.
|
||||
if (!hit || hit.from < at) return found
|
||||
found.push(hit)
|
||||
at = hit.to > hit.from ? hit.to : hit.to + width(doc, hit.to)
|
||||
}
|
||||
}
|
||||
const status = () => {
|
||||
const error = broken(spec)
|
||||
if (error) return { error }
|
||||
const { from, to } = view.state.selection
|
||||
return standing(ranges(), from, to)
|
||||
}
|
||||
const run = (command) => command(view.state, view.dispatch)
|
||||
return {
|
||||
seek(next) {
|
||||
spec = next
|
||||
const { from } = view.state.selection
|
||||
const tr = setSearchState(view.state.tr, query())
|
||||
// From the hit's start: the hit stays while typing.
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(from)))
|
||||
view.dispatch(tr)
|
||||
if (query().valid) run(nextDoc)
|
||||
return status()
|
||||
},
|
||||
step(forward) {
|
||||
if (query().valid) run(forward ? nextDoc : previousDoc)
|
||||
return status()
|
||||
},
|
||||
replace(all) {
|
||||
if (query().valid) run(all ? allDoc : oneDoc)
|
||||
return status()
|
||||
},
|
||||
clear() {
|
||||
spec = { search: '' }
|
||||
view.dispatch(setSearchState(view.state.tr, query()))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Find in CodeMirror; its `search()` extension is installed there. */
|
||||
export function textFinder(view) {
|
||||
let spec = { search: '' }
|
||||
const valid = () => new TextQuery(spec).valid
|
||||
const ranges = () => {
|
||||
const found = []
|
||||
const asked = new TextQuery(spec)
|
||||
if (!asked.valid) return found
|
||||
const cursor = asked.getCursor(view.state)
|
||||
for (let next = cursor.next(); !next.done; next = cursor.next()) found.push(next.value)
|
||||
return found
|
||||
}
|
||||
const status = () => {
|
||||
const error = broken(spec)
|
||||
if (error) return { error }
|
||||
const { from, to } = view.state.selection.main
|
||||
return standing(ranges(), from, to)
|
||||
}
|
||||
return {
|
||||
seek(next) {
|
||||
spec = next
|
||||
const selection = { anchor: view.state.selection.main.from }
|
||||
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)), selection })
|
||||
// CodeMirror opens its own panel for invalid queries.
|
||||
if (valid()) nextText(view)
|
||||
return status()
|
||||
},
|
||||
step(forward) {
|
||||
if (valid()) (forward ? nextText : previousText)(view)
|
||||
return status()
|
||||
},
|
||||
replace(all) {
|
||||
if (valid()) (all ? allText : oneText)(view)
|
||||
return status()
|
||||
},
|
||||
clear() {
|
||||
spec = { search: '' }
|
||||
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)) })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Code block icons, drawn in the text color.
|
||||
const icon = (path) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
|
||||
export const icons = {
|
||||
expandIcon: icon('<path d="m6 9 6 6 6-6"/>'),
|
||||
searchIcon: icon('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
|
||||
clearSearchIcon: icon('<path d="M18 6 6 18M6 6l12 12"/>'),
|
||||
copyIcon: icon(
|
||||
'<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Untitled images: remark gives null, the schema wants text.
|
||||
import { imageSchema } from '@milkdown/kit/preset/commonmark'
|
||||
|
||||
export const image = imageSchema.extendSchema((prev) => (ctx) => {
|
||||
const base = prev(ctx)
|
||||
return {
|
||||
...base,
|
||||
parseMarkdown: {
|
||||
match: base.parseMarkdown.match,
|
||||
runner: (state, node, type) =>
|
||||
state.addNode(type, { src: node.url, alt: node.alt ?? '', title: node.title ?? '' }),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
// The writing view: focus, typewriter, width, font, size (MARK-09).
|
||||
|
||||
let typewriter = false
|
||||
|
||||
/** Applies `look`; the Markdown never changes. */
|
||||
export function apply(look) {
|
||||
const root = document.documentElement
|
||||
root.dataset.focus = look.focus
|
||||
root.dataset.width = look.width
|
||||
root.dataset.font = look.font
|
||||
root.dataset.typewriter = look.typewriter
|
||||
typewriter = look.typewriter
|
||||
const sheet =
|
||||
document.getElementById('ely-look') ??
|
||||
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'ely-look' }))
|
||||
sheet.textContent = `:root { --ely-text-scale: ${look.size / 100}; }`
|
||||
}
|
||||
|
||||
/** Typewriter scrolling: the caret's line stays mid-view. */
|
||||
export function follow(editor) {
|
||||
if (typewriter) editor?.center?.()
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
@import 'katex/dist/katex.min.css';
|
||||
@import '@milkdown/kit/prose/view/style/prosemirror.css';
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--ely-bg);
|
||||
color: var(--ely-fg);
|
||||
font: calc(16px * var(--ely-font-scale, 1) * var(--ely-text-scale, 1)) / 1.6 var(--ely-font),
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--ely-selection);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--ely-link);
|
||||
}
|
||||
|
||||
#document {
|
||||
box-sizing: border-box;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
:root[data-width='narrow'] #document {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
:root[data-width='wide'] #document {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
:root[data-font='serif'] #document:not([data-mode='source']) {
|
||||
font-family: ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
:root[data-font='mono'] #document:not([data-mode='source']) {
|
||||
font-family: var(--ely-mono), monospace;
|
||||
}
|
||||
|
||||
/* Typewriter: room to center the first and last lines. */
|
||||
:root[data-typewriter='true'] #document:not([data-mode='source']) {
|
||||
padding-block: 50vh;
|
||||
}
|
||||
|
||||
:root[data-typewriter='true'] .cm-content {
|
||||
padding-block: 50vh;
|
||||
}
|
||||
|
||||
/* Focus mode: the caret's block, or line, stays bright. */
|
||||
:root[data-focus='true'] .milkdown .ProseMirror > :not(.ely-current),
|
||||
:root[data-focus='true'] .cm-line:not(.cm-activeLine) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.milkdown .ProseMirror > *,
|
||||
.cm-line {
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
}
|
||||
|
||||
#document[data-mode='source'] {
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#document[data-mode='source'] > div,
|
||||
#document[data-mode='source'] .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.milkdown .ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.milkdown blockquote {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid var(--ely-border);
|
||||
color: var(--ely-fg-muted);
|
||||
}
|
||||
|
||||
.milkdown code {
|
||||
font-family: var(--ely-mono), monospace;
|
||||
font-size: 0.875em;
|
||||
background: var(--ely-sunken);
|
||||
border-radius: 4px;
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
|
||||
.milkdown table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.milkdown th,
|
||||
.milkdown td {
|
||||
border: 1px solid var(--ely-border);
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.milkdown li > p,
|
||||
.milkdown th > p,
|
||||
.milkdown td > p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.milkdown li[data-item-type='task'] {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.milkdown li[data-item-type='task']::before {
|
||||
content: '☐ ';
|
||||
}
|
||||
|
||||
.milkdown li[data-item-type='task'][data-checked='true']::before {
|
||||
content: '☑ ';
|
||||
}
|
||||
|
||||
.milkdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--ely-border);
|
||||
}
|
||||
|
||||
.milkdown img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.milkdown-code-block {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
background: var(--ely-surface);
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.milkdown-code-block.selected {
|
||||
outline: 1px solid var(--ely-focus);
|
||||
}
|
||||
|
||||
.milkdown-code-block .hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.milkdown-code-block .tools {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.milkdown-code-block button,
|
||||
.milkdown-code-block .language-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ely-sunken);
|
||||
color: var(--ely-fg-muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-mode='preview'] .milkdown-code-block .language-button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.milkdown-code-block .tools-button-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.milkdown-code-block button:hover,
|
||||
.milkdown-code-block .language-button:hover {
|
||||
background: var(--ely-hover);
|
||||
}
|
||||
|
||||
.milkdown-code-block .tools svg,
|
||||
.milkdown-code-block .language-picker svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ely-diagram svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-picker {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.milkdown-code-block .list-wrapper {
|
||||
width: 15rem;
|
||||
padding-top: 0.5rem;
|
||||
background: var(--ely-surface);
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.milkdown-code-block .search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.milkdown-code-block .search-box input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--ely-fg);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.milkdown-code-block .search-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list {
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 2px 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list-item:hover,
|
||||
.milkdown-code-block .language-list-item:focus-visible {
|
||||
outline: none;
|
||||
background: var(--ely-hover);
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list-item.no-result {
|
||||
cursor: default;
|
||||
color: var(--ely-fg-subtle);
|
||||
}
|
||||
|
||||
.milkdown-code-block .preview-panel .preview-divider {
|
||||
height: 1px;
|
||||
margin: 0.5rem 0;
|
||||
background: var(--ely-border);
|
||||
}
|
||||
|
||||
.milkdown-code-block .preview-panel .preview-label {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ely-fg-muted);
|
||||
}
|
||||
|
||||
.milkdown-code-block .preview {
|
||||
overflow-x: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ely-failure {
|
||||
text-align: left;
|
||||
color: var(--ely-danger);
|
||||
}
|
||||
|
||||
.ely-failure pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: var(--ely-fg);
|
||||
font-family: var(--ely-mono), monospace;
|
||||
}
|
||||
|
||||
.ely-front {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ely-fg-muted);
|
||||
font-family: var(--ely-mono), monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ely-raw {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--ely-sunken);
|
||||
border-radius: 4px;
|
||||
font-family: var(--ely-mono), monospace;
|
||||
font-size: 0.875em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror-search-match,
|
||||
.cm-searchMatch {
|
||||
background: var(--ely-selection);
|
||||
}
|
||||
|
||||
.ProseMirror-active-search-match,
|
||||
.cm-searchMatch-selected {
|
||||
outline: 1px solid var(--ely-focus);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// The page the app drives: one document, three modes.
|
||||
import { visual } from './visual.js'
|
||||
import { source } from './source.js'
|
||||
import { ask } from './bridge.js'
|
||||
import { sameQuery } from './find.js'
|
||||
import { apply, follow } from './look.js'
|
||||
import { html, text } from './render.js'
|
||||
|
||||
let texts = {}
|
||||
let latest = 0
|
||||
let version = 0
|
||||
// The app's count of page loads; reports carry it.
|
||||
let generation = 0
|
||||
let timer
|
||||
let pending = null
|
||||
/** The editor shown. */
|
||||
let current = null
|
||||
// A mode asked for while an editor builds.
|
||||
let asked = null
|
||||
// A place to show once the editor is built.
|
||||
let revealing = null
|
||||
// The search in force, kept across modes.
|
||||
let seeking = null
|
||||
/** The file's texts where a mode with edits ended (§14.3). */
|
||||
let times = []
|
||||
let at = 0
|
||||
|
||||
/** YAML front matter, kept out of the visual editor. */
|
||||
function split(text) {
|
||||
const front = /^---\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(text)
|
||||
return front ? [front[0], text.slice(front[0].length)] : ['', text]
|
||||
}
|
||||
|
||||
const tell = (said) => ask(said).catch((error) => console.error(`ely: ${error.message}`))
|
||||
|
||||
/** Sends the waiting text now. */
|
||||
function flush() {
|
||||
clearTimeout(timer)
|
||||
if (pending) {
|
||||
tell(pending())
|
||||
outline()
|
||||
}
|
||||
pending = null
|
||||
}
|
||||
|
||||
/** The shown editor's headings, for the app's outline. */
|
||||
function outline() {
|
||||
if (current) tell({ kind: 'outline', generation, items: current.outline() })
|
||||
}
|
||||
|
||||
/** A new text: told now, its words once typing rests. */
|
||||
function revised(text) {
|
||||
version += 1
|
||||
const now = version
|
||||
tell({ kind: 'changed', version: now, generation })
|
||||
clearTimeout(timer)
|
||||
pending = () => ({ kind: 'text', version: now, generation, text: text() })
|
||||
timer = setTimeout(flush, 300)
|
||||
}
|
||||
|
||||
/** An edit: later texts in the timeline are gone. */
|
||||
function changed(text) {
|
||||
times.length = at + 1
|
||||
revised(text)
|
||||
}
|
||||
|
||||
/** An editor for `mode`; visual falls back to source. */
|
||||
async function build(text, mode) {
|
||||
const holder = document.createElement('div')
|
||||
holder.hidden = true
|
||||
document.getElementById('document').append(holder)
|
||||
const entry = { mode, holder, fallback: false }
|
||||
if (mode !== 'source') {
|
||||
const [front, body] = split(text)
|
||||
if (front) {
|
||||
const raw = document.createElement('pre')
|
||||
raw.className = 'ely-front'
|
||||
raw.textContent = front
|
||||
holder.append(raw)
|
||||
}
|
||||
const place = document.createElement('div')
|
||||
holder.append(place)
|
||||
const edited = (write) => changed(() => front + write())
|
||||
const made = await visual(place, body, mode === 'visual', texts, edited, edge)
|
||||
if (made) {
|
||||
// Lines count from the file's top, front matter included.
|
||||
const above = front ? front.split('\n').length - 1 : 0
|
||||
const line = (n, query) => {
|
||||
if (n > above) return made.line(n - above, query)
|
||||
holder.firstChild.scrollIntoView({ block: 'center' })
|
||||
return true
|
||||
}
|
||||
const redone = () => {
|
||||
const ahead = made.redone()
|
||||
return ahead === null ? null : front + ahead
|
||||
}
|
||||
const where = () => {
|
||||
const { line, column } = made.where()
|
||||
return { line: above + line, column }
|
||||
}
|
||||
const at = (n, column) => (n > above ? made.at(n - above, column) : line(n, ''))
|
||||
return { ...entry, ...made, text: () => front + made.text(), redone, line, where, at }
|
||||
}
|
||||
// Blocks the editor cannot map open as source.
|
||||
holder.replaceChildren()
|
||||
Object.assign(entry, { mode: 'source', fallback: true })
|
||||
}
|
||||
return { ...entry, ...source(holder, text, changed, edge) }
|
||||
}
|
||||
|
||||
function drop(entry) {
|
||||
entry.close()
|
||||
entry.holder.remove()
|
||||
}
|
||||
|
||||
/** Shows `text` as `want`; the newest request alone lands. */
|
||||
async function show(text, want) {
|
||||
const turn = ++latest
|
||||
asked = null
|
||||
if (current) drop(current)
|
||||
current = null
|
||||
const entry = await build(text, want)
|
||||
if (turn !== latest) return drop(entry)
|
||||
current = entry
|
||||
entry.holder.hidden = false
|
||||
document.getElementById('document').dataset.mode = entry.mode
|
||||
entry.focus()
|
||||
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback })
|
||||
// A newer request owns what is queued now.
|
||||
if (turn !== latest) return
|
||||
outline()
|
||||
if (seeking) found(entry.find.seek(seeking))
|
||||
const place = revealing
|
||||
revealing = null
|
||||
if (place) reveal(place)
|
||||
const next = asked
|
||||
asked = null
|
||||
if (next && next !== entry.mode) return mode(next)
|
||||
}
|
||||
|
||||
/** Undo or redo past an editor's own history. */
|
||||
function edge(direction) {
|
||||
const to = direction === 'undo' ? at - 1 : at + 1
|
||||
if (!current || to < 0 || to >= times.length) return false
|
||||
flush()
|
||||
at = to
|
||||
const text = times[at]
|
||||
revised(() => text)
|
||||
show(text, current.mode)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Relative links and images resolve under `href`. */
|
||||
function base(href) {
|
||||
const link =
|
||||
document.querySelector('base') ?? document.head.appendChild(document.createElement('base'))
|
||||
link.href = href
|
||||
}
|
||||
|
||||
/** Puts the caret on `place.line`, the page at `place.scroll`. */
|
||||
function settle(place) {
|
||||
if (!current || !place) return
|
||||
current.at(place.line, place.column)
|
||||
document.scrollingElement.scrollTop = place.scroll
|
||||
}
|
||||
|
||||
/** A document, after each page load; `place` restores a session. */
|
||||
async function open(text, want, words, load, folder, place) {
|
||||
texts = words
|
||||
generation = load
|
||||
times = [text]
|
||||
at = 0
|
||||
base(folder)
|
||||
await show(text, want)
|
||||
settle(place)
|
||||
}
|
||||
|
||||
/** Changed on disk: the caret and scroll stay near. */
|
||||
async function refresh(text) {
|
||||
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
|
||||
clearTimeout(timer)
|
||||
pending = null
|
||||
times = [text]
|
||||
at = 0
|
||||
await show(text, current?.mode ?? 'visual')
|
||||
settle(place)
|
||||
}
|
||||
|
||||
/** `text` in place of the editor's, as one edit. */
|
||||
function adopt(text) {
|
||||
if (current) flush()
|
||||
times.length = at + 1
|
||||
times.push(text)
|
||||
at += 1
|
||||
revised(() => text)
|
||||
return show(text, current?.mode ?? 'visual')
|
||||
}
|
||||
|
||||
let placing
|
||||
/** The caret's line and the scroll, once they rest. */
|
||||
function placed() {
|
||||
clearTimeout(placing)
|
||||
placing = setTimeout(() => {
|
||||
if (!current) return
|
||||
const scroll = document.scrollingElement.scrollTop
|
||||
tell({ kind: 'place', generation, ...current.where(), scroll })
|
||||
}, 500)
|
||||
}
|
||||
document.addEventListener('selectionchange', () => {
|
||||
placed()
|
||||
follow(current)
|
||||
})
|
||||
window.addEventListener('scroll', placed, { passive: true })
|
||||
|
||||
/** Another mode for the same text; edits are sent first. */
|
||||
function mode(next) {
|
||||
if (!current) {
|
||||
asked = next
|
||||
return
|
||||
}
|
||||
const text = current.text()
|
||||
const ahead = current.redone?.() ?? null
|
||||
flush()
|
||||
// Leaving a mode with edits marks the timeline.
|
||||
if (text !== times[at]) {
|
||||
times.length = at + 1
|
||||
times.push(text)
|
||||
at += 1
|
||||
}
|
||||
// Undone steps stay ahead, for redo.
|
||||
if (ahead !== null && ahead !== text) {
|
||||
times.length = at + 1
|
||||
times.push(ahead)
|
||||
}
|
||||
return show(text, next)
|
||||
}
|
||||
|
||||
/** Shows `{ heading }` or `{ line, query }`. */
|
||||
function reveal(place) {
|
||||
if (!current) {
|
||||
revealing = place
|
||||
return
|
||||
}
|
||||
const shown =
|
||||
'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's find box hears where the search stands. */
|
||||
const found = (status) => tell({ kind: 'found', generation, ...status })
|
||||
|
||||
/** Searches for `spec`: search, caseSensitive, regexp, wholeWord, replace. */
|
||||
function find(spec) {
|
||||
seeking = spec
|
||||
if (current) found(current.find.seek(spec))
|
||||
}
|
||||
|
||||
/** The box's spec rules; a new query starts afresh. */
|
||||
function step({ spec, forward }) {
|
||||
const fresh = !seeking || !sameQuery(seeking, spec)
|
||||
find(spec)
|
||||
if (current && !fresh) found(current.find.step(forward))
|
||||
}
|
||||
|
||||
function replace({ spec, all }) {
|
||||
find(spec)
|
||||
if (current && current.mode !== 'preview') found(current.find.replace(all))
|
||||
}
|
||||
|
||||
function unfind() {
|
||||
seeking = null
|
||||
current?.find.clear()
|
||||
}
|
||||
|
||||
/** The writing view's settings (MARK-09). */
|
||||
function look(settings) {
|
||||
apply(settings)
|
||||
follow(current)
|
||||
}
|
||||
|
||||
/** The whole text as `kind`: html or text, tagged `ask`. */
|
||||
async function render({ kind, ask: asked }) {
|
||||
try {
|
||||
if (!current) throw new Error('no document is shown')
|
||||
const [, body] = split(current.text())
|
||||
const result = kind === 'text' ? { text: await text(body) } : await html(body)
|
||||
tell({ kind: 'rendered', generation, ask: asked, ...result })
|
||||
} catch (error) {
|
||||
tell({ kind: 'rendered', generation, ask: asked, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/** A table step; the app hears whether it ran. */
|
||||
function table(op) {
|
||||
const done = current?.table?.(op) ?? false
|
||||
tell({ kind: 'table', generation, op, done })
|
||||
}
|
||||
|
||||
/** Image links at the caret; preview takes none. */
|
||||
function insert(images) {
|
||||
const done = current?.insert?.(images) ?? false
|
||||
tell({ kind: 'inserted', generation, done })
|
||||
}
|
||||
|
||||
window.ely = { open, refresh, adopt, look, render, mode, base, reveal, find, step, replace, unfind, table, insert }
|
||||
@@ -0,0 +1,100 @@
|
||||
// KaTeX math, from Milkdown Crepe's latex feature.
|
||||
import katex from 'katex'
|
||||
import remarkMath from 'remark-math'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { $nodeSchema, $remark } from '@milkdown/kit/utils'
|
||||
import { codeBlockSchema } from '@milkdown/kit/preset/commonmark'
|
||||
import { failure } from './failure.js'
|
||||
|
||||
/** Saved source as HTML; a bad one shows where. */
|
||||
export function render(source, display, dom) {
|
||||
try {
|
||||
katex.render(source, dom, { displayMode: display, throwOnError: true })
|
||||
} catch (error) {
|
||||
const at = error instanceof katex.ParseError ? error.position : undefined
|
||||
dom.replaceChildren(failure(source, error.message, at))
|
||||
}
|
||||
return dom
|
||||
}
|
||||
|
||||
// remark-math, plus each inline formula's `$` count from the source.
|
||||
const remark = $remark('remarkMath', () => function (options) {
|
||||
remarkMath.call(this, options)
|
||||
return (tree, file) => {
|
||||
const text = String(file.value)
|
||||
visit(tree, 'inlineMath', (node) => {
|
||||
let at = node.position?.start.offset ?? 0
|
||||
let fence = 0
|
||||
while (text[at++] === '$') fence++
|
||||
node.data = { ...node.data, fence: Math.max(fence, 1) }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Block math becomes a code block in language LaTeX.
|
||||
const blocks = $remark(
|
||||
'remarkMathBlock',
|
||||
() => () => (tree) =>
|
||||
visit(tree, 'math', (node, index, parent) => {
|
||||
parent.children.splice(index, 1, {
|
||||
type: 'code',
|
||||
lang: 'LaTeX',
|
||||
value: node.value,
|
||||
position: node.position,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const block = codeBlockSchema.extendSchema((prev) => (ctx) => {
|
||||
const base = prev(ctx)
|
||||
return {
|
||||
...base,
|
||||
toMarkdown: {
|
||||
match: base.toMarkdown.match,
|
||||
runner: (state, node) => {
|
||||
if ((node.attrs.language ?? '').toLowerCase() !== 'latex') {
|
||||
return base.toMarkdown.runner(state, node)
|
||||
}
|
||||
state.addNode('math', undefined, node.content.firstChild?.text || '')
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const inline = $nodeSchema('math_inline', () => ({
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
attrs: { value: { default: '' }, fence: { default: 1 } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'span[data-type="math_inline"]',
|
||||
getAttrs: (dom) => ({ value: dom.dataset.value ?? '', fence: Number(dom.dataset.fence ?? 1) }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const dom = document.createElement('span')
|
||||
dom.dataset.type = 'math_inline'
|
||||
dom.dataset.value = node.attrs.value
|
||||
dom.dataset.fence = node.attrs.fence
|
||||
// `$$…$$` displays, as GitHub shows it.
|
||||
return render(node.attrs.value, node.attrs.fence > 1, dom)
|
||||
},
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === 'inlineMath',
|
||||
runner: (state, node, type) =>
|
||||
state.addNode(type, { value: node.value, fence: node.data?.fence ?? 1 }),
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === 'math_inline',
|
||||
runner: (state, node) => {
|
||||
const { value, fence } = node.attrs
|
||||
if (fence === 1) return state.addNode('inlineMath', undefined, value)
|
||||
// The stringifier knows one `$`; the source's own count stays.
|
||||
const dollars = '$'.repeat(fence)
|
||||
state.addNode('html', undefined, `${dollars}${value}${dollars}`)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export const math = [remark, blocks, block, inline].flat()
|
||||
@@ -0,0 +1,144 @@
|
||||
// Blocks Milkdown cannot write back stay raw text (§14.3).
|
||||
import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { Fragment } from '@milkdown/kit/prose/model'
|
||||
import { Selection } from '@milkdown/kit/prose/state'
|
||||
import { $nodeSchema } from '@milkdown/kit/utils'
|
||||
import { common } from './diff.js'
|
||||
|
||||
/** Source shown and written as it is. */
|
||||
export const raw = $nodeSchema('ely_raw', () => ({
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
code: true,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
parseDOM: [{ tag: 'pre.ely-raw', preserveWhitespace: 'full' }],
|
||||
toDOM: () => ['pre', { class: 'ely-raw' }, 0],
|
||||
parseMarkdown: { match: () => false, runner: () => {} },
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === 'ely_raw',
|
||||
runner: (state, node) => state.addNode('html', undefined, node.textContent),
|
||||
},
|
||||
}))
|
||||
|
||||
/** Each block's span, as Milkdown reads it; null if unplaced. */
|
||||
function spans(ctx, text) {
|
||||
const remark = ctx.get(remarkCtx)
|
||||
const tree = remark.runSync(remark.parse(text), text)
|
||||
const at = tree.children.map(
|
||||
(node) => node.position && [node.position.start.offset, node.position.end.offset],
|
||||
)
|
||||
return at.every(Boolean) ? at : null
|
||||
}
|
||||
|
||||
/** The text in `from`..`to` past its outer whitespace. */
|
||||
function inner(text, from, to) {
|
||||
const piece = text.slice(from, to)
|
||||
const start = piece.search(/\S/)
|
||||
if (start < 0) return null
|
||||
return [from + start, from + piece.trimEnd().length]
|
||||
}
|
||||
|
||||
/** Raw blocks in place; edits then rewrite only their blocks. */
|
||||
export function faithful(ctx, original) {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const parsed = view.state.doc
|
||||
const at = spans(ctx, original)
|
||||
// No blocks: Milkdown still holds one empty paragraph.
|
||||
const blank = at?.length === 0 && parsed.childCount === 1 && parsed.textContent === ''
|
||||
if (!at || (at.length !== parsed.childCount && !blank)) return null
|
||||
const schema = ctx.get(schemaCtx)
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
const written = (nodes) => serializer(schema.topNodeType.create(null, nodes)).replace(/\n+$/, '')
|
||||
const rawOf = ([from, to]) => {
|
||||
const text = original.slice(from, to)
|
||||
return schema.nodes.ely_raw.create(null, text ? schema.text(text) : null)
|
||||
}
|
||||
const nodes = []
|
||||
const places = []
|
||||
// Text Milkdown drops between blocks, as raw blocks.
|
||||
const gap = (from, to) => {
|
||||
const place = inner(original, from, to)
|
||||
if (!place) return
|
||||
nodes.push(rawOf(place))
|
||||
places.push(place)
|
||||
}
|
||||
let last = 0
|
||||
if (!blank)
|
||||
parsed.forEach((node, _, index) => {
|
||||
gap(last, at[index][0])
|
||||
const exact = written([node]) === original.slice(...at[index])
|
||||
nodes.push(exact ? node : rawOf(at[index]))
|
||||
places.push(at[index])
|
||||
last = at[index][1]
|
||||
})
|
||||
gap(last, original.length)
|
||||
if (nodes.length) {
|
||||
const swap = view.state.tr.replaceWith(0, parsed.content.size, Fragment.from(nodes))
|
||||
swap.setSelection(Selection.atStart(swap.doc))
|
||||
view.dispatch(swap.setMeta('addToHistory', false))
|
||||
}
|
||||
const first = view.state.doc
|
||||
const before = []
|
||||
first.forEach((node) => before.push(node))
|
||||
/** Where each fresh block sits in `text` from `at`. */
|
||||
const found = (nodes, text, at, spans) => {
|
||||
let from = 0
|
||||
for (const node of nodes) {
|
||||
const own = written([node])
|
||||
const place = text.indexOf(own, from)
|
||||
spans.push(place < 0 ? null : [at + place, at + place + own.length])
|
||||
if (place >= 0) from = place + own.length
|
||||
}
|
||||
}
|
||||
// The text and each block's span in it.
|
||||
return (doc) => {
|
||||
if (doc.eq(first)) return { text: original, spans: places }
|
||||
const after = []
|
||||
doc.forEach((node) => after.push(node))
|
||||
if (!places.length) {
|
||||
const fresh = written(after)
|
||||
const spans = []
|
||||
found(after, fresh, original.length, spans)
|
||||
return { text: `${original}${fresh}\n`, spans }
|
||||
}
|
||||
const pairs = common(before, after, (x, y) => x === y || x.eq(y))
|
||||
pairs.push([before.length, after.length])
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
let [i, j] = [0, 0]
|
||||
const spans = []
|
||||
for (const [x, y] of pairs) {
|
||||
if (x > i || y > j) {
|
||||
const fresh = y > j ? written(after.slice(j, y)) : ''
|
||||
let start, end, text
|
||||
if (x > i && fresh) {
|
||||
;[start, end, text] = [places[i][0], places[x - 1][1], fresh]
|
||||
} else if (x > i && i > 0) {
|
||||
// Removed blocks take the gap before them.
|
||||
;[start, end, text] = [places[i - 1][1], places[x - 1][1], '']
|
||||
} else if (x > i && x < places.length) {
|
||||
;[start, end, text] = [places[0][0], places[x][0], '']
|
||||
} else if (x > i) {
|
||||
;[start, end, text] = [places[0][0], places[x - 1][1], '']
|
||||
} else if (i > 0) {
|
||||
;[start, end, text] = [places[i - 1][1], places[i - 1][1], `\n\n${fresh}`]
|
||||
} else {
|
||||
;[start, end, text] = [places[0][0], places[0][0], `${fresh}\n\n`]
|
||||
}
|
||||
out += original.slice(cursor, start)
|
||||
found(after.slice(j, y), text, out.length, spans)
|
||||
out += text
|
||||
cursor = end
|
||||
}
|
||||
// Unchanged blocks keep their bytes, shifted.
|
||||
if (x < places.length) {
|
||||
const shift = out.length - cursor
|
||||
spans.push([places[x][0] + shift, places[x][1] + shift])
|
||||
}
|
||||
;[i, j] = [x + 1, y + 1]
|
||||
}
|
||||
return { text: out + original.slice(cursor), spans }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// The whole document as one page or as text (MARK-11).
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import { fromDom } from 'hast-util-from-dom'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
import { toText } from 'hast-util-to-text'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { VFile } from 'vfile'
|
||||
import { bundle } from './diagrams.js'
|
||||
|
||||
const markdown = () => unified().use(remarkParse).use(remarkGfm).use(remarkMath)
|
||||
|
||||
/** Mermaid drawn for paper; a broken one stays code. */
|
||||
async function draw(tree) {
|
||||
const blocks = []
|
||||
visit(tree, 'code', (node) => {
|
||||
if (node.lang === 'mermaid') blocks.push(node)
|
||||
})
|
||||
const failed = []
|
||||
if (!blocks.length) return failed
|
||||
const paint = await bundle()
|
||||
for (const node of blocks) {
|
||||
try {
|
||||
const svg = await paint(node.value, 'paper')
|
||||
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
|
||||
} catch (error) {
|
||||
// Lines only: Mermaid's message quotes the document.
|
||||
console.warn(`ely: a diagram did not draw: ${error.name}`)
|
||||
failed.push(node.position.start.line)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
/** A `srcset`'s first URL, split as the spec does. */
|
||||
function first(srcset) {
|
||||
const url = srcset.replace(/^[\s,]+/, '').split(/\s/)[0]
|
||||
return url.replace(/,+$/, '')
|
||||
}
|
||||
|
||||
/** One `src` per image, swapped for the app's mark. */
|
||||
function marked(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const token = `ely-image-${crypto.randomUUID()}-`
|
||||
// Responsive sources go: the page carries one file per image.
|
||||
for (const source of doc.querySelectorAll('picture > source')) source.remove()
|
||||
const images = []
|
||||
for (const image of doc.querySelectorAll('img')) {
|
||||
const srcset = image.getAttribute('srcset')
|
||||
image.removeAttribute('srcset')
|
||||
image.removeAttribute('sizes')
|
||||
const src = image.getAttribute('src') || (srcset && first(srcset))
|
||||
if (!src) continue
|
||||
images.push(src)
|
||||
image.setAttribute('src', token + (images.length - 1))
|
||||
}
|
||||
return { html: doc.body.innerHTML, images, token }
|
||||
}
|
||||
|
||||
/** KaTeX's failures: line and its message, never logged. */
|
||||
function formulas(file) {
|
||||
return file.messages
|
||||
.filter((message) => message.source === 'rehype-katex')
|
||||
.map((message) => ({
|
||||
line: message.line,
|
||||
error: message.cause?.rawMessage ?? message.cause?.name ?? message.reason
|
||||
}))
|
||||
}
|
||||
|
||||
/** `body` as HTML, images marked; the failed diagrams and formulas. */
|
||||
export async function html(body) {
|
||||
const tree = markdown().parse(body)
|
||||
displayed(tree, body)
|
||||
const failed = await draw(tree)
|
||||
const page = unified()
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeKatex)
|
||||
.use(rehypeStringify, { allowDangerousHtml: true })
|
||||
const file = new VFile()
|
||||
const done = page.stringify(await page.run(tree, file), file)
|
||||
return { ...marked(done), failed, formulas: formulas(file) }
|
||||
}
|
||||
|
||||
/** `$$…$$` inline displays, as in the editor. */
|
||||
function displayed(tree, body) {
|
||||
visit(tree, 'inlineMath', (node) => {
|
||||
const at = node.position?.start.offset ?? 0
|
||||
if (body.startsWith('$$', at)) {
|
||||
node.data.hProperties = { className: ['language-math', 'math-display'] }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** `body` as plain text: blocks on lines, math as TeX. */
|
||||
export async function text(body) {
|
||||
const page = unified().use(remarkRehype, { allowDangerousHtml: true })
|
||||
const hast = await page.run(markdown().parse(body))
|
||||
// Raw HTML alone goes through the DOM; blocks keep lines.
|
||||
visit(hast, 'raw', (node, index, parent) => {
|
||||
const parsed = new DOMParser().parseFromString(node.value, 'text/html').body
|
||||
const shown = fromDom(parsed).children
|
||||
parent.children.splice(index, 1, ...shown)
|
||||
return index + shown.length
|
||||
})
|
||||
return toText(hast)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// CodeMirror 6: the Markdown source.
|
||||
import { basicSetup, EditorView } from 'codemirror'
|
||||
import { Prec } from '@codemirror/state'
|
||||
import { indentWithTab, redo, redoDepth, undoDepth } from '@codemirror/commands'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { markdown as language } from '@codemirror/lang-markdown'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
import { ensureSyntaxTree } from '@codemirror/language'
|
||||
import { theme } from './theme.js'
|
||||
import { search } from '@codemirror/search'
|
||||
import { textFinder } from './find.js'
|
||||
|
||||
/** Alt text that cannot close its brackets. */
|
||||
const escape = (alt) => alt.replace(/[\\[\]]/g, '\\$&')
|
||||
|
||||
export function source(root, markdown, changed, edge) {
|
||||
const view = new EditorView({
|
||||
doc: markdown,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
language({ codeLanguages: languages }),
|
||||
EditorView.lineWrapping,
|
||||
theme,
|
||||
search(),
|
||||
// Tab indents; it never leaves the editor.
|
||||
keymap.of([indentWithTab]),
|
||||
// Past their history, undo and redo reach the page.
|
||||
Prec.low(
|
||||
keymap.of([
|
||||
{ key: 'Mod-z', run: (view) => undoDepth(view.state) === 0 && edge('undo') },
|
||||
{ key: 'Mod-Shift-z', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
|
||||
{ key: 'Mod-y', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
|
||||
]),
|
||||
),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) changed(() => update.state.doc.toString())
|
||||
}),
|
||||
],
|
||||
parent: root,
|
||||
})
|
||||
// ATX and setext headings from the parse, with their starts.
|
||||
const headings = () => {
|
||||
const found = []
|
||||
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 200)
|
||||
tree?.iterate({
|
||||
enter: ({ name, from, to }) => {
|
||||
const level = /^(?:ATX|Setext)Heading(\d)$/.exec(name)?.[1]
|
||||
if (!level) return
|
||||
const head = view.state.doc.sliceString(from, to).split('\n')[0]
|
||||
const title = head.replace(/^#{1,6}[ \t]*/, '').replace(/[ \t]+#+[ \t]*$/, '')
|
||||
found.push({ level: Number(level), title: title.trim(), from })
|
||||
return false
|
||||
},
|
||||
})
|
||||
return found
|
||||
}
|
||||
const reveal = (from) => {
|
||||
view.dispatch({ selection: { anchor: from }, scrollIntoView: true })
|
||||
view.focus()
|
||||
}
|
||||
return {
|
||||
// Images at the caret, as Markdown.
|
||||
insert: (images) => {
|
||||
const text = images.map(({ src, alt }) => ``).join('\n')
|
||||
view.dispatch(view.state.replaceSelection(text))
|
||||
view.focus()
|
||||
return true
|
||||
},
|
||||
find: textFinder(view),
|
||||
outline: () => headings().map(({ level, title }) => ({ level, title })),
|
||||
heading: (key) => {
|
||||
const found = headings()[key]
|
||||
if (found) reveal(found.from)
|
||||
return Boolean(found)
|
||||
},
|
||||
line: (n, query) => {
|
||||
if (n < 1 || n > view.state.doc.lines) return false
|
||||
const line = view.state.doc.line(n)
|
||||
const column = line.text.toLowerCase().indexOf(query.toLowerCase())
|
||||
reveal(line.from + Math.max(0, column))
|
||||
return true
|
||||
},
|
||||
close: () => view.destroy(),
|
||||
center: () => {
|
||||
const { head } = view.state.selection.main
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(head, { y: 'center' }) })
|
||||
},
|
||||
where: () => {
|
||||
const { head } = view.state.selection.main
|
||||
const line = view.state.doc.lineAt(head)
|
||||
return { line: line.number, column: head - line.from }
|
||||
},
|
||||
at: (n, column) => {
|
||||
if (n < 1 || n > view.state.doc.lines) return false
|
||||
const line = view.state.doc.line(n)
|
||||
reveal(line.from + Math.min(column, line.length))
|
||||
return true
|
||||
},
|
||||
text: () => view.state.doc.toString(),
|
||||
// The text once every undone step is redone.
|
||||
redone: () => {
|
||||
let state = view.state
|
||||
if (!redoDepth(state)) return null
|
||||
while (redoDepth(state)) redo({ state, dispatch: (tr) => (state = tr.state) })
|
||||
return state.doc.toString()
|
||||
},
|
||||
focus: () => view.focus(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// The document's own markers, so blocks write back unchanged.
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
/** remark-stringify options most of `text` already uses. */
|
||||
export function style(text) {
|
||||
const votes = {}
|
||||
const vote = (key, value) => {
|
||||
votes[key] ??= new Map()
|
||||
votes[key].set(value, (votes[key].get(value) ?? 0) + 1)
|
||||
}
|
||||
visit(unified().use(remarkParse).parse(text), (node) => {
|
||||
const at = node.position?.start.offset
|
||||
if (at === undefined) return
|
||||
const head = text.slice(at, at + 12)
|
||||
switch (node.type) {
|
||||
case 'listItem': {
|
||||
const ordered = /^\d+([.)])/.exec(head)
|
||||
if (ordered) vote('bulletOrdered', ordered[1])
|
||||
else if (/^[*+-]/.test(head)) vote('bullet', head[0])
|
||||
break
|
||||
}
|
||||
case 'emphasis':
|
||||
case 'strong':
|
||||
vote(node.type, head[0])
|
||||
break
|
||||
case 'code':
|
||||
if (/^[`~]/.test(head)) vote('fence', head[0])
|
||||
break
|
||||
case 'thematicBreak':
|
||||
vote('rule', head[0])
|
||||
break
|
||||
case 'heading':
|
||||
if (node.depth <= 2) vote('setext', head[0] !== '#')
|
||||
break
|
||||
}
|
||||
})
|
||||
const most = (key) => [...votes[key].entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||
return Object.fromEntries(Object.keys(votes).map((key) => [key, most(key)]))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// CodeMirror colors from the page's Ely tokens.
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { tags } from '@lezer/highlight'
|
||||
|
||||
const token = (name) => `var(--ely-${name})`
|
||||
|
||||
const colors = HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: token('keyword') },
|
||||
{ tag: [tags.string, tags.regexp, tags.special(tags.string)], color: token('string') },
|
||||
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: token('number') },
|
||||
{ tag: [tags.comment, tags.meta], color: token('comment') },
|
||||
{
|
||||
tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
|
||||
color: token('function'),
|
||||
},
|
||||
{ tag: [tags.typeName, tags.className, tags.namespace], color: token('type') },
|
||||
{ tag: [tags.tagName, tags.heading], color: token('tag'), fontWeight: 'bold' },
|
||||
{ tag: [tags.attributeName, tags.propertyName], color: token('attribute') },
|
||||
{
|
||||
tag: [tags.punctuation, tags.bracket, tags.processingInstruction],
|
||||
color: token('punctuation'),
|
||||
},
|
||||
{ tag: [tags.link, tags.url], color: token('link') },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
])
|
||||
|
||||
const frame = EditorView.theme({
|
||||
'&': { color: token('fg'), backgroundColor: 'transparent' },
|
||||
'.cm-content': { caretColor: token('fg'), fontFamily: 'var(--ely-mono), monospace' },
|
||||
'.cm-cursor': { borderLeftColor: token('fg') },
|
||||
'.cm-gutters': { backgroundColor: 'transparent', color: token('fg-subtle'), border: 'none' },
|
||||
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: token('hover') },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
|
||||
backgroundColor: token('selection'),
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
})
|
||||
|
||||
export const theme = [frame, syntaxHighlighting(colors)]
|
||||
@@ -0,0 +1,313 @@
|
||||
// Milkdown: visual editing, or read-only preview.
|
||||
import {
|
||||
Editor,
|
||||
defaultValueCtx,
|
||||
remarkStringifyOptionsCtx,
|
||||
editorViewCtx,
|
||||
editorViewOptionsCtx,
|
||||
parserCtx,
|
||||
remarkCtx,
|
||||
rootCtx,
|
||||
} from '@milkdown/kit/core'
|
||||
import { commonmark } from '@milkdown/kit/preset/commonmark'
|
||||
import { gfm } from '@milkdown/kit/preset/gfm'
|
||||
import { history } from '@milkdown/kit/plugin/history'
|
||||
import { codeBlockComponent } from '@milkdown/kit/component/code-block'
|
||||
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import { keymap } from '@milkdown/kit/prose/keymap'
|
||||
import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list'
|
||||
import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history'
|
||||
import { $prose, callCommand } from '@milkdown/kit/utils'
|
||||
import { insertTableCommand } from '@milkdown/kit/preset/gfm'
|
||||
import {
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
addRowAfter,
|
||||
addRowBefore,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
} from '@milkdown/kit/prose/tables'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { image } from './image.js'
|
||||
import { math } from './math.js'
|
||||
import { code } from './code.js'
|
||||
import { diagrams } from './diagrams.js'
|
||||
import { faithful, raw } from './preserve.js'
|
||||
import { docFinder } from './find.js'
|
||||
import { search } from 'prosemirror-search'
|
||||
import { style } from './style.js'
|
||||
|
||||
/** An editor over `markdown`; null when blocks cannot map. */
|
||||
export async function visual(root, markdown, editable, texts, changed, edge) {
|
||||
const owner = diagrams()
|
||||
let write = () => ({ text: markdown, spans: [] })
|
||||
// Setting up raw blocks is no edit.
|
||||
let ready = false
|
||||
const watch = $prose(
|
||||
() =>
|
||||
new Plugin({
|
||||
view: () => ({
|
||||
update: (view, before) => {
|
||||
if (ready && !view.state.doc.eq(before.doc)) changed(() => write(view.state.doc).text)
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// Focus mode dims all blocks but the caret's.
|
||||
const current = $prose(
|
||||
() =>
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const { $from } = state.selection
|
||||
if ($from.depth < 1) return null
|
||||
const start = $from.before(1)
|
||||
const block = Decoration.node(start, start + $from.node(1).nodeSize, {
|
||||
class: 'ely-current',
|
||||
})
|
||||
return DecorationSet.create(state.doc, [block])
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Past their history, undo and redo reach the page.
|
||||
const ends = $prose(() =>
|
||||
keymap({
|
||||
'Mod-z': (state) => undoDepth(state) === 0 && edge('undo'),
|
||||
'Mod-Shift-z': (state) => redoDepth(state) === 0 && edge('redo'),
|
||||
'Mod-y': (state) => redoDepth(state) === 0 && edge('redo'),
|
||||
// Tab moves list items; it never leaves the editor.
|
||||
Tab: (state, dispatch) => sinkListItem(state.schema.nodes.list_item)(state, dispatch) || true,
|
||||
'Shift-Tab': (state, dispatch) =>
|
||||
liftListItem(state.schema.nodes.list_item)(state, dispatch) || true,
|
||||
}),
|
||||
)
|
||||
const made = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root)
|
||||
ctx.set(defaultValueCtx, markdown)
|
||||
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...style(markdown) }))
|
||||
ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable }))
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(image)
|
||||
.use(math)
|
||||
.use(raw)
|
||||
.config(code(texts, editable, owner))
|
||||
.use(codeBlockComponent)
|
||||
.use(watch)
|
||||
.use(current)
|
||||
.use(ends)
|
||||
.use($prose(() => search()))
|
||||
.create()
|
||||
const editor = await made.catch((error) => {
|
||||
owner.release()
|
||||
throw error
|
||||
})
|
||||
const view = editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
const close = () => {
|
||||
editor.destroy()
|
||||
owner.release()
|
||||
}
|
||||
const keep = editor.action((ctx) => faithful(ctx, markdown))
|
||||
if (!keep) {
|
||||
close()
|
||||
return null
|
||||
}
|
||||
write = keep
|
||||
ready = true
|
||||
// The text once every undone step is redone.
|
||||
const redone = () => {
|
||||
let state = view.state
|
||||
if (!redoDepth(state)) return null
|
||||
while (redoDepth(state)) redo(state, (tr) => (state = state.apply(tr)))
|
||||
return write(state.doc).text
|
||||
}
|
||||
// Headings, raw ones too, by the title their source gives.
|
||||
const [remark, parse] = editor.action((ctx) => [ctx.get(remarkCtx), ctx.get(parserCtx)])
|
||||
/** An mdast node's words. */
|
||||
const words = (node) => node.value ?? (node.children ?? []).map(words).join('')
|
||||
// Headings; inside raw blocks, as remark reads their source.
|
||||
const headings = () => {
|
||||
const found = []
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading')
|
||||
found.push({ level: node.attrs.level, title: node.textContent, pos })
|
||||
if (node.type.name !== 'ely_raw') return true
|
||||
visit(remark.parse(node.textContent), 'heading', (heading) => {
|
||||
const start = (heading.children[0] ?? heading).position.start.offset
|
||||
found.push({ level: heading.depth, title: words(heading), pos, at: pos + 1 + start })
|
||||
})
|
||||
return false
|
||||
})
|
||||
return found
|
||||
}
|
||||
// `exact`: the caret at `pos`; else in that block.
|
||||
const reveal = (pos, exact) => {
|
||||
if (editable) {
|
||||
const doc = view.state.doc
|
||||
const place = exact
|
||||
? TextSelection.create(doc, pos)
|
||||
: TextSelection.near(doc.resolve(pos + 1))
|
||||
view.dispatch(view.state.tr.setSelection(place).scrollIntoView())
|
||||
return view.focus()
|
||||
}
|
||||
const { node } = view.domAtPos(pos + 1)
|
||||
;(node.nodeType === 1 ? node : node.parentElement).scrollIntoView({ block: 'center' })
|
||||
}
|
||||
/** `node`'s text, and where a character of it sits. */
|
||||
const reading = (node, start) => {
|
||||
const chunks = []
|
||||
// Text and line breaks, as `textContent` reads them.
|
||||
node.descendants((child, at) => {
|
||||
const words = child.isText
|
||||
? child.text
|
||||
: child.isLeaf
|
||||
? child.type.spec.leafText?.(child)
|
||||
: null
|
||||
if (words) chunks.push({ text: words.toLowerCase(), at: start + 1 + at })
|
||||
})
|
||||
const at = (offset) => {
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const last = index === chunks.length - 1
|
||||
if (offset < chunk.text.length || (last && offset === chunk.text.length))
|
||||
return chunk.at + offset
|
||||
offset -= chunk.text.length
|
||||
}
|
||||
return null
|
||||
}
|
||||
return { joined: chunks.map((chunk) => chunk.text).join(''), at }
|
||||
}
|
||||
// Non-overlapping hits, the same rule as `split`.
|
||||
const hits = (text, needle) => text.split(needle).length - 1
|
||||
/** The block holding file line `n`: its index and start. */
|
||||
const blockOf = (n) => {
|
||||
const { text, spans } = write(view.state.doc)
|
||||
let offset = 0
|
||||
for (let k = 1; k < n && offset >= 0; k++) offset = text.indexOf('\n', offset) + 1
|
||||
if (n < 1 || offset < 0) return null
|
||||
const index = spans.findIndex((span) => span && span[1] >= offset)
|
||||
if (index < 0) return null
|
||||
let pos = 0
|
||||
for (let k = 0; k < index; k++) pos += view.state.doc.child(k).nodeSize
|
||||
return { text, spans, offset, index, pos }
|
||||
}
|
||||
/** Line `n`: the query's place in its block. */
|
||||
const line = (n, query) => {
|
||||
const block = blockOf(n)
|
||||
if (!block) return false
|
||||
const { text, spans, offset, index, pos } = block
|
||||
const needle = query.toLowerCase()
|
||||
const node = view.state.doc.child(index)
|
||||
// Text before and through the line, as shown.
|
||||
const shown = (end) => {
|
||||
const source = text.slice(spans[index][0], Math.max(spans[index][0], end))
|
||||
return (node.type.name === 'ely_raw' ? source : parse(source).textContent).toLowerCase()
|
||||
}
|
||||
const ends = text.indexOf('\n', offset)
|
||||
const before = shown(offset)
|
||||
const through = shown(ends < 0 ? text.length : ends)
|
||||
const { joined, at } = reading(node, pos)
|
||||
const k = needle ? hits(before, needle) : 0
|
||||
let found = -1
|
||||
if (needle && hits(through, needle) > k) {
|
||||
let from = 0
|
||||
for (let seen = 0; seen <= k; seen++) {
|
||||
found = joined.indexOf(needle, from)
|
||||
if (found < 0) break
|
||||
from = found + needle.length
|
||||
}
|
||||
}
|
||||
// A hit hidden, as in a link: the line's start.
|
||||
const place = found >= 0 ? at(found) : at(before.length)
|
||||
reveal(place ?? pos, place !== null)
|
||||
return true
|
||||
}
|
||||
/** The caret: its block's file line, and how far in. */
|
||||
const where = () => {
|
||||
const { text, spans } = write(view.state.doc)
|
||||
const { from } = view.state.selection
|
||||
let [start, pos] = [0, 0]
|
||||
view.state.doc.forEach((node, offset, index) => {
|
||||
if (offset <= from && spans[index]) [start, pos] = [spans[index][0], offset]
|
||||
})
|
||||
return { line: text.slice(0, start).split('\n').length, column: from - pos }
|
||||
}
|
||||
/** The caret back where `where` read it. */
|
||||
const at = (n, column) => {
|
||||
const block = blockOf(n)
|
||||
if (!block) return false
|
||||
const size = view.state.doc.child(block.index).nodeSize
|
||||
reveal(block.pos + Math.min(Math.max(column, 1), size - 1) - 1, false)
|
||||
return true
|
||||
}
|
||||
// Rows and columns; false when the caret is outside tables.
|
||||
const tables = {
|
||||
'row-above': addRowBefore,
|
||||
'row-below': addRowAfter,
|
||||
'column-left': addColumnBefore,
|
||||
'column-right': addColumnAfter,
|
||||
'delete-row': deleteRow,
|
||||
'delete-column': deleteColumn,
|
||||
}
|
||||
const tablesIn = (doc) => {
|
||||
const found = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'table') found.push({ node, pos })
|
||||
})
|
||||
return found
|
||||
}
|
||||
// Milkdown may leave the caret before the new table.
|
||||
const newTable = () => {
|
||||
const old = new Set(tablesIn(view.state.doc).map(({ node }) => node))
|
||||
editor.action(callCommand(insertTableCommand.key, { row: 3, col: 3 }))
|
||||
const made = tablesIn(view.state.doc).find(({ node }) => !old.has(node))
|
||||
if (!made) throw new Error('the inserted table is missing')
|
||||
const cell = TextSelection.near(view.state.doc.resolve(made.pos + 1))
|
||||
view.dispatch(view.state.tr.setSelection(cell).scrollIntoView())
|
||||
return true
|
||||
}
|
||||
const table = (op) => {
|
||||
if (!editable) return false
|
||||
const done = op === 'insert' ? newTable() : tables[op](view.state, view.dispatch)
|
||||
view.focus()
|
||||
return done
|
||||
}
|
||||
/** Images at the caret: `{ src, alt }` each. */
|
||||
const insert = (images) => {
|
||||
if (!editable) return false
|
||||
const image = view.state.schema.nodes.image
|
||||
let tr = view.state.tr
|
||||
for (const { src, alt } of images)
|
||||
tr = tr.replaceSelectionWith(image.create({ src, alt, title: '' }), false)
|
||||
view.dispatch(tr.scrollIntoView())
|
||||
view.focus()
|
||||
return true
|
||||
}
|
||||
return {
|
||||
insert,
|
||||
table,
|
||||
find: docFinder(view),
|
||||
close,
|
||||
text: () => write(view.state.doc).text,
|
||||
redone,
|
||||
focus: () => editable && view.focus(),
|
||||
outline: () => headings().map(({ level, title }) => ({ level, title })),
|
||||
heading: (key) => {
|
||||
const found = headings()[key]
|
||||
if (found) reveal(found.at ?? found.pos, found.at !== undefined)
|
||||
return Boolean(found)
|
||||
},
|
||||
line,
|
||||
where,
|
||||
at,
|
||||
// The caret's line to the middle of the view.
|
||||
center: () => {
|
||||
const { top } = view.coordsAtPos(view.state.selection.head)
|
||||
window.scrollBy(0, top - window.innerHeight / 2)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "ely-pdf"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely-files.workspace = true
|
||||
ely-gpui-component.workspace = true
|
||||
ely-office.workspace = true
|
||||
ely-pdf-engine.workspace = true
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
gpui.workspace = true
|
||||
image.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely-ui = { workspace = true, features = ["test-support"] }
|
||||
ely-pdf-engine = { workspace = true, features = ["test-support"] }
|
||||
ely-gpui-component = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Annotation and form tools on the page (PDF-05, PDF-06).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ely_gpui_component::theme::ActiveTheme as _;
|
||||
use ely_pdf_engine::{FieldKind, Marks, NewAnnot, Shape};
|
||||
use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, canvas, div, point, px};
|
||||
|
||||
use crate::content::{Aim, Tool};
|
||||
use crate::layout::Placed;
|
||||
use crate::pages::{rect_on, to_view};
|
||||
use crate::view::PdfView;
|
||||
|
||||
/// How far a click may move, in window pixels.
|
||||
const CLICK: f32 = 3.0;
|
||||
/// Pages read for their annotations at once.
|
||||
const BATCH: usize = 20;
|
||||
|
||||
/// What the note panel works on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Pick {
|
||||
Annot { page: usize, id: u64 },
|
||||
Field { page: usize, id: u64 },
|
||||
NewNote { page: usize, at: (f32, f32) },
|
||||
NewStamp { page: usize, at: (f32, f32) },
|
||||
NewField { page: usize, rect: [f32; 4], kind: FieldKind },
|
||||
Link { page: usize, index: usize },
|
||||
NewLink { page: usize, rect: [f32; 4] },
|
||||
}
|
||||
|
||||
/// A shape being drawn, in page points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Sketch {
|
||||
pub page: usize,
|
||||
pub points: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Sketch {
|
||||
/// The box from the first point to the last.
|
||||
pub fn rect(&self) -> [f32; 4] {
|
||||
let (a, b) = (self.points[0], self.points[self.points.len() - 1]);
|
||||
[a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Annotations and fields by page.
|
||||
pub type Notes = HashMap<usize, Marks>;
|
||||
|
||||
fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
|
||||
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
|
||||
}
|
||||
|
||||
/// A box around `at`, kept on the page.
|
||||
pub fn around((x, y): (f32, f32), (w, h): (f32, f32), size: (f32, f32)) -> [f32; 4] {
|
||||
let left = (x - w / 2.0).clamp(0.0, (size.0 - w).max(0.0));
|
||||
let top = (y - h / 2.0).clamp(0.0, (size.1 - h).max(0.0));
|
||||
[left, top, left + w, top + h]
|
||||
}
|
||||
|
||||
/// Picked text as one box per line, by page.
|
||||
pub fn line_boxes(chars: &[ely_pdf_engine::Char]) -> Vec<[f32; 4]> {
|
||||
let mut boxes: Vec<(i32, [f32; 4])> = Vec::new();
|
||||
for c in chars {
|
||||
match boxes.last_mut() {
|
||||
Some((line, b)) if *line == c.line => {
|
||||
*b = [b[0].min(c.rect[0]), b[1].min(c.rect[1]), b[2].max(c.rect[2]), b[3].max(c.rect[3])];
|
||||
}
|
||||
_ => boxes.push((c.line, c.rect)),
|
||||
}
|
||||
}
|
||||
boxes.into_iter().map(|(_, b)| b).collect()
|
||||
}
|
||||
|
||||
impl PdfView {
|
||||
/// Reads the annotations of up to a batch of `pages`.
|
||||
pub fn ask_notes(&mut self, pages: impl IntoIterator<Item = usize>, cx: &mut Context<Self>) {
|
||||
if self.reading_notes {
|
||||
return;
|
||||
}
|
||||
let wanted: Vec<usize> = pages.into_iter().filter(|page| !self.notes.contains_key(page)).take(BATCH).collect();
|
||||
if wanted.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.reading_notes = true;
|
||||
let (doc, version) = (self.doc, self.version);
|
||||
self.off(
|
||||
move |worker| {
|
||||
(wanted.into_iter())
|
||||
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?)))
|
||||
.collect::<std::io::Result<Vec<_>>>()
|
||||
},
|
||||
move |view, read, cx| {
|
||||
view.reading_notes = false;
|
||||
if view.version != version {
|
||||
return cx.notify();
|
||||
}
|
||||
match read {
|
||||
Ok(read) => {
|
||||
for (page, marks, links) in read {
|
||||
view.notes.insert(page, marks);
|
||||
view.links.insert(page, links);
|
||||
}
|
||||
}
|
||||
Err(error) => view.fail("reading annotations", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads every field name in the form, once an edit.
|
||||
pub fn ask_names(&mut self, cx: &mut Context<Self>) {
|
||||
if self.form_names.is_some() || self.reading_names {
|
||||
return;
|
||||
}
|
||||
self.reading_names = true;
|
||||
let (doc, version) = (self.doc, self.version);
|
||||
self.off(
|
||||
move |worker| worker.field_names(doc),
|
||||
move |view, read, cx| {
|
||||
view.reading_names = false;
|
||||
if view.version != version {
|
||||
return cx.notify();
|
||||
}
|
||||
match read {
|
||||
Ok(names) => view.form_names = Some(names),
|
||||
Err(error) => view.fail("reading field names", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Ends a text pick: marks it, or clicks.
|
||||
pub fn finish_select(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
self.select_to(at, cx);
|
||||
let Some(selection) = self.selection else {
|
||||
return;
|
||||
};
|
||||
// A hand that shakes a little still clicks.
|
||||
let moved = self.pressed.take().map(|from| f32::from((at.x - from.x).abs() + (at.y - from.y).abs()));
|
||||
let still = moved.is_some_and(|moved| moved < CLICK);
|
||||
match self.tool {
|
||||
Tool::Read if still => {
|
||||
self.selection = None;
|
||||
self.click(selection.anchor, cx)
|
||||
}
|
||||
Tool::Highlight | Tool::Underline | Tool::StrikeOut | Tool::RedactText if !still => {
|
||||
self.marking = Some(selection);
|
||||
self.mark_pending(cx)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A click fills a field or picks an annotation.
|
||||
fn click(&mut self, (page, at): (usize, (f32, f32)), cx: &mut Context<Self>) {
|
||||
let Some(marks) = self.notes.get(&page) else {
|
||||
return;
|
||||
};
|
||||
let field = marks.fields.iter().rev().find(|f| inside(&f.rect, at) && !f.readonly).cloned();
|
||||
let annot = marks.annots.iter().rev().find(|a| inside(&a.rect, at)).map(|a| a.id);
|
||||
self.aim = match (field, annot) {
|
||||
(Some(field), _) => match field.kind {
|
||||
FieldKind::Checkbox | FieldKind::Radio => {
|
||||
let value = if field.checked && field.kind == FieldKind::Checkbox { "Off".to_string() } else { field.on };
|
||||
if !(field.checked && field.kind == FieldKind::Radio) {
|
||||
self.settled_edit("filling a field", cx, move |worker, doc| worker.fill(doc, page, field.id, &value));
|
||||
}
|
||||
None
|
||||
}
|
||||
FieldKind::Text | FieldKind::Choice | FieldKind::List => Some(Aim::Note(Pick::Field { page, id: field.id })),
|
||||
FieldKind::Signature | FieldKind::Button => None,
|
||||
},
|
||||
(None, Some(id)) => Some(Aim::Note(Pick::Annot { page, id })),
|
||||
(None, None) => {
|
||||
let link = self.links.get(&page).and_then(|links| links.iter().rev().find(|l| inside(&l.rect, at))).cloned();
|
||||
if let Some(link) = link {
|
||||
self.follow(&link, cx);
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Queues `op` unless an edit is pending.
|
||||
pub fn settled_edit(
|
||||
&mut self,
|
||||
what: &'static str,
|
||||
cx: &mut Context<Self>,
|
||||
op: impl FnOnce(&mut ely_pdf_engine::Worker, ely_pdf_engine::Doc) -> std::io::Result<String> + Send + 'static,
|
||||
) {
|
||||
// A queued edit may move what the press meant.
|
||||
if !self.settled() {
|
||||
let text = crate::viewer::Viewer::text(cx, "pdf.object.changed", &[]);
|
||||
cx.emit(crate::view::Failed(text.to_string()));
|
||||
return cx.notify();
|
||||
}
|
||||
self.edit(what, op, cx);
|
||||
}
|
||||
|
||||
/// Marks the pending pick once its pages' text is read.
|
||||
pub fn mark_pending(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(selection) = self.marking else {
|
||||
return;
|
||||
};
|
||||
let (first, last) = (selection.anchor.0.min(selection.head.0), selection.anchor.0.max(selection.head.0));
|
||||
let missing: Vec<usize> = (first..=last).filter(|page| !self.text.contains_key(page)).collect();
|
||||
if !missing.is_empty() {
|
||||
for page in missing {
|
||||
self.page_text(page, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.marking = None;
|
||||
self.mark_selection(selection, cx);
|
||||
}
|
||||
|
||||
/// The picked text becomes a highlight, underline or strike.
|
||||
fn mark_selection(&mut self, selection: crate::select::Selection, cx: &mut Context<Self>) {
|
||||
let Some(span) = selection.span(|page| self.text.get(&page).map(|chars| chars.as_slice())) else {
|
||||
return;
|
||||
};
|
||||
let mut annots = Vec::new();
|
||||
for page in span.from.0..=span.to.0 {
|
||||
let Some(chars) = self.text.get(&page) else {
|
||||
return;
|
||||
};
|
||||
let Some(range) = span.on(page, chars.len()).filter(|range| !range.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let boxes = line_boxes(&chars[range]);
|
||||
if self.tool == Tool::RedactText {
|
||||
self.mark_regions(page, boxes, None, cx);
|
||||
continue;
|
||||
}
|
||||
let shape = match self.tool {
|
||||
Tool::Highlight => Shape::Highlight(boxes),
|
||||
Tool::Underline => Shape::Underline(boxes),
|
||||
_ => Shape::StrikeOut(boxes),
|
||||
};
|
||||
annots.push(NewAnnot { page, shape, rgb: None, contents: String::new(), author: String::new() });
|
||||
}
|
||||
self.selection = None;
|
||||
self.annotate("marking text", annots, cx);
|
||||
}
|
||||
|
||||
/// Adds `annots` signed by the account's name.
|
||||
pub fn annotate(&mut self, what: &'static str, mut annots: Vec<NewAnnot>, cx: &mut Context<Self>) {
|
||||
if annots.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.settled_edit(what, cx, move |worker, doc| {
|
||||
let name = ely_platform::user::name()?;
|
||||
annots.iter_mut().for_each(|annot| annot.author = name.clone());
|
||||
worker.annotate(doc, &annots)
|
||||
});
|
||||
}
|
||||
|
||||
/// A press with a drawing or form tool.
|
||||
pub fn sketch_from(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some((page, point)) = self.page_at(at) else {
|
||||
return;
|
||||
};
|
||||
self.held = self.aim;
|
||||
self.grip(page);
|
||||
let hit = self.notes.get(&page).and_then(|marks| marks.fields.iter().rev().find(|f| inside(&f.rect, point)));
|
||||
(self.aim, self.sketch) = match (self.tool, hit) {
|
||||
(Tool::Note, _) => (Some(Aim::Note(Pick::NewNote { page, at: point })), None),
|
||||
(Tool::Stamp, _) => (Some(Aim::Note(Pick::NewStamp { page, at: point })), None),
|
||||
(Tool::Field(_), Some(field)) => (Some(Aim::Note(Pick::Field { page, id: field.id })), None),
|
||||
(Tool::Sign, Some(field)) if field.kind == FieldKind::Signature => {
|
||||
let object = u32::try_from(field.id).ok();
|
||||
self.sign_at = Some(crate::sign::SignAt { page, object, rect: field.rect, version: self.version });
|
||||
(None, None)
|
||||
}
|
||||
(Tool::Link, _) if let Some(index) = self.links.get(&page).and_then(|l| l.iter().rposition(|l| inside(&l.rect, point))) => {
|
||||
(Some(Aim::Note(Pick::Link { page, index })), None)
|
||||
}
|
||||
_ => (None, Some(Sketch { page, points: vec![point] })),
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn sketch_to(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some((page, point)) = self.held_at(at) else {
|
||||
return;
|
||||
};
|
||||
let ink = self.tool == Tool::Ink;
|
||||
if let Some(sketch) = self.sketch.as_mut().filter(|sketch| sketch.page == page) {
|
||||
match ink || sketch.points.len() < 2 {
|
||||
true => sketch.points.push(point),
|
||||
false => *sketch.points.last_mut().expect("two points") = point,
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a drawing: a shape lands, a field asks.
|
||||
pub fn sketch_end(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(sketch) = self.sketch.take() else {
|
||||
return;
|
||||
};
|
||||
let (page, rect) = (sketch.page, sketch.rect());
|
||||
let small = rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0;
|
||||
let first = sketch.points[0];
|
||||
let shape = match self.tool {
|
||||
Tool::Square if !small => Shape::Square(rect),
|
||||
Tool::Circle if !small => Shape::Circle(rect),
|
||||
Tool::Line if (rect[2] - rect[0]).max(rect[3] - rect[1]) >= 4.0 => {
|
||||
let last = sketch.points[sketch.points.len() - 1];
|
||||
Shape::Line([first.0, first.1, last.0, last.1])
|
||||
}
|
||||
Tool::Ink if sketch.points.len() > 1 => Shape::Ink(vec![sketch.points]),
|
||||
Tool::RedactArea => {
|
||||
let click = small.then_some(first);
|
||||
return self.mark_regions(page, if small { Vec::new() } else { vec![rect] }, click, cx);
|
||||
}
|
||||
Tool::PlaceSignature => return self.place_stamp(page, rect, small, cx),
|
||||
Tool::Sign => {
|
||||
let rect = if small { [first.0, first.1, first.0 + 200.0, first.1 + 60.0] } else { rect };
|
||||
self.sign_at = Some(crate::sign::SignAt { page, object: None, rect, version: self.version });
|
||||
return cx.notify();
|
||||
}
|
||||
Tool::Link => {
|
||||
let rect = if small { [first.0, first.1, first.0 + 120.0, first.1 + 20.0] } else { rect };
|
||||
self.aim = Some(Aim::Note(Pick::NewLink { page, rect }));
|
||||
return cx.notify();
|
||||
}
|
||||
Tool::Field(kind) => {
|
||||
let rect = match (small, kind) {
|
||||
(false, _) => rect,
|
||||
(true, FieldKind::Checkbox | FieldKind::Radio) => [first.0, first.1, first.0 + 14.0, first.1 + 14.0],
|
||||
(true, _) => [first.0, first.1, first.0 + 150.0, first.1 + 20.0],
|
||||
};
|
||||
self.aim = Some(Aim::Note(Pick::NewField { page, rect, kind }));
|
||||
return cx.notify();
|
||||
}
|
||||
_ => return cx.notify(),
|
||||
};
|
||||
let annot = NewAnnot { page, shape, rgb: None, contents: String::new(), author: String::new() };
|
||||
self.annotate("drawing", vec![annot], cx);
|
||||
}
|
||||
|
||||
/// The aimed annotation or field, and a shape being drawn.
|
||||
pub fn note_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec<AnyElement> {
|
||||
let color = cx.theme().colors.focus;
|
||||
let size = self.sizes[place.page];
|
||||
let outline = |rect: [f32; 4]| {
|
||||
let (x, y, w, h) = rect_on(rect, size, self.rotate, scale);
|
||||
div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)).border_1().border_color(color)
|
||||
};
|
||||
let mut marks = Vec::new();
|
||||
let aimed = match self.aim {
|
||||
Some(Aim::Note(Pick::Annot { page, id })) if page == place.page => {
|
||||
self.notes.get(&page).and_then(|m| m.annots.iter().find(|a| a.id == id)).map(|a| a.rect)
|
||||
}
|
||||
Some(Aim::Note(Pick::Field { page, id })) if page == place.page => {
|
||||
self.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id)).map(|f| f.rect)
|
||||
}
|
||||
Some(Aim::Note(Pick::NewField { page, rect, .. } | Pick::NewLink { page, rect })) if page == place.page => Some(rect),
|
||||
Some(Aim::Note(Pick::Link { page, index })) if page == place.page => {
|
||||
self.links.get(&page).and_then(|links| links.get(index)).map(|l| l.rect)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
marks.extend(aimed.map(|rect| outline(rect).into_any_element()));
|
||||
let Some(sketch) = self.sketch.as_ref().filter(|sketch| sketch.page == place.page) else {
|
||||
return marks;
|
||||
};
|
||||
match self.tool {
|
||||
Tool::Square | Tool::Field(_) | Tool::Link | Tool::RedactArea | Tool::Sign | Tool::PlaceSignature => marks.push(outline(sketch.rect()).into_any_element()),
|
||||
Tool::Circle => marks.push(outline(sketch.rect()).rounded_full().into_any_element()),
|
||||
_ => {
|
||||
let points: Vec<Point<Pixels>> = (sketch.points.iter())
|
||||
.map(|p| to_view(*p, size, self.rotate))
|
||||
.map(|(x, y)| point(px(x * scale), px(y * scale)))
|
||||
.collect();
|
||||
marks.push(stroke(points, color).into_any_element());
|
||||
}
|
||||
}
|
||||
marks
|
||||
}
|
||||
}
|
||||
|
||||
/// A line through `points`, over the page.
|
||||
fn stroke(points: Vec<Point<Pixels>>, color: gpui::Hsla) -> impl IntoElement {
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let mut path = gpui::PathBuilder::stroke(px(2.0));
|
||||
for (at, p) in points.iter().enumerate() {
|
||||
let p = bounds.origin + *p;
|
||||
if at == 0 { path.move_to(p) } else { path.line_to(p) }
|
||||
}
|
||||
match path.build() {
|
||||
Ok(path) => window.paint_path(path, color),
|
||||
Err(error) => log::error!("pdf: a stroke would not build: {error}"),
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ely_pdf_engine::Char;
|
||||
|
||||
#[test]
|
||||
fn picked_text_marks_one_box_a_line_and_boxes_stay_on_the_page() {
|
||||
let c = |line, x: f32, y: f32| Char { line, c: 'a', rect: [x, y, x + 10.0, y + 12.0] };
|
||||
let chars = [c(0, 0.0, 0.0), c(0, 10.0, 1.0), c(1, 0.0, 20.0)];
|
||||
assert_eq!(line_boxes(&chars), [[0.0, 0.0, 20.0, 13.0], [0.0, 20.0, 10.0, 32.0]]);
|
||||
assert_eq!(around((5.0, 5.0), (20.0, 10.0), (100.0, 100.0)), [0.0, 0.0, 20.0, 10.0]);
|
||||
assert_eq!(around((50.0, 50.0), (20.0, 10.0), (100.0, 100.0)), [40.0, 45.0, 60.0, 55.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Crop, extract and split (PDF-02).
|
||||
|
||||
use std::{
|
||||
io::{self, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
forms::NumberInput,
|
||||
overlays::Dialog as Card,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::{Closing as _, Saving as _, Tabs as _, in_window};
|
||||
use gpui::{AnyElement, App, Context, Entity, IntoElement, ParentElement as _, TaskExt as _, Window};
|
||||
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
/// Points in a millimetre.
|
||||
const MM: f32 = 72.0 / 25.4;
|
||||
|
||||
/// The viewer's own dialogs.
|
||||
pub enum Ask {
|
||||
/// Pages to crop, margins in mm: top, right, bottom, left.
|
||||
Crop { tab: u64, pages: Vec<usize>, margins: [f32; 4] },
|
||||
/// Pages in each file.
|
||||
Split { tab: u64, every: usize },
|
||||
}
|
||||
|
||||
/// Files of `every` pages from `count`: (first, last) each.
|
||||
pub fn parts(count: usize, every: usize) -> Vec<(usize, usize)> {
|
||||
(0..count).step_by(every.max(1)).map(|first| (first, (first + every).min(count) - 1)).collect()
|
||||
}
|
||||
|
||||
/// Margins as seen through a view turned `rotate` degrees, unturned.
|
||||
pub fn unturn(seen: [f32; 4], rotate: i32) -> [f32; 4] {
|
||||
let turns = (rotate.rem_euclid(360) / 90) as usize;
|
||||
std::array::from_fn(|side| seen[(side + turns) % 4])
|
||||
}
|
||||
|
||||
/// A file name's stem.
|
||||
fn stem(name: &str) -> &str {
|
||||
name.rsplit_once('.').map_or(name, |(stem, _)| stem)
|
||||
}
|
||||
|
||||
impl Viewer {
|
||||
pub fn ask_crop(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let pages = view.read(cx).chosen_pages();
|
||||
self.ask = Some(Ask::Crop { tab, pages, margins: [0.0; 4] });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn ask_split(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.core.active else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
self.ask = Some(Ask::Split { tab, every: 1 });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn crop(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Ask::Crop { tab, pages, margins }) = self.ask.take() else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
|
||||
return;
|
||||
};
|
||||
let turned = unturn(margins, view.read(cx).rotate);
|
||||
let points = turned.map(|side| side * MM);
|
||||
view.update(cx, |view, cx| {
|
||||
view.edit("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Asks where, then writes the chosen pages there.
|
||||
pub fn extract(&mut self, cx: &mut Context<Self>) {
|
||||
let (Some(tab), Some(view)) = (self.active_tab(), self.active_view()) else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let (id, pages) = (tab.id, view.read(cx).chosen_pages());
|
||||
let Some(dir) = ely_office::folder(tab) else {
|
||||
return self.panel_failed("no documents folder", cx);
|
||||
};
|
||||
let name = format!("{}-{}.pdf", stem(&tab.name), pages[0] + 1);
|
||||
let chosen = cx.prompt_for_new_path(&dir, Some(&name));
|
||||
cx.spawn(async move |this, cx| {
|
||||
let chosen = match chosen.await {
|
||||
Ok(Ok(path)) => Ok(path),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
};
|
||||
in_window(&this, cx, |viewer, _, cx| match chosen {
|
||||
Ok(Some(path)) => viewer.write_pages(id, pages, path, cx),
|
||||
Ok(None) => log::info!("pdf: the extract panel was cancelled"),
|
||||
Err(error) => viewer.panel_failed(&error, cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// Writes `pages` to `target` once tab `id` settles.
|
||||
pub(crate) fn write_pages(&mut self, id: u64, pages: Vec<usize>, target: PathBuf, cx: &mut Context<Self>) {
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |viewer, _, cx| viewer.write_pages(id, pages, target, cx));
|
||||
}
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let (name, opened) = (ely_office::files::file_name(&target), target.clone());
|
||||
match self.holder(&target) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &name)]), Severity::Danger, cx),
|
||||
Err(error) => return self.failed_write(&name, &error, cx),
|
||||
}
|
||||
let (work, open, revision) = (tab.work.join("extract.pdf"), tab.session.path().map(PathBuf::from), tab.session.revision());
|
||||
log::info!("pdf: extracts {} pages", pages.len());
|
||||
self.read_in(
|
||||
id,
|
||||
move |worker, doc| {
|
||||
worker.get()?.extract(*doc, &pages, &work)?;
|
||||
let bytes = std::fs::read(&work)?;
|
||||
std::fs::remove_file(&work)?;
|
||||
ely_files::export(&target, open.as_deref(), &bytes, revision).map(|_| ())
|
||||
},
|
||||
move |viewer, done, _, cx| match done {
|
||||
Ok(()) => viewer.toast_output(Self::text(cx, "pdf.extract.done", &[("name", &name)]), opened.clone(), cx),
|
||||
Err(error) => viewer.failed_write(&name, &error, cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) {
|
||||
if error.kind() == io::ErrorKind::PermissionDenied {
|
||||
return self.failed(error, cx);
|
||||
}
|
||||
let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// Asks for a folder, then writes the parts there.
|
||||
fn split(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Ask::Split { tab, every }) = self.ask.take() else {
|
||||
return;
|
||||
};
|
||||
cx.notify();
|
||||
self.pick(true, cx, move |viewer, folder, cx| viewer.write_parts(tab, every, folder, cx));
|
||||
}
|
||||
|
||||
pub(crate) fn write_parts(&mut self, id: u64, every: usize, folder: PathBuf, cx: &mut Context<Self>) {
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |viewer, _, cx| viewer.write_parts(id, every, folder, cx));
|
||||
}
|
||||
let (Some(tab), Some(view)) = (self.tab(id), self.tab(id).map(|tab| tab.view.clone())) else {
|
||||
return;
|
||||
};
|
||||
let parts = parts(view.read(cx).sizes.len(), every);
|
||||
let stem = stem(&tab.name).to_string();
|
||||
let targets: Vec<PathBuf> = (1..=parts.len()).map(|n| folder.join(format!("{stem}-{n}.pdf"))).collect();
|
||||
let opened = folder.clone();
|
||||
let work = tab.work.join("part.pdf");
|
||||
log::info!("pdf: splits into {} files", parts.len());
|
||||
self.read_in(
|
||||
id,
|
||||
move |worker, doc| {
|
||||
let claimed = ely_files::claim_all(&targets)?;
|
||||
let written = (parts.iter().zip(claimed)).try_for_each(|((first, last), file)| {
|
||||
let pages: Vec<usize> = (*first..=*last).collect();
|
||||
worker.get()?.extract(*doc, &pages, &work)?;
|
||||
copy_into(&work, file)
|
||||
});
|
||||
// A failure leaves none of this run's files.
|
||||
if let Err(error) = &written
|
||||
&& let Err(left) = targets.iter().try_for_each(std::fs::remove_file)
|
||||
{
|
||||
return Err(io::Error::other(format!("{error}; then {left}")));
|
||||
}
|
||||
written.map(|()| targets.len())
|
||||
},
|
||||
move |viewer, done, _, cx| match done {
|
||||
Ok(count) => viewer.toast_output(Self::text(cx, "pdf.split.done", &[("count", &count.to_string())]), opened.clone(), cx),
|
||||
Err(error) => viewer.failed_write(&stem, &error, cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// The open dialog of the viewer's own, if any.
|
||||
pub fn ask_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let this = cx.entity();
|
||||
let label = |key: &str| Self::text(cx, key, &[]);
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |viewer, cx| {
|
||||
viewer.ask = None;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
};
|
||||
let card = match self.ask.as_ref()? {
|
||||
Ask::Crop { pages, margins, .. } => {
|
||||
let count = pages.len().to_string();
|
||||
let title = Self::text(cx, "pdf.crop.title", &[("count", &count)]);
|
||||
let sides = ["top", "right", "bottom", "left"];
|
||||
let rows = sides.iter().enumerate().fold(DescriptionList::new(), |rows, (at, side)| {
|
||||
let name = label(&format!("pdf.crop.{side}"));
|
||||
rows.item(name.clone(), margin(&this, at, margins[at], name))
|
||||
});
|
||||
let go = label("pdf.crop.go");
|
||||
Card::new("pdf-crop", title, dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| {
|
||||
Button::new("pdf-crop-go", go)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.crop(cx)))
|
||||
})
|
||||
}
|
||||
Ask::Split { every, .. } => {
|
||||
let changed = this.clone();
|
||||
let field = NumberInput::new("pdf-split-every", *every as f64)
|
||||
.range(1.0, 100_000.0)
|
||||
.label(label("pdf.split.every"))
|
||||
.on_commit(move |value, _, cx| {
|
||||
changed.update(cx, |viewer, _| {
|
||||
if let Some(Ask::Split { every, .. }) = viewer.ask.as_mut() {
|
||||
*every = value.round() as usize;
|
||||
}
|
||||
})
|
||||
});
|
||||
let rows = DescriptionList::new().item(label("pdf.split.every"), field);
|
||||
let go = label("pdf.split.go");
|
||||
Card::new("pdf-split", label("pdf.split.title"), dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| {
|
||||
Button::new("pdf-split-go", go)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.split(cx)))
|
||||
})
|
||||
}
|
||||
};
|
||||
Some(card.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// A margin field in millimetres.
|
||||
fn margin(this: &Entity<Viewer>, at: usize, value: f32, name: gpui::SharedString) -> NumberInput {
|
||||
let this = this.clone();
|
||||
NumberInput::new(("pdf-crop", at), f64::from(value))
|
||||
.range(0.0, 1000.0)
|
||||
.suffix("mm")
|
||||
.label(name)
|
||||
.on_commit(move |value, _, cx| {
|
||||
this.update(cx, |viewer, _| {
|
||||
if let Some(Ask::Crop { margins, .. }) = viewer.ask.as_mut() {
|
||||
margins[at] = value as f32;
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Copies a written part into its claimed file.
|
||||
pub(crate) fn copy_into(from: &Path, mut to: std::fs::File) -> io::Result<()> {
|
||||
to.write_all(&std::fs::read(from)?)?;
|
||||
to.sync_all()?;
|
||||
std::fs::remove_file(from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn parts_cover_every_page_once() {
|
||||
assert_eq!(super::parts(5, 2), [(0, 1), (2, 3), (4, 4)]);
|
||||
assert_eq!(super::parts(3, 5), [(0, 2)]);
|
||||
assert_eq!(super::parts(2, 1), [(0, 0), (1, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turned_view_crops_the_side_it_shows() {
|
||||
// Turned right, the page's left side shows on top.
|
||||
assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 90), [2.0, 3.0, 4.0, 1.0]);
|
||||
assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 0), [1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 270), [4.0, 1.0, 2.0, 3.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Attachments: listed beside the pages, saved, added, removed (PDF-07).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::{
|
||||
feedback::EmptyState,
|
||||
lists::{ListItem, SelectableList},
|
||||
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
|
||||
primitives::{IconName, Severity},
|
||||
};
|
||||
use ely_office::{Closing as _, Saving as _, Tabs as _, in_window};
|
||||
use gpui::{
|
||||
Action, AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
|
||||
StatefulInteractiveElement as _, Styled as _, TaskExt as _, div,
|
||||
};
|
||||
|
||||
use crate::document::{RemoveAttachment, SaveAttachment};
|
||||
use crate::view::PdfView;
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
/// A size as people read it.
|
||||
pub fn size(bytes: u64) -> String {
|
||||
match bytes {
|
||||
0..1024 => format!("{bytes} B"),
|
||||
1024..1_048_576 => format!("{:.1} KB", bytes as f64 / 1024.0),
|
||||
_ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfView {
|
||||
/// Reads the attachments once an edit.
|
||||
pub fn ask_files(&mut self, cx: &mut Context<Self>) {
|
||||
if self.files.is_some() || self.reading_files {
|
||||
return;
|
||||
}
|
||||
self.reading_files = true;
|
||||
let (doc, version) = (self.doc, self.version);
|
||||
self.off(
|
||||
move |worker| worker.attachments(doc),
|
||||
move |view, read, cx| {
|
||||
view.reading_files = false;
|
||||
if view.version != version {
|
||||
return cx.notify();
|
||||
}
|
||||
match read {
|
||||
Ok(files) => view.files = Some(files),
|
||||
Err(error) => view.fail("reading attachments", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The attachments of `view`; a right press saves or removes.
|
||||
pub fn list(view: &Entity<PdfView>, cx: &mut App) -> AnyElement {
|
||||
view.update(cx, |view, cx| view.ask_files(cx));
|
||||
let read = view.read(cx);
|
||||
let Some(files) = read.files.as_ref() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
if files.is_empty() {
|
||||
return EmptyState::new("pdf-files-none", IconName::File, Viewer::text(cx, "pdf.files.none", &[])).into_any_element();
|
||||
}
|
||||
let total = files.len();
|
||||
let rows = files.iter().enumerate().fold(SelectableList::new("pdf-files"), |list, (at, file)| {
|
||||
let mut about = size(file.size);
|
||||
if !file.mime.is_empty() {
|
||||
about = format!("{about} · {}", file.mime);
|
||||
}
|
||||
if let Some(page) = file.page {
|
||||
about = format!("{about} · {}", Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]));
|
||||
}
|
||||
let mut item = ListItem::new(("pdf-file", at), file.name.clone()).description(about).place(at + 1, total);
|
||||
if !file.note.is_empty() {
|
||||
item = item.detail(file.note.clone());
|
||||
}
|
||||
list.row(file.key.clone(), item)
|
||||
});
|
||||
let picked = view.clone();
|
||||
let rows = rows
|
||||
.selected(read.file_pick.clone())
|
||||
.label(Viewer::text(cx, "pdf.side.files", &[]))
|
||||
.on_change(move |keys, _, cx| {
|
||||
let name = keys.first().map(|key| key.to_string());
|
||||
picked.update(cx, |view, cx| {
|
||||
view.file_pick = name;
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
let item = |key: &str, action: Box<dyn Action>| {
|
||||
PopupItem::new(Viewer::text(cx, key, &[])).on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
|
||||
};
|
||||
let popup = Popup::new().item(item("pdf.file.save", Box::new(SaveAttachment))).item(item("pdf.file.remove", Box::new(RemoveAttachment)));
|
||||
let body = div().id("pdf-files-scroll").size_full().overflow_y_scroll().child(rows);
|
||||
ContextMenu::new("pdf-files-menu", popup).size_full().child(body).into_any_element()
|
||||
}
|
||||
|
||||
impl Viewer {
|
||||
/// Asks for a file, then embeds it by name.
|
||||
pub fn attach_file(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
self.pick(false, cx, move |_, path: PathBuf, cx| {
|
||||
let name = ely_office::files::file_name(&path);
|
||||
log::info!("pdf: attaches a file");
|
||||
view.update(cx, |view, cx| view.edit("attaching a file", move |worker, doc| worker.attach(doc, &name, &path), cx));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn remove_attachment(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let Some(key) = view.read(cx).file_pick.clone() else {
|
||||
return;
|
||||
};
|
||||
view.update(cx, |view, cx| {
|
||||
view.file_pick = None;
|
||||
view.edit("removing an attachment", move |worker, doc| worker.detach(doc, &key), cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// Asks where, then writes the picked attachment there.
|
||||
pub fn save_attachment(&mut self, cx: &mut Context<Self>) {
|
||||
let (Some(tab), Some(view)) = (self.active_tab(), self.active_view()) else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let read = view.read(cx);
|
||||
let picked = read.file_pick.as_ref().and_then(|key| read.files.as_ref()?.iter().find(|file| &file.key == key));
|
||||
let Some((key, name)) = picked.map(|file| (file.key.clone(), file.name.clone())) else {
|
||||
return;
|
||||
};
|
||||
let Some(dir) = ely_office::folder(tab) else {
|
||||
return self.panel_failed("no documents folder", cx);
|
||||
};
|
||||
let id = tab.id;
|
||||
let chosen = cx.prompt_for_new_path(&dir, Some(&name));
|
||||
cx.spawn(async move |this, cx| {
|
||||
let chosen = match chosen.await {
|
||||
Ok(Ok(path)) => Ok(path),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
};
|
||||
in_window(&this, cx, |viewer, _, cx| match chosen {
|
||||
Ok(Some(target)) => viewer.write_attachment(id, key, target, cx),
|
||||
Ok(None) => log::info!("pdf: the save panel was cancelled"),
|
||||
Err(error) => viewer.panel_failed(&error, cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn write_attachment(&mut self, id: u64, key: String, target: PathBuf, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab(id) else {
|
||||
return;
|
||||
};
|
||||
let (shown, opened) = (ely_office::files::file_name(&target), target.clone());
|
||||
match self.holder(&target) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx),
|
||||
Err(error) => return self.failed(&error, cx),
|
||||
}
|
||||
let (open, revision) = (tab.session.path().map(PathBuf::from), tab.session.revision());
|
||||
self.read_in(
|
||||
id,
|
||||
move |worker, doc| {
|
||||
let bytes = worker.get()?.attachment(*doc, &key)?;
|
||||
ely_files::export(&target, open.as_deref(), &bytes, revision).map(|_| ())
|
||||
},
|
||||
move |viewer, done, _, cx| match done {
|
||||
Ok(()) => viewer.toast_output(Self::text(cx, "pdf.extract.done", &[("name", &shown)]), opened.clone(), cx),
|
||||
Err(error) => viewer.failed(&error, cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn sizes_read_in_their_unit() {
|
||||
assert_eq!(super::size(33), "33 B");
|
||||
assert_eq!(super::size(2048), "2.0 KB");
|
||||
assert_eq!(super::size(3 * 1_048_576), "3.0 MB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Text blocks and drawn objects, picked and dragged (PDF-03, PDF-04).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ely_gpui_component::theme::ActiveTheme as _;
|
||||
use ely_pdf_engine::{Drawn, TextBlock};
|
||||
use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, div, px};
|
||||
|
||||
use crate::layout::Placed;
|
||||
use crate::pages::rect_on;
|
||||
use crate::view::PdfView;
|
||||
|
||||
/// What a press on a page does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
#[default]
|
||||
Read,
|
||||
Edit,
|
||||
AddText,
|
||||
Highlight,
|
||||
Underline,
|
||||
StrikeOut,
|
||||
Note,
|
||||
Square,
|
||||
Circle,
|
||||
Line,
|
||||
Ink,
|
||||
Stamp,
|
||||
Field(ely_pdf_engine::FieldKind),
|
||||
Link,
|
||||
/// Marks picked text, or drawn areas, to remove.
|
||||
RedactText,
|
||||
RedactArea,
|
||||
/// Places a signature signed with a certificate.
|
||||
Sign,
|
||||
/// Places a visible signature made in its dialog.
|
||||
PlaceSignature,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// Presses pick text, as in reading.
|
||||
pub fn selects(self) -> bool {
|
||||
matches!(self, Tool::Read | Tool::Highlight | Tool::Underline | Tool::StrikeOut | Tool::RedactText)
|
||||
}
|
||||
|
||||
/// Presses aim at text blocks and objects.
|
||||
pub fn edits(self) -> bool {
|
||||
matches!(self, Tool::Edit | Tool::AddText)
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's text blocks and drawn objects.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Layer {
|
||||
pub blocks: Vec<TextBlock>,
|
||||
pub drawn: Vec<Drawn>,
|
||||
}
|
||||
|
||||
/// What the edit panel works on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Aim {
|
||||
Text { page: usize, index: usize },
|
||||
Object { page: usize, index: usize },
|
||||
/// New text whose box starts here, in page points.
|
||||
New { page: usize, at: (f32, f32) },
|
||||
/// An annotation or form field.
|
||||
Note(crate::annotate::Pick),
|
||||
}
|
||||
|
||||
/// An object being moved, or scaled from its corner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Drag {
|
||||
pub page: usize,
|
||||
pub index: usize,
|
||||
pub rect: [f32; 4],
|
||||
pub from: (f32, f32),
|
||||
pub now: (f32, f32),
|
||||
pub scale: bool,
|
||||
}
|
||||
|
||||
/// The corner handle's reach, in page points.
|
||||
const HANDLE: f32 = 6.0;
|
||||
|
||||
impl Drag {
|
||||
/// The page-space change the drag makes.
|
||||
pub fn change(&self) -> [f32; 6] {
|
||||
let ([x0, y0, x1, y1], (dx, dy)) = (self.rect, (self.now.0 - self.from.0, self.now.1 - self.from.1));
|
||||
match self.scale {
|
||||
false => [1.0, 0.0, 0.0, 1.0, dx, dy],
|
||||
true => {
|
||||
// Aspect kept, the top left fixed.
|
||||
let s = ((x1 - x0 + dx) / (x1 - x0)).max((y1 - y0 + dy) / (y1 - y0)).max(0.05);
|
||||
[s, 0.0, 0.0, s, x0 - s * x0, y0 - s * y0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The object's box as the drag has it now.
|
||||
pub fn shown(&self) -> [f32; 4] {
|
||||
let [a, _, _, d, e, f] = self.change();
|
||||
let [x0, y0, x1, y1] = self.rect;
|
||||
[a * x0 + e, d * y0 + f, a * x1 + e, d * y1 + f]
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
|
||||
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
|
||||
}
|
||||
|
||||
impl PdfView {
|
||||
/// A drag or drawing is under way.
|
||||
pub fn gesturing(&self) -> bool {
|
||||
self.drag.is_some() || self.sketch.is_some()
|
||||
}
|
||||
|
||||
/// What the side panel shows; held still mid-gesture.
|
||||
pub fn panel_aim(&self) -> Option<Aim> {
|
||||
if self.gesturing() { self.held } else { self.aim }
|
||||
}
|
||||
|
||||
/// Changes the tool; what was aimed at is let go.
|
||||
pub fn set_tool(&mut self, tool: Tool, cx: &mut Context<Self>) {
|
||||
log::info!("pdf: the {tool:?} tool");
|
||||
(self.tool, self.aim, self.drag, self.sketch, self.selection, self.marking) = (tool, None, None, None, None, None);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reads the layers of `pages`, one page at a time.
|
||||
pub fn ask_layers(&mut self, pages: &[usize], cx: &mut Context<Self>) {
|
||||
if !self.tool.edits() || self.reading_layer {
|
||||
return;
|
||||
}
|
||||
let Some(page) = pages.iter().copied().find(|page| !self.layers.contains_key(page)) else {
|
||||
return;
|
||||
};
|
||||
self.reading_layer = true;
|
||||
let (doc, version) = (self.doc, self.version);
|
||||
self.off(
|
||||
move |worker| Ok(Layer { blocks: worker.text_blocks(doc, page)?, drawn: worker.objects(doc, page)? }),
|
||||
move |view, layer, cx| {
|
||||
// Cleared either way; reads from before an edit retry.
|
||||
view.reading_layer = false;
|
||||
if view.version != version {
|
||||
return cx.notify();
|
||||
}
|
||||
match layer {
|
||||
Ok(layer) => {
|
||||
view.layers.insert(page, layer);
|
||||
}
|
||||
Err(error) => view.fail("reading what the page holds", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// A press aims at an object, text, or new text.
|
||||
pub fn press(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some((page, point)) = self.page_at(at) else {
|
||||
return;
|
||||
};
|
||||
(self.drag, self.held) = (None, self.aim);
|
||||
self.grip(page);
|
||||
if self.tool == Tool::AddText {
|
||||
self.aim = Some(Aim::New { page, at: point });
|
||||
return cx.notify();
|
||||
}
|
||||
let Some(layer) = self.layers.get(&page) else {
|
||||
return;
|
||||
};
|
||||
let corner = |rect: &[f32; 4]| (point.0 - rect[2]).abs() <= HANDLE && (point.1 - rect[3]).abs() <= HANDLE;
|
||||
let held = match self.aim {
|
||||
Some(Aim::Object { page: on, index }) if on == page => layer.drawn.get(index).filter(|d| corner(&d.rect)).map(|_| (index, true)),
|
||||
_ => None,
|
||||
};
|
||||
let hit = held.or_else(|| layer.drawn.iter().rposition(|d| inside(&d.rect, point)).map(|index| (index, false)));
|
||||
self.aim = match hit {
|
||||
Some((index, scale)) => {
|
||||
let rect = layer.drawn[index].rect;
|
||||
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale });
|
||||
Some(Aim::Object { page, index })
|
||||
}
|
||||
None => (layer.blocks.iter())
|
||||
.position(|b| b.editable && inside(&b.rect, point))
|
||||
.map(|index| Aim::Text { page, index }),
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn drag_to(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
let Some((page, point)) = self.held_at(at) else {
|
||||
return;
|
||||
};
|
||||
if let Some(drag) = self.drag.as_mut().filter(|drag| drag.page == page) {
|
||||
drag.now = point;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a drag; a real move is one edit.
|
||||
pub fn release(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(drag) = self.drag.take() else {
|
||||
return;
|
||||
};
|
||||
if (drag.now.0 - drag.from.0).abs() < 1.0 && (drag.now.1 - drag.from.1).abs() < 1.0 {
|
||||
return cx.notify();
|
||||
}
|
||||
// A queued edit may renumber what was dragged.
|
||||
if !self.settled() {
|
||||
let text = crate::viewer::Viewer::text(cx, "pdf.object.changed", &[]);
|
||||
cx.emit(crate::view::Failed(text.to_string()));
|
||||
return cx.notify();
|
||||
}
|
||||
let (page, index, change) = (drag.page, drag.index, drag.change());
|
||||
self.edit(
|
||||
if drag.scale { "scaling an object" } else { "moving an object" },
|
||||
move |worker, doc| worker.place(doc, page, index, change),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Outlines of what edits on `place`; the aimed one bold.
|
||||
pub fn edit_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec<AnyElement> {
|
||||
let Some(layer) = self.layers.get(&place.page).filter(|_| self.tool == Tool::Edit) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let colors = &cx.theme().colors;
|
||||
let size = self.sizes[place.page];
|
||||
let outline = |rect: [f32; 4], strong: bool| {
|
||||
let (x, y, w, h) = rect_on(rect, size, self.rotate, scale);
|
||||
let color = if strong { colors.focus } else { colors.focus.opacity(0.35) };
|
||||
div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)).border_1().border_color(color).into_any_element()
|
||||
};
|
||||
let aimed = |object: bool, index: usize| match self.aim {
|
||||
Some(Aim::Object { page, index: at }) => object && page == place.page && at == index,
|
||||
Some(Aim::Text { page, index: at }) => !object && page == place.page && at == index,
|
||||
_ => false,
|
||||
};
|
||||
let mut marks: Vec<AnyElement> = (layer.blocks.iter().enumerate())
|
||||
.filter(|(_, b)| b.editable)
|
||||
.map(|(index, b)| outline(b.rect, aimed(false, index)))
|
||||
.collect();
|
||||
for (index, drawn) in layer.drawn.iter().enumerate() {
|
||||
let dragged = self.drag.filter(|d| d.page == place.page && d.index == index);
|
||||
let rect = dragged.map_or(drawn.rect, |d| d.shown());
|
||||
marks.push(outline(rect, aimed(true, index)));
|
||||
if aimed(true, index) {
|
||||
let (x, y, w, h) = rect_on(rect, size, self.rotate, scale);
|
||||
let corner = div().absolute().left(px(x + w - 4.0)).top(px(y + h - 4.0)).size(px(8.0)).bg(colors.focus);
|
||||
marks.push(corner.into_any_element());
|
||||
}
|
||||
}
|
||||
marks
|
||||
}
|
||||
}
|
||||
|
||||
/// Layers by page, dropped with each edit.
|
||||
pub type Layers = HashMap<usize, Layer>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_drag_moves_or_scales_from_the_top_left() {
|
||||
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false };
|
||||
assert_eq!(drag.change(), [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
|
||||
assert_eq!(drag.shown(), [20.0, 25.0, 120.0, 75.0]);
|
||||
drag = Drag { from: (110.0, 70.0), now: (210.0, 80.0), scale: true, ..drag };
|
||||
assert_eq!(drag.shown(), [10.0, 20.0, 210.0, 120.0], "twice as wide, twice as tall");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The Document menu (PDF-07 to PDF-12).
|
||||
|
||||
use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions};
|
||||
|
||||
use crate::content::Tool;
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
actions!(
|
||||
pdf,
|
||||
[
|
||||
DocumentProperties,
|
||||
RecognizeText,
|
||||
AddBookmark,
|
||||
RenameBookmark,
|
||||
DeleteBookmark,
|
||||
AddLink,
|
||||
AttachFile,
|
||||
SaveAttachment,
|
||||
RemoveAttachment,
|
||||
RedactText,
|
||||
RedactArea,
|
||||
ClearRedactions,
|
||||
ApplyRedactions,
|
||||
CleanDocument,
|
||||
OwnerPassword,
|
||||
ProtectDocument,
|
||||
SignDocument,
|
||||
AddVisibleSignature,
|
||||
ShowSignatures,
|
||||
CloseSignatures,
|
||||
ShowRepairReport
|
||||
]
|
||||
);
|
||||
|
||||
pub fn menu(cx: &App) -> Menu {
|
||||
let label = |key: &str| Viewer::text(cx, key, &[]);
|
||||
Menu::new(label("menu.document")).items([
|
||||
MenuItem::action(label("pdf.props.open"), DocumentProperties),
|
||||
MenuItem::action(label("pdf.ocr.open"), RecognizeText),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("pdf.mark.add"), AddBookmark),
|
||||
MenuItem::action(label("pdf.tool.link"), AddLink),
|
||||
MenuItem::action(label("pdf.file.attach"), AttachFile),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("pdf.redact.text"), RedactText),
|
||||
MenuItem::action(label("pdf.redact.area"), RedactArea),
|
||||
MenuItem::action(label("pdf.redact.clear"), ClearRedactions),
|
||||
MenuItem::action(label("pdf.redact.apply"), ApplyRedactions),
|
||||
MenuItem::action(label("pdf.clean.open"), CleanDocument),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(label("pdf.owner.open"), OwnerPassword),
|
||||
MenuItem::action(label("pdf.protect.open"), ProtectDocument),
|
||||
MenuItem::action(label("pdf.sign.show"), ShowSignatures),
|
||||
MenuItem::action(label("pdf.sign.open"), SignDocument),
|
||||
MenuItem::action(label("pdf.visible.open"), AddVisibleSignature),
|
||||
])
|
||||
}
|
||||
|
||||
/// Wires the Document actions onto the root.
|
||||
pub fn on(root: Div, cx: &mut Context<Viewer>) -> Div {
|
||||
root.on_action(cx.listener(|v, _: &DocumentProperties, window, cx| v.ask_properties(window, cx)))
|
||||
.on_action(cx.listener(|v, _: &RecognizeText, window, cx| v.ask_ocr(window, cx)))
|
||||
.on_action(cx.listener(|v, _: &AddBookmark, window, cx| v.ask_bookmark(true, window, cx)))
|
||||
.on_action(cx.listener(|v, _: &RenameBookmark, window, cx| v.ask_bookmark(false, window, cx)))
|
||||
.on_action(cx.listener(|v, _: &DeleteBookmark, _, cx| v.delete_bookmark(cx)))
|
||||
.on_action(cx.listener(|v, _: &AddLink, _, cx| v.toggle_tool(Tool::Link, cx)))
|
||||
.on_action(cx.listener(|v, _: &AttachFile, _, cx| v.attach_file(cx)))
|
||||
.on_action(cx.listener(|v, _: &SaveAttachment, _, cx| v.save_attachment(cx)))
|
||||
.on_action(cx.listener(|v, _: &RemoveAttachment, _, cx| v.remove_attachment(cx)))
|
||||
.on_action(cx.listener(|v, _: &RedactText, _, cx| v.toggle_tool(Tool::RedactText, cx)))
|
||||
.on_action(cx.listener(|v, _: &RedactArea, _, cx| v.toggle_tool(Tool::RedactArea, cx)))
|
||||
.on_action(cx.listener(|v, _: &ClearRedactions, _, cx| v.clear_redactions(cx)))
|
||||
.on_action(cx.listener(|v, _: &ApplyRedactions, _, cx| v.ask_redact(cx)))
|
||||
.on_action(cx.listener(|v, _: &CleanDocument, _, cx| v.ask_clean(cx)))
|
||||
.on_action(cx.listener(|v, _: &OwnerPassword, window, cx| v.ask_owner(window, cx)))
|
||||
.on_action(cx.listener(|v, _: &ProtectDocument, window, cx| v.ask_protect(window, cx)))
|
||||
.on_action(cx.listener(|v, _: &SignDocument, window, cx| v.ask_sign(window, cx)))
|
||||
.on_action(cx.listener(|v, _: &AddVisibleSignature, window, cx| v.ask_visible(window, cx)))
|
||||
.on_action(cx.listener(|v, _: &ShowSignatures, _, cx| v.show_signatures(true, cx)))
|
||||
.on_action(cx.listener(|v, _: &CloseSignatures, _, cx| v.show_signatures(false, cx)))
|
||||
.on_action(cx.listener(|v, _: &ShowRepairReport, _, cx| v.show_repair(true, cx)))
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! The edit panel: text, font, size, colour; objects (PDF-03, PDF-04).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
forms::{Choice, ColorPalette, Input, NumberInput, Select, TextInput},
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::Tabs as _;
|
||||
use ely_pdf_engine::Writing;
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, Hsla, IntoElement, ParentElement as _, Render, RenderImage, SharedString,
|
||||
Styled as _, WeakEntity, Window, div, rgb,
|
||||
};
|
||||
|
||||
use crate::content::Aim;
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
/// Built-in fonts by value and label key.
|
||||
pub(crate) const FONTS: [(&str, &str); 8] = [
|
||||
("original", "pdf.font.original"),
|
||||
("helvetica", "pdf.font.helvetica"),
|
||||
("times", "pdf.font.times"),
|
||||
("courier", "pdf.font.courier"),
|
||||
("cjk-sc", "pdf.font.sc"),
|
||||
("cjk-tc", "pdf.font.tc"),
|
||||
("cjk-jp", "pdf.font.jp"),
|
||||
("cjk-kr", "pdf.font.kr"),
|
||||
];
|
||||
|
||||
/// A change that alters layout, shown before it is made.
|
||||
pub struct Preview {
|
||||
pub tab: u64,
|
||||
/// The view's version when asked; any edit since voids it.
|
||||
pub version: u64,
|
||||
pub page: usize,
|
||||
pub writing: Writing,
|
||||
pub image: Arc<RenderImage>,
|
||||
}
|
||||
|
||||
pub struct EditPanel {
|
||||
viewer: WeakEntity<Viewer>,
|
||||
/// The tab, view version and aim the fields show.
|
||||
shown: Option<(u64, u64, Aim)>,
|
||||
/// Made on the first draw, which has a window.
|
||||
pub(crate) field: Option<Entity<TextInput>>,
|
||||
pub font: SharedString,
|
||||
pub size: f32,
|
||||
pub rgb: u32,
|
||||
/// The text box width, in points.
|
||||
pub width: f32,
|
||||
/// Why the last write did not happen.
|
||||
pub note: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl EditPanel {
|
||||
pub fn new(viewer: WeakEntity<Viewer>) -> Self {
|
||||
EditPanel { viewer, shown: None, field: None, font: "original".into(), size: 12.0, rgb: 0, width: 200.0, note: None }
|
||||
}
|
||||
|
||||
/// Fills the fields for what the view aims at now.
|
||||
fn follow(&mut self, aimed: (u64, u64, Aim), cx: &mut Context<Self>) {
|
||||
if self.shown == Some(aimed) {
|
||||
return;
|
||||
}
|
||||
let Some(viewer) = self.viewer.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(view) = viewer.read(cx).active_view() else {
|
||||
return;
|
||||
};
|
||||
let view = view.read(cx);
|
||||
let (text, font, size, rgb, width) = match aimed.2 {
|
||||
Aim::Text { page, index } => match view.layers.get(&page).and_then(|layer| layer.blocks.get(index)) {
|
||||
Some(b) => (b.text.clone(), "original", b.size, b.rgb, b.rect[2] - b.rect[0]),
|
||||
None => return,
|
||||
},
|
||||
Aim::New { page, at } => (String::new(), "helvetica", 12.0, 0, (view.sizes[page].0 - at.0 - 36.0).max(72.0)),
|
||||
Aim::Object { .. } => (String::new(), "original", 12.0, 0, 200.0),
|
||||
Aim::Note(_) => return,
|
||||
};
|
||||
(self.shown, self.font, self.size, self.rgb, self.width, self.note) = (Some(aimed), font.into(), size, rgb, width, None);
|
||||
if let Some(field) = &self.field {
|
||||
field.update(cx, |field, cx| field.set_text(&text, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// The text to write for `aim` on `view`.
|
||||
pub fn writing(&self, view: &crate::view::PdfView, aim: Aim, cx: &gpui::App) -> Option<(usize, Writing)> {
|
||||
let text = self.field.as_ref()?.read(cx).text().to_string();
|
||||
let (page, block, expected, bx) = match aim {
|
||||
Aim::Text { page, index } => {
|
||||
let b = view.layers.get(&page)?.blocks.get(index)?;
|
||||
(page, Some(index), b.text.clone(), [b.rect[0], b.rect[1], self.width])
|
||||
}
|
||||
Aim::New { page, at } => (page, None, String::new(), [at.0, at.1, self.width]),
|
||||
Aim::Object { .. } | Aim::Note(_) => return None,
|
||||
};
|
||||
let writing = Writing { block, expected, text, font: self.font.to_string(), size: self.size, rgb: self.rgb, bx };
|
||||
Some((page, writing))
|
||||
}
|
||||
}
|
||||
|
||||
/// A row's label, as the viewer says it.
|
||||
fn label(cx: &gpui::App, key: &str) -> SharedString {
|
||||
Viewer::text(cx, key, &[])
|
||||
}
|
||||
|
||||
impl Render for EditPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.field.is_none() {
|
||||
self.field = Some(cx.new(|cx| TextInput::new(window, cx).multi_line(3, 12)));
|
||||
}
|
||||
let Some(viewer) = self.viewer.upgrade() else {
|
||||
return div();
|
||||
};
|
||||
let Some((tab, view)) = viewer.read(cx).active_tab().map(|tab| (tab.id, tab.view.clone())) else {
|
||||
return div();
|
||||
};
|
||||
let Some(aim) = view.read(cx).aim else {
|
||||
return div();
|
||||
};
|
||||
self.follow((tab, view.read(cx).version, aim), cx);
|
||||
let this = cx.entity();
|
||||
let buttons = div().flex().gap_2();
|
||||
let body = match aim {
|
||||
Aim::Object { .. } => {
|
||||
let image = matches!(aim, Aim::Object { page, index } if view.read(cx).layers.get(&page).and_then(|l| l.drawn.get(index)).is_some_and(|d| !d.form));
|
||||
let (swap, gone) = (viewer.downgrade(), viewer.downgrade());
|
||||
buttons
|
||||
.children(image.then(|| {
|
||||
Button::new("pdf-object-replace", label(cx, "pdf.object.replace"))
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
swap.update(cx, |viewer, cx| viewer.replace_image(cx)).ok();
|
||||
})
|
||||
}))
|
||||
.child(
|
||||
Button::new("pdf-object-delete", label(cx, "pdf.object.delete"))
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Danger)
|
||||
.on_click(move |_, _, cx| {
|
||||
gone.update(cx, |viewer, cx| viewer.delete_object(cx)).ok();
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
_ => self.text_rows(&this, &viewer, aim, cx),
|
||||
};
|
||||
div().p_3().flex().flex_col().gap_3().child(body)
|
||||
}
|
||||
}
|
||||
|
||||
impl EditPanel {
|
||||
fn text_rows(&self, this: &Entity<Self>, viewer: &Entity<Viewer>, aim: Aim, cx: &mut Context<Self>) -> gpui::AnyElement {
|
||||
let set = |this: &Entity<Self>, change: fn(&mut EditPanel, f64)| {
|
||||
let this = this.clone();
|
||||
move |value: f64, _: &mut Window, cx: &mut gpui::App| this.update(cx, |panel, cx| {
|
||||
change(panel, value);
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
let fonts = FONTS
|
||||
.iter()
|
||||
.filter(|(value, _)| *value != "original" || matches!(aim, Aim::Text { .. }))
|
||||
.map(|(value, key)| Choice::new(*value, label(cx, key)));
|
||||
let picked = this.clone();
|
||||
let font = Select::new("pdf-text-font", fonts.collect::<Vec<_>>())
|
||||
.selected(self.font.clone())
|
||||
.label(label(cx, "pdf.text.font"))
|
||||
.on_change(move |value, _, cx| picked.update(cx, |panel, cx| {
|
||||
(panel.font, panel.note) = (value.clone(), None);
|
||||
cx.notify();
|
||||
}));
|
||||
let size = NumberInput::new("pdf-text-size", f64::from(self.size))
|
||||
.range(1.0, 400.0)
|
||||
.label(label(cx, "pdf.text.size"))
|
||||
.on_commit(set(this, |panel, value| panel.size = value as f32));
|
||||
let width = NumberInput::new("pdf-text-width", f64::from(self.width))
|
||||
.range(10.0, 2000.0)
|
||||
.suffix("pt")
|
||||
.label(label(cx, "pdf.text.width"))
|
||||
.on_commit(set(this, |panel, value| panel.width = value as f32));
|
||||
let swatches = ely_office::panel::COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color))));
|
||||
let painted = this.clone();
|
||||
let mut color = ColorPalette::new("pdf-text-color", swatches.clone()).columns(5).on_change(move |color, _, cx| {
|
||||
let at = swatches.iter().position(|(_, swatch)| *swatch == color).expect("the palette hands back its own colors");
|
||||
painted.update(cx, |panel, cx| {
|
||||
panel.rgb = ely_office::panel::COLORS[at];
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
if ely_office::panel::COLORS.contains(&self.rgb) {
|
||||
color = color.selected(rgb(self.rgb));
|
||||
}
|
||||
let rows = DescriptionList::new()
|
||||
.item(label(cx, "pdf.text.font"), font)
|
||||
.item(label(cx, "pdf.text.size"), size)
|
||||
.item(label(cx, "pdf.text.color"), color)
|
||||
.item(label(cx, "pdf.text.width"), width);
|
||||
let (apply, erase) = (viewer.downgrade(), viewer.downgrade());
|
||||
let mut buttons = div().flex().gap_2().child(
|
||||
Button::new("pdf-text-apply", label(cx, "pdf.text.apply"))
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| {
|
||||
apply.update(cx, |viewer, cx| viewer.write_text(false, cx)).ok();
|
||||
}),
|
||||
);
|
||||
if matches!(aim, Aim::Text { .. }) {
|
||||
buttons = buttons.child(
|
||||
Button::new("pdf-text-erase", label(cx, "pdf.text.erase"))
|
||||
.size(ControlSize::Sm)
|
||||
.variant(ButtonVariant::Danger)
|
||||
.on_click(move |_, _, cx| {
|
||||
erase.update(cx, |viewer, cx| viewer.write_text(true, cx)).ok();
|
||||
}),
|
||||
);
|
||||
}
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.children(self.field.as_ref().map(Input::new))
|
||||
.child(rows)
|
||||
.children(self.note.clone().map(|note| div().text_sm().child(note)))
|
||||
.child(buttons)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 817 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user