Compare commits
No files matched your search
@@ -0,0 +1,63 @@
|
||||
name: Bug Report
|
||||
description: Something behaves, draws or builds wrong.
|
||||
title: "[BUG REPORT] "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: subject
|
||||
attributes:
|
||||
label: App
|
||||
options:
|
||||
- ElyOffice
|
||||
- ElyDoc
|
||||
- ElyTable
|
||||
- ElyShow
|
||||
- ElyPDF
|
||||
- ElyMark
|
||||
- ElyTree
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: where
|
||||
attributes:
|
||||
label: Where
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What happened
|
||||
description: A screenshot or a short recording helps most.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What you expected
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: "Version"
|
||||
placeholder: "0.1.8"
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Terminal output or error messages.
|
||||
render: shell
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ely Office
|
||||
url: https://office.elygpui.com
|
||||
about: "Downloads and what each app does."
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Feature Request
|
||||
description: Something new, or something that should do more.
|
||||
title: "[FEATURE REQUEST] "
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: subject
|
||||
attributes:
|
||||
label: App
|
||||
options:
|
||||
- ElyOffice
|
||||
- ElyDoc
|
||||
- ElyTable
|
||||
- ElyShow
|
||||
- ElyPDF
|
||||
- ElyMark
|
||||
- ElyTree
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: use
|
||||
attributes:
|
||||
label: What it is for
|
||||
description: The problem, and the moment you need it in.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: behavior
|
||||
attributes:
|
||||
label: How it should work
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: Links or screenshots of it elsewhere.
|
||||
- type: checkboxes
|
||||
id: searched
|
||||
attributes:
|
||||
label: Checked
|
||||
options:
|
||||
- label: I searched the open issues for the same request.
|
||||
required: true
|
||||
@@ -0,0 +1,11 @@
|
||||
name: Other
|
||||
description: Questions, docs, ideas, anything else.
|
||||
title: "[Other] "
|
||||
labels: [question]
|
||||
body:
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What's on your mind
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,35 @@
|
||||
<!--
|
||||
Title: start with the type, for example
|
||||
[FEATURE REQUEST] Add dark mode to the settings page
|
||||
[BUG REPORT] Fix a crash when opening an empty file
|
||||
[Other] Update the README install steps
|
||||
-->
|
||||
|
||||
## Type
|
||||
|
||||
- [ ] **[FEATURE REQUEST]**: a new feature or an improvement
|
||||
- [ ] **[BUG REPORT]**: a bug fix
|
||||
- [ ] **[Other]**: docs, refactoring, CI, dependencies or chores
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- One or two sentences. Link the issue: Closes #123 -->
|
||||
|
||||
## Bug details
|
||||
|
||||
<!-- [BUG REPORT] only; delete this section otherwise. -->
|
||||
|
||||
- Steps to reproduce:
|
||||
- Expected:
|
||||
- Actual:
|
||||
- Cause and fix:
|
||||
|
||||
## How it was tested
|
||||
|
||||
<!-- Commands you ran, and what you checked by hand. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The title starts with `[FEATURE REQUEST]`, `[BUG REPORT]` or `[Other]`
|
||||
- [ ] Tests and the build pass locally
|
||||
- [ ] Docs are updated if behaviour changed
|
||||
@@ -1,58 +1,68 @@
|
||||
# A tag builds, packages and uploads all three platforms.
|
||||
# Tags only: no pull request ever reaches the self-hosted runners.
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
macos:
|
||||
runs-on: macos-arm64
|
||||
# A GUI-session agent: daemons see no login keychain.
|
||||
runs-on: [self-hosted, macos-signing]
|
||||
timeout-minutes: 720
|
||||
steps:
|
||||
- name: Build, package and upload for macOS
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
|
||||
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
|
||||
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
|
||||
# Repository variables; both live in the runner's keychain.
|
||||
ELY_SIGN_IDENTITY: ${{ vars.ELY_SIGN_IDENTITY }}
|
||||
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
|
||||
run: |
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
git -C "$dir" fetch --force --tags origin
|
||||
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||
"$dir/scripts/ci.sh" macos "${{ github.ref_name }}"
|
||||
windows:
|
||||
runs-on: windows
|
||||
runs-on: [self-hosted, ely-windows]
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Build, package and upload for Windows
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ELY_SHARE: /c/Users/zhang/Documents/Github/ElyOffice
|
||||
ELY_PATH: /c/Program Files/CMake/bin:/c/Users/zhang/AppData/Local/Microsoft/WinGet/Packages/Ninja-build.Ninja_Microsoft.Winget.Source_8wekyb3d8bbwe
|
||||
run: |
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
git -C "$dir" fetch --force --tags origin
|
||||
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||
"$dir/scripts/ci.sh" windows "${{ github.ref_name }}"
|
||||
linux:
|
||||
# WSL on the Windows runner keeps a built kernel.
|
||||
runs-on: windows
|
||||
runs-on: [self-hosted, ely-windows]
|
||||
needs: windows
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Build, package and upload for Linux
|
||||
shell: bash
|
||||
env:
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WSLENV: ELY_RELEASE_TOKEN
|
||||
run: |
|
||||
wsl -d Ubuntu --exec bash -lc '
|
||||
set -euo pipefail
|
||||
dir=$HOME/ely-ci/ElyOffice
|
||||
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
|
||||
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
|
||||
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
|
||||
git -C "$dir" fetch --force --tags origin
|
||||
git -C "$dir" checkout --force "${{ github.sha }}"
|
||||
ELY_SHARE=$HOME/Documents/Github/ElyOffice "$dir/scripts/ci.sh" linux "${{ github.ref_name }}"
|
||||
@@ -18,3 +18,5 @@ node_modules/
|
||||
/assets/papirus_icons-master.zip
|
||||
/Gitea.md
|
||||
/.work/
|
||||
/TyporaRef
|
||||
/oauth.env
|
||||
+5
-8
@@ -1,15 +1,12 @@
|
||||
# Contributing
|
||||
|
||||
## Opening a pull request on git.zacharyzhang.com
|
||||
## Opening a pull request on GitHub
|
||||
|
||||
Forking is disabled on this server. Open pull requests with AGit instead:
|
||||
|
||||
1. Register an account at https://git.zacharyzhang.com
|
||||
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
|
||||
1. Fork https://github.com/ELY-Open-Source/ElyOffice and clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
|
||||
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
|
||||
git clone https://github.com/<your-account>/ElyOffice.git
|
||||
```
|
||||
|
||||
3. To update the pull request, push again with the same `topic`.
|
||||
2. Commit your changes on a branch and push it to your fork.
|
||||
3. Open a pull request against `main`. To update it, push to the same branch.
|
||||
Generated
+630
-24
File diff suppressed because it is too large.
Load diff
+19
-3
@@ -5,10 +5,10 @@ members = ["apps/*", "crates/*"]
|
||||
exclude = ["engines", "reference"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.4"
|
||||
version = "0.1.9"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "AGPL-3.0-or-later"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -19,7 +19,7 @@ jiff = "0.2"
|
||||
anyhow = "1"
|
||||
directories = "6"
|
||||
dunce = "1"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "8fd3e431db3c57c67328869cffdcd43d5f7fd056" }
|
||||
ely-gpui-component = { git = "https://github.com/ELY-Open-Source/Ely-GPUI-Components", rev = "cd365e92ea0c4467c439b8253963461ba0f6d155" }
|
||||
env_logger = "0.11"
|
||||
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
||||
@@ -39,10 +39,15 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
|
||||
semver = { version = "1", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
html5ever = "0.39"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
ureq = "3"
|
||||
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
|
||||
windows = { version = "0.62", features = ["Data_Pdf", "Graphics_Imaging", "Storage_Streams", "Win32_Graphics_Gdi", "Win32_Graphics_Printing", "Win32_Storage_Xps", "Win32_System_Memory", "Win32_System_WinRT", "Win32_UI_Controls_Dialogs", "Win32_UI_Input_KeyboardAndMouse"] }
|
||||
lopdf = { version = "0.45", default-features = false }
|
||||
zbus = { version = "5", default-features = false, features = ["async-io"] }
|
||||
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"] }
|
||||
@@ -53,10 +58,21 @@ 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"] }
|
||||
imap-proto = "0.17"
|
||||
nom = "8"
|
||||
mail-builder = { version = "1.0", default-features = false }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12"] }
|
||||
rcgen = "0.14"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "tls12", "std"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
tokio = { version = "1", features = ["net", "time", "io-util", "rt", "macros"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||
|
||||
ely-files = { path = "crates/ely-files" }
|
||||
ely-ipc = { path = "crates/ely-ipc" }
|
||||
ely-lok = { path = "crates/ely-lok" }
|
||||
ely-mail-engine = { path = "crates/ely-mail-engine" }
|
||||
ely-office = { path = "crates/ely-office" }
|
||||
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
|
||||
ely-platform = { path = "crates/ely-platform" }
|
||||
|
||||
@@ -1,661 +1,21 @@
|
||||
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/>.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Yang Zhang
|
||||
|
||||
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.
|
||||
@@ -22,12 +22,12 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
|
||||
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
|
||||
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
|
||||
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | Rust 的 mail-send、mail-parser、mail-builder(IMAP 与 POP3 由引擎自写),加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
|
||||
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
|
||||
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
|
||||
|
||||
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
LOK、MuPDF 和 GPUI 是已确定的方向。邮件协议 crate、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
|
||||
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
|
||||
|
||||
@@ -375,16 +375,18 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
|
||||
|
||||
### 12.1 定位与后端
|
||||
|
||||
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
|
||||
提供多账号邮件客户端,不建设邮件托管服务。协议与 MIME 用 Rust crate(用户 2026-10-19 定,取代 MailCore 2):IMAP(imap-proto 解析)与 POP3 由 Ely 自写(async-imap 与 async-pop 有数处缺陷,C12.1 审查查出),mail-send 发 SMTP,mail-parser 读 MIME,mail-builder 写 MIME。它们在应用进程的后台任务里运行,不另起工作进程。Ely 负责账号流程、本地存储、搜索、同步队列和界面。
|
||||
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 HTML 解析当成完整页面渲染。
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文与写信不用 CEF 与 Electron(用户 2026-10-19 定):正文以 html5ever 解析,净化后按块的子集由 GPUI 绘制(段落、标题、列表、引用、表格、图片、链接、粗斜体与颜色),脚本、定位、浮动与样式表之外的版式不画,不当作完整页面渲染;写信用组件库的 Markdown 文档组件,发出纯文本与由 markdown-rs 生成的 HTML 两部分。
|
||||
|
||||
内置登录:Gmail 与 Microsoft 走 OAuth;iCloud 不给第三方 OAuth,走应用专用密码向导(服务器 `imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)[S10]。OAuth 的客户端 ID 在构建时注入,缺 ID 的构建里该登录写明不可用。
|
||||
|
||||
### 12.2 功能要求
|
||||
|
||||
| 编号 | 功能 | 完成要求 |
|
||||
| --- | --- | --- |
|
||||
| MAIL-01 | 账号 | 支持多个 IMAP/SMTP 账号、POP3 账号、服务器配置、TLS 与连通性检查 |
|
||||
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程;支持服务商允许的应用密码或自定义认证配置 |
|
||||
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程,iCloud 的应用专用密码向导;支持服务商允许的应用密码或自定义认证配置 |
|
||||
| MAIL-03 | 邮箱视图 | 支持统一收件箱、账号独立视图、文件夹、未读、标记和附件筛选 |
|
||||
| MAIL-04 | 阅读 | 支持纯文本、HTML、内嵌图片、附件、原始邮件头与会话线程 |
|
||||
| MAIL-05 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
|
||||
@@ -478,6 +480,8 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
|
||||
|
||||
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则、Markion 的 GPUI 原生 Markdown 编辑,不复制其整个应用窗口。不用 Electron,不用 CEF 或其他网页内容宿主。[S17][S18]
|
||||
|
||||
解析、渲染、编辑命令与右键菜单以 Typora 1.14.10 的默认设置为对照,不输于它;可视模式仍是按下的块以原文编辑,不改为所见即所得的行内编辑(用户 2026-10-13 定)。Typora 是参照,不求一一相同:验收看每项功能可用,缺 Typora 的某项、次序或措辞不同不算缺陷(用户 2026-10-14 定)。[S25]
|
||||
|
||||
### 14.2 功能要求
|
||||
|
||||
| 编号 | 功能 | 完成要求 |
|
||||
@@ -485,7 +489,7 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
|
||||
| MARK-01 | 本地文件 | 新建、打开、保存、重命名和另存;支持直接打开文件或文件夹 |
|
||||
| MARK-02 | 编辑模式 | 可视化、源码和只读预览可切换;切换本身不修改文件内容 |
|
||||
| MARK-03 | Markdown | 支持 CommonMark 与 GFM 常用语法:标题、列表、任务、引用、链接、图片、表格和代码块 |
|
||||
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;资源随包提供 |
|
||||
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;照 Typora 默认显示 `:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块与 `[toc]` 目录;`==`、`^`、单个 `~` 照 GFM,不作高亮、上下标;Typora 与 GitHub 读法不同处随 GitHub(表情名与转义、标签与强调交叉);资源随包提供 |
|
||||
| MARK-05 | 导航 | 提供文件树、文档大纲、多标签、最近文件和按标题跳转 |
|
||||
| MARK-06 | 编辑工具 | 支持查找替换、源码正则搜索、缩进、列表续写、表格行列操作和代码高亮 |
|
||||
| MARK-07 | 附件 | 支持拖放和粘贴图片,默认复制到文档旁的资源目录并写入相对路径 |
|
||||
@@ -496,6 +500,8 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
|
||||
| MARK-12 | 会话恢复 | 恢复打开标签、未保存内容、光标、滚动和视图设置 |
|
||||
| MARK-13 | Git 配合 | 在仓库内显示文件是否有改动,并可调用 ElyTree 查看;不内置第二套 Git 客户端 |
|
||||
| MARK-14 | 大文件 | 大文件采用延迟渲染或源码视图,说明限制,不截断保存内容 |
|
||||
| MARK-15 | 格式命令 | 段落与格式菜单照 Typora 的项与快捷键:标题与段落、引用与列表、插入块、行内格式与清除格式;每条命令一步撤销,用不了的地方说明原因 |
|
||||
| MARK-16 | 右键菜单 | 右键处的块、链接、图片、表格或代码决定菜单内容,照 Typora 的布局(剪贴板与格式为图标行);预览只读时只留复制类 |
|
||||
|
||||
数学与 Mermaid 默认仅渲染文档中已保存的源代码,不执行任意本地命令。语法错误显示原文与错误位置,不清空代码块。
|
||||
|
||||
@@ -626,7 +632,7 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
|
||||
|
||||
### 17.3 内容渲染
|
||||
|
||||
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
|
||||
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件(§12.1)。
|
||||
|
||||
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
|
||||
|
||||
@@ -752,7 +758,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
|
||||
|
||||
### 21.1 自有代码默认许可
|
||||
|
||||
本 PRD 对新增应用代码采用 AGPL-3.0-or-later 作为发行默认,便于与 MuPDF 的开源使用路线保持一致。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
|
||||
本 PRD 对新增应用代码采用 MIT 作为发行默认(用户 2026-10-19 定,原为 AGPL-3.0-or-later)。MIT 只管 Ely 的自有代码:链接 MuPDF 的 ElyPDF 发行包整体仍须按 MuPDF 的 AGPL 条款提供对应源码。Ely GPUI Components 继续保留现有 MIT/Apache-2.0 许可,不因被应用使用而改写其许可证。[S1]
|
||||
|
||||
第三方代码保持原许可证。不得把 LibreOffice、Git 或其他依赖的版权声明统一替换成 Ely 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
|
||||
|
||||
@@ -762,7 +768,6 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
|
||||
| --- | --- |
|
||||
| MuPDF | 按锁定版本的 AGPL 条款提供对应源码、修改、集成和构建资料;不把公开 UI 仓库当成已完成合规 [S8][S9] |
|
||||
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
|
||||
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
|
||||
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
|
||||
| Markdown | 保留 markdown-rs、RaTeX(含随带字体)、mermaid-rs-renderer 及其依赖的许可;逐项检查实际发行包 [S14][S15][S16] |
|
||||
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
|
||||
@@ -837,7 +842,7 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
|
||||
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
|
||||
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
|
||||
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
|
||||
| 邮件协议 crate | 三端 TLS、OAuth、IMAP 与 POP3 同步、SMTP 发送和 MIME 测试 | 修复或换 crate;换后端须显式修订选型 |
|
||||
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
|
||||
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
|
||||
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
|
||||
@@ -891,8 +896,8 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
[S9] GNU Affero General Public License 第 3 版原文。
|
||||
`https://www.gnu.org/licenses/agpl-3.0.en.html`
|
||||
|
||||
[S10] MailCore 2 官方介绍,协议、API 与平台范围。
|
||||
`https://etpan.org/mailcore2.html`
|
||||
[S10] Apple,iCloud 邮件用于其他邮件应用的服务器设置。
|
||||
`https://support.apple.com/en-us/102525`
|
||||
|
||||
[S11] Google,Gmail IMAP/POP/SMTP OAuth 2.0 与权限要求。
|
||||
`https://developers.google.com/workspace/gmail/imap/xoauth2-protocol`
|
||||
@@ -931,5 +936,5 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
[S23] Git 官方 COPYING 文件。
|
||||
`https://raw.githubusercontent.com/git/git/master/COPYING`
|
||||
|
||||
[S24] MailCore 2 官方 LICENSE 文件。
|
||||
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
|
||||
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
|
||||
`https://support.typora.io/Shortcut-Keys/`
|
||||
@@ -43,4 +43,4 @@ Packages: `packaging/macos/release.sh` writes each app's `.app` and `.dmg`, the
|
||||
|
||||
## Licence
|
||||
|
||||
AGPL-3.0-or-later (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
|
||||
MIT (`LICENSE`). Third-party components keep their own licences; see `docs/licenses.md`.
|
||||
@@ -14,7 +14,7 @@
|
||||
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
|
||||
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
|
||||
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
|
||||
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
|
||||
| Mail | mail-send、mail-parser、mail-builder,IMAP(imap-proto 解析)与 POP3 由引擎自写(`crates/ely-mail-engine`,应用进程内的后台任务,无 worker)+ SQLite(FTS5);正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制;写信用 `documents::MarkdownDocument`,发出纯文本与 HTML;Gmail、Microsoft 走 OAuth,iCloud 走应用专用密码 |
|
||||
| Tree | Git CLI,参数数组启动 |
|
||||
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
|
||||
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
```text
|
||||
apps/ 八个应用,各自一个二进制
|
||||
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine 随其任务建
|
||||
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine(邮件协议、同步与本地存储)
|
||||
reference/ 上游源码,只读,不属于本项目,不审查
|
||||
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
|
||||
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
|
||||
@@ -33,14 +33,14 @@ assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns
|
||||
dist/ 打包产物,不入库
|
||||
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
|
||||
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
|
||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
|
||||
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely;mail/sample.eml:带编码主题、分组抄送、纯文本加 HTML、cid 图片与附件的邮件,LF 行尾,测试换成 CRLF)
|
||||
tasks/ TASKS.md 进度真相
|
||||
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
|
||||
```
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `ely-gpui-component`(pin 8fd3e43,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
依赖 `ely-gpui-component`(pin cd365e9,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
|
||||
| 需要 | 用 |
|
||||
| --- | --- |
|
||||
@@ -55,7 +55,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
| Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` |
|
||||
| Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` |
|
||||
| Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` |
|
||||
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode}` `editor::{CodeEditor, FindWidget}` |
|
||||
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode, MarkdownReader, MarkdownBlock, alert, is_toc, BlockCommand, InlineFormat, TableEdit, EditRefusal, TableRefusal, DocumentTarget, TargetBlock}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法;段落、格式与表格菜单经文档的 `block`、`format`、`edit_table`;右键菜单听 `DocumentEvent::Pointed` |
|
||||
| Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` |
|
||||
| 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` |
|
||||
| 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 |
|
||||
@@ -80,11 +80,11 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
| 幻灯片缩略图条(`documents::PageThumbnailList` 只收图片路径,无隐藏标记、右键菜单与重排) | `apps/ely-show` 的 `slides.rs`,内核按页绘制到内存图 |
|
||||
| PDF 页面缩略图(同上,且一次铺全部页,不合 PERF-05) | `apps/ely-pdf` 的 `side.rs`,`uniform_list` 只画可见行,worker 按需渲染 |
|
||||
| 演示的参考线:LOK 不把参考线画进图块,也无参考线接口 | 补丁 0027 读写参考线;`apps/ely-show` 的 `guides.rs` 画在画布上,拖动移动,拖出幻灯片即删除 |
|
||||
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库的 `MarkdownRenderer` 把行内公式显示为 TeX 原文(着色),块公式才排版 |
|
||||
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库 `render/math_text.rs`:以不断行的空格占出公式的宽(整个公式作一个词折行),排好的公式画在其上,按下处读回公式的原文 |
|
||||
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
|
||||
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
|
||||
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
|
||||
| 组件内部文案未接 i18n(面板族之外:欢迎页标题、菜单项、`SortBuilder` 与 `FilterBuilder` 等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
|
||||
| 组件内部文案未接 i18n(Ely 用到的组件已接:提示、默认占位、补全空行、字号示例,6df9ab0;查找框、排序与筛选、密码开关,a4bf696;`i18n/tests/shipped.rs` 防回退;其余组件之外:欢迎页标题、菜单项等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
|
||||
| LOK 的 `.uno:StyleApply` 状态给界面名(`Default Paragraph Style`),样式列表给程序名(`Standard`),两者对不上 | ElyDoc 属性栏:列表外的当前样式显示为占位文字;更新样式用状态里的界面名。上游待办:状态带程序名 |
|
||||
|
||||
## gpui 规则
|
||||
@@ -123,7 +123,7 @@ scripts/fetch-references.sh 取上游源码到 reference/
|
||||
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
|
||||
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
|
||||
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
|
||||
scripts/review.sh ID 轮次 路径 审查一条任务
|
||||
scripts/review.sh ID 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6.1-sol,effort xhigh,fast)
|
||||
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
|
||||
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
|
||||
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
|
||||
@@ -132,8 +132,9 @@ tests/scripts/linux-backends.sh Linux 构建带 Wayland 与 X11;Hyprland 上
|
||||
packaging/linux/container.sh 命令 在 Ubuntu 26.04 镜像里运行(引擎、应用、打包;目标在 target/ubuntu)
|
||||
tests/scripts/linux-glibc.sh Linux 的应用、worker 与引擎要求的 glibc 不高于 2.43
|
||||
packaging/linux/package.sh 应用 出该应用的 AppImage、deb、rpm 与 Arch 包到 dist/(在容器里运行)
|
||||
packaging/source.sh dist 本提交的源码包与随包的 copyleft 上游源码(打过补丁的 MuPDF、Git),文件名带提交号;工作区有未提交改动即拒绝(macOS 的 release.sh 与 CI 的 Windows、Linux 调用)
|
||||
packaging/source.sh dist 本提交的源码包与随包的 copyleft 上游源码(打过补丁的 MuPDF、Git),文件名带提交号;工作区有未提交改动即拒绝(macOS 的 release.sh 与 CI 的 Linux 调用,发布只上传 Linux 的;Windows 的检出把符号链接写成文件,不出源码包)
|
||||
tests/scripts/ci-share.sh ci.sh 只经指向 ELY_SHARE 的链接用共享引擎:自建目录、指向别处或目标带换行的链接即拒绝
|
||||
tests/scripts/ci-upload.sh ci.sh 的上传:macOS 建源码包而不上传,Linux 上传,重跑遇已有的同名文件即失败、什么也不传(假 curl)
|
||||
tests/scripts/linux-packages.sh deb、rpm、Arch 包在干净的 Ubuntu 26.04、Fedora 44、Arch 容器里装上,每个 ELF 都找得到库
|
||||
tests/scripts/linux-pins.sh Linux 打包工具(appimagetool、linuxdeploy、运行时、nfpm)的下载与钉的散列相符
|
||||
tests/scripts/linux-installed.sh installed|appimage Hyprland 上装好的六个应用或 AppImage:一个窗口、随 Omarchy(Catppuccin)、读出页面文字(PDF、Markdown 经各自引擎),ElyTree 随换主题到 Latte(grim 按窗口截取)
|
||||
@@ -141,12 +142,14 @@ tests/scripts/linux-distros.sh deb 在 Ubuntu 26.04、rpm 在 Fedora 44 容器
|
||||
cargo test -p ely-show --test compose 切换半程帧经 Metal 取像素(主线程,仅 macOS)
|
||||
tests/scripts/*.sh *.py 脚本测试(缺工具时报 SKIP)
|
||||
packaging/macos/bundle.sh ely-doc debug 出 dist/ElyDoc.app(带图标、文档类型与该应用的引擎)
|
||||
packaging/macos/release.sh [批次] 该批各应用的 release 包与 dmg;ELY_SIGN_IDENTITY 加 ELY_NOTARY_PROFILE 即公证
|
||||
packaging/macos/release.sh [批次] 该批各应用的 release 包与 dmg,另有装全部的 ElyOffice dmg;ELY_SIGN_IDENTITY 加 ELY_NOTARY_PROFILE 即公证
|
||||
packaging/windows/bundle.sh 由 dist 里各应用的安装程序出合集 ElyOffice 安装程序(先跑 installer.sh)
|
||||
packaging/windows/templates.sh 以本构建的内核重写 packaging/windows/new 的空白 DOCX、XLSX、PPTX(右键“新建”用)
|
||||
packaging/icons/masters.py 写出 assets/icons 的全部 SVG 母版(图标设计只改这里)
|
||||
packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico(只在 macOS)
|
||||
tests/scripts/icons.sh 八个图标齐全、未过期,macOS 为 squircle,其余无底形
|
||||
tests/scripts/release-macos.sh dist 里首批六个 .app:以访达的方式打开文件(启动与运行中),引擎都取自包内,带齐许可,不含系统会登记的内嵌包,每个二进制要求的系统不高于应用所写;源码包齐全
|
||||
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
|
||||
tests/engines/*.sh 引擎探测(LOK、MuPDF);LOK 探测本构建的内核,不挂载官方镜像
|
||||
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
|
||||
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
|
||||
tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构建出的包不声明任何文件类型
|
||||
@@ -159,7 +162,7 @@ tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地
|
||||
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
|
||||
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
|
||||
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
|
||||
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
|
||||
tests/scripts/one-instance.sh 第二份 ElyDoc 把文件交给第一份后退出,第一份开出它、工作目录不动
|
||||
tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限)
|
||||
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
|
||||
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
|
||||
@@ -169,11 +172,15 @@ tests/scripts/recovery.sh 真窗口 ElyDoc:恢复点、崩溃后副本、
|
||||
tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点、空闲引擎退出后重开时再问密码(需辅助功能权限)
|
||||
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
|
||||
tests/scripts/print.sh 真窗口 ElyDoc:打印用文档自己的纸张,取消不动文档(需辅助功能权限)
|
||||
cargo test -p ely-platform --test print Windows 经“Microsoft Print to PDF”出作业,核对纸张、方向与页面位置;Linux 起私有会话总线与替身打印门户,核对页面设置、令牌与交出的字节、取消(缺 dbus-daemon 时 SKIP)
|
||||
tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住整块屏幕(菜单栏与程序坞之上,需辅助功能权限)
|
||||
tests/scripts/dock-macos.sh dist 里 ElyDoc、ElyTable、ElyShow 与包外的 target/debug/ely-doc 各启动数次:LOK worker 从未登记为程序坞应用,图块照出
|
||||
tests/scripts/windows-bundle.sh 合集安装程序逐个静默运行所选应用的安装程序;某个被拒即停下并报出其名与退出码(缺 makensis 时 SKIP)
|
||||
tests/scripts/windows-installer.sh 假应用的安装程序:所选目录原有的文件经两次安装与卸载都在,旧版按注册表清理,文件被占用或程序在运行时什么也不改,写不进时放回旧版(缺 makensis 时 SKIP)
|
||||
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
|
||||
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
|
||||
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
|
||||
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:GitHub API 的最新版本与全部发布的下载总数,加上 Gitea 上迁移前的下载(Gitea 不通时只计 GitHub;都不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
|
||||
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
|
||||
cargo check --workspace
|
||||
@@ -193,20 +200,19 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删Line truncated
|
||||
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
|
||||
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
|
||||
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
|
||||
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,交系统打印面板(`ely_platform::print`,三端见 R7.6)。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
|
||||
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
|
||||
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
|
||||
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
|
||||
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案未接 i18n,中文界面下仍为英文(上游待办)。
|
||||
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案 a4bf696 起接 i18n(原为英文)。
|
||||
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
|
||||
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
|
||||
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
|
||||
- 2026-10-04(用户定):macOS 包标识前缀 `ely.zacharyzhang.office`(`packaging/macos/bundle.sh`);发布者 ZacharyZhang,网址 zacharyzhang.com(macOS `NSHumanReadableCopyright`,Windows 卸载项 `Publisher` 与 `URLInfoAbout`;Linux 桌面文件无此字段)。界面语言英文与简体中文,以后再加。拼写词典只随英文:LibreOffice 的词典库没有中文(中文不用 Hunspell 拼写)。内核界面语言固定英文,函数名照英文;ElyTable 把内核英文的选区统计(Sum、Average 等七个固定名)换成界面语言,数值照内核。批注与修订的作者取各用户的账户全名(无全名用登录名,与内核自身次序一致),由补丁 0013 的 `setOption("author")` 在 worker 启动时设定;作者设置页随设置任务接入。邮件合并字段写法 `«列名»` 由我定(用户交我决定)。
|
||||
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。2026-10-09 起(用户定:0.1.2 加自动更新):每个应用一份清单,`<slug>.json`,随发布上传,应用经 git.zacharyzhang.com 的 API 读最新发布(`api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest`),在其文件里找 `<slug>.json`(`ely_platform::release::asset_url`;Gitea 没有 `releases/latest/download` 路径;`ELY_UPDATE_MANIFEST` 直接给清单地址,测试用);只在从 `.app` 运行时可用(开发构建没有);照 PRD §4.2 自动检查默认关闭:设置“通用”的“启动时检查更新”(通用设置 `check_updates`)打开后启动时查一次、查不到只记日志,应用菜单的“检查更新…”随时手动查(已是最新或查不到都以提示写明);有新版即在窗口上方显示组件库的 `UpdateBanner`(文案进内建中英目录,组件库 5d6efd4):横幅在标题栏下方(`Shell` 的页眉里,`Updates` 自己是一个视图);下载(进度)、校验散列、“重新启动以更新”只走正常的退出流程(有未保存的照常询问;取消即什么也不装,按钮仍在);应用真正退出时(`on_app_quit`)以本程序的安装模式(`--ely-install-update dmg 包 进程号`,`ely_ui::run` 最先识别)起一个独立进程:等本进程结束,`ely_platform::install` 挂载 dmg、把同名 .app 拷到旁边再换掉原包(换上前失败即放回原包),无论成败都 `open` 该包;失败的原因写在数据目录 `updates/failed.txt`,重开的应用以提示写明。下载完成只归属于它下载的那个版本;下载中或已下载时再查不覆盖。`packaging/macos/release.sh` 为每个应用写出清单(版本、本机架构的 dmg 地址与 SHA-256),dmg 里另有指向“应用程序”的替身,可直接拖入。新包换上即为装好,旧副本删不掉只记警告。`tests/scripts/update.sh`:装好的 ElyMark 在默认设置下启动不发任何请求;按“检查更新…”后从本机服务的 9.9.9 下载;有未保存的文字时“重新启动以更新”先问,取消则原包不动,“不保存”后装上新版并重新打开(新版本身的进程在跑)。
|
||||
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
|
||||
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
|
||||
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
|
||||
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
|
||||
- 2026-10-04(用户定),2026-10-18 改(用户定):Doc 页面、Table 单元格、Show 幻灯片、PDF 页面在任何外观下都照文件原色(白底即白底),只有页面四周的界面随深浅色;邮件正文、Mark 编辑区、Agent 输出随系统深浅色。LOK:`ely.xcd` 只有 `COLOR_SCHEME_ELY_LIGHT`(文档底色白、自动文字色黑、网格、链接等);方案按视图记(LOK 下全局改动只作用于当前视图),每个 `LokView` 首帧经有序队列发一次 `.uno:ChangeTheme`(`ely_lok::SCHEME`,不算编辑),ElyShow 放映与输出的副本同样各发一次;旧版写进内核配置的 `COLOR_SCHEME_ELY_DARK` 已删,内核读不到即退回自动方案,首帧的那次改回 Ely 方案。页面底色 `ely_lok::paper()` 为白,同方案的 `DocColor`。MuPDF 照原样画,不反相。图块作废时旧图仍显示,新图到达后替换,编辑不闪。
|
||||
- 2026-10-01:主题。`Appearance` 为系统、浅色、深色。系统档在有 Omarchy 时跟随 Omarchy(`ThemeSettings::omarchy_sync`)。读 Omarchy 的 `$HOME/.local/state/omarchy/current/theme/colors.toml`(Omarchy 写死此路径,不随 `XDG_STATE_HOME`),目录由 `ELY_OMARCHY_DIR` 可改。读法与回退链照 `reference/omarchy/bin/omarchy-theme-color`:松散引号、`colorN`、`purple`、`selection_background`、`theme_type`、`light.mode`、亮度和 382 比。缺的色相用 Ely 自己的色并警告。色板照组件库 `A11yChecker` 的规则收口:正文、状态色、代码色在各自的表面上 4.5:1,焦点与图表色在页面上 3:1;先拉近前景再拉到纯黑白,最后逐项核对,核对不过的主题整套忽略。切换时 Omarchy 先暂存 `next-theme` 再整体替换 `theme`,用文件监视跟随,不用它的钩子。新主题无 `colors.toml` 时退回 Ely 色板。`ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION` 是覆盖开关,设置页接入前先用环境变量。
|
||||
- 2026-10-05:组件库 8658999(组件库重写历史后的 main,加本项目三个提交):属主不经 `Close` 移除的浮层(一起移除的嵌套浮层同样,回到最外层打开前之处)(对话框动作只清自身状态、异步校验通过后才关)照样把焦点交回打开前之处;原先焦点落在根上,键入无处可去,须先点一下网格。同批带入上游的 ColumnShell、输入法组字时光标重新闪烁、`Icon::from_path`、MIT 许可。旧 pin 2f02c82 已不在 main 上,只能按完整哈希取得。`Select::label` 与 `TextInput::label` 给控件无障碍名称而不绘制;对话框里描述列表旁的控件以此命名,开关用自身的 `label`。7186179 加 `NumberInput::label`(经内部文本框的 `TextInput::set_label`),同批带入上游的 rustfmt 修正。80c46b1:`TemplatePicker` 的列表是一个 Tab 停靠点,上下、Home、End 移动,回车使用,行带列表项语义,搜索提示、全部、使用、无匹配的文案进内建中英目录。7df3045:`SelectableList` 在 AX 树里是列表(`label` 命名),`ListItem` 是列表项(标题为名,带说明、选中、在可选列表中的序号与总数);原先行无角色,不在 AX 树里。af54877:`NumberInput::blank` 显示占位文字而非数字(混合选区),空着回车或失焦不提交,键入后照常。408f2b0:`CommentSidebar` 与 `RelativeTime` 的文案进内建中英目录;属主不处理“解决”时(无 `on_resolve`)不分未解决与已解决,一列显示全部。bcf0378:`CommentSidebar` 的批注列是列表,每条是 Tab 停靠点(列表项语义,名为作者与正文,带选中、序号与总数),回车或空格选中。71bfc93:`init_for_tests`(`test-support` 下)装主题与键位、不要资源,应用单测可渲染 Ely 组件。617a8d6:`FindWidget` 在查询框里按 Esc 即关闭(调 `on_close`)。2ce3368:`ContextMenu` 可定宿主大小。3d08744:`DescriptionList` 的值占满所在列,其中的文本框随之全宽;同批带入上游的图表、分页改动。a6b1bb0:`SignaturePad` 的“在此签名”与“清除”进内建中英目录;同批带入上游 P01 至 P15(各组件的越界与失效状态记日志而不 panic)。490b022:`ChangesList` 分列冲突、已暂存、改动、未跟踪、已忽略(各有其动作:冲突与已忽略只可打开),标题与提示进内建中英目录;同批带入上游 P16 至 P20。7bb53f4(ElyTree 用):`ChangesList` 行数可缺、按区报按下、当前行;`DiffViewer` 外算的段、块按钮、可选的变动行、逐行读给辅助技术;`CommitList`、`FileHistory`、`BlameView`、`ThreeWayMerge` 只画可见行,`CommitList` 近尾加载;`DescriptionList::text`;`SegmentedControl` 各段为单选;`BranchList`、`TagList` 可选中、只画属主处理的行内动作;`marked`、`conflicts`、`resolve` 按标记宽度(`MARKER` 为默认 7),a284c71 起只按 LF 断行;c5799cc:`StashList` 文案进内建中英目录,行可选(`selected`、`on_pick`),只画属主处理的行内动作;7fab072 至 3741d5d:分支、标签、储藏的行分两行(上行名称与行内动作,下行说明与行尾文字),窄时名称与说明先让;行内动作一律显示(原先只在悬停时显示,键盘停在其上时看不见),改动列表同;各 git 行带透明的 1 像素边框,焦点框才画得出(原先 `focus_ring` 无边框宽度,键盘焦点不可见);同批带入上游 P21 至 P29。4f8f3bd:`Changed::lines` 可缺(二进制文件、文件夹不计行数,不显示);37c5187 缩短其注释。19b0b29:`DiffViewer::from_stretches`(别处算好的差异,如 Git 自己的)、`hunk_action`(每块标题上一个按钮)、`selected` 与 `on_line`(改动行可按,带 Shift),`diff::mark_words` 给外来的行标出改动的词,“行内”“并排”“N 行未改动”进内建中英目录;30fea9e:有块按钮时各行同高,底色相接。edd2c12:`ChangesList` 每次按下带分区(`ChangeSection`),行是 Tab 停靠点(列表项语义),`selected` 标出当前文件。2e080b8:`SegmentedControl` 每段是单选按钮(名为其文字,带选中状态),原先不在 AX 树里。e226d79:差异查看器的行模型与配色移入 `git/rows.rs`(文件不超过 500 行);7b807a9 缩短其注释。51d2aea:`CommitList`、`FileHistory`、`BlameView` 只画视口内的行(`uniform_list`,填满所在的框;`CommitList` 收 `Rc` 的提交与 `lanes` 算好的图,`on_end` 在接近末尾时要下一页),各行是 Tab 停靠点(列表项语义,选中状态;Blame 每段首行可按,名为作者、时间与摘要),`FileHistory` 的行数可缺;`CommitInput` 的“提交到”“修改最近提交”进内建中英目录。e6220ad 与 3fc5a46:提交行窄时作者、提交号与时Line truncated
|
||||
- 2026-10-05:ElyTable 透视表与图表(C09.4,TABLE-10、11)。补丁 `0019-lok-calc-pivot-tables.patch` 与 `0020-lok-calc-charts.patch`:`.uno:DataPilotExec` 收 `Pivot`(JSON:源区域、行、列、筛选、值字段与函数 sum/count/average/max/min),光标在透视表上即原地修改,否则从源区域在新工作表创建;行、列、筛选里的空名表示内核的数据布局字段(多个值字段横排或竖排的位置),读出与写回都保留,已在表中的行、列、筛选字段保留各自的小计函数,读出的布局原样写回不改表;照内核布局对话框的步骤(`ConvertOrientation`,保留各字段的成员、排序与版式),新表的筛选按钮、钻取、展开收起都关(同对话框初值);放到新工作表时插表与透视表是两步撤销(同桌面版)。刷新 `.uno:RecalcPivotTable` 在内核里本就不进撤销(它改缓存)。`.uno:InsertObjectChart` 收 `Chart`(JSON:类型 column/bar/line/scatter/pie、标题、图例、X/Y 轴标题;`other` 保持原类型),LOK 下不开图表向导、插入为一步撤销、插入后图表选中;给出的区域每段都要解析得了、所指工作表都要存在,否则插入与修改都拒绝、文档不动(内核原会改用示例数据,修改时 `ChartModel::setArguments` 抛出未捕获的异常使 worker 退出);`.uno:ChangeChartData` 也收 `Chart`,区域与外观合成一步撤销(撤销保存整个图表的图示与主标题的副本,堆积等外观以外的设置一并恢复)(列表动作带当前视图号,否则 LOK 拒绝撤销别的视图的动作)。读取:`.uno:Chart`(选中图表的对象名、区域、表头、外观)、`.uno:PivotTable`(光标处透视表的名称、位置、源区域、字段与布局),`.uno:SelectedRange` 加 `data`(选区内数据的绝对区域)。界面在“插入”菜单:数据透视表、刷新数据透视表、图表;对话框在光标处有透视表、或有选中图表时为修改,否则以选中的数据(单格扩到数据区)新建,源与图表区域取 `data`,不含空行。为对话框发起的读取(`Tabs::ask_in`)回来时,发起的工作簿已不是当前标签即丢弃,不为隐藏的工作簿开对话框(ElyTable 全部对话框、合并询问)。没有数据时恢复原光标(`select.data` 会把单格标成区域,回车随之只在格内打转)。公式语法是 Excel A1,区域写 `Sheet1!$A$1:$C$5`。
|
||||
@@ -225,7 +231,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
|
||||
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
|
||||
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
|
||||
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
|
||||
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git(2026-10-15 起 Windows 同此:`git::own` 先找本应用旁的,再按 `apps` 的同级查找与 macOS 的登记找 ElyTree 旁的,都没有即 `git::NoGit`,ElyMark 的 Git 项写明要装 ElyTree;Windows 的 ElyMark 原随带一份,用户问起后去掉。Linux 的 ElyMark 照带:AppImage 自成一体,找不到别的映像里的 Git,deb、rpm、Arch 包与它同一 AppDir,R6.6)。Windows 安装程序只动自己装下的:装好后写下所装的每个文件与文件夹(`.ely-installed`,由 `packaging/windows/files.sh` 按分装目录生成),升级与卸载只移去其中的文件,文件夹空了才删;清单之前的版本只在注册表记下的安装目录里按旧的通配名与其文件夹里的全部文件移去;先查每个文件能否写(运行中或被占用即不能,文件夹里的也查),再逐个挪进 `.ely-old`(按清单次序编号,备份路径不随原路径变长,近 260 字的路径照样升级与卸载),任一项挪不动即放回、什么也不改、退出码 2;卸载时清单本身也先查、删不掉即放回全部;新文件的位置已有不属于本应用的文件即拒绝、什么也不改、退出码 2;写到一半失败即删去本次写下的、放回旧版;升级后不留旧的 `git` 与 CEF 文件(R6.11,`tests/scripts/windows-installer.sh`)。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、Line truncated
|
||||
- 2026-10-08(用户定):深色主题不用纯黑或近黑,底色取柔和的灰 `#252525`。组件库 fd0efc7:深色色板 bg `#252525`、surface `#2c2c2c`、sunken `#202020`、overlay `#323232`、hover `#383838`、active 与 border `#3f3f3f`、border_strong `#555555`;为让说明文字在较亮的 hover 与 active 上仍有 4.5:1,muted 提到 `#c4c2c0`、subtle 与代码注释提到 `#acaaa8`;色盲友好图表的紫与橙改为 `#9d8dec`、`#ffd93d`(3:1 且各色两两分得开)。`ely.xcd` 的深色方案随之:页面 `#2c2c2c`、页外 `#252525`、边界与网格 `#555555`、域底纹 `#3f3f3f`。跨应用同步外观、设置里的浅色/深色/跟随系统、Omarchy 下随 Omarchy 主题,本来已有(共用 `settings.json` 加文件监视;`settings.sh` 核对 ElyPDF 选深色后 ElyTree 即刻变深);Omarchy 的读法与 OmaPhoto 相同(`~/.local/state/omarchy/current/theme/colors.toml`、`mode` 与具名颜色)。
|
||||
- 2026-10-08:图像的显存与内存(C20.2 长时测试查出)。GPUI 把每张画过的 `RenderImage` 留在窗口的图集里,直到调 `window.drop_image`;LOK 的图块、PDF 的图块与缩略图、演示的缩略图、动画预览与放映图层原先都不释放,ElyDoc 编辑保存 60 轮后应用进程涨到约 2 GB(内核不涨)。组件库 `primitives::Retired`:不再画的图先交给它,下一次绘制时释放(`release` 只放别处已不持有的,`release_all` 给自知不再画的属主,`append` 接过关闭者的);LokView、PdfView 在替换、挤出、清空图块时交出旧图,绘制前释放;ElyShow 每个标签一份,关闭的标签与结束的预览交给 Deck;放映按窗口各一份(观众屏、演讲者视图),离场中的幻灯片还在画时其图层等它结束。修后同样 100 轮在 340 至 440 MB 之间起落。
|
||||
- 2026-10-08:性能基准(C20.1,§20.1)。`tests/perf/bench.py` 在发行构建上测 PERF-01 至 08(每项 30 次,P50、P95,含全部子进程的内存),样本由 `tests/perf/samples.py` 按固定种子生成到 `engines/bench`,记录写到 `docs/perf/<日期>-<机型>.md`(格式见其 README);`ELY_STALLS=1` 开主线程忙碌记录(只供测量,空闲的应用照样不做事)。实测改动:Doc、Table、Show 原先要等内核启动完(约 2.5 至 3.9 秒)才开窗口,ElyDoc 日常启动 P95 3.9 秒不达标;现 `Worker::launch` 只起进程,内核的版本与图块核对在第一个请求前做(`Worker::ready`,任何请求都先过它,未核对的 worker 收不到请求),`ely_office::start` 在后台核对并设作者名,失败即 `startup::fail`;窗口约 0.15 秒可操作,空白文档在内核首次载入后出现(中位约 2.7 秒,其中约 2.2 秒是内核首次载入文档)。启动参数改为按当前目录取绝对路径(原相对路径成了 `file://相对` 而打不开)。补丁 `0039-skia-macos-no-unused-font-manager.patch`:macOS Skia 首次画字时以全部系统字体再建一个无人使用的 `SkFontMgr`(约 370 个字体枚举加排序,主线程约 175 毫秒),新文档的第一个字与打开文档的首块带字图块因此迟到;去掉后 PERF-03 的 10 次对比 P95 由 2.6 秒降到 1.5 秒。PERF-02 的计时原以输入落地时的代际为基准,内核的作废事件先于落地回报到达时,该次编辑的图块不计,钟一直走到下一次按键(记下的是按键间隔约 250 毫秒);现按编辑的修订认定(`view/clock.rs`:修订不低于该编辑的批次所升的代际,其图块即为所求),实测中位 2 毫秒、P95 5 毫秒。PERF-08:关闭的标签原把其图块留在 GPUI 的图集里(每个文档约 20 MB 显存,40 次新建关闭后应用涨约 250 MB);`Engine::retire` 交出关闭视图的全部图像(LOK 图块、PDF 图块与缩略图、ElyMark 页面的画面),外壳在窗口的下一帧释放,修后 40 次在 454 至 462 MB 之间。历史首屏改为从按下“历史”计(原从进程启动计,含打开仓库)。R1 后基准更严:PERF-03、04 到视图收下的第一块图块(`lok: tile (x, y) shown`),其间有图块画失败即整次作废;DOCX 样本原以单独一段的分页符分页,内核只排出 60 余页(`Heading1` 段上的 `pageBreakBefore` 同样丢页),改为分页符放在标题段内,PERF-03 之前先核对恰为 100 页(视图记录页数);PERF-02 每键须有自己的计时,缺即失败;PERF-08 循环前记基线,记内核中的文档数(worker 每次载入关闭写 `N documents open`),文档数须回到基线,文件数自第一次之后不变(第一次打开模板对话框时进程一次性载入两个系统字体与两个 AppKit 本地化表)。worker 的启动核对失败后保持失败,此后一切请求被拒(原核对失败的状态与成功相同,第二次即放行)。空白文档就绪由约 2.5 秒降到约 1.0 秒,发生在本次重建内核之后,与 0039 无关(对比实测),原因未查明。
|
||||
@@ -265,12 +271,12 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
|
||||
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
|
||||
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
|
||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
|
||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后先发令牌,门答“开”或“拒”之后才发各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上、令牌被拒(旧的门文件)或答“开”之前断开时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。门为每个来者各开一个线程,一个来者从连上到答完共 5 秒(零星补字不续期),令牌前的帧至多 64 字节、路径帧至多 128 KiB(`ely_ipc::read_capped`,超出即在分配前拒绝)。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。每个应用的启动(`ely_ui::run`,`blank` 的 ElyMail、ElyAgent 同)在主窗口开出后装上交接的接收者(`opening::home`):先把主窗口带到前台(最小化的先复原),再把文件放进 `ely_ui::on_open` 的同一队列(各应用不改),然后才回执;主窗口已不在即不回执,第二份照报未收下。不带文件的再次启动只带到前台。主窗口关闭即退出应用(只剩设置窗口时进程原会一直持锁,却开不了文件)。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。
|
||||
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
|
||||
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
|
||||
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
|
||||
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;本提交的源码包只由 Linux 上传(`packaging/source.sh`;macOS 照建供 `release-macos.sh` 核对,不上传,两端并行时不重复;Windows 的检出把符号链接写成文件,不出);重跑时所传文件有任一同名的已在发布里即失败、什么也不传,写明须先删去哪些(重建的字节不同,更新清单的散列会对不上已在的 dmg);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
|
||||
- 2026-10-11:gpui 的 Windows 窗口关闭(W-03 冒烟查出)。`gpui-pre-windows` 0.3.8 在窗口关闭时报三条错:系统已销毁窗口(ElyTree 的关窗交给系统)后 Drop 仍对旧句柄 `RevokeDragDrop` 与 `DestroyWindow`(拖放处理器因此不释放,连带窗口状态);销毁时的失活回报排在异步任务里,执行时窗口已移除(“window not found”);退出后后台任务向已销毁的平台窗口投递(`Invalid window handle`)。修复在 `vendor/gpui-pre-windows`:`WM_DESTROY` 时记下已销毁、在句柄仍有效时撤销拖放、去掉失活回调,Drop 见已销毁即不再动句柄;平台窗口已不在时投递失败只记 debug(应用已退出),其余投递失败照报。三个应用关窗与退出的日志无错。
|
||||
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(`sheet.rs` 的 `a_theme_switch_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
|
||||
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(2026-10-18 起只剩浅色方案,`sheet.rs` 的 `the_scheme_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
|
||||
- 2026-10-11(用户报:Windows 上标题栏的最小化、最大化、关闭三个按钮都按不动):gpui 回答系统的命中测试(`WM_NCHITTEST`)时按绘制次序取第一个含指针的窗口控制区,标题栏的拖动区先于其中的按钮画出,按钮上一律答“标题栏”,系统把按下当作拖动窗口的开始,点击到不了按钮。修复在 `vendor/gpui-pre`(原样一个提交,其后是修复):按命中测试的次序取最上层的控制区,且只看未被遮挡的部分(同 `is_hovered`,盖在标题栏上的对话框挡住其下的按钮)。实测(发行构建,DPI 感知的探针移动真实指针):自右向左依次答关闭、最大化、最小化、拖动;按下最小化即最小化,最大化即最大化,关闭即退出、worker 随之结束;v0.1.3 的安装版同一探针处处答拖动。该回调只有 Windows 平台调用。
|
||||
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
|
||||
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
|
||||
@@ -283,6 +289,40 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
|
||||
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
|
||||
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
|
||||
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
|
||||
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格(2026-10-15 起屏上读作换行,见下),嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、Line truncated
|
||||
- 2026-10-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进),保存后交给会话与 `Saved` 的路径以 `dunce::simplified` 整体判定后为普通写法;ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。
|
||||
- 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。
|
||||
- 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。
|
||||
- 2026-10-14(用户定):Typora 是参照,不求一一相同;ElyMark 的验收与评审只看每项功能是否可用,缺 Typora 的某行、次序或措辞不同不算缺陷。
|
||||
- 2026-10-14(用户要求照 Word):ElyDoc 的右键与表格(R5)。右键菜单由 Ely 按目标自建(`apps/ely-doc/src/menu.rs`),开在所点之处(`LokView` 记下右键点,`take_menu` 一并交出):目标、各项可否与拼写建议都取自内核这次给的菜单(`ely_lok::menu`:有 `WrapMenu` 为图片,有 `TableInsertMenu` 为表格,否则正文;缺 `enabled` 即报错),命令只经 `COMMANDS`。剪切、复制、粘贴为图标行(组件库 `Menu::buttons`);正文为链接…、新建批注;表格另有插入、删除、合并、拆分、平均分布;图片为文字环绕(嵌入、四周、上下、浮于文字上方;内核无紧密、穿越、衬于文字下方的对应项,不列)、置于顶层、置于底层、插入题注…,新建批注灰显(内核的图片菜单无批注)。链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显(Word 未选中也可用;照做须改内核,未做)。拖动表格边线:worker 把 `LOK_CALLBACK_TABLE_SELECTED` 读成 `ely_lok::table::Table`(列与行边线、上下限),视图在边线上显示调整指针(命中范围不超过与邻线距离的四分之一,矮行的中间仍落在文档),按住时记下读到它的批次的修订与按住处的偏移,拖动时画参考线(出视图照跟),松开以 `.uno:TableChangeCurrentBorderPosition` 发一条命令(一步撤销),原地松开不发;其间文档变了即以过期拒绝(`view/borders.rs`)。内核在 LOK 下不处理边线的指针与拖动(依赖 LOK 没有的标尺)。属性栏在光标进表格时把表格组排在最前(组件库 9ff403d 起按标题记折叠)。Windows 真窗口逐项核对过(右键三种目标、插入行列、拖动列宽行高、撤销、保存后重开);拼写建议在 Windows 上内核不给(P3-01),未核对。
|
||||
- 2026-10-14(用户要求核对 ElyTable 与 ElyShow):ElyTable 的右键(R5.5)。菜单机制移入 `ely_office::menu`(取内核这次给的菜单、开在所点之处、剪贴板图标行、子菜单可否随其中各项),ElyDoc 与 ElyTable 共用;`ely_lok::menu` 的目标另有单元格(有 `.uno:InsertCell`)与 Writer 之外的绘图对象(有 `.uno:ArrangeMenu`)。网格照 Excel:插入 ▸、删除 ▸、清除内容(`.uno:ClearContents`,同 Delete 键,不弹内核的删除内容对话框)、合并、拆分、设置单元格格式(打开格式面板);对象为置于顶层、置于底层;编辑单元格时只有剪贴板行。行列表头由 Ely 自画,内核不给菜单:右键先选中该行或列(已选的整行整列含它即不动,同 Excel)。选中对象或正编辑图形里的文字时,绘图外壳取代了单元格外壳,`.uno:SelectRow`/`.uno:SelectColumn` 原无处执行而被丢弃;补丁 `0046-lok-calc-headers-leave-drawings.patch` 让 LOK 下视图外壳接下这两条:先结束图形编辑(键入的文字留下)并取消选中,再选行列,同 Calc 自己的表头(`DrawDeselectAll`),菜单为插入、删除、清除内容、行高或列宽、隐藏、显示。ElyTable 每一项的可否都取自内核对它所发的那条命令此刻的状态(内核的菜单只定目标):补丁 `0045-lok-command-states.patch` 加 `getCommandValues(".uno:CommandStates?commands=…")`,先冲出调度器、再逐条问调度器(同菜单栏),未知的命令即报错(`ely_lok::menu::runs`);LOK 推送的状态只有固定的一组,且在空闲时才更新。因此受保护而许插入行列的工作表照样可插;行高、列宽只在文档只读时禁用(内核的规则),受保护时由内核拒绝并报出其错误。ElyTable 无批注界面,不列批注。
|
||||
- 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。
|
||||
- 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。
|
||||
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0;R4.11 真窗口查出 Windows 的中文输入法把 Ctrl+Shift+0 留作热键(注册表 `HKCU\Control Panel\Input Method\Hot Keys\00000104`),应用收不到,Windows 另绑 Ctrl+Shift+9(菜单显示它)。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点与数字一律写所出的字符(`secondary-{`、`secondary-+`、`alt-%`):三端的 gpui 都把 Shift 加该键报成字符并去掉 Shift(Windows 见 `gpui-pre-windows` 的 `get_keystroke_key`);R4.9 原在 Windows 写键名,真键盘按不出(R4.11 真窗口查出)。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
|
||||
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。第 2 轮后:转换完成时剪贴板已不是开始时的内容(此后有任何复制,含编辑器自己的 Ctrl+C)即丢弃这次结果,同内容的再次写入分不出;打开的块里只认最后一次右键(组件 2b8bd9f),所点字节对回所画字节的读法逐段只对齐一次(原逐字重读,8000 字的段落要近 2 秒)。第 3 轮后:剪贴板的比对另含其 HTML(gpui 读剪贴板不含 HTML,纯文字相同而富文本不同的后来复制原被盖掉;`ely_platform::clipboard::html`);组件的计时测试改用 `web_time::Instant`(组件 e07fd34)。
|
||||
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
|
||||
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。2026-10-19 起:macOS 任务在 `macos-signing` 运行器上跑(Mac mini 上以 LaunchAgent 在登录会话里运行);`macos-arm64` 运行器是以 `UserName` 运行的 LaunchDaemon,其会话的钥匙串搜索列表里没有登录钥匙串,codesign 报“no identity found”(v0.1.8 实测)。两个变量此前未设,0.1.6 至 0.1.8 的 macOS 任务因此在 `ci.sh` 的检查处即失败,已在仓库变量里设好。强化运行时下 LibreOffice 的 UNO 桥以 `MAP_JIT` 映射虚表,`ely-lok-worker` 须带 `com.apple.security.cs.allow-jit`(`packaging/macos/lok-worker.entitlements`,同 LibreOffice 自己的 `hardened_runtime.xcent`;其余几项不需要,实测);0.1.5 首发的三个 LOK 应用缺它,worker 在首个请求时中止(R6.5)。
|
||||
- 2026-10-15(用户报:ElyMark 点表格整个表格变成源码,应在格内编辑;用户定:格内显示该格原文):表格单元格编辑(R6.1)。组件库 2594b3f:可视模式按下表格只打开所按的那一格,在格内原位以单行输入框显示该格的 Markdown 原文(`**粗体**` 带记号),表格其余照常画出;按在格内文字上即落在所按的字,按在格内空白落在格末;以表格边缘、键盘或上下键从邻块进入的表格块打开其首格或末格;Tab、Shift+Tab 到下一格、上一格(跨行),上下键到上下一格,越出表格即到邻块;回车与 Esc 写回,写回为一步撤销;格内键入的 `|` 写回时转义为 `\|`(表格编辑读到的也是转义后的文字);格开着时做表格编辑(插删行列、整理等)先写回该格再执行,格随之关闭。列表、引用里的表格同此。表格绘制移入组件 `render/table.rs`,按格的空白处也带出所按的格(`Place::Cell`)。第 1 轮后(组件库 6a3f348):开着的格里的按下只给其输入框,插入点落在所按处、仍是一步撤销;格末单数的反斜杠写回时补成一对,不吞下一格的竖线;查找在可视模式里打开匹配所在的格并选中,跨格的匹配转到其行;格开着时右键所按的字节随格内键入的竖线换算;在未开的格的空白处右键,目标为该格末尾。第 2 轮后(组件库 2b6bd1d):格末的反斜杠只在紧接竖线时补成一对(`| a\ |` 的反斜杠后是空格,原样写回,只打开不键入不改一个字节);改首格后仍认得所在表格(无外侧竖线的表格首格始于块首,原被当作写掉了块),Tab 与按邻格照常;开着的格的空白处左键把插入点移到格末、右键以格末为目标,都不写回,仍是一步撤销。第 3 轮后(组件库 f1771b3,最后一轮,修完不再审查):无外侧竖线的行里清空首格或末格时写回一个竖线(`| | beta`、`alpha | |`),列数不变;插入点照写回的转义文字报出(键入竖线后不再落在多字节字中间);改过一格后右键邻格照常给出目标;长文(后台解析)里改格后 Tab 等到解析完成再打开下一格。
|
||||
- 2026-10-15(用户报:二级菜单选不到,往子菜单移动时经过下面的项会弹出它们的子菜单,七个应用都如此;用户定照桌面系统的悬停意图做:安全三角加打开与关闭的延时):级联菜单(R6.2,组件库 d4fdb69,`ContextMenu`、`DropdownMenu`、`MenuBar` 共用的面板)。菜单面板挡住其下的元素(原先子菜单被挤回、盖在父菜单上时,被盖住的父菜单行照收悬停);子菜单右侧放不下且左侧放得下即开在所在行的左侧,从右滑入,不盖父菜单的行(两侧都放不下时贴窗口边,仍被面板挡住);指针停在有子菜单的行上 150 毫秒才打开;子菜单开着时,指针落在“上一位置与子菜单近侧上下两角”构成的三角形里的另一行,等 300 毫秒,指针仍在该行才切换,其间到达子菜单即作废;三角形外的行即刻标记。键盘与按下照旧即刻打开。实现在组件 `menus::aim`。第 1 轮后(组件库 5a53c08):指针离开一行(到边距、分隔线、禁用的行或菜单外)即作废其延时,回到该行重新计时;菜单在别处重开即忘掉旧的悬停;图标按钮行同样受三角形保护。
|
||||
- 2026-10-15(用户报:ElyMark 的最近列表清不掉):侧栏“清除最近文件”(R6.3,98692cb)写空 `recent.json`(`ely_files::recent::clear`),失败即提示。会话恢复的标签不把文件加回列表(`Desk::remembered` 预先记下),清空在重启后照旧;显式打开照样加入,已开着的文件(按文件身份,含硬链接,`Sessions::is_open`)只切到该标签,也加入列表。
|
||||
- 2026-10-15(用户报:ElyMark 代码块后的“你好↵测试”离开输入框后成了“你好 测试”):单个换行照 Typora 的默认(编辑时保留,导出与打印不保留):组件库 93efa7f 起 `MarkdownReader::new`(屏上:可视、预览、右键与命令的读法)把软换行读作换行,`MarkdownReader::joined`(输出)照 GFM 读作空格;标题的大纲与目录文字照旧一行。ElyMark 的 HTML、PDF、富文本复制与纯文本导出用 `joined`(R6.7)。组件库 c31d092 与 26af4be:画出成一个换行的 CRLF 对回其起点(原先按在 CRLF 行末键入落在 CR 与 LF 之间),行内代码照留两字则逐字对回。
|
||||
- 2026-10-15(用户报:ElyMark 源码模式“测”后的插入点画在“测”的中间):组件库 `CodeEditor` 原按“列 × m 的宽”放插入点、选区、按下处与输入法框,中日韩字与表情约宽两格。组件库 9fb607b 起按 gpui 排好的这一行定位(`editor::measure`,含着色带来的字重),块状与下划线插入点取所在字的宽;标尺与缩进参考线仍按列(R6.8)。上下移动仍按字数记目标列。cd61653:按下处取最近的字首或行末(gpui 的 `closest_index_for_x` 越过末字字首即给行末);上次绘制得出的括号与配对位置只属当时的文字(`Buffer` 每次改动换一个戳),输入法改字后、下次绘制前的测量不用它们(原先把新字切进不同字体段,DirectWrite 崩溃)。
|
||||
- 2026-10-15(用户报:ElyMark 三、四级标题不显眼):组件库 5954132 起标题字号随正文按 Typora 的倍数(一至六级 2.25、1.75、1.5、1.25、1、1),一律加粗,六级为次要色,行高为字号的 1.3 倍(原为固定的 24、20、16、14 像素,正文 13,三、四级只是中等字重)(R6.9)。90bd37b:粗体与标题同一字重(原粗体为半粗,标题里的粗体反而更细,正文粗体随之由半粗改为粗,同 Typora);标题行高按自身字号取 1.3 倍。Windows 真窗口核对(`.work/r69`):可视模式各级标题分明、“你好↵测试”两行,源码模式“测”后的插入点落在两字之间。
|
||||
- 2026-10-15(用户报:macOS 打开一个文档,程序坞里有两个同样的图标):LOK worker 在应用包的 `Contents/MacOS` 里启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 里应用与 worker 都是 Foreground),程序坞多一个图标;内核自己从不设激活策略。worker 在内核启动 AppKit 之前(`Lok::hook` 打开内核之后、调钩子之前)把本进程主包信息里的 `LSUIElement` 设为真,AppKit 登记时即为辅助应用(无程序坞图标、无菜单栏,仍可建屏外窗口);钩子之后核对激活策略是辅助,不是即报错、不启动(`ffi/accessory.rs`);包外的 worker(`cargo run`、单测)无包可登记,策略为禁止,此时改设辅助(R6.12 第 2 轮)。原先在登记之后才改策略,审查实测 15 次启动有 11 次先登记为 Foreground 约 30 毫秒(R6.12 第 1 轮)。`tests/scripts/dock-macos.sh` 连续启动三个应用与包外的开发构建,从 worker 启动起采样其类型到首块图块,见 Foreground 即失败(R6.12)。
|
||||
- 2026-10-15(用户报:ElyShow 编辑区滚轮与上下键不换页,三端):内核在未选中对象时把上下键当作滚动窗口(`fupoor.cxx`),Ely 的画布整页适合、无处可滚,滚轮同样。`LokView::turn_parts`(ElyShow 首次适合时调用)之后:整页可见时滚轮换页(`view/turn.rs`):滚轮的行按本平台每格的行数(`ely_lok::notch`:macOS 1,Windows 取系统设置,X11 与 Wayland 为 3)折成格,整格的行每格一页(快速连滚与一次多格都照格数);一串行里出现不足一格的(X11、Windows 的触控板)即为平滑滚动,此后到停顿前不再换页,像素(macOS、Wayland 的触控板)同按手势:一次手势换一页(满一格或 50 像素),惯性不算;有起止的手势(macOS)只到结束才算完,没有起止的停 200 毫秒即为下一次。X11 的滚轮与触控板同为行、gpui 不分,以整格开头的触控板滑动在出现小数之前会按格换页(已知限制)。未选中对象、未编辑文字时上下键换页。换页一律经有序输入队列交 worker(`TURN`):worker 按之前各输入回报后的状态判定,有选区、选中对象或插入点即把方向键照常交内核,否则当场切页(到首末页即停,同缩略图条的 `setPart`),视图在落地时换上 worker 所示的页,其后排队的键与点按都落在新页上(Tab 后紧接的 Down 移动所选对象;Down、Tab、Down 是换页后在新页上选中并移动);ElyShow 后台读回的当前页只在视图已无待落地输入时采用(R6.13)。
|
||||
- 2026-10-16(用户定):合集安装包(R7.1)。macOS 一个公证的 `ElyOffice-<版本>-<架构>.dmg`,装首批六个已签名的 .app 与“应用程序”替身,用户拖入所要的(`release.sh` 的 `dmg` 与单个应用共用);无 Developer ID Installer 证书,不出 .pkg。Windows 一个 `ElyOffice-<版本>-windows-x86_64-setup.exe`(`packaging/windows/bundle.nsi`),组件页勾选应用,所选的各自的安装程序以 `/S` 运行,所以装法、位置、升级、卸载项与单装完全相同;某个退出码非零即停下并写明其名与退出码(2 为文件被占用或位置被占),之前装上的照旧。Linux 不出合集。单个应用的包、更新清单照旧。
|
||||
- 2026-10-16(Gitea #6):设置窗口多出一行应用菜单(R7.2)。设置窗口沿用主窗口的外壳 `Shell`,Windows 与 Linux 上随之画出菜单栏(macOS 菜单在系统菜单栏,不受影响)与更新横幅。菜单栏与更新横幅只属主窗口:`Shell::secondary` 去掉两者,设置窗口用它;`settings/tests.rs` 量内容起点距标题栏底:只装菜单时主窗口大于零(macOS 为零),再有待下载的新版时更大,设置窗口为零。
|
||||
- 2026-10-16(Gitea #4):装了宋体仍报缺少(R7.3)。内核按名找字体时比较 `GetEnglishSearchFontName`(小写 ASCII、去空格、中日韩本地名译为英文名,`宋体` 即 `simsun`),所以文档的 `宋体` 找到并用上 SimSun;缺字检查却比较原名与所用字体名,报为缺失。Linux 上内核以界面语言(英文)为 fontconfig 字体取名,文档里的本地名一律报缺。补丁 `0048-lok-fonts-missing-by-search-name.patch`:缺字检查同样比较检索名。实测(2026-10-16):Arch 容器装 ElyDoc 0.1.5 的 Arch 包与 simsun.ttc,40 行宋体五号、文档网格的 docx 报缺宋体,正文实为 SimSun(无此字体时为方框),与 Windows 同为一页;换上打过 0048 的内核后同一容器不再报缺、仍为一页。用户所说一页变两页未能复现,需其文件:十种宋体版式(44 与 45 行的文档网格、无网格、折行两端对齐、字符网格、粗体、小四、固定行距、1.5 倍行距)在 Windows 与 Linux 内核上页数、断行、分页逐行相同(`.work/r7/pages`),网格下每页 44 行、每行约 39 字,同 Word 的 A4 默认;差别若在,是该文件里 LibreOffice 与 Word 的排版不同,与平台无关。缺字检查测试:`navigate/tests.rs` 以随包的 DejaVu Sans 写作 `DEJAVU SANS`,只报真缺的字体。另见:Linux 上属性栏的字体框为空(字体列表只有 SimSun,文档写宋体),未改。
|
||||
- 2026-10-16(Gitea #5):每个 PDF 打开即报已损坏(R7.4)。C11.8 把画图块与导出时 MuPDF 的任何警告都算作损坏;读页面时载入未嵌入的字体也发警告(中文文件里写名而不嵌入的宋体等:`non-embedded font using identity encoding`),合法的文件因此转为修复副本、保存一律另存。现在警告监视(`keep_warning`,打开时的扫描、画图块、导出与 Markdown 的 PDF 共用)只略去这一句与紧随其后的 MuPDF 重复计数(`... repeated N times...`);其余照算损坏,含内容的语法错误、无字体写字、坏的嵌入字体与图片流。读文字与搜索也受监视(坏图片读过即被缓存,之后画图块、导出与检查都不再报警告):其间遇到的损坏记入报告与该文档,下一次画图块或检查图片报出,报出之前导出图片与文字一律拒绝。监视开始时,MuPDF 尚未报出的重复计数(来自其余未受监视的读取)按它所重复的那句归类(`ctx->warn.message`):字体提示的重复略去,其余照算。`tests/output.rs` 核对:两个同名未嵌入字体的文件先读文字再画图块、导出图片与文字都不算损坏;无字体写字与 `BT ] ET` 的图块为损坏;`[/FlateDecode /ASCIIHexDecode]` 下校验和错的图片使两种导出都被拒、图块为损坏。先读文字或搜索后,这样的两张图片、颜色键掩码无效的一张图片、以及它在两个未嵌入字体之前或之后画出,画图块、导出与检查图片仍报损坏。
|
||||
- 2026-10-16(Gitea #3):Windows 与 Linux 打印(R7.6)。`print_pdf` 改为异步:对话框在应用线程上,读页与出作业在自己的线程上(`print::off`),应用不卡。Windows:系统打印对话框(`PrintDlgEx`,Windows 11 显示为新式对话框),打开前把默认打印机的设置改成文档的纸张与方向(按打印机自己的纸型匹配(纸型须装得下页面,至多大 2 毫米,Linux 的 PWG 名同此);没有即自定义尺寸),设置须连同打印机名(`DEVNAMES`)一起交给对话框,否则对话框改用打印机自己的偏好(实测:Letter 文档印成 A4);所选页码范围照办,份数交驱动;勾“打印到文件”时文档输出为 `FILE:`,由打印子系统问文件名。驱动不支持所设纸张时会换成别的(实测“Microsoft Print to PDF”没有自定义尺寸,1000×600 点的页面换成 A4),所以出作业之前(`StartDoc` 之前)按打印机实际的纸张核对每一页:超出纸张半点以上(纸张尺寸至多舍入到整点,`print::ROUNDING`)、转过来也超出即报错,什么也不印(实测:600×842 点的页面原在 A4 上印出、右缘被裁)。页面由 Windows 自带的 PDF 渲染(`Windows.Data.Pdf`)按打印机分辨率分条画出,按原大从纸张的角上放(扣去不可打印的偏移),只在转过来才放得下时左转 90 度;渲染器的尺寸按 DIP 算、乘显示缩放,每次作业先试画一次量出倍数。`ELY_PRINT_TO` 时不弹对话框,经“Microsoft Print to PDF”写成文件(测试用)。`GlobalFree` 成功时返回空,windows-rs 读成错误码 0 的错误,须当作成功(否则打印报“操作成功完成”)。Linux:经 xdg 桌面门户的 `org.freedesktop.portal.Print`(`PreparePrint` 后带其令牌 `Print`,交出 PDF 的文件描述符),页面设置为文档纸张(标准纸型给 PWG 名,否则自定义毫米尺寸),边距为零,只许输出 PDF(`supported_output_file_formats`,否则对话框可选 PostScript,写出的却是 PDF);门户照原样交出所给的字节,对话框的选择由应用排定(`print/linux/sheets.rs`):GTK 设置里的页码范围(从零起)、奇偶页、倒序、份数与逐份、缩放、每张页数与排列,排到对话框所选纸张与方向上,每页为一个表单对象(带可打印批注的外观;带 NoRotate 的批注在转过的页面上绕其左上角转回、保持正立,同 MuPDF;外观的资源名避开页面已有的;可打印却没有外观的批注即报错,链接与弹出说明本无外观),单页按原大乘缩放从纸张左上角放,超出半点以上而转过来放得下即左转,否则报错;方向照 GTK 的写法(`reverse-portrait`,门户文档的页面设置同此);奇偶页只在 GTK 的文件与 lpr 后端时由 Ely 排(设置里有 `output-uri` 或 `lpr-commandline`),CUPS 后端自己把它交给 CUPS(`cups-page-set`,GTK 3.24.52 实测),Ely 再排一次即丢页;份数、每张页数等 GTK 留给应用,Ely 照排;裁剪框超出媒体框的部分不印(取两者的交);页码范围的开放终点 GTK 写作 -1(`1--1`),倒着的范围即报错;多页按格居中,再交内存文件(`memfd`);直接用 zbus:ashpd 0.13.13 的 `PageSetup`、`Settings` 缺任一字段即解析失败,真门户的回复常不带 `PPDName`。页面尺寸由 lopdf 读(CropBox,否则 MediaBox,继承、`/Rotate` 与页面自己的 `/UserUnit`)。打印不再按平台禁用(原 `print::BUILT` 与“此平台尚未构建”删去)。替身门户的测试核对选页、缩放、倒序与份数后交出的页面与位置,与只许 PDF。ElyPDF 的打印副本(`ely_print_copy`)按打印的规则画:可打印(不隐藏)的批注与表单控件,外观画进页面内容(位置照 MuPDF 自己的 `pdf_annot_transform`,与屏上一致;无外观的先由 MuPDF 生成,MuPDF 原只在显示时放在本地表里、不存入文件),其余批注与表单去掉,只留链接;系统的渲染器按屏幕规则画(不打印的照画,“打印但不显示”的不画,实测 Windows),画进内容后各渲染器都只见要打印的;仍画不出的即报错;`tests/output.rs` 渲染副本核对。真窗口(Windows):ElyDoc 的 Ctrl+P、Ely 的打印对话框、系统对话框、“Microsoft Print to PDF”的另存为,印出的 PDF 为文档的 Letter;系统对话框取消时不打印、不报错(`.work/r76/print.ps1`)。未验证:Linux 真桌面上的门户(只对替身门户核对);实体打印机。
|
||||
- 2026-10-16(用户定):Windows 资源管理器的右键“新建”可建 DOCX、XLSX、PPTX 与 Markdown 文件(R7.9)。空白文件在 `packaging/windows/new`(`templates.sh` 以本构建的内核把空文件转存而得,无作者与日期;Markdown 为空文件),随所属的应用装在安装目录(列在 `.ely-installed` 里),`HKCU\…\.<扩展名>\ShellNew` 的 `FileName` 指向它。资源管理器只在扩展名有默认 ProgID 时列出新建项(实测:只有 `ShellNew`、或只在 `OpenWithProgids` 下时都不列);所以只在该类型没有任何程序处理时(HKCR 下无默认值,或默认值所指的类不存在:本机 Windows 11 的 `.md` 指向不存在的 `Markdown Document`)把默认值设为本应用的 ProgID(写在 HKCU,用户自己的 `UserChoice` 不动),已有的从不改动(装着 Office、WPS 时新建菜单用它们自己的项);没有处理程序的这四种类型从此由 Ely 打开。卸载只删仍是本安装写下的 `ShellNew` 与默认值。ProgID 的名称为“<应用> Document”(新建菜单与类型列显示它)。`tests/scripts/types.sh` 核对四种都有新建项,默认值只在判空之后写。
|
||||
- 2026-10-16(用户定):ElyMark 打开的块保持原样(R7.5)。组件库 bff7ebc 起,可视模式里按下的块以原文显示在它合上时的框里(同样的边距与透明边框),字号、行高与颜色同合上时:段落、列表、引用等为正文,标题按其级数的字号、1.3 倍行高、加粗(六级为次要色),照原文写出的块(链接定义等)为等宽次要色;不再加深色底板与另一套边距,按下前后不挪位置。代码块、块公式与 front matter 仍有自己的底板(代码与 front matter 为等宽小号,front matter 为次要色,同合上时)。输入框的着色(源码模式同用)给标题与粗体同一字重(原为半粗,压过所设的加粗),六级标题里的字照为次要色(行内代码照为正文色,2e65b05),front matter 照 front matter 读(原读成分隔线加标题,键名变亮变粗)(1ff993d、962964e)。表格单元格照旧。打字机模式的居中另按两端余量判定(原只按块高与视图高,打开前后同高时首块不再居中)。同批带入组件库 cdc5ce8(标题栏拖动区里的控件按下归控件,Windows)。Windows 真窗口核对(`.work/r75`):段落、三级标题、代码块打开前后位置相同。
|
||||
- 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 `<?>`);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。
|
||||
- 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。
|
||||
- 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。
|
||||
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。账号(C12.5):`ely_mail_engine::Account` 存在 accounts 表的 settings 列(JSON),凭据库名 `account:地址`;删账号先删凭据再删本地数据,本地删除失败即放回凭据,凭据库删不掉时内存里的凭据照留,不动服务端;地址在库里唯一,不分 ASCII 大小写(`Error::Taken`)。应用的库读写在后台、锁后进行,网络任务在应用自带的两线程 tokio 运行时上,结果交回 gpui。iCloud 收发都以完整地址为用户名。删去原文先改名为 `.散列.gone` 留在原处,事务提交后才删,失败即改回;打开库时按库里是否仍有邮件指向它放回或删去(C12.5 第 1 轮后)。对话框的请求(检查、登录、保存)在整个窗口里编号,回来时只作用于仍在等它的那个对话框;保存的结果照样更新账号列表,失败而对话框已换即以提示写明。端口记下上次自动填入的值,与之不同的即为键入,换安全方式不覆盖。IMAP 同步(C12.6):文件夹按 LIST 存为邮箱(角色按 RFC 6154,INBOX 按名、库里不分大小写;有 LIST-EXTENDED 与 SPECIAL-USE 即以 `RETURN (SPECIAL-USE)` 取角色),不再列出的连同邮件删去;EXAMINE 缺 UIDVALIDITY 或 EXISTS 即报错;UIDVALIDITY 变了即清空重建;同一 UID 的 FETCH 回复合并;邮件头全取,正文只取 90 天内(`sync::BODY_DAYS`):先读 BODYSTRUCTURE,无附件即取整封,有附件只取头与正文部分、按原结构重组,附件内容为空、部分号记在 `missing`,附件按需(C12.17),处置为 attachment 的多部分整个按需,related 里不看处置(RFC 2387);新列由结构 v2 升级加上,UIDVALIDITY 不同于所记时只删别的 ULine truncated
|
||||
- 2026-10-19(用户定):代码与发布从 Gitea 搬到 GitHub 组织 ELY-Open-Source(`ELY-Open-Source/ElyOffice`;组件库 `ELY-Open-Source/Ely-GPUI-Components`,Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变;Kigi CLI 同在此组织)。历史、标签与发布已迁过,Gitea 停用,GitHub 是唯一的家。发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时运行(只此一种触发,拉取请求到不了自托管机器):macOS 在 `[self-hosted, macos-signing]`(Mac mini 的图形会话,Developer ID 签名并公证),Windows 与 Linux(WSL)在 `[self-hosted, ely-windows]`;各机的常驻克隆 `$HOME/ely-ci/ElyOffice` 从 GitHub 拉取。`scripts/ci.sh` 经 GitHub API 按标签取发布、没有就建(`GITHUB_TOKEN`,`contents: write`),同名文件先删再传:重跑只换本平台的文件。更新检查读 `api.github.com/repos/ELY-Open-Source/ElyOffice/releases/latest` 的 `<slug>.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。
|
||||
- 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。
|
||||
- 2026-10-19(GitHub #2,R9.1):Impress 在母版视图里 `getParts` 数母版、`getPartName` 给母版名,`getPartInfo` 却读同一序号的幻灯片,母版多于幻灯片时信息里没有 `visible`,ElyShow 每次重读部件都报错;补丁 `0052-lok-impress-master-view-part-info.patch` 让母版视图里取母版页(`client/tests/show.rs` 核对)。`ELY_SHARE` 下发布流程不重建内核:新补丁须先在三台机器的共享引擎上各自重建。GitHub PR #1(R9.2,用户定合并):选区画在图块之上,不透明的选区色盖住文字,`LokView` 画选区时不透明度至多 0.3。
|
||||
- 2026-10-20(GitHub #4,R9.3):整列看不出标题时 Calc 开自动筛选会弹异步询问,worker 当场关掉即在其回调里崩溃;补丁 `0053-lok-calc-autofilter-header-without-asking.patch`:LOK 下不问,首行作标题(同 Excel)。R9.4:arboard 在 Windows 把剪贴板上没有 HTML 读成错误,`ely_platform::clipboard` 先查格式;剪贴板错误的提示写出整条原因。补丁 0049 补上 `doc.hxx`(Linux 的 gcc 编不过;MSVC 与 clang 经别的头文件带入)。Gitea 上另发同名的过渡发布(用户定):0.1.8 及更早的应用在 Gitea 查更新,过渡发布的清单指向 GitHub 的下载,装上的 0.1.9 起从 GitHub 查;Gitea 只为此推上 main 与标签,不再是发布之处。
|
||||
- 2026-10-20(GitHub PR #6,R9.5,用户定合并):ElyTable 的函数候选由 Tab、不带修饰的 Enter 或单击接受(原 Enter 提交半个公式并下移);接受后到对应输入与其修订的事件批次到达前不再提供候选,不能重复接受(`view/formula/completion.rs`);单击按所点的函数名接受。接受与否由 worker 在执行时按内核此刻的候选判定(`worker/complete.rs`,同换页的 `TURN`):界面上回报的候选可能已被其后排队的键入作废;所选的仍在即取它,按键时不在即取菜单首项(名最短者,同菜单的次序),没有候选即照常交按键,单击的已不在即什么也不做。有效性下拉按钮挡鼠标的框至少为按钮的主题高度(默认行比按钮矮)。候选、参数提示、有效性下拉按钮与输入说明挡住滚轮以外的鼠标,按下不落到其下的格;填充与行列拖动在其上松开即取消。PR 的 ElyTable 测试原设空的错误样式,补丁 0017 拒绝该规则,改为 `stop`;空的单格读不出选区文字,改读 E2:F2。
|
||||
@@ -48,7 +48,7 @@ impl Docs {
|
||||
.size_full()
|
||||
.child(tab.view.clone())
|
||||
.children(self.find_box(cx))
|
||||
.children(self.spelling_menu(cx))
|
||||
.children(self.context_menu(cx))
|
||||
.into_any_element();
|
||||
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
|
||||
let fonts = tab.view.read(cx).missing_fonts().join(", ");
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct Docs {
|
||||
pub review: Entity<Review>,
|
||||
/// What the right column shows.
|
||||
pub side: Side,
|
||||
pub offer: Option<crate::spell::Offer>,
|
||||
pub offer: Option<ely_office::menu::Offer<ely_lok::menu::Offered>>,
|
||||
pub merge: Entity<crate::merge::Merge>,
|
||||
pub navigator: Entity<crate::navigate::Navigator>,
|
||||
/// The left column shows the navigator.
|
||||
@@ -88,7 +88,7 @@ impl Host for Docs {
|
||||
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
self.count(id, view, cx);
|
||||
self.found(id, view, cx);
|
||||
self.offer_spelling(id, view, cx);
|
||||
self.offer_menu(id, view, cx);
|
||||
}
|
||||
|
||||
/// A dialog about a closed tab goes with it.
|
||||
|
||||
@@ -7,13 +7,13 @@ mod compare;
|
||||
mod docs;
|
||||
mod files;
|
||||
mod insert;
|
||||
mod menu;
|
||||
mod merge;
|
||||
mod navigate;
|
||||
mod output;
|
||||
mod props;
|
||||
mod refs;
|
||||
mod review;
|
||||
mod spell;
|
||||
|
||||
use ely_platform::AppId;
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! The right-click menu, laid out as Word's (DOC-02, DOC-07, DOC-12).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::{
|
||||
LokView,
|
||||
menu::{Offered, Target},
|
||||
};
|
||||
use ely_office::menu::{self, Entry, Offer};
|
||||
use gpui::{App, Context, Entity, Window};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use crate::refs::{InsertCaption, InsertComment, InsertLink};
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// One thing the menu does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Pick {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
Link,
|
||||
Comment,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
RowAbove,
|
||||
RowBelow,
|
||||
DeleteRows,
|
||||
DeleteColumns,
|
||||
DeleteTable,
|
||||
Merge,
|
||||
SplitColumns,
|
||||
SplitRows,
|
||||
EvenRows,
|
||||
EvenColumns,
|
||||
Inline,
|
||||
Square,
|
||||
TopBottom,
|
||||
InFront,
|
||||
Front,
|
||||
Forward,
|
||||
Back,
|
||||
Backward,
|
||||
Caption,
|
||||
}
|
||||
|
||||
const TABLE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub(
|
||||
"doc.menu.insert",
|
||||
&[Pick::ColumnLeft, Pick::ColumnRight, Pick::RowAbove, Pick::RowBelow],
|
||||
),
|
||||
Entry::Sub(
|
||||
"doc.menu.delete",
|
||||
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable],
|
||||
),
|
||||
Entry::Row(Pick::Merge),
|
||||
Entry::Sub("doc.menu.split", &[Pick::SplitColumns, Pick::SplitRows]),
|
||||
Entry::Row(Pick::EvenRows),
|
||||
Entry::Row(Pick::EvenColumns),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Link),
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const FRAME: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub(
|
||||
"doc.menu.wrap",
|
||||
&[Pick::Inline, Pick::Square, Pick::TopBottom, Pick::InFront],
|
||||
),
|
||||
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
|
||||
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Caption),
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const TEXT: &[Entry<Pick>] = &[Entry::Line, Entry::Row(Pick::Link), Entry::Row(Pick::Comment)];
|
||||
|
||||
impl menu::Pick for Pick {
|
||||
fn row(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
|
||||
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
|
||||
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
|
||||
Pick::Link => ("doc.menu.link", ".uno:InsertHyperlink"),
|
||||
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
|
||||
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
|
||||
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
|
||||
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
|
||||
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
|
||||
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
|
||||
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
|
||||
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
|
||||
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
|
||||
Pick::SplitColumns => ("doc.menu.split_columns", ".uno:SplitCell"),
|
||||
Pick::SplitRows => ("doc.menu.split_rows", ".uno:SplitCell"),
|
||||
Pick::EvenRows => ("doc.menu.even_rows", ".uno:DistributeRows"),
|
||||
Pick::EvenColumns => ("doc.menu.even_columns", ".uno:DistributeColumns"),
|
||||
Pick::Inline => ("doc.menu.inline", ".uno:SetAnchorToChar"),
|
||||
Pick::Square => ("doc.menu.square", ".uno:WrapOn"),
|
||||
Pick::TopBottom => ("doc.menu.top_bottom", ".uno:WrapOff"),
|
||||
Pick::InFront => ("doc.menu.in_front", ".uno:WrapThrough"),
|
||||
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
|
||||
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
|
||||
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
|
||||
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
|
||||
Pick::Caption => ("doc.menu.caption", ".uno:InsertCaptionDialog"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pick {
|
||||
/// The kernel offers it and it runs now.
|
||||
pub(crate) fn on(self, offered: &Offered) -> bool {
|
||||
offered.allows(menu::Pick::row(self).1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Turns the kernel's context menu into Ely's.
|
||||
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
let (offered, at) = match menu::take(view, cx) {
|
||||
None => return,
|
||||
Some(Ok(taken)) => taken,
|
||||
Some(Err(error)) => {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
if matches!(offered.target, Target::Cell | Target::Object | Target::Picture | Target::Slide) {
|
||||
let error = format!("a {:?} menu in a text document", offered.target);
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error)]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
let suggestions = offered.spelling.as_ref().map_or(0, |spelling| spelling.suggestions.len());
|
||||
log::info!("doc: {suggestions} suggestions");
|
||||
self.offer = Some(Offer::new(self.offer.as_ref(), id, offered, at));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The menu's host, for the active tab.
|
||||
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
|
||||
let offer = self
|
||||
.offer
|
||||
.as_ref()
|
||||
.filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
Some(offer.host("doc-menu", menu_of(&cx.entity(), &offer.what, cx)))
|
||||
}
|
||||
|
||||
/// Runs one menu row.
|
||||
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("doc: the menu picks {pick:?}");
|
||||
let (id, values): (&str, &[&str]) = match pick {
|
||||
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
|
||||
Pick::Link => return self.on_link(&InsertLink, window, cx),
|
||||
Pick::Comment => return self.on_comment(&InsertComment, window, cx),
|
||||
Pick::Caption => return self.on_caption(&InsertCaption, window, cx),
|
||||
Pick::ColumnLeft => ("table.column_left", &[]),
|
||||
Pick::ColumnRight => ("table.column_right", &[]),
|
||||
Pick::RowAbove => ("table.row_above", &[]),
|
||||
Pick::RowBelow => ("table.row_below", &[]),
|
||||
Pick::DeleteRows => ("table.delete_rows", &[]),
|
||||
Pick::DeleteColumns => ("table.delete_columns", &[]),
|
||||
Pick::DeleteTable => ("table.delete", &[]),
|
||||
Pick::Merge => ("table.merge", &[]),
|
||||
Pick::SplitColumns => ("table.split", &["2", "false"]),
|
||||
Pick::SplitRows => ("table.split", &["2", "true"]),
|
||||
Pick::EvenRows => ("table.even_rows", &[]),
|
||||
Pick::EvenColumns => ("table.even_columns", &[]),
|
||||
Pick::Inline => ("image.anchor_as_char", &[]),
|
||||
Pick::Square => ("image.wrap_on", &[]),
|
||||
Pick::TopBottom => ("image.wrap_off", &[]),
|
||||
Pick::InFront => ("image.wrap_through", &[]),
|
||||
Pick::Front => ("image.front", &[]),
|
||||
Pick::Forward => ("image.forward", &[]),
|
||||
Pick::Back => ("image.back", &[]),
|
||||
Pick::Backward => ("image.backward", &[]),
|
||||
};
|
||||
self.run_active(id, values, cx);
|
||||
}
|
||||
|
||||
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
|
||||
log::warn!("doc: {pick:?} with no document");
|
||||
return;
|
||||
};
|
||||
view.update(cx, |view, _| match pick {
|
||||
Pick::Paste => view.paste(),
|
||||
_ => view.copy(pick == Pick::Cut),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Spelling first, then the clipboard row, then the target's rows.
|
||||
pub(crate) fn menu_of(this: &Entity<Docs>, offered: &Offered, cx: &App) -> Menu {
|
||||
let item = |pick: Pick| {
|
||||
let this = this.clone();
|
||||
MenuItem::new(Docs::text(cx, menu::Pick::row(pick).0, &[]))
|
||||
.disabled(!pick.on(offered))
|
||||
.on_click(move |window, cx| this.update(cx, |docs, cx| docs.choose(pick, window, cx)))
|
||||
};
|
||||
let mut menu = Menu::new();
|
||||
if let Some(spelling) = &offered.spelling {
|
||||
for (label, rule) in &spelling.suggestions {
|
||||
let (this, rule) = (this.clone(), rule.clone());
|
||||
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
|
||||
}));
|
||||
}
|
||||
if spelling.suggestions.is_empty() {
|
||||
menu = menu.item(MenuItem::new(Docs::text(cx, "doc.spell.none", &[])).disabled(true));
|
||||
}
|
||||
let this = this.clone();
|
||||
let ignore = MenuItem::new(Docs::text(cx, "doc.spell.ignore_all", &[])).on_click(
|
||||
move |_, cx| {
|
||||
this.update(cx, |docs, cx| {
|
||||
docs.run_active("spell.ignore_all", &["Spelling"], cx)
|
||||
})
|
||||
},
|
||||
);
|
||||
menu = menu.item(ignore).separator();
|
||||
}
|
||||
let menu = menu::clipboard(menu, [Pick::Cut, Pick::Copy, Pick::Paste], item);
|
||||
let rows = match offered.target {
|
||||
Target::Table => TABLE,
|
||||
Target::Frame => FRAME,
|
||||
Target::Text => TEXT,
|
||||
Target::Cell | Target::Object | Target::Picture | Target::Slide => unreachable!("offer_menu refuses them"),
|
||||
};
|
||||
menu::entries(menu, rows, cx, |pick| pick.on(offered), item)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,117 @@
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use ely_lok::{LokView, menu::Target};
|
||||
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::Pick;
|
||||
use crate::docs::Docs;
|
||||
use crate::tests::{blank, docs, settled, text_of, type_into, with_window, written};
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// The view with the menu's host over it.
|
||||
struct Host(Entity<Docs>, Entity<LokView>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, _: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
let menu = self.0.update(cx, |docs, cx| docs.context_menu(cx));
|
||||
gpui::div().relative().size_full().child(self.1.clone()).children(menu)
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
/// The caret's middle, in window pixels.
|
||||
fn caret(cx: &mut VisualTestContext, view: &Entity<LokView>) -> Point<Pixels> {
|
||||
frames(cx);
|
||||
cx.update(|_, app| view.read(app).cursor_bounds().expect("a caret").center())
|
||||
}
|
||||
|
||||
/// Right-clicks through the view and waits for the target's menu.
|
||||
fn right_click(cx: &mut VisualTestContext, docs: &Entity<Docs>, at: Point<Pixels>, target: Target) {
|
||||
cx.update(|_, app| docs.update(app, |docs, _| docs.offer = None));
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||
let shown = |app: &gpui::App| {
|
||||
docs.read(app)
|
||||
.offer
|
||||
.as_ref()
|
||||
.is_some_and(|offer| offer.what.target == target)
|
||||
};
|
||||
for _ in 0..500 {
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| shown(app)) {
|
||||
return frames(cx);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
panic!("no {target:?} menu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_right_click_opens_word_rows_where_it_lands() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| {
|
||||
ely_gpui_component::init_for_tests(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
let id = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Text");
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
let window = cx.add_window(|_, _| Host(docs.clone(), view.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
let text = caret(&mut shown, &view) - gpui::point(gpui::px(8.0), gpui::px(0.0));
|
||||
right_click(&mut shown, &docs, text, Target::Text);
|
||||
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
|
||||
let off = cut.origin - text;
|
||||
assert!(
|
||||
off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0),
|
||||
"the menu opens at the click, not the caret: {off:?}"
|
||||
);
|
||||
let on = |shown: &mut VisualTestContext, pick: Pick| {
|
||||
shown.update(|_, app| pick.on(&docs.read(app).offer.as_ref().expect("an offer").what))
|
||||
};
|
||||
assert!(on(&mut shown, Pick::Comment));
|
||||
assert!(!on(&mut shown, Pick::Link), "the kernel links a selection");
|
||||
assert!(!on(&mut shown, Pick::Cut), "nothing is selected");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| docs.insert_table(2.0, 2.0, cx));
|
||||
settled(&cx, &docs);
|
||||
let cell = caret(&mut shown, &view);
|
||||
right_click(&mut shown, &docs, cell, Target::Table);
|
||||
assert!(on(&mut shown, Pick::RowBelow) && on(&mut shown, Pick::SplitRows));
|
||||
assert!(!on(&mut shown, Pick::Merge), "one cell does not merge");
|
||||
for pick in [Pick::RowBelow, Pick::ColumnRight] {
|
||||
with_window(&mut cx, &docs, |docs, window, cx| docs.choose(pick, window, cx));
|
||||
settled(&cx, &docs);
|
||||
}
|
||||
let path = dir.path().join("grown.odt");
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, path.clone(), None, window, cx)
|
||||
});
|
||||
written(&cx, &path, None);
|
||||
let flat = text_of(&mut cx, &docs, &path);
|
||||
assert_eq!(flat.matches("<table:table-row").count(), 3, "a row below");
|
||||
assert_eq!(flat.matches("<table:table-cell").count(), 9, "and a column right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_follows_the_kernel_flag() {
|
||||
let menu = |enabled: bool| {
|
||||
let json = format!(
|
||||
r#"{{"menu":[{{"text":"x","type":"command","command":".uno:InsertHyperlink","enabled":"{enabled}"}}]}}"#
|
||||
);
|
||||
ely_lok::menu::read(&json).expect("the menu parses")
|
||||
};
|
||||
assert!(Pick::Link.on(&menu(true)));
|
||||
assert!(!Pick::Link.on(&menu(false)), "protected text gets no link");
|
||||
}
|
||||
@@ -332,3 +332,39 @@ fn an_outline_click_goes_where_its_row_was_drawn() {
|
||||
});
|
||||
assert_eq!(target(vcx), Some((0, 300, 10, 10)), "the click goes where its row was");
|
||||
}
|
||||
|
||||
/// A bundled font spelled otherwise, and one nowhere.
|
||||
const SPELLED: &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: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="DEJAVU SANS" svg:font-family="'DEJAVU SANS'"/>
|
||||
<style:font-face style:name="Nowhere Sans" svg:font-family="'Nowhere Sans'"/>
|
||||
</office:font-face-decls>
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="T1" style:family="text"><style:text-properties style:font-name="DEJAVU SANS"/></style:style>
|
||||
<style:style style:name="T2" style:family="text"><style:text-properties style:font-name="Nowhere Sans"/></style:style>
|
||||
</office:automatic-styles>
|
||||
<office:body><office:text>
|
||||
<text:p><text:span text:style-name="T1">found</text:span> <text:span text:style-name="T2">lost</text:span></text:p>
|
||||
</office:text></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
#[test]
|
||||
fn a_font_found_by_its_search_name_is_not_missing() {
|
||||
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("spelled.fodt"), SPELLED);
|
||||
settle(&cx, |app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
!tab.view.read(app).missing_fonts().is_empty()
|
||||
});
|
||||
let missing = cx.update(|app| {
|
||||
let tab = docs.read(app).active_tab().expect("a tab");
|
||||
tab.view.read(app).missing_fonts().to_vec()
|
||||
});
|
||||
assert_eq!(missing, ["Nowhere Sans"], "the lookup found DejaVu Sans");
|
||||
}
|
||||
@@ -250,15 +250,15 @@ impl Render for Props {
|
||||
let grid = if graphic {
|
||||
grid.group(self.object_group(cx))
|
||||
} else {
|
||||
let grid = grid
|
||||
.group(self.text_group(window, cx))
|
||||
.group(self.paragraph_group(cx))
|
||||
.group(self.style_group(cx))
|
||||
.group(self.page_group(cx));
|
||||
match self.state(".uno:DeleteTable", cx).as_deref() {
|
||||
// In a table, its group leads, as in Word.
|
||||
let grid = match self.state(".uno:DeleteTable", cx).as_deref() {
|
||||
Some("enabled") => grid.group(self.table_group(cx)),
|
||||
_ => grid,
|
||||
}
|
||||
};
|
||||
grid.group(self.text_group(window, cx))
|
||||
.group(self.paragraph_group(cx))
|
||||
.group(self.style_group(cx))
|
||||
.group(self.page_group(cx))
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use ely_gpui_component::{data_display::PropertyGroup, primitives::IconName};
|
||||
use ely_office::panel::{Panel, color_state, font_box, palette, row, size_menu, toggle};
|
||||
use gpui::{App, Context, SharedString, Window};
|
||||
use gpui::{App, Context, InteractiveElement, ParentElement, SharedString, Window, div};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
@@ -77,7 +77,12 @@ impl Props {
|
||||
PropertyGroup::new(tip("doc.props.text"))
|
||||
.row(tip("doc.props.font"), font)
|
||||
.row(tip("doc.props.size"), size_menu(self, "doc-props-size", cx))
|
||||
.row(tip("doc.props.effects"), effects)
|
||||
.row(
|
||||
tip("doc.props.effects"),
|
||||
div()
|
||||
.debug_selector(|| "doc-props-text".into())
|
||||
.child(effects),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.color"),
|
||||
palette(&this, "format.color", (color, AUTOMATIC), None, cx),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use ely_gpui_component::{buttons::Button, data_display::PropertyGroup, theme::ControlSize};
|
||||
use ely_office::panel::{button, color_state, palette, row, send, toggle};
|
||||
use gpui::{AnyElement, Context, IntoElement, SharedString};
|
||||
use gpui::{AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, div};
|
||||
|
||||
use super::Props;
|
||||
use crate::docs::Docs;
|
||||
@@ -14,11 +14,13 @@ impl Props {
|
||||
PropertyGroup::new(tip("doc.props.table"))
|
||||
.row(
|
||||
tip("doc.props.rows"),
|
||||
row([
|
||||
button(self, "table.row_above", "doc.props.above_row", why, cx),
|
||||
button(self, "table.row_below", "doc.props.below_row", why, cx),
|
||||
button(self, "table.delete_rows", "doc.props.delete", why, cx),
|
||||
]),
|
||||
div()
|
||||
.debug_selector(|| "doc-props-table".into())
|
||||
.child(row([
|
||||
button(self, "table.row_above", "doc.props.above_row", why, cx),
|
||||
button(self, "table.row_below", "doc.props.below_row", why, cx),
|
||||
button(self, "table.delete_rows", "doc.props.delete", why, cx),
|
||||
])),
|
||||
)
|
||||
.row(
|
||||
tip("doc.props.columns"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use ely_lok::LokError;
|
||||
use ely_office::panel::Panel as _;
|
||||
use gpui::{Entity, TestAppContext};
|
||||
@@ -296,3 +297,82 @@ fn quick_spacing_edits_keep_each_other() {
|
||||
"both quick edits land"
|
||||
);
|
||||
}
|
||||
|
||||
/// A window root that shows the panel.
|
||||
struct Shown(Entity<Props>);
|
||||
|
||||
impl gpui::Render for Shown {
|
||||
fn render(
|
||||
&mut self,
|
||||
_: &mut gpui::Window,
|
||||
_: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
gpui::div().size_full().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// The top of the element marked `selector`.
|
||||
fn top(cx: &mut gpui::VisualTestContext, selector: &'static str) -> Option<gpui::Pixels> {
|
||||
// Past reduced motion's 1ms folds.
|
||||
for _ in 0..3 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
cx.debug_bounds(selector).map(|bounds| bounds.origin.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_a_table_the_table_group_comes_first_and_folds_stay_put() {
|
||||
const DOWN: i32 = 1024;
|
||||
const UP: i32 = 1025;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| {
|
||||
ely_gpui_component::init_for_tests(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
});
|
||||
blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Table");
|
||||
let props = panel(&mut cx, &docs);
|
||||
let window = cx.add_window(|_, _| Shown(props.clone()));
|
||||
let mut shown = gpui::VisualTestContext::from_window(window.into(), &cx);
|
||||
assert_eq!(
|
||||
top(&mut shown, "doc-props-table"),
|
||||
None,
|
||||
"no table, no group"
|
||||
);
|
||||
let header = gpui::point(gpui::px(10.0), gpui::px(12.0));
|
||||
shown.simulate_click(header, gpui::Modifiers::none());
|
||||
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds");
|
||||
shown.simulate_click(header, gpui::Modifiers::none());
|
||||
assert!(top(&mut shown, "doc-props-text").is_some(), "Text opens");
|
||||
with_window(&mut cx, &docs, |docs, _, cx| {
|
||||
docs.insert_table(3.0, 2.0, cx)
|
||||
});
|
||||
let in_table = |app: &gpui::App| props.read(app).state(".uno:DeleteTable", app);
|
||||
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
|
||||
let table = top(&mut shown, "doc-props-table").expect("the table group");
|
||||
let text = top(&mut shown, "doc-props-text").expect("the text group");
|
||||
assert!(table < text, "the table group leads: {table:?} vs {text:?}");
|
||||
keys(&mut cx, &docs, &[UP]);
|
||||
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
|
||||
shown.simulate_click(header, gpui::Modifiers::none());
|
||||
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds again");
|
||||
keys(&mut cx, &docs, &[DOWN]);
|
||||
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
|
||||
assert!(top(&mut shown, "doc-props-table").is_some(), "Table opens");
|
||||
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text stays folded");
|
||||
keys(&mut cx, &docs, &[UP]);
|
||||
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
|
||||
assert_eq!(
|
||||
top(&mut shown, "doc-props-text"),
|
||||
None,
|
||||
"and after the table"
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
//! Spelling suggestions from the kernel's context menu (DOC-12).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::LokView;
|
||||
use gpui::{Context, Entity, IntoElement, Pixels, Point};
|
||||
|
||||
use crate::docs::Docs;
|
||||
use ely_office::prelude::*;
|
||||
|
||||
/// A misspelled word's menu, asked for at a point.
|
||||
pub struct Offer {
|
||||
tab: u64,
|
||||
pub(crate) suggestions: Vec<(String, String)>,
|
||||
/// Bumps for each menu, so each opens once.
|
||||
request: (u64, Point<Pixels>),
|
||||
}
|
||||
|
||||
impl Docs {
|
||||
/// Turns the kernel's context menu into a spelling offer.
|
||||
pub(crate) fn offer_spelling(
|
||||
&mut self,
|
||||
id: u64,
|
||||
view: &Entity<LokView>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
|
||||
return;
|
||||
};
|
||||
let spelling = match ely_lok::review::spelling(&json) {
|
||||
Ok(spelling) => spelling,
|
||||
Err(error) => {
|
||||
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
return self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
};
|
||||
let Some(spelling) = spelling else {
|
||||
log::info!("doc: the kernel menu has no spelling");
|
||||
return;
|
||||
};
|
||||
let Some(caret) = view.read(cx).cursor_bounds() else {
|
||||
log::warn!("doc: a spelling menu without a caret");
|
||||
return;
|
||||
};
|
||||
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
|
||||
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
|
||||
self.offer = Some(Offer {
|
||||
tab: id,
|
||||
suggestions: spelling.suggestions,
|
||||
request: (number, caret.bottom_left()),
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The spelling menu's host, for the active tab.
|
||||
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
let offer = self
|
||||
.offer
|
||||
.as_ref()
|
||||
.filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
let this = cx.entity();
|
||||
let mut menu = Menu::new();
|
||||
for (label, rule) in &offer.suggestions {
|
||||
let (this, rule) = (this.clone(), rule.clone());
|
||||
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
|
||||
}));
|
||||
}
|
||||
if offer.suggestions.is_empty() {
|
||||
let none = Docs::text(cx, "doc.spell.none", &[]);
|
||||
menu = menu.item(MenuItem::new(none).disabled(true));
|
||||
}
|
||||
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
|
||||
let menu = menu
|
||||
.separator()
|
||||
.item(MenuItem::new(ignore).on_click(move |_, cx| {
|
||||
this.update(cx, |docs, cx| {
|
||||
docs.run_active("spell.ignore_all", &["Spelling"], cx)
|
||||
})
|
||||
}));
|
||||
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,8 @@ fn a_misspelled_word_offers_suggestions() {
|
||||
settle(&cx, |app| docs.read(app).offer.is_some());
|
||||
let rule = cx.update(|app| {
|
||||
let offer = docs.read(app).offer.as_ref().expect("an offer");
|
||||
let (_, rule) = offer
|
||||
let spelling = offer.what.spelling.as_ref().expect("a spelling menu");
|
||||
let (_, rule) = spelling
|
||||
.suggestions
|
||||
.iter()
|
||||
.find(|(label, _)| label == "The")
|
||||
|
||||
@@ -11,8 +11,20 @@ build = "../../packaging/windows/icon.rs"
|
||||
embed-resource.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ely-gpui-component.workspace = true
|
||||
ely-mail-engine.workspace = true
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
gpui.workspace = true
|
||||
jiff.workspace = true
|
||||
log.workspace = true
|
||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
ely-ui = { 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,285 @@
|
||||
//! The add-account dialog (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
data_display::DescriptionList,
|
||||
feedback::{Banner, InlineMessage},
|
||||
forms::{Input, PasswordInput, Switch},
|
||||
overlays::Dialog,
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_mail_engine::{Auth, Error, Security};
|
||||
use gpui::{
|
||||
AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _,
|
||||
WeakEntity, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
adding::{Adding, Step},
|
||||
kinds::{APPLE_ACCOUNT, KINDS, Kind},
|
||||
mail::{Mail, text},
|
||||
view::auth,
|
||||
};
|
||||
|
||||
type Edit = fn(&mut Adding, &str, &mut App);
|
||||
|
||||
/// A segmented choice whose change edits the dialog.
|
||||
fn choice(
|
||||
this: &WeakEntity<Mail>,
|
||||
id: &'static str,
|
||||
picked: &str,
|
||||
options: impl IntoIterator<Item = (&'static str, SharedString)>,
|
||||
edit: Edit,
|
||||
) -> SegmentedControl {
|
||||
let this = this.clone();
|
||||
options
|
||||
.into_iter()
|
||||
.fold(
|
||||
SegmentedControl::new(id, picked.to_owned()),
|
||||
|control, (key, label)| control.segment(key, label, None),
|
||||
)
|
||||
.on_change(move |key, _, cx| {
|
||||
this.update(cx, |mail, cx| {
|
||||
if let Some(adding) = &mut mail.adding {
|
||||
edit(adding, key, cx);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
fn security_key(security: Security) -> &'static str {
|
||||
match security {
|
||||
Security::Tls => "tls",
|
||||
Security::StartTls => "starttls",
|
||||
}
|
||||
}
|
||||
|
||||
fn security_of(key: &str) -> Security {
|
||||
match key {
|
||||
"tls" => Security::Tls,
|
||||
_ => Security::StartTls,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_key(auth: Auth) -> &'static str {
|
||||
match auth {
|
||||
Auth::Password => "password",
|
||||
Auth::Google => "google",
|
||||
Auth::Microsoft => "microsoft",
|
||||
}
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn add_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let adding = self.adding.as_ref()?;
|
||||
let this = cx.weak_entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |mail, cx| mail.close_adding(cx)).ok();
|
||||
}
|
||||
};
|
||||
let kinds = KINDS.map(|kind| {
|
||||
(
|
||||
kind.key(),
|
||||
text(cx, &format!("mail.kind.{}", kind.key()), &[]),
|
||||
)
|
||||
});
|
||||
let kinds = choice(
|
||||
&this,
|
||||
"mail-add-kind",
|
||||
adding.kind.key(),
|
||||
kinds,
|
||||
|adding, key, cx| {
|
||||
if let Some(kind) = Kind::from_key(key) {
|
||||
adding.set_kind(kind, cx);
|
||||
}
|
||||
},
|
||||
);
|
||||
let oauth = adding.auth() != Auth::Password;
|
||||
let unavailable = match adding.auth().provider() {
|
||||
Some(Err(Error::Unavailable(name))) => {
|
||||
Some(text(cx, &format!("mail.add.unavailable.{name}"), &[]))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let cancel = text(cx, "mail.cancel", &[]);
|
||||
let (go_key, go): (&str, fn(&mut Mail, &mut Context<Mail>)) = match &adding.step {
|
||||
Step::Checked { checked, .. } if checked.passed() => ("mail.add.save", Mail::save),
|
||||
_ if oauth => ("mail.add.sign_in", Mail::sign_in),
|
||||
_ => ("mail.add.check", Mail::check),
|
||||
};
|
||||
let go_label = text(cx, go_key, &[("method", &auth(adding.auth(), cx))]);
|
||||
let busy = adding.busy();
|
||||
let blocked = busy || (unavailable.is_some() && oauth);
|
||||
let dialog = Dialog::new("mail-add", text(cx, "mail.add.title", &[]), dismiss)
|
||||
.child(kinds)
|
||||
.children(
|
||||
unavailable
|
||||
.map(|words| Banner::new("mail-add-unavailable", Severity::Danger, words)),
|
||||
)
|
||||
.child(self.add_fields(adding, &this, cx))
|
||||
.children(status(adding, cx))
|
||||
.action(move |close| {
|
||||
Button::new("mail-add-cancel", cancel)
|
||||
.on_click(move |_, window, cx| close(window, cx))
|
||||
})
|
||||
.action(move |_| {
|
||||
Button::new("mail-add-go", go_label)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.loading(busy)
|
||||
.disabled(blocked)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, go).ok();
|
||||
})
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
|
||||
fn add_fields(&self, adding: &Adding, this: &WeakEntity<Mail>, cx: &App) -> AnyElement {
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let field = |input| div().w_full().child(Input::new(input));
|
||||
let fields = &adding.fields;
|
||||
let password = div().w_full().child(PasswordInput::new(&fields.password));
|
||||
match adding.kind {
|
||||
Kind::Gmail | Kind::Microsoft => DescriptionList::new()
|
||||
.item(label("mail.add.address_hint"), field(&fields.address))
|
||||
.into_any_element(),
|
||||
Kind::ICloud => {
|
||||
let open = Button::new("mail-add-apple", label("mail.icloud.open"))
|
||||
.on_click(|_, _, cx| cx.open_url(APPLE_ACCOUNT));
|
||||
let rows = DescriptionList::new()
|
||||
.item(label("mail.add.address"), field(&fields.address))
|
||||
.item(label("mail.icloud.password"), password);
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(label("mail.icloud.steps"))
|
||||
.child(div().child(open))
|
||||
.child(rows)
|
||||
.into_any_element()
|
||||
}
|
||||
Kind::Imap | Kind::Pop => {
|
||||
let auths = [Auth::Password, Auth::Google, Auth::Microsoft]
|
||||
.map(|each| (auth_key(each), auth(each, cx)));
|
||||
let methods = choice(
|
||||
this,
|
||||
"mail-add-auth",
|
||||
auth_key(adding.auth),
|
||||
auths,
|
||||
|adding, key, _| {
|
||||
adding.stop();
|
||||
adding.step = Step::Editing;
|
||||
adding.auth = match key {
|
||||
"google" => Auth::Google,
|
||||
"microsoft" => Auth::Microsoft,
|
||||
_ => Auth::Password,
|
||||
};
|
||||
},
|
||||
);
|
||||
let securities = |outgoing: bool, picked: Security| {
|
||||
let options = [Security::Tls, Security::StartTls].map(|each| {
|
||||
(
|
||||
security_key(each),
|
||||
label(&format!("mail.security.{}", security_key(each))),
|
||||
)
|
||||
});
|
||||
let id = if outgoing {
|
||||
"mail-add-out-security"
|
||||
} else {
|
||||
"mail-add-in-security"
|
||||
};
|
||||
let edit: Edit = if outgoing {
|
||||
|adding, key, cx| adding.set_security(true, security_of(key), cx)
|
||||
} else {
|
||||
|adding, key, cx| adding.set_security(false, security_of(key), cx)
|
||||
};
|
||||
choice(this, id, security_key(picked), options, edit)
|
||||
};
|
||||
let mut rows = DescriptionList::new()
|
||||
.item(label("mail.add.address"), field(&fields.address))
|
||||
.item(label("mail.auth.title"), methods);
|
||||
if adding.auth == Auth::Password {
|
||||
rows = rows
|
||||
.item(label("mail.add.user"), field(&fields.user))
|
||||
.item(label("mail.add.password"), password);
|
||||
}
|
||||
if adding.kind == Kind::Pop {
|
||||
let this = this.clone();
|
||||
let keep = Switch::new("mail-add-keep", adding.keep)
|
||||
.name(label("mail.add.keep"))
|
||||
.on_change(move |on, _, cx| {
|
||||
this.update(cx, |mail, cx| {
|
||||
if let Some(adding) = &mut mail.adding {
|
||||
adding.set_keep(on);
|
||||
cx.notify();
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
rows = rows.item(label("mail.add.keep"), keep);
|
||||
}
|
||||
rows.item(label("mail.add.incoming"), field(&fields.incoming))
|
||||
.item(label("mail.add.port"), field(&fields.incoming_port))
|
||||
.item(
|
||||
label("mail.add.security"),
|
||||
securities(false, adding.incoming_security),
|
||||
)
|
||||
.item(label("mail.add.outgoing"), field(&fields.outgoing))
|
||||
.item(label("mail.add.port"), field(&fields.outgoing_port))
|
||||
.item(
|
||||
label("mail.add.security"),
|
||||
securities(true, adding.outgoing_security),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each server's answer, or what is under way.
|
||||
fn status(adding: &Adding, cx: &App) -> Option<AnyElement> {
|
||||
let line =
|
||||
|severity, words: SharedString| InlineMessage::new(severity, words).into_any_element();
|
||||
let said = |key: &str, result: &ely_mail_engine::Result<()>| match result {
|
||||
Ok(()) => line(
|
||||
Severity::Success,
|
||||
text(cx, key, &[("result", &text(cx, "mail.check.passed", &[]))]),
|
||||
),
|
||||
Err(error) => line(
|
||||
Severity::Danger,
|
||||
text(cx, key, &[("result", &failure(error, cx))]),
|
||||
),
|
||||
};
|
||||
Some(match &adding.step {
|
||||
Step::Editing => return None,
|
||||
Step::Signing => line(Severity::Info, text(cx, "mail.add.signing", &[])),
|
||||
Step::Checking => line(Severity::Info, text(cx, "mail.add.checking", &[])),
|
||||
Step::Saving => line(Severity::Info, text(cx, "mail.add.saving", &[])),
|
||||
Step::Failed(words) => line(Severity::Danger, words.clone()),
|
||||
Step::Checked { checked, .. } => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(said("mail.check.incoming", &checked.incoming))
|
||||
.child(said("mail.check.outgoing", &checked.outgoing))
|
||||
.into_any_element(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A server's refusal in the reader's language, its words kept.
|
||||
pub fn failure(error: &Error, cx: &App) -> SharedString {
|
||||
let (key, detail) = match error {
|
||||
Error::Connect(err) => ("mail.error.connect", err.to_string()),
|
||||
Error::Tls(detail) => ("mail.error.tls_failed", detail.clone()),
|
||||
Error::Rejected(detail) => ("mail.error.rejected", detail.clone()),
|
||||
Error::Server(detail) => ("mail.error.server", detail.clone()),
|
||||
Error::Protocol(detail) => ("mail.error.protocol", detail.clone()),
|
||||
Error::Timeout(step) => ("mail.error.timeout", (*step).to_owned()),
|
||||
other => ("mail.error.other", other.to_string()),
|
||||
};
|
||||
text(cx, key, &[("detail", &detail)])
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The add-account dialog's fields and steps (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::forms::{InputEvent, TextInput};
|
||||
use ely_mail_engine::{Account, Auth, Checked, Security, Server};
|
||||
use gpui::{App, AppContext as _, Context, Entity, SharedString, Subscription, Window};
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use crate::{
|
||||
kinds::{self, Kind},
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
pub struct Fields {
|
||||
pub address: Entity<TextInput>,
|
||||
pub user: Entity<TextInput>,
|
||||
pub password: Entity<TextInput>,
|
||||
pub incoming: Entity<TextInput>,
|
||||
pub incoming_port: Entity<TextInput>,
|
||||
pub outgoing: Entity<TextInput>,
|
||||
pub outgoing_port: Entity<TextInput>,
|
||||
}
|
||||
|
||||
pub enum Step {
|
||||
Editing,
|
||||
/// The browser holds the provider's page.
|
||||
Signing,
|
||||
Checking,
|
||||
/// `kept` goes to the vault: a password or refresh token.
|
||||
Checked {
|
||||
account: Account,
|
||||
kept: String,
|
||||
checked: Checked,
|
||||
},
|
||||
Failed(SharedString),
|
||||
Saving,
|
||||
}
|
||||
|
||||
pub struct Adding {
|
||||
pub kind: Kind,
|
||||
pub fields: Fields,
|
||||
pub incoming_security: Security,
|
||||
pub outgoing_security: Security,
|
||||
/// Typed-in accounts choose; providers fix it.
|
||||
pub auth: Auth,
|
||||
/// POP3 leaves a copy on the server (§12.3).
|
||||
pub keep: bool,
|
||||
pub step: Step,
|
||||
/// The ports last filled in; any other was typed.
|
||||
filled: [String; 2],
|
||||
/// The request awaited; any other answer is dropped.
|
||||
pub asked: Option<u64>,
|
||||
pub task: Option<AbortHandle>,
|
||||
_edits: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl Drop for Adding {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
impl Adding {
|
||||
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Mail>) -> Self {
|
||||
let mut field = |key: &str, masked: bool, cx: &mut Context<Mail>| {
|
||||
let words = text(cx, key, &[]);
|
||||
cx.new(|cx| {
|
||||
let input = TextInput::new(window, cx)
|
||||
.placeholder(words.clone())
|
||||
.label(words);
|
||||
if masked { input.masked() } else { input }
|
||||
})
|
||||
};
|
||||
let address = field("mail.add.address", false, cx);
|
||||
let user = field("mail.add.user", false, cx);
|
||||
let password = field("mail.add.password", true, cx);
|
||||
let incoming = field("mail.add.incoming", false, cx);
|
||||
let incoming_port = field("mail.add.port", false, cx);
|
||||
let outgoing = field("mail.add.outgoing", false, cx);
|
||||
let outgoing_port = field("mail.add.port", false, cx);
|
||||
let fields = Fields {
|
||||
address,
|
||||
user,
|
||||
password,
|
||||
incoming,
|
||||
incoming_port,
|
||||
outgoing,
|
||||
outgoing_port,
|
||||
};
|
||||
let edits = [
|
||||
&fields.address,
|
||||
&fields.user,
|
||||
&fields.password,
|
||||
&fields.incoming,
|
||||
&fields.incoming_port,
|
||||
&fields.outgoing,
|
||||
&fields.outgoing_port,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
cx.subscribe(field, |mail: &mut Mail, _, event: &InputEvent, cx| {
|
||||
if *event == InputEvent::Changed {
|
||||
mail.edited(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut adding = Adding {
|
||||
kind,
|
||||
fields,
|
||||
incoming_security: Security::Tls,
|
||||
outgoing_security: Security::StartTls,
|
||||
auth: Auth::Password,
|
||||
keep: true,
|
||||
step: Step::Editing,
|
||||
filled: Default::default(),
|
||||
asked: None,
|
||||
task: None,
|
||||
_edits: edits,
|
||||
};
|
||||
adding.set_ports(cx);
|
||||
adding
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
self.asked = None;
|
||||
}
|
||||
|
||||
/// A new kind keeps what was typed.
|
||||
pub fn set_kind(&mut self, kind: Kind, cx: &mut App) {
|
||||
self.stop();
|
||||
self.kind = kind;
|
||||
self.step = Step::Editing;
|
||||
self.set_ports(cx);
|
||||
}
|
||||
|
||||
pub fn set_security(&mut self, outgoing: bool, security: Security, cx: &mut App) {
|
||||
self.stop();
|
||||
self.step = Step::Editing;
|
||||
if outgoing {
|
||||
self.outgoing_security = security;
|
||||
} else {
|
||||
self.incoming_security = security;
|
||||
}
|
||||
self.set_ports(cx);
|
||||
}
|
||||
|
||||
pub fn set_keep(&mut self, keep: bool) {
|
||||
self.stop();
|
||||
self.step = Step::Editing;
|
||||
self.keep = keep;
|
||||
}
|
||||
|
||||
/// The usual ports, unless one was typed.
|
||||
fn set_ports(&mut self, cx: &mut App) {
|
||||
let ports = [
|
||||
kinds::port(Some(self.kind.protocol(self.keep)), self.incoming_security),
|
||||
kinds::port(None, self.outgoing_security),
|
||||
];
|
||||
let fields = [&self.fields.incoming_port, &self.fields.outgoing_port];
|
||||
for ((field, port), filled) in fields.into_iter().zip(ports).zip(&mut self.filled) {
|
||||
let typed = field.read(cx).text().trim().to_owned();
|
||||
if typed.is_empty() || typed == *filled {
|
||||
*filled = port.to_string();
|
||||
field.update(cx, |field, cx| field.set_text(filled.clone(), cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-in this kind uses.
|
||||
pub fn auth(&self) -> Auth {
|
||||
match self.kind.known() {
|
||||
Some((auth, ..)) => auth,
|
||||
None => self.auth,
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self, field: &Entity<TextInput>, cx: &App) -> String {
|
||||
field.read(cx).text().trim().to_owned()
|
||||
}
|
||||
|
||||
/// The servers as known or typed.
|
||||
pub fn servers(&self, cx: &App) -> Result<(Server, Server), SharedString> {
|
||||
if let Some((_, incoming, outgoing)) = self.kind.known() {
|
||||
return Ok((incoming, outgoing));
|
||||
}
|
||||
let server = |host: &Entity<TextInput>, port: &Entity<TextInput>, security| {
|
||||
let host = self.read(host, cx);
|
||||
if host.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_host", &[]));
|
||||
}
|
||||
let port = self.read(port, cx);
|
||||
let port = port
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|port| *port > 0)
|
||||
.ok_or_else(|| text(cx, "mail.add.bad_port", &[("port", &port)]))?;
|
||||
Ok(Server {
|
||||
host,
|
||||
port,
|
||||
security,
|
||||
})
|
||||
};
|
||||
Ok((
|
||||
server(
|
||||
&self.fields.incoming,
|
||||
&self.fields.incoming_port,
|
||||
self.incoming_security,
|
||||
)?,
|
||||
server(
|
||||
&self.fields.outgoing,
|
||||
&self.fields.outgoing_port,
|
||||
self.outgoing_security,
|
||||
)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// The account the fields name; `signed` is an OAuth address.
|
||||
pub fn account(&self, signed: Option<&str>, cx: &App) -> Result<Account, SharedString> {
|
||||
let address = match signed {
|
||||
Some(address) => address.to_owned(),
|
||||
None => self.read(&self.fields.address, cx),
|
||||
};
|
||||
if address.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_address", &[]));
|
||||
}
|
||||
let user = match (signed, self.kind) {
|
||||
(None, Kind::Imap | Kind::Pop) => self.read(&self.fields.user, cx),
|
||||
_ => address.clone(),
|
||||
};
|
||||
if user.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_user", &[]));
|
||||
}
|
||||
let (incoming, outgoing) = self.servers(cx)?;
|
||||
let account = Account {
|
||||
address,
|
||||
user,
|
||||
auth: self.auth(),
|
||||
protocol: self.kind.protocol(self.keep),
|
||||
incoming,
|
||||
outgoing,
|
||||
};
|
||||
account
|
||||
.valid()
|
||||
.map_err(|err| text(cx, "mail.add.invalid", &[("error", &err.to_string())]))?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub fn password(&self, cx: &App) -> Result<String, SharedString> {
|
||||
let password = self.fields.password.read(cx).text().to_owned();
|
||||
if password.is_empty() {
|
||||
return Err(text(cx, "mail.add.need_password", &[]));
|
||||
}
|
||||
Ok(password)
|
||||
}
|
||||
|
||||
pub fn busy(&self) -> bool {
|
||||
matches!(self.step, Step::Signing | Step::Checking | Step::Saving)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! What the window has open, and its list.
|
||||
|
||||
use std::{collections::HashMap, rc::Rc};
|
||||
|
||||
use ely_gpui_component::mail::Mail as Row;
|
||||
use ely_mail_engine::{AccountId, Filter, MailboxId, Role, Scope, Summary};
|
||||
use gpui::{Context, SharedString};
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
pub type Unread = HashMap<MailboxId, u32>;
|
||||
|
||||
/// What the middle of the window shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Open {
|
||||
Inboxes,
|
||||
Mailbox(AccountId, MailboxId),
|
||||
/// The account's settings and sync.
|
||||
Account(AccountId),
|
||||
}
|
||||
|
||||
impl Open {
|
||||
pub fn key(self) -> SharedString {
|
||||
match self {
|
||||
Open::Inboxes => "inboxes".into(),
|
||||
Open::Mailbox(_, mailbox) => format!("box-{}", mailbox.0).into(),
|
||||
Open::Account(id) => format!("account-{}", id.0).into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn account(self) -> Option<AccountId> {
|
||||
match self {
|
||||
Open::Inboxes => None,
|
||||
Open::Mailbox(id, _) | Open::Account(id) => Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(self) -> Option<Scope> {
|
||||
match self {
|
||||
Open::Inboxes => Some(Scope::Inboxes),
|
||||
Open::Mailbox(_, mailbox) => Some(Scope::Mailbox(mailbox)),
|
||||
Open::Account(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows read, and where each key sits.
|
||||
pub struct Indexed {
|
||||
rows: Vec<Row>,
|
||||
at: HashMap<SharedString, usize>,
|
||||
}
|
||||
|
||||
/// Off the UI thread: a long list takes a while.
|
||||
pub fn index(rows: Vec<Row>) -> Indexed {
|
||||
let at = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, row)| (row.key.clone(), ix))
|
||||
.collect();
|
||||
Indexed { rows, at }
|
||||
}
|
||||
|
||||
/// The open list and the messages picked in it.
|
||||
#[derive(Default)]
|
||||
pub struct Listing {
|
||||
/// Numbers reads; an older one is dropped.
|
||||
pub read: u64,
|
||||
/// None until read, or when nothing lists mail.
|
||||
pub mails: Option<Result<Rc<Vec<Row>>, String>>,
|
||||
at: HashMap<SharedString, usize>,
|
||||
pub selected: Vec<SharedString>,
|
||||
/// The one message picked, for the reading pane.
|
||||
pub chosen: Option<Row>,
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
pub fn show(&mut self, mails: Option<Result<Indexed, String>>) {
|
||||
self.at = HashMap::new();
|
||||
self.mails = mails.map(|read| {
|
||||
read.map(|indexed| {
|
||||
self.at = indexed.at;
|
||||
Rc::new(indexed.rows)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Keeps the picks still listed.
|
||||
fn pick(&mut self, keys: Vec<SharedString>) {
|
||||
let Some(Ok(mails)) = &self.mails else {
|
||||
self.selected.clear();
|
||||
self.chosen = None;
|
||||
return;
|
||||
};
|
||||
self.selected = keys
|
||||
.into_iter()
|
||||
.filter(|key| self.at.contains_key(key))
|
||||
.collect();
|
||||
self.chosen = match &self.selected[..] {
|
||||
[key] => Some(mails[self.at[key]].clone()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn open_box(&mut self, open: Open, cx: &mut Context<Self>) {
|
||||
log::info!("mail: open {}", open.key());
|
||||
if open != self.open {
|
||||
self.open = open;
|
||||
self.listing.show(None);
|
||||
self.listing.pick(Vec::new());
|
||||
}
|
||||
self.read_mail(cx);
|
||||
self.notify(cx);
|
||||
}
|
||||
|
||||
pub fn set_filter(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
log::info!("mail: filter {filter:?}");
|
||||
self.filter = filter;
|
||||
self.read_mail(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn select(&mut self, keys: &[SharedString], cx: &mut Context<Self>) {
|
||||
self.listing.pick(keys.to_vec());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Unread counts, and the open list when it lists mail.
|
||||
pub fn read_mail(&mut self, cx: &mut Context<Self>) {
|
||||
self.listing.read += 1;
|
||||
let (read, scope, filter) = (self.listing.read, self.open.scope(), self.filter);
|
||||
let none = (
|
||||
text(cx, "mail.no_sender", &[]),
|
||||
text(cx, "mail.no_subject", &[]),
|
||||
);
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| {
|
||||
let rows = match scope {
|
||||
Some(scope) => {
|
||||
let list = store.list(scope, filter)?;
|
||||
Some(index(
|
||||
list.into_iter().map(|each| row(each, &none)).collect(),
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok((rows, store.unread()?))
|
||||
},
|
||||
move |mail, read_now, _| {
|
||||
if mail.listing.read != read {
|
||||
return log::info!("mail: list read {read} ended after a newer one");
|
||||
}
|
||||
match read_now {
|
||||
Ok((rows, unread)) => {
|
||||
mail.unread = unread;
|
||||
mail.listing.show(rows.map(Ok));
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("mail: list read {read} failed: {error}");
|
||||
mail.listing.show(scope.map(|_| Err(error)));
|
||||
}
|
||||
}
|
||||
let keys = std::mem::take(&mut mail.listing.selected);
|
||||
mail.listing.pick(keys);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Unread mail in every account's inbox.
|
||||
pub fn inboxes_unread(&self) -> u32 {
|
||||
self.syncs
|
||||
.values()
|
||||
.flat_map(|syncing| &syncing.folders)
|
||||
.filter(|folder| folder.role == Some(Role::Inbox))
|
||||
.filter_map(|folder| self.unread.get(&folder.id))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// `none` names a missing sender, then subject.
|
||||
fn row(summary: Summary, none: &(SharedString, SharedString)) -> Row {
|
||||
let key = summary.id.0.to_string();
|
||||
let from = summary.from.first().map(|from| match &from.name {
|
||||
Some(name) if !name.trim().is_empty() => name.clone(),
|
||||
_ => from.email.clone(),
|
||||
});
|
||||
let from: SharedString = from.map_or_else(|| none.0.clone(), Into::into);
|
||||
let subject: SharedString = summary.subject.map_or_else(|| none.1.clone(), Into::into);
|
||||
let row = match summary.date.map(jiff::Timestamp::from_second) {
|
||||
Some(Ok(at)) => Row::new(key, from, subject, at),
|
||||
Some(Err(error)) => {
|
||||
log::warn!("mail: message {} date unshown: {error}", summary.id.0);
|
||||
Row::undated(key, from, subject)
|
||||
}
|
||||
None => Row::undated(key, from, subject),
|
||||
};
|
||||
let row = row.unread(!summary.seen).starred(summary.flagged);
|
||||
match summary.attachments {
|
||||
Some(count) => row.attachments(count as usize),
|
||||
None => row,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Sync now, pause, and folders kept whole.
|
||||
|
||||
use ely_gpui_component::{buttons::Button, feedback::Banner, forms::Switch, primitives::Severity};
|
||||
use ely_mail_engine::{AccountId, Protocol};
|
||||
use gpui::{
|
||||
AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div,
|
||||
};
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
impl Mail {
|
||||
pub fn sync_buttons(&self, id: AccountId, cx: &mut Context<Self>) -> AnyElement {
|
||||
let running = self.syncs.get(&id).is_some_and(|each| each.task.is_some());
|
||||
let this = cx.weak_entity();
|
||||
let now = Button::new("mail-sync-now", text(cx, "mail.sync.now", &[]))
|
||||
.disabled(running || self.paused)
|
||||
.on_click({
|
||||
let this = this.clone();
|
||||
move |_, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.sync(id, cx)).ok();
|
||||
}
|
||||
});
|
||||
let key = if self.paused {
|
||||
"mail.sync.resume"
|
||||
} else {
|
||||
"mail.sync.pause"
|
||||
};
|
||||
let pause = Button::new("mail-sync-pause", text(cx, key, &[])).on_click(move |_, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.set_paused(!mail.paused, cx))
|
||||
.ok();
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.gap_2()
|
||||
.child(now)
|
||||
.child(pause)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub fn paused_banner(&self, cx: &Context<Self>) -> Option<Banner> {
|
||||
self.paused.then(|| {
|
||||
Banner::new(
|
||||
"mail-sync-paused",
|
||||
Severity::Info,
|
||||
text(cx, "mail.sync.paused", &[]),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// One switch per IMAP folder.
|
||||
pub fn offline_switches(
|
||||
&self,
|
||||
id: AccountId,
|
||||
protocol: Protocol,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<AnyElement> {
|
||||
if protocol != Protocol::Imap {
|
||||
return None;
|
||||
}
|
||||
let folders = &self.syncs.get(&id)?.folders;
|
||||
if folders.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
let switches = folders.iter().map(|folder| {
|
||||
let mailbox = folder.id;
|
||||
let this = this.clone();
|
||||
let label = text(cx, "mail.sync.offline", &[("name", &folder.name)]);
|
||||
let switch = Switch::new(("mail-offline", mailbox.0 as u64), folder.offline)
|
||||
.label(label)
|
||||
.on_change(move |on, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.set_offline(id, mailbox, on, cx))
|
||||
.ok();
|
||||
});
|
||||
div()
|
||||
.debug_selector(move || format!("mail-offline-{}", mailbox.0))
|
||||
.child(switch)
|
||||
});
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.children(switches)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Checking, signing in and adding an account (MAIL-01, 02).
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{Account, Checked, Error, Kept, Secret, oauth};
|
||||
use gpui::{Context, SharedString, Window};
|
||||
|
||||
use crate::{
|
||||
adding::{Adding, Step},
|
||||
kinds::Kind,
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
impl Mail {
|
||||
pub fn start_adding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.adding.is_none() && self.removing.is_none() {
|
||||
self.adding = Some(Adding::new(Kind::Gmail, window, cx));
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn close_adding(&mut self, cx: &mut Context<Self>) {
|
||||
self.adding = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A field changed: an earlier check no longer holds.
|
||||
pub fn edited(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &mut self.adding else {
|
||||
return;
|
||||
};
|
||||
if !matches!(adding.step, Step::Editing | Step::Saving) {
|
||||
adding.stop();
|
||||
adding.step = Step::Editing;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a request for the open dialog.
|
||||
fn ask(&mut self) -> Option<u64> {
|
||||
let adding = self.adding.as_mut()?;
|
||||
adding.stop();
|
||||
self.asked += 1;
|
||||
adding.asked = Some(self.asked);
|
||||
Some(self.asked)
|
||||
}
|
||||
|
||||
/// The open dialog, if it awaits `asked`.
|
||||
fn awaiting(&mut self, asked: u64) -> Option<&mut Adding> {
|
||||
self.adding
|
||||
.as_mut()
|
||||
.filter(|adding| adding.asked == Some(asked))
|
||||
}
|
||||
|
||||
fn halt(&mut self, words: SharedString, cx: &mut Context<Self>) {
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.stop();
|
||||
adding.step = Step::Failed(words);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The words when the address is already here.
|
||||
fn taken(&self, address: &str, cx: &Context<Self>) -> Option<SharedString> {
|
||||
self.accounts()
|
||||
.iter()
|
||||
.any(|(_, account)| account.address.eq_ignore_ascii_case(address))
|
||||
.then(|| text(cx, "mail.add.taken", &[("address", address)]))
|
||||
}
|
||||
|
||||
/// Checks a typed-in password with both servers.
|
||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &self.adding else { return };
|
||||
let ready = adding
|
||||
.account(None, cx)
|
||||
.and_then(|account| Ok((account, adding.password(cx)?)));
|
||||
match ready {
|
||||
Ok((account, password)) => {
|
||||
self.probe(account, password.clone(), Secret::Password(password), cx)
|
||||
}
|
||||
Err(words) => self.halt(words, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs in with `secret`; `kept` goes to the vault after.
|
||||
fn probe(&mut self, account: Account, kept: String, secret: Secret, cx: &mut Context<Self>) {
|
||||
if let Some(words) = self.taken(&account.address, cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let tls = match &self.tls {
|
||||
Ok(tls) => tls.clone(),
|
||||
Err(error) => return self.halt(text(cx, "mail.error.tls", &[("error", error)]), cx),
|
||||
};
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Checking;
|
||||
}
|
||||
log::info!("mail: checking a {:?} account", account.protocol);
|
||||
let probe = account.clone();
|
||||
let work = async move { probe.check(secret, &tls).await };
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
mail.checked(asked, account, kept, done, cx)
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn checked(
|
||||
&mut self,
|
||||
asked: u64,
|
||||
account: Account,
|
||||
kept: String,
|
||||
done: Result<Checked, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(adding) = self.awaiting(asked) else {
|
||||
return log::info!("mail: a stale check dropped");
|
||||
};
|
||||
adding.task = None;
|
||||
adding.step = match done {
|
||||
Ok(checked) => Step::Checked {
|
||||
account,
|
||||
kept,
|
||||
checked,
|
||||
},
|
||||
Err(error) => Step::Failed(text(cx, "mail.add.check_failed", &[("error", &error)])),
|
||||
};
|
||||
}
|
||||
|
||||
/// Opens the provider's page, then checks what it grants.
|
||||
pub fn sign_in(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(adding) = &self.adding else { return };
|
||||
let provider = match adding.auth().provider() {
|
||||
Some(Ok(provider)) => provider,
|
||||
Some(Err(error)) => return self.halt(unsigned(&error, cx), cx),
|
||||
None => return log::error!("mail: sign-in asked of a password account"),
|
||||
};
|
||||
if let Err(words) = adding.servers(cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let hint = adding.fields.address.read(cx).text().trim().to_owned();
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Signing;
|
||||
}
|
||||
let work = async move { oauth::SignIn::start(provider, &hint).await };
|
||||
let task = self.on_runtime(cx, work, move |mail, started, cx| {
|
||||
if mail.awaiting(asked).is_none() {
|
||||
return log::info!("mail: a stale sign-in dropped");
|
||||
}
|
||||
match started.map_err(|error| text(cx, "mail.add.check_failed", &[("error", &error)])) {
|
||||
Ok(Ok(sign_in)) => mail.wait_for_browser(asked, sign_in, cx),
|
||||
Ok(Err(error)) => mail.halt(unsigned(&error, cx), cx),
|
||||
Err(words) => mail.halt(words, cx),
|
||||
}
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn wait_for_browser(&mut self, asked: u64, sign_in: oauth::SignIn, cx: &mut Context<Self>) {
|
||||
cx.open_url(sign_in.url());
|
||||
log::info!("mail: the browser has the sign-in page");
|
||||
let work = async move {
|
||||
let tokens = sign_in.finish().await?;
|
||||
let email = tokens
|
||||
.email
|
||||
.clone()
|
||||
.ok_or_else(|| Error::SignIn("the provider named no address".into()))?;
|
||||
let refresh = tokens
|
||||
.refresh
|
||||
.clone()
|
||||
.ok_or_else(|| Error::SignIn("no refresh token granted".into()))?;
|
||||
Ok::<_, Error>((tokens.access, refresh, email))
|
||||
};
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
if mail.awaiting(asked).is_none() {
|
||||
return log::info!("mail: a stale sign-in dropped");
|
||||
}
|
||||
let account = match done {
|
||||
Ok(Ok((access, refresh, email))) => mail.adding.as_ref().map(|adding| {
|
||||
adding
|
||||
.account(Some(&email), cx)
|
||||
.map(|account| (account, access, refresh))
|
||||
}),
|
||||
Ok(Err(error)) => Some(Err(unsigned(&error, cx))),
|
||||
Err(error) => Some(Err(text(cx, "mail.add.check_failed", &[("error", &error)]))),
|
||||
};
|
||||
match account {
|
||||
Some(Ok((account, access, refresh))) => {
|
||||
mail.probe(account, refresh, Secret::Token(access), cx)
|
||||
}
|
||||
Some(Err(words)) => mail.halt(words, cx),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.task = Some(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the checked account, then its secret.
|
||||
pub fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(Adding {
|
||||
step:
|
||||
Step::Checked {
|
||||
account,
|
||||
kept,
|
||||
checked,
|
||||
},
|
||||
..
|
||||
}) = &self.adding
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !checked.passed() {
|
||||
return;
|
||||
}
|
||||
if let Some(words) = self.taken(&account.address, cx) {
|
||||
return self.halt(words, cx);
|
||||
}
|
||||
let (account, kept) = (account.clone(), kept.clone());
|
||||
let Some(asked) = self.ask() else { return };
|
||||
if let Some(adding) = &mut self.adding {
|
||||
adding.step = Step::Saving;
|
||||
}
|
||||
let vault = self.vault.clone();
|
||||
let work = move |store: &mut ely_mail_engine::Store| {
|
||||
let id = match store.add_account(&account) {
|
||||
Ok(id) => id,
|
||||
Err(Error::Taken(address)) => return Ok(Err(address)),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let held = vault.put(&account.secret_name(), &kept);
|
||||
Ok(Ok((id, account, held)))
|
||||
};
|
||||
self.with_store(cx, work, move |mail, saved, cx| match saved {
|
||||
Ok(Err(address)) => {
|
||||
let words = text(cx, "mail.add.taken", &[("address", &address)]);
|
||||
match mail.awaiting(asked) {
|
||||
Some(_) => mail.halt(words, cx),
|
||||
None => mail.toast(words, Severity::Danger, cx),
|
||||
}
|
||||
}
|
||||
Ok(Ok((id, account, held))) => {
|
||||
log::info!("mail: account {} added", id.0);
|
||||
let words = text(cx, "mail.add.added", &[("address", &account.address)]);
|
||||
if let Some(Ok(accounts)) = &mut mail.accounts {
|
||||
accounts.push((id, account));
|
||||
}
|
||||
mail.open_box(crate::boxes::Open::Account(id), cx);
|
||||
mail.sync(id, cx);
|
||||
if mail.awaiting(asked).is_some() {
|
||||
mail.adding = None;
|
||||
}
|
||||
mail.toast(words, Severity::Success, cx);
|
||||
if let Kept::Session(reason) = held {
|
||||
mail.toast(
|
||||
text(cx, "mail.add.session_only", &[("error", &reason)]),
|
||||
Severity::Warning,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let words = text(cx, "mail.add.save_failed", &[("error", &error)]);
|
||||
match mail.awaiting(asked) {
|
||||
Some(_) => mail.halt(words, cx),
|
||||
None => mail.toast(words, Severity::Danger, cx),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign-in failures, with this build's missing client named.
|
||||
fn unsigned(error: &Error, cx: &gpui::App) -> SharedString {
|
||||
match error {
|
||||
Error::Unavailable(name) => text(cx, &format!("mail.add.unavailable.{name}"), &[]),
|
||||
other => text(
|
||||
cx,
|
||||
"mail.add.sign_in_failed",
|
||||
&[("error", &other.to_string())],
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! The kinds of account and their known servers.
|
||||
|
||||
use ely_mail_engine::{Auth, Protocol, Security, Server};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Gmail,
|
||||
Microsoft,
|
||||
ICloud,
|
||||
Imap,
|
||||
Pop,
|
||||
}
|
||||
|
||||
pub const KINDS: [Kind; 5] = [
|
||||
Kind::Gmail,
|
||||
Kind::Microsoft,
|
||||
Kind::ICloud,
|
||||
Kind::Imap,
|
||||
Kind::Pop,
|
||||
];
|
||||
|
||||
/// Where iCloud makes app-specific passwords.
|
||||
pub const APPLE_ACCOUNT: &str = "https://account.apple.com";
|
||||
|
||||
impl Kind {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Gmail => "gmail",
|
||||
Kind::Microsoft => "microsoft",
|
||||
Kind::ICloud => "icloud",
|
||||
Kind::Imap => "imap",
|
||||
Kind::Pop => "pop",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_key(key: &str) -> Option<Kind> {
|
||||
KINDS.into_iter().find(|kind| kind.key() == key)
|
||||
}
|
||||
|
||||
/// `keep` is POP3's server copy.
|
||||
pub fn protocol(self, keep: bool) -> Protocol {
|
||||
match self {
|
||||
Kind::Pop => Protocol::Pop { keep },
|
||||
_ => Protocol::Imap,
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider's servers and sign-in; None when typed in.
|
||||
pub fn known(self) -> Option<(Auth, Server, Server)> {
|
||||
let server = |host: &str, port, security| Server {
|
||||
host: host.into(),
|
||||
port,
|
||||
security,
|
||||
};
|
||||
match self {
|
||||
Kind::Gmail => Some((
|
||||
Auth::Google,
|
||||
server("imap.gmail.com", 993, Security::Tls),
|
||||
server("smtp.gmail.com", 465, Security::Tls),
|
||||
)),
|
||||
Kind::Microsoft => Some((
|
||||
Auth::Microsoft,
|
||||
server("outlook.office365.com", 993, Security::Tls),
|
||||
server("smtp.office365.com", 587, Security::StartTls),
|
||||
)),
|
||||
// Apple: IMAP takes the full address too.
|
||||
Kind::ICloud => Some((
|
||||
Auth::Password,
|
||||
server("imap.mail.me.com", 993, Security::Tls),
|
||||
server("smtp.mail.me.com", 587, Security::StartTls),
|
||||
)),
|
||||
Kind::Imap | Kind::Pop => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The usual port for a protocol under a security.
|
||||
pub fn port(protocol: Option<Protocol>, security: Security) -> u16 {
|
||||
match (protocol, security) {
|
||||
(Some(Protocol::Imap), Security::Tls) => 993,
|
||||
(Some(Protocol::Imap), Security::StartTls) => 143,
|
||||
(Some(Protocol::Pop { .. }), Security::Tls) => 995,
|
||||
(Some(Protocol::Pop { .. }), Security::StartTls) => 110,
|
||||
(None, Security::Tls) => 465,
|
||||
(None, Security::StartTls) => 587,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! The window: accounts and the dialogs that change them.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{Account, AccountId, Filter, Store, TlsConnector, Vault};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Root;
|
||||
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, SharedString, WeakEntity, Window};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{
|
||||
adding::Adding,
|
||||
boxes::{Listing, Open, Unread},
|
||||
removing::Removing,
|
||||
side::Side,
|
||||
syncing::Syncs,
|
||||
};
|
||||
|
||||
/// What the engine runs on and keeps to.
|
||||
pub struct Parts {
|
||||
pub store: Result<Store, String>,
|
||||
pub vault: Vault,
|
||||
pub tls: Result<TlsConnector, String>,
|
||||
}
|
||||
|
||||
impl Parts {
|
||||
pub fn system(dir: &Path) -> Self {
|
||||
Self {
|
||||
store: Store::open(dir).map_err(|err| err.to_string()),
|
||||
vault: Vault::system(),
|
||||
tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Mail {
|
||||
pub store: Result<Arc<Mutex<Store>>, String>,
|
||||
pub read_only: Option<String>,
|
||||
pub vault: Arc<Vault>,
|
||||
pub tls: Result<TlsConnector, String>,
|
||||
runtime: Arc<Runtime>,
|
||||
/// None until read.
|
||||
pub accounts: Option<Result<Vec<(AccountId, Account)>, String>>,
|
||||
/// Remove acts on its account.
|
||||
pub open: Open,
|
||||
pub filter: Filter,
|
||||
pub listing: Listing,
|
||||
pub unread: Unread,
|
||||
pub adding: Option<Adding>,
|
||||
pub removing: Option<Removing>,
|
||||
pub syncs: Syncs,
|
||||
/// Numbers every sync, even across forgotten accounts.
|
||||
pub runs: u64,
|
||||
/// Until resumed, no account syncs.
|
||||
pub paused: bool,
|
||||
/// Numbers every request across dialogs.
|
||||
pub asked: u64,
|
||||
pub focus: FocusHandle,
|
||||
pub root: WeakEntity<Root>,
|
||||
pub side: Entity<Side>,
|
||||
}
|
||||
|
||||
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
|
||||
ely_ui::i18n::text(cx, key, args)
|
||||
}
|
||||
|
||||
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let dir = match ely_platform::data_dir(app) {
|
||||
Ok(dir) => dir,
|
||||
Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")),
|
||||
};
|
||||
let parts = Parts::system(&dir);
|
||||
let mail = cx.new(|cx| Mail::new(parts, cx));
|
||||
let root = ely_ui::tool_root(app, mail.clone().into(), crate::menu::menus(cx), window, cx);
|
||||
mail.update(cx, |mail, cx| {
|
||||
mail.root = root.downgrade();
|
||||
window.focus(&mail.focus, cx);
|
||||
});
|
||||
root
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
pub fn new(parts: Parts, cx: &mut Context<Self>) -> Self {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("mail")
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap_or_else(|error| {
|
||||
ely_platform::startup::fail(AppId::Mail, &format!("no mail runtime: {error}"))
|
||||
});
|
||||
let read_only = match &parts.store {
|
||||
Ok(store) => store.read_only().map(str::to_owned),
|
||||
Err(error) => {
|
||||
log::error!("mail: the store did not open: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Err(error) = &parts.tls {
|
||||
log::error!("mail: no system TLS: {error}");
|
||||
}
|
||||
let this = cx.weak_entity();
|
||||
let mut mail = Mail {
|
||||
store: parts.store.map(|store| Arc::new(Mutex::new(store))),
|
||||
read_only,
|
||||
vault: Arc::new(parts.vault),
|
||||
tls: parts.tls,
|
||||
runtime: Arc::new(runtime),
|
||||
accounts: None,
|
||||
open: Open::Inboxes,
|
||||
filter: Filter::default(),
|
||||
listing: Listing::default(),
|
||||
unread: Unread::new(),
|
||||
adding: None,
|
||||
removing: None,
|
||||
syncs: Syncs::new(),
|
||||
runs: 0,
|
||||
paused: false,
|
||||
asked: 0,
|
||||
focus: cx.focus_handle(),
|
||||
root: WeakEntity::new_invalid(),
|
||||
side: cx.new(|_| Side::new(this)),
|
||||
};
|
||||
mail.read_accounts(cx);
|
||||
Self::sync_on_a_timer(cx);
|
||||
mail
|
||||
}
|
||||
|
||||
pub fn read_accounts(&mut self, cx: &mut Context<Self>) {
|
||||
self.with_store(
|
||||
cx,
|
||||
|store| store.accounts(),
|
||||
|mail, read, cx| {
|
||||
let first = mail.accounts.is_none();
|
||||
if let Ok(accounts) = &read {
|
||||
let still = |id| accounts.iter().any(|(each, _)| *each == id);
|
||||
if mail.open.account().is_some_and(|id| !still(id)) {
|
||||
mail.open = Open::Inboxes;
|
||||
}
|
||||
}
|
||||
mail.accounts = Some(read);
|
||||
if first {
|
||||
mail.sync_all(cx);
|
||||
}
|
||||
mail.read_mail(cx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn accounts(&self) -> &[(AccountId, Account)] {
|
||||
match &self.accounts {
|
||||
Some(Ok(accounts)) => accounts,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Redraws the window and its account list.
|
||||
pub fn notify(&self, cx: &mut Context<Self>) {
|
||||
self.side.update(cx, |_, cx| cx.notify());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Store work off the window's thread.
|
||||
pub fn with_store<R: Send + 'static>(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
work: impl FnOnce(&mut Store) -> ely_mail_engine::Result<R> + Send + 'static,
|
||||
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
|
||||
) {
|
||||
let store = match &self.store {
|
||||
Ok(store) => store.clone(),
|
||||
Err(error) => {
|
||||
self.failed("mail.error.store", error, cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let read = cx.background_spawn(async move {
|
||||
let mut store = store
|
||||
.lock()
|
||||
.map_err(|_| "a store task panicked".to_owned())?;
|
||||
work(&mut store).map_err(|err| err.to_string())
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
let read = read.await;
|
||||
this.update(cx, |mail, cx| {
|
||||
then(mail, read, cx);
|
||||
mail.notify(cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Network work on the mail runtime; abort stops it.
|
||||
pub fn on_runtime<R: Send + 'static>(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
work: impl Future<Output = R> + Send + 'static,
|
||||
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
|
||||
) -> tokio::task::AbortHandle {
|
||||
let task = self.runtime.spawn(work);
|
||||
let abort = task.abort_handle();
|
||||
cx.spawn(async move |this, cx| {
|
||||
let done = task.await;
|
||||
if done.as_ref().is_err_and(|err| err.is_cancelled()) {
|
||||
return;
|
||||
}
|
||||
let done = done.map_err(|err| format!("a mail task failed: {err}"));
|
||||
this.update(cx, |mail, cx| {
|
||||
then(mail, done, cx);
|
||||
mail.notify(cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
abort
|
||||
}
|
||||
|
||||
/// Not logged: the words may name an address.
|
||||
pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) {
|
||||
if let Some(root) = self.root.upgrade() {
|
||||
root.update(cx, |root, cx| root.toast(text, severity, cx));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(&self, key: &str, error: &str, cx: &mut App) {
|
||||
log::warn!("mail: {key}");
|
||||
let words = text(cx, key, &[("error", error)]);
|
||||
self.toast(words, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,25 @@
|
||||
// Release builds open no console on Windows.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod add_view;
|
||||
mod adding;
|
||||
mod boxes;
|
||||
mod controls;
|
||||
mod flow;
|
||||
mod kinds;
|
||||
mod mail;
|
||||
mod menu;
|
||||
mod panes;
|
||||
mod removing;
|
||||
mod side;
|
||||
mod syncing;
|
||||
mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use ely_platform::AppId;
|
||||
|
||||
fn main() {
|
||||
ely_ui::run(AppId::Mail, ely_ui::blank);
|
||||
ely_ui::run(AppId::Mail, mail::open);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The Account and Sync menus.
|
||||
|
||||
use gpui::{App, Menu, MenuItem, actions};
|
||||
|
||||
use crate::mail::text;
|
||||
|
||||
actions!(mail, [AddAccount, RemoveAccount, SyncAll]);
|
||||
|
||||
pub const CONTEXT: &str = "mail";
|
||||
|
||||
pub fn menus(cx: &App) -> Vec<Menu> {
|
||||
vec![
|
||||
Menu::new(text(cx, "mail.menu.account", &[])).items([
|
||||
MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount),
|
||||
MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount),
|
||||
]),
|
||||
Menu::new(text(cx, "mail.menu.sync", &[]))
|
||||
.items([MenuItem::action(text(cx, "mail.sync.now", &[]), SyncAll)]),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! The message list and the reading pane (C12.9).
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{ToggleGroup, ToggleItem},
|
||||
data_display::DescriptionList,
|
||||
feedback::{EmptyState, ErrorView},
|
||||
layout::SplitPane,
|
||||
mail::MailList,
|
||||
primitives::IconName,
|
||||
};
|
||||
use ely_mail_engine::Filter;
|
||||
use gpui::{
|
||||
AnyElement, Axis, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
mail::{Mail, text},
|
||||
view::local_time,
|
||||
};
|
||||
|
||||
type Filtered = fn(Filter) -> bool;
|
||||
|
||||
const FILTERS: [(&str, &str, IconName, Filtered); 3] = [
|
||||
("unread", "mail.filter.unread", IconName::Mail, |f| f.unread),
|
||||
("flagged", "mail.filter.flagged", IconName::Star, |f| {
|
||||
f.flagged
|
||||
}),
|
||||
(
|
||||
"attachments",
|
||||
"mail.filter.attachments",
|
||||
IconName::Paperclip,
|
||||
|f| f.attachments,
|
||||
),
|
||||
];
|
||||
|
||||
impl Mail {
|
||||
pub fn panes(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
SplitPane::new("mail-panes", Axis::Horizontal, px(240.))
|
||||
.pane(self.list_pane(cx))
|
||||
.pane(self.reader(cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn list_pane(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let on: Vec<&str> = FILTERS
|
||||
.iter()
|
||||
.filter(|(_, _, _, on)| on(self.filter))
|
||||
.map(|(key, _, _, _)| *key)
|
||||
.collect();
|
||||
let toggles = FILTERS.iter().fold(
|
||||
ToggleGroup::new("mail-filters").multiple().selected(on),
|
||||
|group, (key, label, icon, _)| {
|
||||
let words = text(cx, label, &[]);
|
||||
group.item(ToggleItem::new(*key).icon(*icon).label(words))
|
||||
},
|
||||
);
|
||||
let this = cx.weak_entity();
|
||||
let toggles = toggles.on_change(move |keys, _, cx| {
|
||||
let has = |key: &str| keys.iter().any(|each| each == key);
|
||||
let filter = Filter {
|
||||
unread: has("unread"),
|
||||
flagged: has("flagged"),
|
||||
attachments: has("attachments"),
|
||||
};
|
||||
this.update(cx, |mail, cx| mail.set_filter(filter, cx)).ok();
|
||||
});
|
||||
let list = match &self.listing.mails {
|
||||
None => div().into_any_element(),
|
||||
Some(Err(detail)) => ErrorView::new(
|
||||
"mail-list-error",
|
||||
IconName::TriangleAlert,
|
||||
text(cx, "mail.list.unread", &[]),
|
||||
)
|
||||
.detail(detail.clone())
|
||||
.into_any_element(),
|
||||
Some(Ok(mails)) => {
|
||||
let this = cx.weak_entity();
|
||||
MailList::new("mail-list", mails.clone())
|
||||
.selected(self.listing.selected.clone())
|
||||
.on_select(move |keys, _, cx| {
|
||||
this.update(cx, |mail, cx| mail.select(keys, cx)).ok();
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(div().p_2().child(toggles))
|
||||
.child(div().flex_1().min_h_0().child(list))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn reader(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(row) = &self.listing.chosen else {
|
||||
return EmptyState::new(
|
||||
"mail-reader-empty",
|
||||
IconName::Mail,
|
||||
text(cx, "mail.reader.none", &[]),
|
||||
)
|
||||
.into_any_element();
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let date: SharedString = match row.at {
|
||||
Some(at) => local_time(at.as_second()).into(),
|
||||
None => label("mail.reader.no_date"),
|
||||
};
|
||||
let details = DescriptionList::new()
|
||||
.text(label("mail.reader.from"), row.from.clone())
|
||||
.text(label("mail.reader.date"), date);
|
||||
div()
|
||||
.p_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(div().text_xl().child(row.subject.clone()))
|
||||
.child(details)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Removing an account: its local data and secret only.
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_mail_engine::{AccountId, Cached, Error, Kept};
|
||||
use gpui::Context;
|
||||
|
||||
use crate::mail::{Mail, text};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Removing {
|
||||
pub id: AccountId,
|
||||
pub address: String,
|
||||
pub cached: Cached,
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
/// Measures what goes, then asks.
|
||||
pub fn ask_remove(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(id) = self.open.account() else {
|
||||
let words = text(cx, "mail.remove.none", &[]);
|
||||
return self.toast(words, Severity::Info, cx);
|
||||
};
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.cached(id),
|
||||
move |mail, cached, cx| {
|
||||
let address = mail
|
||||
.accounts()
|
||||
.iter()
|
||||
.find(|(each, _)| *each == id)
|
||||
.map(|(_, account)| account.address.clone());
|
||||
match (cached, address) {
|
||||
(Ok(cached), Some(address))
|
||||
if mail.adding.is_none() && mail.removing.is_none() =>
|
||||
{
|
||||
mail.removing = Some(Removing {
|
||||
id,
|
||||
address,
|
||||
cached,
|
||||
});
|
||||
}
|
||||
(Ok(_), _) => {
|
||||
log::info!("mail: removal asked of a gone account or over a dialog")
|
||||
}
|
||||
(Err(error), _) => mail.failed("mail.remove.failed", &error, cx),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The secret goes first, and back if the rest fails.
|
||||
pub fn remove(&mut self, removing: Removing, cx: &mut Context<Self>) {
|
||||
let Some((_, account)) = self
|
||||
.accounts()
|
||||
.iter()
|
||||
.find(|(each, _)| *each == removing.id)
|
||||
else {
|
||||
return log::info!("mail: the account to remove is already gone");
|
||||
};
|
||||
let (id, name, vault) = (removing.id, account.secret_name(), self.vault.clone());
|
||||
self.stop_sync(id);
|
||||
let address = removing.address;
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| {
|
||||
let secret = vault.get(&name)?;
|
||||
vault.remove(&name)?;
|
||||
store.remove_account(id).map_err(|error| {
|
||||
match secret.map(|secret| vault.put(&name, &secret)) {
|
||||
Some(Kept::Session(reason)) => Error::Vault(format!(
|
||||
"{error}; its secret is back for this session only: {reason}"
|
||||
)),
|
||||
_ => error,
|
||||
}
|
||||
})
|
||||
},
|
||||
move |mail, removed, cx| {
|
||||
match removed {
|
||||
Ok(()) => {
|
||||
log::info!("mail: account {} removed", id.0);
|
||||
mail.toast(
|
||||
text(cx, "mail.remove.done", &[("address", &address)]),
|
||||
Severity::Success,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
mail.failed("mail.remove.failed", &error, cx);
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
}
|
||||
mail.read_accounts(cx);
|
||||
},
|
||||
);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Every inbox, then each account's folders, down the left.
|
||||
|
||||
use ely_gpui_component::{
|
||||
layout::ScrollArea,
|
||||
mail::{Mailbox, MailboxList},
|
||||
primitives::IconName,
|
||||
};
|
||||
use ely_mail_engine::{LocalFolder, Role};
|
||||
use gpui::{
|
||||
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, WeakEntity,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
boxes::Open,
|
||||
mail::{Mail, text},
|
||||
};
|
||||
|
||||
pub struct Side {
|
||||
mail: WeakEntity<Mail>,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn new(mail: WeakEntity<Mail>) -> Self {
|
||||
Self { mail }
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Side {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(mail) = self.mail.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let mail = mail.read(cx);
|
||||
let inboxes = Mailbox::new(
|
||||
Open::Inboxes.key(),
|
||||
text(cx, "mail.boxes.all", &[]),
|
||||
IconName::Inbox,
|
||||
)
|
||||
.unread(mail.inboxes_unread());
|
||||
let mut list =
|
||||
MailboxList::new("mail-boxes").section(text(cx, "mail.boxes.unified", &[]), [inboxes]);
|
||||
for (id, account) in mail.accounts() {
|
||||
let folders = mail.syncs.get(id).map_or(&[][..], |each| &each.folders);
|
||||
let mut sorted: Vec<&LocalFolder> = folders.iter().collect();
|
||||
sorted.sort_by(|a, b| rank(a.role).cmp(&rank(b.role)).then(a.name.cmp(&b.name)));
|
||||
let boxes = sorted.into_iter().map(|folder| {
|
||||
let open = Open::Mailbox(*id, folder.id);
|
||||
// Boxes with nothing unread are absent.
|
||||
let unread = mail.unread.get(&folder.id).copied().unwrap_or(0);
|
||||
Mailbox::new(open.key(), name(folder, cx), icon(folder.role)).unread(unread)
|
||||
});
|
||||
let settings = Mailbox::new(
|
||||
Open::Account(*id).key(),
|
||||
text(cx, "mail.boxes.settings", &[]),
|
||||
IconName::Settings,
|
||||
);
|
||||
list = list.section(account.address.clone(), boxes.chain([settings]));
|
||||
}
|
||||
let this = self.mail.clone();
|
||||
let list = list.open(mail.open.key()).on_open(move |key, _, cx| {
|
||||
this.update(cx, |mail, cx| match mail.opened(key) {
|
||||
Some(open) => mail.open_box(open, cx),
|
||||
None => log::error!("mail: the mailbox list sent an unknown box"),
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
ScrollArea::new("mail-side-scroll")
|
||||
.vertical()
|
||||
.size_full()
|
||||
.child(div().p_2().child(list))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
/// The box a side key names, if still here.
|
||||
fn opened(&self, key: &SharedString) -> Option<Open> {
|
||||
if *key == Open::Inboxes.key() {
|
||||
return Some(Open::Inboxes);
|
||||
}
|
||||
self.accounts().iter().find_map(|(id, _)| {
|
||||
let folders = self.syncs.get(id).map_or(&[][..], |each| &each.folders);
|
||||
let boxes = folders.iter().map(|folder| Open::Mailbox(*id, folder.id));
|
||||
boxes
|
||||
.chain([Open::Account(*id)])
|
||||
.find(|open| open.key() == *key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox in the reader's words; others as named.
|
||||
fn name(folder: &LocalFolder, cx: &App) -> SharedString {
|
||||
match folder.role {
|
||||
Some(Role::Inbox) => text(cx, "mail.boxes.inbox", &[]),
|
||||
_ => folder.name.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rank(role: Option<Role>) -> u8 {
|
||||
match role {
|
||||
Some(Role::Inbox) => 0,
|
||||
Some(Role::Flagged) => 1,
|
||||
Some(Role::Drafts) => 2,
|
||||
Some(Role::Sent) => 3,
|
||||
Some(Role::Archive) => 4,
|
||||
Some(Role::All) => 5,
|
||||
Some(Role::Junk) => 6,
|
||||
Some(Role::Trash) => 7,
|
||||
None => 8,
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(role: Option<Role>) -> IconName {
|
||||
match role {
|
||||
Some(Role::Inbox) => IconName::Inbox,
|
||||
Some(Role::Flagged) => IconName::Star,
|
||||
Some(Role::Drafts) => IconName::File,
|
||||
Some(Role::Sent) => IconName::Send,
|
||||
Some(Role::Archive) => IconName::Archive,
|
||||
Some(Role::All) => IconName::Mail,
|
||||
Some(Role::Junk) => IconName::TriangleAlert,
|
||||
Some(Role::Trash) => IconName::Trash2,
|
||||
None => IconName::Folder,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Accounts sync on open, on demand and on a timer.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Protocol, Synced, sync};
|
||||
use gpui::Context;
|
||||
use tokio::task::AbortHandle;
|
||||
|
||||
use crate::mail::Mail;
|
||||
|
||||
/// How often every account syncs while the window is open.
|
||||
pub const EVERY: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// One account's sync as the window shows it.
|
||||
#[derive(Default)]
|
||||
pub struct Syncing {
|
||||
pub task: Option<AbortHandle>,
|
||||
/// Which sync `task` is; older completions are stale.
|
||||
pub run: u64,
|
||||
/// A folder changed mid-sync: sync once more.
|
||||
pub again: bool,
|
||||
pub synced: Option<Synced>,
|
||||
pub failed: Option<String>,
|
||||
pub folders: Vec<LocalFolder>,
|
||||
}
|
||||
|
||||
pub type Syncs = HashMap<AccountId, Syncing>;
|
||||
|
||||
impl Mail {
|
||||
pub fn sync(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
if self.paused {
|
||||
return log::info!("mail: account {} not synced: paused", id.0);
|
||||
}
|
||||
let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else {
|
||||
return log::info!("mail: sync asked of a gone account");
|
||||
};
|
||||
if self.syncs.get(&id).is_some_and(|each| each.task.is_some()) {
|
||||
return log::info!("mail: account {} is already syncing", id.0);
|
||||
}
|
||||
let Ok(store) = self.store.clone() else {
|
||||
return log::error!("mail: no store to sync with");
|
||||
};
|
||||
let tls = match self.tls.clone() {
|
||||
Ok(tls) => tls,
|
||||
Err(error) => {
|
||||
log::warn!("mail: account {} cannot sync without TLS", id.0);
|
||||
self.syncs.entry(id).or_default().failed = Some(error);
|
||||
return self.read_synced(id, cx);
|
||||
}
|
||||
};
|
||||
let (account, vault) = (account.clone(), self.vault.clone());
|
||||
self.runs += 1;
|
||||
let run = self.runs;
|
||||
self.syncs.entry(id).or_default().run = run;
|
||||
let work = async move {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| ely_mail_engine::Error::Store(format!("the clock: {err}")))?
|
||||
.as_secs() as i64;
|
||||
let secret = sync::secret(&account, &vault).await?;
|
||||
match account.protocol {
|
||||
Protocol::Imap => sync::imap(&account, id, secret, &tls, &store, now).await,
|
||||
Protocol::Pop { .. } => sync::pop(&account, id, secret, &tls, &store, now).await,
|
||||
}
|
||||
};
|
||||
let task = self.on_runtime(cx, work, move |mail, done, cx| {
|
||||
let failed = match done {
|
||||
Ok(Ok(_)) => None,
|
||||
Ok(Err(error)) => Some(error.to_string()),
|
||||
Err(error) => Some(error),
|
||||
};
|
||||
if failed.is_some() {
|
||||
log::warn!("mail: account {} did not sync", id.0);
|
||||
}
|
||||
let Some(syncing) = mail.syncs.get_mut(&id) else {
|
||||
return log::info!("mail: a sync ended for a removed account");
|
||||
};
|
||||
if syncing.run != run {
|
||||
return log::info!("mail: account {} sync {run} ended after a newer one", id.0);
|
||||
}
|
||||
syncing.task = None;
|
||||
syncing.failed = failed;
|
||||
let again = std::mem::take(&mut syncing.again);
|
||||
mail.read_synced(id, cx);
|
||||
if again {
|
||||
log::info!("mail: account {} syncs again for a changed folder", id.0);
|
||||
mail.sync(id, cx);
|
||||
}
|
||||
});
|
||||
self.syncs.entry(id).or_default().task = Some(task);
|
||||
self.read_synced(id, cx);
|
||||
}
|
||||
|
||||
pub fn sync_all(&mut self, cx: &mut Context<Self>) {
|
||||
if self.paused {
|
||||
return log::info!("mail: sync is paused");
|
||||
}
|
||||
let ids: Vec<_> = self.accounts().iter().map(|(id, _)| *id).collect();
|
||||
for id in ids {
|
||||
self.sync(id, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pausing stops running syncs; resuming syncs every account.
|
||||
pub fn set_paused(&mut self, paused: bool, cx: &mut Context<Self>) {
|
||||
log::info!("mail: sync paused: {paused}");
|
||||
self.paused = paused;
|
||||
if paused {
|
||||
for syncing in self.syncs.values_mut() {
|
||||
syncing.again = false;
|
||||
if let Some(task) = syncing.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.sync_all(cx);
|
||||
}
|
||||
self.notify(cx);
|
||||
}
|
||||
|
||||
/// Syncs every account each `EVERY` until the window goes.
|
||||
pub fn sync_on_a_timer(cx: &mut Context<Self>) {
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(EVERY).await;
|
||||
if this.update(cx, |mail, cx| mail.sync_all(cx)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Takes the folder's mail whole, then syncs to fetch it.
|
||||
pub fn set_offline(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
mailbox: MailboxId,
|
||||
offline: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| store.set_offline(mailbox, offline),
|
||||
move |mail, done, cx| match done {
|
||||
Ok(()) => {
|
||||
mail.read_synced(id, cx);
|
||||
match mail.syncs.get_mut(&id) {
|
||||
Some(syncing) if offline && syncing.task.is_some() => {
|
||||
log::info!("mail: account {} syncs again after this one", id.0);
|
||||
syncing.again = true;
|
||||
}
|
||||
_ if offline => mail.sync(id, cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(error) => mail.failed("mail.sync.offline_failed", &error, cx),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops the account's sync and forgets it.
|
||||
pub fn stop_sync(&mut self, id: AccountId) {
|
||||
if let Some(task) = self.syncs.remove(&id).and_then(|each| each.task) {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn read_synced(&mut self, id: AccountId, cx: &mut Context<Self>) {
|
||||
self.with_store(
|
||||
cx,
|
||||
move |store| Ok((store.synced(id)?, store.folders(id)?)),
|
||||
move |mail, read, cx| match (read, mail.syncs.get_mut(&id)) {
|
||||
(Ok((synced, folders)), Some(syncing)) => {
|
||||
syncing.synced = Some(synced);
|
||||
syncing.folders = folders;
|
||||
mail.read_mail(cx);
|
||||
}
|
||||
(Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx),
|
||||
(_, None) => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Accounts in the window: add, check, keep, remove (C12.5).
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{
|
||||
Account, Auth, Backend, Checked, Protocol, Security, Server, Store, TlsConnector, Vault,
|
||||
};
|
||||
use gpui::{App, Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use crate::{
|
||||
adding::Step,
|
||||
kinds::Kind,
|
||||
mail::{Mail, Parts},
|
||||
};
|
||||
|
||||
mod controls;
|
||||
mod flows;
|
||||
mod panes;
|
||||
mod syncs;
|
||||
|
||||
/// A credential store that keeps or refuses.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Keys {
|
||||
pub kept: Arc<Mutex<HashMap<String, String>>>,
|
||||
pub refuse: Option<&'static str>,
|
||||
pub deletes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Backend for Keys {
|
||||
fn get(&self, name: &str) -> Result<Option<String>, String> {
|
||||
Ok(self.kept.lock().unwrap().get(name).cloned())
|
||||
}
|
||||
|
||||
fn set(&self, name: &str, secret: &str) -> Result<(), String> {
|
||||
if let Some(reason) = self.refuse {
|
||||
return Err(reason.into());
|
||||
}
|
||||
self.kept.lock().unwrap().insert(name.into(), secret.into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, name: &str) -> Result<(), String> {
|
||||
self.deletes.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(reason) = self.refuse {
|
||||
return Err(reason.into());
|
||||
}
|
||||
self.kept.lock().unwrap().remove(name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
if cx.update(|_, app| done(app)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the state never settled");
|
||||
}
|
||||
|
||||
pub(crate) fn window<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
dir: &Path,
|
||||
keys: Keys,
|
||||
) -> (Entity<Mail>, &'a mut VisualTestContext) {
|
||||
let tls = ely_mail_engine::system_tls().map_err(|err| err.to_string());
|
||||
window_with(cx, dir, keys, tls)
|
||||
}
|
||||
|
||||
pub(crate) fn window_with<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
dir: &Path,
|
||||
keys: Keys,
|
||||
tls: Result<TlsConnector, String>,
|
||||
) -> (Entity<Mail>, &'a mut VisualTestContext) {
|
||||
cx.executor().allow_parking();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
cx.update(|cx| {
|
||||
ely_ui::settings::install_defaults(cx);
|
||||
ely_ui::i18n::install_as("en-US", cx);
|
||||
// As tool_root does: menus before the window.
|
||||
let menus = crate::menu::menus(cx);
|
||||
let menus = ely_ui::commands::menus(
|
||||
ely_platform::AppId::Mail,
|
||||
&ely_ui::commands::OPENING,
|
||||
false,
|
||||
menus,
|
||||
cx,
|
||||
);
|
||||
cx.set_menus(menus);
|
||||
});
|
||||
let parts = Parts {
|
||||
store: Store::open(dir).map_err(|err| err.to_string()),
|
||||
vault: Vault::new(Box::new(keys)),
|
||||
tls,
|
||||
};
|
||||
let (mail, cx) = cx.add_window_view(move |_, cx| Mail::new(parts, cx));
|
||||
settle(cx, |app| mail.read(app).accounts.is_some());
|
||||
(mail, cx)
|
||||
}
|
||||
|
||||
/// A port nothing listens on.
|
||||
pub(crate) fn closed_port() -> u16 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
/// On this machine's discard port, so syncs end at once.
|
||||
pub(crate) fn account(address: &str) -> Account {
|
||||
let server = |port| Server {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
security: Security::Tls,
|
||||
};
|
||||
Account {
|
||||
address: address.into(),
|
||||
user: address.into(),
|
||||
auth: Auth::Password,
|
||||
protocol: Protocol::Imap,
|
||||
incoming: server(9),
|
||||
outgoing: server(9),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the dialog on `kind`.
|
||||
pub(crate) fn adding(mail: &Entity<Mail>, kind: Kind, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.start_adding(window, cx);
|
||||
mail.adding.as_mut().unwrap().set_kind(kind, cx);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn type_in(
|
||||
mail: &Entity<Mail>,
|
||||
cx: &mut VisualTestContext,
|
||||
pick: fn(&crate::adding::Fields) -> &Entity<ely_gpui_component::forms::TextInput>,
|
||||
words: &str,
|
||||
) {
|
||||
cx.update(|_, app| {
|
||||
let field = pick(&mail.read(app).adding.as_ref().unwrap().fields).clone();
|
||||
field.update(app, |field, cx| field.set_text(words.to_owned(), cx));
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// A passed check, as servers would answer.
|
||||
pub(crate) fn passed(
|
||||
mail: &Entity<Mail>,
|
||||
account: Account,
|
||||
kept: &str,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, _| {
|
||||
mail.adding.as_mut().unwrap().step = Step::Checked {
|
||||
account,
|
||||
kept: kept.into(),
|
||||
checked: Checked {
|
||||
incoming: Ok(()),
|
||||
outgoing: Ok(()),
|
||||
},
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn step_words(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> String {
|
||||
cx.update(
|
||||
|_, app| match &mail.read(app).adding.as_ref().unwrap().step {
|
||||
Step::Failed(words) => words.to_string(),
|
||||
Step::Editing => "editing".into(),
|
||||
Step::Signing => "signing".into(),
|
||||
Step::Checking => "checking".into(),
|
||||
Step::Saving => "saving".into(),
|
||||
Step::Checked { .. } => "checked".into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
use std::{
|
||||
net::TcpListener,
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Role, Store};
|
||||
use gpui::{
|
||||
Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, size,
|
||||
};
|
||||
|
||||
use super::{Keys, account, settle, window};
|
||||
use crate::{boxes::Open, mail::Mail, syncing::EVERY, view::imap_range};
|
||||
|
||||
/// An account on a server that never answers.
|
||||
fn silent(dir: &std::path::Path, port: u16) -> AccountId {
|
||||
let mut ann = account("ann@example.com");
|
||||
ann.incoming.port = port;
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let id = store.add_account(&ann).unwrap();
|
||||
store
|
||||
.set_folders(id, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn with_secret() -> Keys {
|
||||
let keys = Keys::default();
|
||||
let secret = ("account:ann@example.com".to_owned(), "pw".to_owned());
|
||||
keys.kept.lock().unwrap().extend([secret]);
|
||||
keys
|
||||
}
|
||||
|
||||
/// Waits on the network task alone, not the window.
|
||||
fn finished(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let done = |cx: &mut VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let task = mail.read(app).syncs[&id].task.as_ref();
|
||||
task.unwrap().is_finished()
|
||||
})
|
||||
};
|
||||
while !done(cx) {
|
||||
assert!(Instant::now() < deadline, "the sync never ended");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// An account whose sync fails at once: no secret saved.
|
||||
fn failing(dir: &std::path::Path) -> AccountId {
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
store
|
||||
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
ann
|
||||
}
|
||||
|
||||
fn failed(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) -> bool {
|
||||
cx.update(|_, app| mail.read(app).syncs[&id].failed.is_some())
|
||||
}
|
||||
|
||||
fn forget(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, _| {
|
||||
mail.syncs.get_mut(&id).unwrap().failed = None
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn settled_failure(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&id)
|
||||
.is_some_and(|each| each.failed.is_some() && each.task.is_none())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_account_syncs_again_on_the_timer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settled_failure(&mail, ann, cx);
|
||||
forget(&mail, ann, cx);
|
||||
cx.executor().advance_clock(EVERY);
|
||||
settled_failure(&mail, ann, cx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paused_sync_waits_until_resumed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settled_failure(&mail, ann, cx);
|
||||
forget(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
|
||||
cx.executor().advance_clock(EVERY);
|
||||
cx.run_until_parked();
|
||||
assert!(!failed(&mail, ann, cx), "a paused account synced");
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(false, cx)));
|
||||
settled_failure(&mail, ann, cx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_kept_offline_is_saved_and_synced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.failed.is_some() && !each.folders.is_empty())
|
||||
});
|
||||
let inbox = cx.update(|_, app| mail.read(app).syncs[&ann].folders[0].clone());
|
||||
assert!(!inbox.offline);
|
||||
forget(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox.id, true, cx)));
|
||||
settle(cx, |app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
syncing.folders[0].offline && syncing.failed.is_some() && syncing.task.is_none()
|
||||
});
|
||||
assert!(Store::open(dir.path()).unwrap().offline(inbox.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_end_queued_across_a_resume_keeps_the_new_sync() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
settled_failure(&mail, ann, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
|
||||
finished(&mail, ann, cx);
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let resumed = cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.set_paused(true, cx);
|
||||
mail.set_paused(false, cx);
|
||||
mail.syncs[&ann].task.clone().unwrap()
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
|
||||
"an old end cleared the new sync"
|
||||
);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !resumed.is_finished() {
|
||||
assert!(Instant::now() < deadline, "pausing left the sync running");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_end_after_a_failed_removal_keeps_the_new_sync() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
settled_failure(&mail, ann, cx);
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.stop_sync(ann);
|
||||
mail.sync(ann, cx);
|
||||
})
|
||||
});
|
||||
finished(&mail, ann, cx);
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let restarted = cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.stop_sync(ann);
|
||||
mail.sync(ann, cx);
|
||||
mail.syncs[&ann].task.clone().unwrap()
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
|
||||
"an old end cleared the restarted sync"
|
||||
);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !restarted.is_finished() {
|
||||
assert!(Instant::now() < deadline, "pausing left the sync running");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_kept_offline_mid_sync_syncs_once_more() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (hang_up, hung) = mpsc::channel::<()>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let held = listener.accept().unwrap();
|
||||
hung.recv().unwrap();
|
||||
drop((held, listener));
|
||||
});
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = silent(dir.path(), port);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), with_secret());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.task.is_some() && !each.folders.is_empty())
|
||||
});
|
||||
let (inbox, run) = cx.update(|_, app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
(syncing.folders[0].id, syncing.run)
|
||||
});
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox, true, cx)));
|
||||
settle(cx, |app| mail.read(app).syncs[&ann].again);
|
||||
hang_up.send(()).unwrap();
|
||||
server.join().unwrap();
|
||||
settle(cx, |app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
syncing.run == run + 1 && syncing.task.is_none() && syncing.failed.is_some()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_account_page_scrolls_to_its_last_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = failing(dir.path());
|
||||
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
|
||||
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
|
||||
let last = *Store::open(dir.path())
|
||||
.unwrap()
|
||||
.set_folders(ann, &folders)
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(ann), cx)));
|
||||
cx.simulate_resize(size(px(1100.), px(800.)));
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.folders.len() == 40)
|
||||
});
|
||||
let selector: &'static str = format!("mail-offline-{}", last.0).leak();
|
||||
let height = cx.update(|window, _| window.viewport_size().height);
|
||||
let before = cx
|
||||
.debug_bounds(selector)
|
||||
.expect("the last switch is laid out");
|
||||
assert!(before.bottom() > height, "the folders fit the window");
|
||||
let at = point(px(400.), height / 2.0);
|
||||
for _ in 0..20 {
|
||||
cx.simulate_event(ScrollWheelEvent {
|
||||
position: at,
|
||||
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
|
||||
..Default::default()
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let after = cx
|
||||
.debug_bounds(selector)
|
||||
.expect("the last switch is laid out");
|
||||
assert!(
|
||||
after.bottom() <= height,
|
||||
"the last switch is out of reach: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_range_names_the_folders_kept_whole() {
|
||||
let folder = |id, name: &str, offline| LocalFolder {
|
||||
id: MailboxId(id),
|
||||
name: name.into(),
|
||||
role: None,
|
||||
offline,
|
||||
};
|
||||
let cx = TestAppContext::single();
|
||||
cx.update(|app| {
|
||||
ely_ui::i18n::install_as("en-US", app);
|
||||
let rows = |folders: &[LocalFolder]| -> Vec<String> {
|
||||
imap_range(folders, app)
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}: {value}"))
|
||||
.collect()
|
||||
};
|
||||
let both = [folder(1, "INBOX", true), folder(2, "Sent", false), folder(3, "Work", true)];
|
||||
assert_eq!(
|
||||
rows(&both),
|
||||
[
|
||||
"Downloaded: Other folders: headers of all mail; text from the last 90 days; attachments when asked",
|
||||
"Kept whole: Every message in INBOX, Work, with attachments",
|
||||
]
|
||||
);
|
||||
assert_eq!(rows(&[folder(1, "INBOX", true)]), ["Kept whole: Every message in INBOX, with attachments"]);
|
||||
assert_eq!(
|
||||
rows(&[folder(2, "Sent", false)]),
|
||||
["Downloaded: Headers of all mail; text from the last 90 days; attachments when asked"]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_needs_an_open_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert!(mail.removing.is_none());
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use ely_mail_engine::{Auth, Error, Fetched, Key, Protocol, Security, Store, oauth};
|
||||
use gpui::{Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{Keys, account, adding, closed_port, passed, settle, step_words, type_in, window};
|
||||
use crate::{adding::Step, boxes::Open, kinds::Kind, mail::Mail};
|
||||
|
||||
#[test]
|
||||
fn an_imap_form_says_what_is_missing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
let check = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)))
|
||||
};
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the email address.");
|
||||
type_in(&mail, cx, |f| &f.address, "ann@example.com");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the user name.");
|
||||
type_in(&mail, cx, |f| &f.user, "ann");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type both server names.");
|
||||
type_in(&mail, cx, |f| &f.incoming, "imap.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "70000");
|
||||
check(cx);
|
||||
assert_eq!(
|
||||
step_words(&mail, cx),
|
||||
"70000 is not a port; use 1 to 65535."
|
||||
);
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "587");
|
||||
check(cx);
|
||||
assert_eq!(step_words(&mail, cx), "Type the password.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_follow_security_but_a_typed_one_stays() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Pop, cx);
|
||||
let ports = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let fields = &mail.read(app).adding.as_ref().unwrap().fields;
|
||||
(
|
||||
fields.incoming_port.read(app).text().to_owned(),
|
||||
fields.outgoing_port.read(app).text().to_owned(),
|
||||
)
|
||||
})
|
||||
};
|
||||
assert_eq!(ports(cx), ("995".into(), "587".into()));
|
||||
let secure = |cx: &mut gpui::VisualTestContext, outgoing, security| {
|
||||
cx.update(|_, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.adding
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.set_security(outgoing, security, cx)
|
||||
})
|
||||
});
|
||||
};
|
||||
secure(cx, false, Security::StartTls);
|
||||
assert_eq!(ports(cx), ("110".into(), "587".into()));
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, "2525");
|
||||
secure(cx, true, Security::Tls);
|
||||
assert_eq!(ports(cx), ("110".into(), "2525".into()));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.close_adding(cx)));
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
assert_eq!(ports(cx), ("993".into(), "587".into()));
|
||||
type_in(&mail, cx, |f| &f.incoming_port, "995");
|
||||
secure(cx, true, Security::Tls);
|
||||
assert_eq!(ports(cx), ("995".into(), "465".into()));
|
||||
secure(cx, false, Security::StartTls);
|
||||
assert_eq!(ports(cx), ("995".into(), "465".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_reports_each_server() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "ann@example.com");
|
||||
type_in(&mail, cx, |f| &f.user, "ann");
|
||||
type_in(&mail, cx, |f| &f.password, "pw");
|
||||
type_in(&mail, cx, |f| &f.incoming, "127.0.0.1");
|
||||
type_in(&mail, cx, |f| &f.incoming_port, &closed_port().to_string());
|
||||
type_in(&mail, cx, |f| &f.outgoing, "127.0.0.1");
|
||||
type_in(&mail, cx, |f| &f.outgoing_port, &closed_port().to_string());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)));
|
||||
assert_eq!(step_words(&mail, cx), "checking");
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
mail.read(app).adding.as_ref().unwrap().step,
|
||||
Step::Checked { .. }
|
||||
)
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let Step::Checked {
|
||||
checked,
|
||||
account,
|
||||
kept,
|
||||
} = &mail.read(app).adding.as_ref().unwrap().step
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
matches!(checked.incoming, Err(Error::Connect(_))),
|
||||
"{:?}",
|
||||
checked.incoming
|
||||
);
|
||||
assert!(
|
||||
matches!(checked.outgoing, Err(Error::Connect(_))),
|
||||
"{:?}",
|
||||
checked.outgoing
|
||||
);
|
||||
assert_eq!(
|
||||
(account.user.as_str(), account.protocol, kept.as_str()),
|
||||
("ann", Protocol::Imap, "pw")
|
||||
);
|
||||
let said = crate::add_view::failure(checked.incoming.as_ref().unwrap_err(), app);
|
||||
assert!(said.starts_with("cannot connect ("), "{said}");
|
||||
});
|
||||
type_in(&mail, cx, |f| &f.password, "pw2");
|
||||
assert_eq!(step_words(&mail, cx), "editing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icloud_account_takes_apples_servers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::ICloud, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "ann@icloud.com");
|
||||
let account = cx
|
||||
.update(|_, app| mail.read(app).adding.as_ref().unwrap().account(None, app))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(account.incoming.host.as_str(), account.incoming.port),
|
||||
("imap.mail.me.com", 993)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
account.outgoing.host.as_str(),
|
||||
account.outgoing.port,
|
||||
account.outgoing.security
|
||||
),
|
||||
("smtp.mail.me.com", 587, Security::StartTls)
|
||||
);
|
||||
assert_eq!(
|
||||
(account.user.as_str(), account.auth),
|
||||
("ann@icloud.com", Auth::Password)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_without_a_client_id_says_so() {
|
||||
if oauth::google().is_ok() {
|
||||
return eprintln!("SKIP: this build has a Google client ID");
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Gmail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.sign_in(cx)));
|
||||
assert_eq!(
|
||||
step_words(&mail, cx),
|
||||
"Gmail sign-in is not in this build: it was built without a Google client ID."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_keychain_keeps_the_account_and_says_why() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys {
|
||||
refuse: Some("locked"),
|
||||
..Keys::default()
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
settle(cx, |app| mail.read(app).adding.is_none());
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
assert_eq!(mail.open, Open::Account(mail.accounts()[0].0));
|
||||
assert_eq!(
|
||||
mail.vault
|
||||
.get("account:ann@example.com")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("pw")
|
||||
);
|
||||
});
|
||||
assert!(keys.kept.lock().unwrap().is_empty());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| {
|
||||
let mail = mail.read(app);
|
||||
assert_eq!(mail.accounts().len(), 1);
|
||||
assert_eq!(
|
||||
mail.vault
|
||||
.get("account:ann@example.com")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("pw")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_is_kept_and_removed_with_its_secret() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys::default();
|
||||
{
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
settle(cx, |app| mail.read(app).adding.is_none());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ANN@example.com"), "pw", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
}
|
||||
assert_eq!(
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("account:ann@example.com")
|
||||
.map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).accounts()[0].1, account("ann@example.com")));
|
||||
open_first(&mail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
cx.update(|_, app| {
|
||||
let removing = mail.read(app).removing.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(removing.address.as_str(), removing.cached.messages),
|
||||
("ann@example.com", 0)
|
||||
);
|
||||
});
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |app| mail.read(app).accounts().is_empty());
|
||||
assert!(keys.kept.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_secret_removal_keeps_the_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let keys = Keys {
|
||||
refuse: Some("locked"),
|
||||
..Keys::default()
|
||||
};
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
open_first(&mail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
assert_eq!(
|
||||
Store::open(dir.path()).unwrap().accounts().unwrap().len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_removal_puts_the_secret_back() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let inbox = store.mailbox(ann, "INBOX").unwrap();
|
||||
let raw: &[u8] = b"Subject: Kept
|
||||
|
||||
still here
|
||||
";
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid: 1,
|
||||
};
|
||||
let kept = store.put(inbox, &key, &[], Fetched::Whole(raw)).unwrap();
|
||||
drop(store);
|
||||
let keys = Keys::default();
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("account:ann@example.com".into(), "pw".into());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
// A folder where the original would be set aside.
|
||||
let shard = std::fs::read_dir(dir.path().join("content"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let name = std::fs::read_dir(&shard)
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.file_name();
|
||||
std::fs::create_dir(shard.join(format!(".{}.gone", name.to_str().unwrap()))).unwrap();
|
||||
open_first(&mail, cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
|
||||
settle(cx, |app| mail.read(app).removing.is_some());
|
||||
press_remove(&mail, cx);
|
||||
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get("account:ann@example.com")
|
||||
.map(String::as_str),
|
||||
Some("pw")
|
||||
);
|
||||
let store = store.lock().unwrap();
|
||||
assert_eq!(store.accounts().unwrap().len(), 1);
|
||||
assert_eq!(store.original(kept).unwrap().unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_saves_in_flight_add_an_address_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let keys = Keys::default();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.save(cx);
|
||||
mail.close_adding(cx);
|
||||
mail.start_adding(window, cx);
|
||||
})
|
||||
});
|
||||
passed(&mail, account("ANN@example.com"), "pw2", cx);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
|
||||
// Either save may reach the store first.
|
||||
settle(cx, |app| {
|
||||
let mail = mail.read(app);
|
||||
mail.accounts().len() == 1
|
||||
&& !matches!(
|
||||
mail.adding.as_ref().map(|adding| &adding.step),
|
||||
Some(Step::Saving)
|
||||
)
|
||||
});
|
||||
let won = Store::open(dir.path()).unwrap().accounts().unwrap();
|
||||
assert_eq!(won.len(), 1);
|
||||
let (address, secret) = if won[0].1 == account("ann@example.com") {
|
||||
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
|
||||
("ann", "pw")
|
||||
} else {
|
||||
assert_eq!(won[0].1, account("ANN@example.com"));
|
||||
cx.update(|_, app| assert!(mail.read(app).adding.is_none()));
|
||||
("ANN", "pw2")
|
||||
};
|
||||
let kept = keys.kept.lock().unwrap();
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(
|
||||
kept.get(&format!("account:{address}@example.com"))
|
||||
.map(String::as_str),
|
||||
Some(secret)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keyboard_opens_another_accounts_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bo = store.add_account(&account("bo@example.com")).unwrap();
|
||||
drop(store);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
// As `mail::open` does.
|
||||
cx.update(|window, app| window.focus(&mail.read(app).focus.clone(), app));
|
||||
for _ in 0..2 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
for _ in 0..8 {
|
||||
cx.simulate_keystrokes("tab space");
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| mail.read(app).open) == Open::Account(bo) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("no Tab stop let Space open the second account");
|
||||
}
|
||||
|
||||
/// Through the dialog: back to its last stop, Remove.
|
||||
fn press_remove(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
|
||||
for _ in 0..2 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
cx.simulate_keystrokes("shift-tab enter");
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse("enter").unwrap(),
|
||||
});
|
||||
settle(cx, |app| mail.read(app).removing.is_none());
|
||||
}
|
||||
|
||||
/// Opens the first account's page, as a user would.
|
||||
fn open_first(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
|
||||
settle(cx, |app| !mail.read(app).accounts().is_empty());
|
||||
let first = cx.update(|_, app| mail.read(app).accounts()[0].0);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(first), cx)));
|
||||
}
|
||||
|
||||
/// The save lands after a second dialog opened.
|
||||
fn save_then_reopen(fail: bool) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Imap, cx);
|
||||
passed(&mail, account("ann@example.com"), "pw", cx);
|
||||
if fail {
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
let added = store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"));
|
||||
added.unwrap();
|
||||
}
|
||||
cx.update(|window, app| {
|
||||
mail.update(app, |mail, cx| {
|
||||
mail.save(cx);
|
||||
mail.close_adding(cx);
|
||||
mail.start_adding(window, cx);
|
||||
let adding = mail.adding.as_mut().unwrap();
|
||||
adding.set_kind(Kind::Pop, cx);
|
||||
let address = adding.fields.address.clone();
|
||||
address.update(cx, |field, cx| {
|
||||
field.set_text("bob@example.com".to_owned(), cx)
|
||||
});
|
||||
})
|
||||
});
|
||||
if fail {
|
||||
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
|
||||
settle(cx, |_| store.try_lock().is_ok());
|
||||
cx.run_until_parked();
|
||||
cx.update(|_, app| assert!(mail.read(app).accounts().is_empty()));
|
||||
} else {
|
||||
settle(cx, |app| mail.read(app).accounts().len() == 1);
|
||||
}
|
||||
cx.update(|_, app| {
|
||||
let adding = mail.read(app).adding.as_ref().unwrap();
|
||||
assert_eq!(adding.kind, Kind::Pop);
|
||||
assert!(matches!(adding.step, Step::Editing));
|
||||
assert_eq!(adding.fields.address.read(app).text(), "bob@example.com");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_save_leaves_a_newer_dialog_alone() {
|
||||
save_then_reopen(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_failed_save_leaves_a_newer_dialog_alone() {
|
||||
save_then_reopen(true);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_gpui_component::mail::Mail as Row;
|
||||
use ely_mail_engine::{AccountId, Fetched, Filter, Key, MailboxId, Role, Store};
|
||||
use gpui::{
|
||||
Entity, ScrollDelta, ScrollWheelEvent, SharedString, TestAppContext, VisualTestContext, point,
|
||||
px, size,
|
||||
};
|
||||
|
||||
use super::{Keys, account, settle, window};
|
||||
use crate::{
|
||||
boxes::{self, Open},
|
||||
mail::Mail,
|
||||
};
|
||||
|
||||
fn message(subject: &str, date: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"From: Ann <ann@example.com>\r\nSubject: {subject}\r\nDate: {date}\r\n\
|
||||
Content-Type: text/plain\r\n\r\nhi\r\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn put(store: &mut Store, mailbox: MailboxId, uid: u32, flags: &[&str], subject: &str, day: u8) {
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 1,
|
||||
uid,
|
||||
};
|
||||
let bytes = message(subject, &format!("{day} Jan 2024 10:00:00 +0000"));
|
||||
store
|
||||
.put(mailbox, &key, flags, Fetched::Whole(&bytes))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Ann has an inbox and an archive, Bob an inbox.
|
||||
fn seeded(dir: &std::path::Path) -> (AccountId, MailboxId) {
|
||||
let mut store = Store::open(dir).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bob = store.add_account(&account("bob@example.com")).unwrap();
|
||||
let [ann_inbox, archive] = store
|
||||
.set_folders(
|
||||
ann,
|
||||
&[
|
||||
("INBOX", Some(Role::Inbox)),
|
||||
("Archive", Some(Role::Archive)),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let [bob_inbox] = store
|
||||
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
put(&mut store, ann_inbox, 1, &["\\Seen"], "old", 1);
|
||||
put(&mut store, ann_inbox, 2, &[], "new", 3);
|
||||
put(&mut store, archive, 3, &[], "kept", 2);
|
||||
put(&mut store, bob_inbox, 4, &["\\Flagged"], "bob", 2);
|
||||
(ann, archive)
|
||||
}
|
||||
|
||||
/// The listed subjects, once a list is read.
|
||||
fn listed(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> Option<Vec<String>> {
|
||||
cx.update(|_, app| match &mail.read(app).listing.mails {
|
||||
Some(Ok(rows)) => Some(rows.iter().map(|row| row.subject.to_string()).collect()),
|
||||
Some(Err(error)) => panic!("the list failed: {error}"),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn shows(mail: &Entity<Mail>, cx: &mut VisualTestContext, subjects: &[&str]) {
|
||||
let want: Vec<String> = subjects.iter().map(|each| each.to_string()).collect();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while listed(mail, cx).as_ref() != Some(&want) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"listed {:?}, not {want:?}",
|
||||
listed(mail, cx)
|
||||
);
|
||||
cx.run_until_parked();
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn key_of(mail: &Entity<Mail>, cx: &mut VisualTestContext, subject: &str) -> SharedString {
|
||||
cx.update(|_, app| {
|
||||
let mails = mail.read(app).listing.mails.clone();
|
||||
let rows = mails.unwrap().unwrap();
|
||||
rows.iter()
|
||||
.find(|row| row.subject == subject)
|
||||
.unwrap()
|
||||
.key
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_inbox_is_one_list_newest_first() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
settle(cx, |app| mail.read(app).inboxes_unread() == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_narrow_the_list() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
let unread = Filter {
|
||||
unread: true,
|
||||
..Filter::default()
|
||||
};
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(unread, cx)));
|
||||
shows(&mail, cx, &["new", "bob"]);
|
||||
let flagged = Filter {
|
||||
unread: true,
|
||||
flagged: true,
|
||||
..Filter::default()
|
||||
};
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(flagged, cx)));
|
||||
shows(&mail, cx, &["bob"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_lists_its_own_mail() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ann, archive) = seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
let open = Open::Mailbox(ann, archive);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
|
||||
shows(&mail, cx, &["kept"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pick_fills_the_reader() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ann, archive) = seeded(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &["new", "bob", "old"]);
|
||||
let (new, bob) = (key_of(&mail, cx, "new"), key_of(&mail, cx, "bob"));
|
||||
let chosen = |cx: &mut VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let chosen = mail.read(app).listing.chosen.as_ref();
|
||||
chosen.map(|row| row.subject.to_string())
|
||||
})
|
||||
};
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(std::slice::from_ref(&new), cx)));
|
||||
assert_eq!(chosen(cx).as_deref(), Some("new"));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[new, bob], cx)));
|
||||
assert_eq!(chosen(cx), None);
|
||||
let open = Open::Mailbox(ann, archive);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
|
||||
cx.update(|_, app| assert!(mail.read(app).listing.selected.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rebuilt_mailbox_drops_the_pick() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ann, archive) = seeded(dir.path());
|
||||
// The newest row, whose id SQLite would hand out again.
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
put(&mut store, archive, 9, &[], "picked", 4);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
let open = Open::Mailbox(ann, archive);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
|
||||
shows(&mail, cx, &["picked", "kept"]);
|
||||
let picked = key_of(&mail, cx, "picked");
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[picked], cx)));
|
||||
store.open_mailbox(archive, 2).unwrap();
|
||||
let key = Key::Imap {
|
||||
uidvalidity: 2,
|
||||
uid: 3,
|
||||
};
|
||||
let bytes = message("other", "2 Jan 2024 10:00:00 +0000");
|
||||
store
|
||||
.put(archive, &key, &[], Fetched::Whole(&bytes))
|
||||
.unwrap();
|
||||
drop(store);
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.read_mail(cx)));
|
||||
shows(&mail, cx, &["other"]);
|
||||
cx.update(|_, app| {
|
||||
let listing = &mail.read(app).listing;
|
||||
assert!(listing.selected.is_empty(), "kept {:?}", listing.selected);
|
||||
assert!(listing.chosen.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picking_every_one_of_many_messages_is_quick() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
shows(&mail, cx, &[]);
|
||||
let rows: Vec<Row> = (0..100_000)
|
||||
.map(|n| Row::undated(n.to_string(), "Ann", "hi"))
|
||||
.collect();
|
||||
let keys: Vec<SharedString> = rows.iter().map(|row| row.key.clone()).collect();
|
||||
let listed = boxes::index(rows);
|
||||
cx.update(|_, app| mail.update(app, |mail, _| mail.listing.show(Some(Ok(listed)))));
|
||||
let started = Instant::now();
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
|
||||
let took = started.elapsed();
|
||||
assert!(took < Duration::from_secs(1), "picking took {took:?}");
|
||||
cx.update(|_, app| assert_eq!(mail.read(app).listing.selected.len(), 100_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_side_scrolls_past_many_folders_to_the_next_account() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let ann = store.add_account(&account("ann@example.com")).unwrap();
|
||||
let bob = store.add_account(&account("bob@example.com")).unwrap();
|
||||
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
|
||||
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
|
||||
store.set_folders(ann, &folders).unwrap();
|
||||
store
|
||||
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(bob), cx)));
|
||||
cx.simulate_resize(size(px(1100.), px(800.)));
|
||||
settle(cx, |app| {
|
||||
let syncs = &mail.read(app).syncs;
|
||||
syncs.get(&ann).is_some_and(|each| each.folders.len() == 40) && syncs.contains_key(&bob)
|
||||
});
|
||||
let selector = "item-selected-Account settings";
|
||||
let height = cx.update(|window, _| window.viewport_size().height);
|
||||
let before = cx.debug_bounds(selector).expect("Bob's settings row");
|
||||
assert!(before.bottom() > height, "the side fits the window");
|
||||
let at = point(px(100.), height / 2.0);
|
||||
for _ in 0..20 {
|
||||
cx.simulate_event(ScrollWheelEvent {
|
||||
position: at,
|
||||
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
|
||||
..Default::default()
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
let after = cx.debug_bounds(selector).expect("Bob's settings row");
|
||||
assert!(
|
||||
after.bottom() <= height,
|
||||
"Bob's settings are out of reach: {after:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use ely_mail_engine::{Protocol, Store};
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::{Keys, account, adding, closed_port, settle, type_in, window, window_with};
|
||||
use crate::kinds::Kind;
|
||||
|
||||
#[test]
|
||||
fn every_account_syncs_on_open_and_says_why_it_failed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
let mut ann = account("ann@example.com");
|
||||
ann.incoming.port = closed_port();
|
||||
let ann = store.add_account(&ann).unwrap();
|
||||
let mut pop = ely_mail_engine::Account {
|
||||
protocol: Protocol::Pop { keep: true },
|
||||
..account("bo@example.com")
|
||||
};
|
||||
pop.incoming.port = closed_port();
|
||||
let bo = store.add_account(&pop).unwrap();
|
||||
drop(store);
|
||||
let keys = Keys::default();
|
||||
keys.kept
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend(["ann", "bo"].map(|name| (format!("account:{name}@example.com"), "pw".into())));
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), keys);
|
||||
settle(cx, |app| {
|
||||
[ann, bo].iter().all(|id| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(id)
|
||||
.is_some_and(|each| each.task.is_none() && each.synced.is_some())
|
||||
})
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
for id in [ann, bo] {
|
||||
let syncing = &mail.read(app).syncs[&id];
|
||||
let failed = syncing.failed.as_deref().unwrap();
|
||||
assert!(failed.starts_with("cannot connect"), "{failed}");
|
||||
assert_eq!(syncing.synced.unwrap().at, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_account_without_its_secret_says_so() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.failed.is_some())
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let failed = mail.read(app).syncs[&ann].failed.clone().unwrap();
|
||||
assert_eq!(
|
||||
failed,
|
||||
"credential store failed: no saved secret for account:ann@example.com"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_tls_is_the_sync_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ann = Store::open(dir.path())
|
||||
.unwrap()
|
||||
.add_account(&account("ann@example.com"))
|
||||
.unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let tls = Err("platform trust store unavailable".to_owned());
|
||||
let (mail, cx) = window_with(&mut cx, dir.path(), Keys::default(), tls);
|
||||
settle(cx, |app| {
|
||||
mail.read(app)
|
||||
.syncs
|
||||
.get(&ann)
|
||||
.is_some_and(|each| each.synced.is_some())
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
let syncing = &mail.read(app).syncs[&ann];
|
||||
assert_eq!(
|
||||
syncing.failed.as_deref(),
|
||||
Some("platform trust store unavailable")
|
||||
);
|
||||
assert!(syncing.task.is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pop_account_keeps_a_server_copy_unless_told() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
|
||||
adding(&mail, Kind::Pop, cx);
|
||||
type_in(&mail, cx, |f| &f.address, "bo@example.com");
|
||||
type_in(&mail, cx, |f| &f.user, "bo");
|
||||
type_in(&mail, cx, |f| &f.incoming, "pop.example.com");
|
||||
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
|
||||
let protocol = |cx: &mut gpui::VisualTestContext| {
|
||||
cx.update(|_, app| {
|
||||
let adding = mail.read(app).adding.as_ref().unwrap();
|
||||
adding.account(None, app).unwrap().protocol
|
||||
})
|
||||
};
|
||||
assert_eq!(protocol(cx), Protocol::Pop { keep: true });
|
||||
cx.update(|_, app| mail.update(app, |mail, _| mail.adding.as_mut().unwrap().set_keep(false)));
|
||||
assert_eq!(protocol(cx), Protocol::Pop { keep: false });
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! What the window draws.
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::DescriptionList,
|
||||
feedback::{Banner, EmptyState, ErrorView},
|
||||
layout::ScrollArea,
|
||||
overlays::ConfirmDialog,
|
||||
primitives::{IconName, Severity},
|
||||
typography::format,
|
||||
};
|
||||
use ely_mail_engine::{AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
|
||||
use ely_platform::AppId;
|
||||
use ely_ui::Shell;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
|
||||
SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
boxes::Open,
|
||||
mail::{Mail, text},
|
||||
menu,
|
||||
};
|
||||
|
||||
fn server(server: &Server, cx: &App) -> SharedString {
|
||||
let security = match server.security {
|
||||
Security::Tls => "TLS",
|
||||
Security::StartTls => "STARTTLS",
|
||||
};
|
||||
text(
|
||||
cx,
|
||||
"mail.account.server",
|
||||
&[
|
||||
("host", &server.host),
|
||||
("port", &server.port.to_string()),
|
||||
("security", security),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn auth(auth: Auth, cx: &App) -> SharedString {
|
||||
let key = match auth {
|
||||
Auth::Password => "mail.auth.password",
|
||||
Auth::Google => "mail.auth.google",
|
||||
Auth::Microsoft => "mail.auth.microsoft",
|
||||
};
|
||||
text(cx, key, &[])
|
||||
}
|
||||
|
||||
impl Mail {
|
||||
fn add_button(&self, cx: &App) -> Button {
|
||||
Button::new("mail-add", text(cx, "mail.add.open", &[]))
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::AddAccount), cx))
|
||||
}
|
||||
|
||||
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let error = |key: &str, detail: &str, cx: &App| {
|
||||
ErrorView::new("mail-error", IconName::TriangleAlert, text(cx, key, &[]))
|
||||
.detail(detail.to_owned())
|
||||
.into_any_element()
|
||||
};
|
||||
if let Err(detail) = &self.store {
|
||||
return error("mail.store.unopened", detail, cx);
|
||||
}
|
||||
let accounts = match &self.accounts {
|
||||
None => return div().into_any_element(),
|
||||
Some(Err(detail)) => return error("mail.store.unread", detail, cx),
|
||||
Some(Ok(accounts)) => accounts,
|
||||
};
|
||||
if accounts.is_empty() {
|
||||
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
|
||||
.action(self.add_button(cx))
|
||||
.into_any_element();
|
||||
}
|
||||
let Open::Account(open) = self.open else {
|
||||
return self.panes(cx);
|
||||
};
|
||||
let Some((id, account)) = accounts.iter().find(|(id, _)| *id == open) else {
|
||||
log::error!("mail: account {} open but not listed", open.0);
|
||||
return error("mail.store.unread", "", cx);
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let incoming = format!(
|
||||
"{} · {}",
|
||||
protocol_name(account.protocol),
|
||||
server(&account.incoming, cx)
|
||||
);
|
||||
let details = DescriptionList::new()
|
||||
.text(label("mail.account.address"), account.address.clone())
|
||||
.text(label("mail.account.user"), account.user.clone())
|
||||
.text(label("mail.account.incoming"), incoming)
|
||||
.text(
|
||||
label("mail.account.outgoing"),
|
||||
server(&account.outgoing, cx),
|
||||
)
|
||||
.text(label("mail.account.auth"), auth(account.auth, cx));
|
||||
let (id, protocol) = (*id, account.protocol);
|
||||
let (details, failed) = self.sync_rows(id, protocol, details, cx);
|
||||
let remove = Button::new("mail-remove", label("mail.remove.open"))
|
||||
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
|
||||
let page = div()
|
||||
.p_6()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.max_w_128()
|
||||
.children(self.paused_banner(cx))
|
||||
.children(failed)
|
||||
.child(details)
|
||||
.child(self.sync_buttons(id, cx))
|
||||
.children(self.offline_switches(id, protocol, cx))
|
||||
.child(div().child(remove));
|
||||
ScrollArea::new("mail-account-scroll")
|
||||
.vertical()
|
||||
.size_full()
|
||||
.child(page)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// When it last synced, what is here, why it failed.
|
||||
fn sync_rows(
|
||||
&self,
|
||||
id: AccountId,
|
||||
protocol: Protocol,
|
||||
details: DescriptionList,
|
||||
cx: &App,
|
||||
) -> (DescriptionList, Option<Banner>) {
|
||||
let Some(syncing) = self.syncs.get(&id) else {
|
||||
return (details, None);
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let last = match (&syncing.task, syncing.synced.and_then(|synced| synced.at)) {
|
||||
(Some(_), _) => label("mail.sync.running"),
|
||||
(None, Some(at)) => local_time(at).into(),
|
||||
(None, None) => label("mail.sync.never"),
|
||||
};
|
||||
let mut details = details.text(label("mail.sync.last"), last);
|
||||
if let Protocol::Pop { keep } = protocol {
|
||||
let server = label(if keep {
|
||||
"mail.sync.kept"
|
||||
} else {
|
||||
"mail.sync.deleted"
|
||||
});
|
||||
details = details
|
||||
.text(label("mail.sync.range"), label("mail.sync.range_pop"))
|
||||
.text(label("mail.sync.server"), server);
|
||||
} else {
|
||||
for (key, value) in imap_range(&syncing.folders, cx) {
|
||||
details = details.text(key, value);
|
||||
}
|
||||
}
|
||||
if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) {
|
||||
for (key, n) in [
|
||||
("mail.sync.uncached", synced.uncached),
|
||||
("mail.sync.partial", synced.partial),
|
||||
] {
|
||||
let value = format!("{key}_value");
|
||||
let words = ely_ui::i18n::counted(cx, &value, n, &[("n", &n.to_string())]);
|
||||
details = details.text(label(key), words);
|
||||
}
|
||||
}
|
||||
let failed = syncing.failed.as_ref().map(|error| {
|
||||
Banner::new(
|
||||
"mail-sync-failed",
|
||||
Severity::Danger,
|
||||
text(cx, "mail.sync.failed", &[("error", error)]),
|
||||
)
|
||||
});
|
||||
(details, failed)
|
||||
}
|
||||
|
||||
fn remove_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let removing = self.removing.as_ref()?;
|
||||
let this = cx.weak_entity();
|
||||
let dismiss = {
|
||||
let this = this.clone();
|
||||
move |_: &mut Window, cx: &mut App| {
|
||||
this.update(cx, |mail, cx| {
|
||||
mail.removing = None;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
};
|
||||
let size = format::file_size(removing.cached.bytes, false);
|
||||
let count = removing.cached.messages;
|
||||
let args = [
|
||||
("address", removing.address.as_str()),
|
||||
("n", &count.to_string()),
|
||||
("size", &size),
|
||||
];
|
||||
let message = ely_ui::i18n::counted(cx, "mail.remove.message", count, &args);
|
||||
let dialog = ConfirmDialog::new(
|
||||
"mail-remove",
|
||||
text(cx, "mail.remove.title", &args),
|
||||
message,
|
||||
dismiss,
|
||||
)
|
||||
.confirm(text(cx, "mail.remove.go", &[]))
|
||||
.destructive()
|
||||
.on_confirm({
|
||||
let removing = removing.clone();
|
||||
move |_, cx| {
|
||||
let removing = removing.clone();
|
||||
this.update(cx, |mail, cx| mail.remove(removing, cx)).ok();
|
||||
}
|
||||
});
|
||||
Some(dialog.into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rest download, then folders kept whole.
|
||||
pub fn imap_range(folders: &[LocalFolder], cx: &App) -> Vec<(SharedString, SharedString)> {
|
||||
let (whole, rest): (Vec<_>, Vec<_>) = folders.iter().partition(|folder| folder.offline);
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let days = sync::BODY_DAYS.to_string();
|
||||
let mut rows = Vec::new();
|
||||
if whole.is_empty() || !rest.is_empty() {
|
||||
let key = match whole.is_empty() {
|
||||
true => "mail.sync.range_value",
|
||||
false => "mail.sync.range_rest",
|
||||
};
|
||||
rows.push((label("mail.sync.range"), text(cx, key, &[("days", &days)])));
|
||||
}
|
||||
if !whole.is_empty() {
|
||||
let sep = label("mail.sync.whole_sep");
|
||||
let names: Vec<&str> = whole.iter().map(|folder| folder.name.as_str()).collect();
|
||||
let value = text(cx, "mail.sync.whole_value", &[("names", &names.join(&sep))]);
|
||||
rows.push((label("mail.sync.whole"), value));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
pub fn protocol_name(protocol: Protocol) -> &'static str {
|
||||
match protocol {
|
||||
Protocol::Imap => "IMAP",
|
||||
Protocol::Pop { .. } => "POP3",
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Mail {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let left = (!self.accounts().is_empty()).then(|| self.side.clone().into());
|
||||
let read_only = self.read_only.as_ref().map(|reason| {
|
||||
Banner::new(
|
||||
"mail-read-only",
|
||||
Severity::Warning,
|
||||
text(cx, "mail.store.read_only", &[("error", reason)]),
|
||||
)
|
||||
});
|
||||
let shell = Shell::new(AppId::Mail.name(), &self.focus)
|
||||
.left(left)
|
||||
.children(read_only)
|
||||
.child(self.body(cx));
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(menu::CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.on_action(
|
||||
cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx)))
|
||||
.on_action(cx.listener(|mail, _: &menu::SyncAll, _, cx| mail.sync_all(cx)))
|
||||
.child(shell)
|
||||
.children(self.add_dialog(cx))
|
||||
.children(self.remove_dialog(cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// Local time to the minute.
|
||||
pub fn local_time(at: i64) -> String {
|
||||
match jiff::Timestamp::from_second(at) {
|
||||
Ok(at) => at
|
||||
.to_zoned(jiff::tz::TimeZone::system())
|
||||
.strftime("%Y-%m-%d %H:%M")
|
||||
.to_string(),
|
||||
Err(error) => {
|
||||
log::error!("mail: a sync time out of range: {error}");
|
||||
at.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Typora's Paragraph and Format menus at the caret (MARK-15).
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{BlockCommand, EditRefusal, InlineFormat, MarkdownDocument, TableRefusal},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{
|
||||
Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, SharedString,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::{desk::Desk, keys::item};
|
||||
|
||||
actions!(
|
||||
mark,
|
||||
[
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
Paragraph,
|
||||
Promote,
|
||||
Demote,
|
||||
MathBlock,
|
||||
CodeFences,
|
||||
Quote,
|
||||
OrderedList,
|
||||
BulletList,
|
||||
TaskList,
|
||||
ParagraphBefore,
|
||||
ParagraphAfter,
|
||||
LinkReference,
|
||||
Footnote,
|
||||
Rule,
|
||||
Toc,
|
||||
FrontMatter,
|
||||
Strong,
|
||||
Emphasis,
|
||||
Underline,
|
||||
InlineCode,
|
||||
Strike,
|
||||
Hyperlink,
|
||||
InsertImage,
|
||||
ClearFormat,
|
||||
DeleteBlock
|
||||
]
|
||||
);
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-1", Heading1, at),
|
||||
KeyBinding::new("secondary-2", Heading2, at),
|
||||
KeyBinding::new("secondary-3", Heading3, at),
|
||||
KeyBinding::new("secondary-4", Heading4, at),
|
||||
KeyBinding::new("secondary-5", Heading5, at),
|
||||
KeyBinding::new("secondary-6", Heading6, at),
|
||||
KeyBinding::new("secondary-0", Paragraph, at),
|
||||
KeyBinding::new("secondary-=", Promote, at),
|
||||
KeyBinding::new("secondary--", Demote, at),
|
||||
KeyBinding::new("secondary-shift-m", MathBlock, at),
|
||||
KeyBinding::new("secondary-shift-k", CodeFences, at),
|
||||
KeyBinding::new("secondary-shift-q", Quote, at),
|
||||
KeyBinding::new("secondary-{", OrderedList, at),
|
||||
KeyBinding::new("secondary-}", BulletList, at),
|
||||
KeyBinding::new("secondary-shift-x", TaskList, at),
|
||||
KeyBinding::new("secondary-b", Strong, at),
|
||||
KeyBinding::new("secondary-i", Emphasis, at),
|
||||
KeyBinding::new("secondary-u", Underline, at),
|
||||
KeyBinding::new("secondary-~", InlineCode, at),
|
||||
KeyBinding::new("alt-%", Strike, at),
|
||||
KeyBinding::new("secondary-k", Hyperlink, at),
|
||||
KeyBinding::new("secondary-shift-i", InsertImage, at),
|
||||
KeyBinding::new("secondary-\\", ClearFormat, at),
|
||||
]);
|
||||
}
|
||||
|
||||
pub fn paragraph_menu(cx: &App) -> Menu {
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
Menu::new(text(cx, "mark.menu.paragraph", &[])).items([
|
||||
item("mark.block.h1", Box::new(Heading1)),
|
||||
item("mark.block.h2", Box::new(Heading2)),
|
||||
item("mark.block.h3", Box::new(Heading3)),
|
||||
item("mark.block.h4", Box::new(Heading4)),
|
||||
item("mark.block.h5", Box::new(Heading5)),
|
||||
item("mark.block.h6", Box::new(Heading6)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.paragraph", Box::new(Paragraph)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.promote", Box::new(Promote)),
|
||||
item("mark.block.demote", Box::new(Demote)),
|
||||
MenuItem::separator(),
|
||||
MenuItem::submenu(crate::tools::table_menu(cx)),
|
||||
item("mark.block.math", Box::new(MathBlock)),
|
||||
item("mark.block.code", Box::new(CodeFences)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.quote", Box::new(Quote)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.ordered", Box::new(OrderedList)),
|
||||
item("mark.block.bullet", Box::new(BulletList)),
|
||||
item("mark.block.task", Box::new(TaskList)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.before", Box::new(ParagraphBefore)),
|
||||
item("mark.block.after", Box::new(ParagraphAfter)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.link_reference", Box::new(LinkReference)),
|
||||
item("mark.block.footnote", Box::new(Footnote)),
|
||||
MenuItem::separator(),
|
||||
item("mark.block.rule", Box::new(Rule)),
|
||||
item("mark.block.toc", Box::new(Toc)),
|
||||
item("mark.block.front_matter", Box::new(FrontMatter)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn format_menu(cx: &App) -> Menu {
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
Menu::new(text(cx, "mark.menu.format", &[])).items([
|
||||
item("mark.format.strong", Box::new(Strong)),
|
||||
item("mark.format.emphasis", Box::new(Emphasis)),
|
||||
item("mark.format.underline", Box::new(Underline)),
|
||||
item("mark.format.code", Box::new(InlineCode)),
|
||||
MenuItem::separator(),
|
||||
item("mark.format.strike", Box::new(Strike)),
|
||||
MenuItem::separator(),
|
||||
item("mark.format.link", Box::new(Hyperlink)),
|
||||
item("mark.format.image", Box::new(InsertImage)),
|
||||
MenuItem::separator(),
|
||||
item("mark.format.clear", Box::new(ClearFormat)),
|
||||
])
|
||||
}
|
||||
|
||||
/// What to tell the user for `why`.
|
||||
pub(crate) fn refusal(why: EditRefusal, cx: &App) -> SharedString {
|
||||
let key = match why {
|
||||
EditRefusal::Preview => "mark.preview.read_only",
|
||||
EditRefusal::Code => "mark.refused.code",
|
||||
EditRefusal::Math => "mark.refused.math",
|
||||
EditRefusal::Html => "mark.refused.html",
|
||||
EditRefusal::FrontMatter => "mark.refused.front_matter",
|
||||
EditRefusal::Outside => "mark.refused.outside",
|
||||
EditRefusal::Table => "mark.refused.table",
|
||||
EditRefusal::NoImage => "mark.refused.no_image",
|
||||
EditRefusal::Present => "mark.refused.present",
|
||||
EditRefusal::Across => "mark.refused.across",
|
||||
EditRefusal::TooLong => "mark.refused.too_long",
|
||||
EditRefusal::Busy => "mark.refused.busy",
|
||||
EditRefusal::Changed => "mark.refused.changed",
|
||||
EditRefusal::Misread => "mark.refused.misread",
|
||||
EditRefusal::Cells(TableRefusal::Outside) => "mark.table.none",
|
||||
EditRefusal::Cells(TableRefusal::Kept) => "mark.table.kept",
|
||||
EditRefusal::Cells(TableRefusal::Beyond) => "mark.table.beyond",
|
||||
EditRefusal::Cells(TableRefusal::Mixed) => "mark.table.mixed",
|
||||
EditRefusal::Cells(TableRefusal::Edge) => "mark.table.edge",
|
||||
};
|
||||
text(cx, key, &[])
|
||||
}
|
||||
|
||||
/// Each action to its document edit.
|
||||
macro_rules! edits {
|
||||
($root:expr, $cx:expr, $($action:ident => $edit:expr),* $(,)?) => {
|
||||
$root$(.on_action($cx.listener(|desk, _: &$action, _, cx| desk.edit($edit, cx))))*
|
||||
};
|
||||
}
|
||||
pub(crate) use edits;
|
||||
|
||||
impl Desk {
|
||||
/// `edit` on the shown document; a refusal is said.
|
||||
pub(crate) fn edit(
|
||||
&mut self,
|
||||
edit: impl FnOnce(
|
||||
&mut MarkdownDocument,
|
||||
&mut Context<MarkdownDocument>,
|
||||
) -> Result<(), EditRefusal>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
if let Err(why) = document.update(cx, edit) {
|
||||
self.toast(refusal(why, cx), Severity::Info, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn block_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
use BlockCommand as B;
|
||||
use InlineFormat as F;
|
||||
edits!(root, cx,
|
||||
Heading1 => |d, cx| d.block(B::Heading(1), cx),
|
||||
Heading2 => |d, cx| d.block(B::Heading(2), cx),
|
||||
Heading3 => |d, cx| d.block(B::Heading(3), cx),
|
||||
Heading4 => |d, cx| d.block(B::Heading(4), cx),
|
||||
Heading5 => |d, cx| d.block(B::Heading(5), cx),
|
||||
Heading6 => |d, cx| d.block(B::Heading(6), cx),
|
||||
Paragraph => |d, cx| d.block(B::Paragraph, cx),
|
||||
Promote => |d, cx| d.block(B::Promote, cx),
|
||||
Demote => |d, cx| d.block(B::Demote, cx),
|
||||
MathBlock => |d, cx| d.block(B::MathBlock, cx),
|
||||
CodeFences => |d, cx| d.block(B::CodeBlock, cx),
|
||||
Quote => |d, cx| d.block(B::Quote, cx),
|
||||
OrderedList => |d, cx| d.block(B::OrderedList, cx),
|
||||
BulletList => |d, cx| d.block(B::BulletList, cx),
|
||||
TaskList => |d, cx| d.block(B::TaskList, cx),
|
||||
ParagraphBefore => |d, cx| d.block(B::ParagraphBefore, cx),
|
||||
ParagraphAfter => |d, cx| d.block(B::ParagraphAfter, cx),
|
||||
LinkReference => |d, cx| d.block(B::LinkDefinition, cx),
|
||||
Footnote => |d, cx| d.block(B::Footnote, cx),
|
||||
Rule => |d, cx| d.block(B::Rule, cx),
|
||||
Toc => |d, cx| d.block(B::Toc, cx),
|
||||
FrontMatter => |d, cx| d.block(B::FrontMatter, cx),
|
||||
Strong => |d, cx| d.format(F::Strong, cx),
|
||||
Emphasis => |d, cx| d.format(F::Emphasis, cx),
|
||||
Underline => |d, cx| d.format(F::Underline, cx),
|
||||
InlineCode => |d, cx| d.format(F::Code, cx),
|
||||
Strike => |d, cx| d.format(F::Strike, cx),
|
||||
Hyperlink => |d, cx| d.format(F::Link, cx),
|
||||
InsertImage => |d, cx| d.format(F::Image, cx),
|
||||
ClearFormat => |d, cx| d.format(F::Clear, cx),
|
||||
DeleteBlock => |d, cx| d.block(B::Delete, cx),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// macOS menus are the system's, not drawn.
|
||||
#[cfg(test)]
|
||||
mod context;
|
||||
#[cfg(all(test, not(target_os = "macos")))]
|
||||
mod menus;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,251 @@
|
||||
//! The right-click menu, driven as a user would (R4.10).
|
||||
|
||||
use ely_gpui_component::theme::{ActiveTheme as _, TextSize};
|
||||
use gpui::{
|
||||
ClipboardItem, Entity, Modifiers, MouseButton, Point, TestAppContext, VisualTestContext, point,
|
||||
px,
|
||||
};
|
||||
|
||||
use super::tests::{current, document, opened, toasted};
|
||||
use crate::{desk::Desk, view::Mode};
|
||||
|
||||
/// A right press on block `ix`'s first line, character `k`.
|
||||
fn right_on(ix: usize, k: usize, cx: &mut VisualTestContext) {
|
||||
let block = cx
|
||||
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
|
||||
.expect("the block is drawn");
|
||||
let size = cx.update(|window, cx| {
|
||||
cx.theme()
|
||||
.text_size(TextSize::Base)
|
||||
.to_pixels(window.rem_size())
|
||||
});
|
||||
// The test text system sets glyphs 0.6 em apart.
|
||||
let x = block.left() + px(13.0) + size * 0.6 * (k as f32 + 0.25);
|
||||
right_at(point(x, block.top() + px(10.0)), cx);
|
||||
}
|
||||
|
||||
fn right_at(at: Point<gpui::Pixels>, cx: &mut VisualTestContext) {
|
||||
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Arrow `keys`, then Enter; menus choose on its release.
|
||||
fn choose(keys: &str, cx: &mut VisualTestContext) {
|
||||
if !keys.is_empty() {
|
||||
cx.simulate_keystrokes(keys);
|
||||
}
|
||||
cx.simulate_keystrokes("enter");
|
||||
let enter = gpui::Keystroke::parse("enter").expect("a key");
|
||||
cx.simulate_event(gpui::KeyUpEvent { keystroke: enter });
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
|
||||
cx.read_from_clipboard().and_then(|item| item.text())
|
||||
}
|
||||
|
||||
fn switch(desk: &Entity<Desk>, mode: Mode, cx: &mut VisualTestContext) {
|
||||
desk.update_in(cx, |desk, window, cx| desk.switch(mode, window, cx));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn paste_from_the_menu_lands_at_the_pressed_caret(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Intro\n", cx);
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("New ".into()));
|
||||
right_on(0, 0, cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(current(&desk, cx), "New Intro\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_link_opens_and_copies_its_address(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened("[docs](https://x.test)\n", cx);
|
||||
right_on(0, 1, cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(cx.opened_url().as_deref(), Some("https://x.test"));
|
||||
right_on(0, 1, cx);
|
||||
choose("down down down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("https://x.test"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_code_block_copies_its_code_and_deletes_by_name(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("```rust\nlet x = 1;\n```\n\nNext\n", cx);
|
||||
right_on(0, 0, cx);
|
||||
choose("down down down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("let x = 1;"));
|
||||
right_on(0, 0, cx);
|
||||
choose("up", cx);
|
||||
assert!(
|
||||
!current(&desk, cx).contains("let x"),
|
||||
"{}",
|
||||
current(&desk, cx)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_quote_is_deleted_from_the_last_row(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("> quoted\n\nNext\n", cx);
|
||||
right_on(0, 1, cx);
|
||||
choose("up", cx);
|
||||
let text = current(&desk, cx);
|
||||
assert!(!text.contains("quoted") && text.contains("Next"), "{text}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_picture_shows_its_file_or_goes(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Lead.\n\n\n", cx);
|
||||
right_on(1, 1, cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(toasted(&desk, "no file here", cx), 1);
|
||||
right_on(1, 1, cx);
|
||||
choose("down down", cx);
|
||||
assert!(!current(&desk, cx).contains("!["), "{}", current(&desk, cx));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn source_cuts_the_selection_a_right_press_keeps(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
switch(&desk, Mode::Source, cx);
|
||||
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
|
||||
let line = cx.debug_bounds("editor-line-0").expect("drawn");
|
||||
right_at(point(line.left() + px(20.0), line.top() + px(6.0)), cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(current(&desk, cx), " beta\n");
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_block_copies_its_selection_and_stays(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Intro words\n", cx);
|
||||
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
cx.simulate_mouse_down(block.center(), MouseButton::Left, Modifiers::none());
|
||||
cx.simulate_mouse_up(block.center(), MouseButton::Left, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
cx.simulate_keystrokes("home shift-right shift-right shift-right shift-right shift-right");
|
||||
let field = cx.debug_bounds("markdown-open").expect("open");
|
||||
right_at(point(field.left() + px(20.0), field.top() + px(14.0)), cx);
|
||||
choose("down right", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("Intro"));
|
||||
assert!(
|
||||
cx.debug_bounds("markdown-open").is_some(),
|
||||
"the block stays open"
|
||||
);
|
||||
assert_eq!(current(&desk, cx), "Intro words\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn preview_offers_copies_only(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha **beta**\n", cx);
|
||||
switch(&desk, Mode::Preview, cx);
|
||||
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
|
||||
right_at(block.center(), cx);
|
||||
choose("down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha beta\n"));
|
||||
right_at(block.center(), cx);
|
||||
choose("down down", cx);
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha **beta**\n"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn html_code_keeps_picture_addresses(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened(" and *it*\n", cx);
|
||||
cx.dispatch_action(crate::export::CopyHtml);
|
||||
cx.run_until_parked();
|
||||
let html = clipboard(cx).expect("copied");
|
||||
assert!(
|
||||
html.contains("src=\"missing.png\"") && html.contains("<em>it</em>"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn copies_convert_off_the_window_thread(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into()));
|
||||
// The context's dispatch parks; this one does not.
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
assert_eq!(
|
||||
clipboard(cx).as_deref(),
|
||||
Some("before"),
|
||||
"not on the window thread"
|
||||
);
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyPlain), cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha it\n"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_later_markdown_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
|
||||
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::clip::CopyMarkdown), cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha *it*\n"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_later_editor_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
|
||||
use gpui::Focusable as _;
|
||||
let (desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
switch(&desk, Mode::Source, cx);
|
||||
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
|
||||
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
|
||||
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
let copy = gpui::Keystroke::parse("secondary-c").expect("key");
|
||||
cx.update(|window, cx| window.dispatch_keystroke(copy, cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_later_rich_copy_with_the_same_text_outlives_a_pending_conversion(cx: &mut TestAppContext) {
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
|
||||
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
|
||||
return;
|
||||
}
|
||||
let rich = |html: &str| Clip::Html {
|
||||
html: html.into(),
|
||||
text: "x".into(),
|
||||
};
|
||||
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
|
||||
clipboard::write(&rich("<b>x</b>")).expect("writes");
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("x".into()));
|
||||
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
|
||||
clipboard::write(&rich("<i>x</i>")).expect("writes");
|
||||
cx.run_until_parked();
|
||||
let html = clipboard::html().expect("reads");
|
||||
assert_eq!(
|
||||
clipboard(cx).as_deref(),
|
||||
Some("x"),
|
||||
"the conversion is dropped"
|
||||
);
|
||||
assert!(html.is_some_and(|html| html.contains("<i>x</i>")));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_shut_menu_stays_shut_when_its_tab_returns(cx: &mut TestAppContext) {
|
||||
let (desk, cx, files) = opened("alpha\n", cx);
|
||||
right_on(0, 1, cx);
|
||||
assert!(cx.debug_bounds("menu-button-0-0").is_some(), "opened");
|
||||
cx.simulate_keystrokes("escape");
|
||||
cx.run_until_parked();
|
||||
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "shut");
|
||||
let other = files[1].path().join("b.md");
|
||||
std::fs::write(&other, "beta\n").expect("b");
|
||||
crate::desk::tests::open(&desk, &other, cx);
|
||||
let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id);
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.core.active = Some(first);
|
||||
cx.notify();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "not replayed");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use ely_gpui_component::theme::{ActiveTheme as _, ControlSize};
|
||||
use gpui::{
|
||||
App, Entity, KeyUpEvent, Keystroke, Menu, Modifiers, TestAppContext, VisualTestContext, point,
|
||||
px,
|
||||
};
|
||||
|
||||
use super::tests::{current, document, opened, press};
|
||||
use crate::{desk::Desk, view::Mode};
|
||||
|
||||
/// Selects `alpha` in the first block, opened.
|
||||
fn select_alpha(desk: &Entity<Desk>, cx: &mut VisualTestContext) {
|
||||
let bounds = cx.debug_bounds("markdown-block-0").expect("a block");
|
||||
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
press(
|
||||
"home shift-right shift-right shift-right shift-right shift-right",
|
||||
cx,
|
||||
);
|
||||
let selected = document(desk, cx).read_with(cx, |document, cx| document.selected(cx));
|
||||
assert_eq!(selected, "alpha");
|
||||
}
|
||||
|
||||
/// Runs `menu`'s row `row` from the title bar.
|
||||
fn choose(menu: fn(&App) -> Menu, row: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, cx| {
|
||||
cx.set_menus(vec![menu(cx)]);
|
||||
window.refresh();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let at = cx.update(|window, cx| {
|
||||
let rem = window.rem_size();
|
||||
let bar = cx.theme().titlebar_height().to_pixels(rem);
|
||||
let title = cx.theme().control_height(ControlSize::Sm).to_pixels(rem);
|
||||
point(px(12.), bar + title / 2.)
|
||||
});
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
press(&format!("{} enter", vec!["down"; row].join(" ")), cx);
|
||||
let enter = Keystroke::parse("enter").expect("a key");
|
||||
cx.simulate_event(KeyUpEvent { keystroke: enter });
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn edit_menu(cx: &App) -> Menu {
|
||||
let look = crate::look::Look::default();
|
||||
crate::keys::menus(&look, cx).remove(0)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_menu_formats_the_open_blocks_selection(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
select_alpha(&desk, cx);
|
||||
choose(super::format_menu, 1, cx);
|
||||
assert_eq!(current(&desk, cx), "**alpha** beta\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_menu_copies_the_open_blocks_selection(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
select_alpha(&desk, cx);
|
||||
// Copy as Markdown, after Copy as Plain Text.
|
||||
choose(edit_menu, 2, cx);
|
||||
let copied = cx.read_from_clipboard().and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("alpha"));
|
||||
}
|
||||
|
||||
const TABLED: &str = "before\n\n| A |\n| - |\n| 1 |\n\nafter\n";
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_table_from_the_menu_is_one_undo_step(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened(TABLED, cx);
|
||||
desk.update_in(cx, |desk, window, cx| desk.switch(Mode::Source, window, cx));
|
||||
let document = document(&desk, cx);
|
||||
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
|
||||
choose(crate::tools::table_menu, 13, cx);
|
||||
assert_eq!(current(&desk, cx), "before\n\nafter\n");
|
||||
press("secondary-z", cx);
|
||||
assert_eq!(current(&desk, cx), TABLED);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn delete_table_in_an_open_block_keeps_keyboard_undo(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened(TABLED, cx);
|
||||
let bounds = cx.debug_bounds("markdown-block-1").expect("the table");
|
||||
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
|
||||
cx.simulate_click(at, Modifiers::none());
|
||||
choose(crate::tools::table_menu, 13, cx);
|
||||
assert_eq!(current(&desk, cx), "before\n\nafter\n");
|
||||
press("secondary-z", cx);
|
||||
assert_eq!(current(&desk, cx), TABLED);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use ely_gpui_component::documents::MarkdownDocument;
|
||||
#[cfg(target_os = "windows")]
|
||||
use gpui::Action;
|
||||
use gpui::{
|
||||
ClipboardItem, Entity, Focusable as _, Menu, MenuItem, TestAppContext, VisualTestContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
desk::{
|
||||
Desk,
|
||||
tests::{open, start},
|
||||
},
|
||||
view::Mode,
|
||||
};
|
||||
|
||||
pub(super) fn opened(
|
||||
text: &str,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (
|
||||
Entity<Desk>,
|
||||
&'static mut VisualTestContext,
|
||||
[tempfile::TempDir; 2],
|
||||
) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("a.md");
|
||||
std::fs::write(&path, text).expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
(desk, cx, [data, files])
|
||||
}
|
||||
|
||||
pub(super) fn document(
|
||||
desk: &Entity<Desk>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Entity<MarkdownDocument> {
|
||||
desk.read_with(cx, |desk, cx| {
|
||||
let view = desk.active_view().expect("a tab");
|
||||
view.read(cx).document().clone()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn current(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
|
||||
let document = document(desk, cx);
|
||||
document.read_with(cx, |document, cx| document.text(cx))
|
||||
}
|
||||
|
||||
fn mode(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Mode {
|
||||
desk.read_with(cx, |desk, cx| {
|
||||
desk.active_view().expect("a tab").read(cx).mode
|
||||
})
|
||||
}
|
||||
|
||||
/// `keys`, then the background reads.
|
||||
pub(super) fn press(keys: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(keys);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// The window's toasts that hold `words`.
|
||||
pub(super) fn toasted(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) -> usize {
|
||||
let root = desk.read_with(cx, |desk, _| desk.core.root.upgrade().expect("the window"));
|
||||
let toasts = root.read_with(cx, |root, cx| root.toasts(cx));
|
||||
toasts
|
||||
.iter()
|
||||
.filter(|(said, _)| said.contains(words))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn paragraph_keys_retype_the_caret_block(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("Intro\n", cx);
|
||||
press("secondary-1", cx);
|
||||
assert_eq!(current(&desk, cx), "# Intro\n");
|
||||
press("secondary--", cx);
|
||||
assert_eq!(current(&desk, cx), "## Intro\n");
|
||||
press("secondary-=", cx);
|
||||
assert_eq!(current(&desk, cx), "# Intro\n");
|
||||
press("secondary-0", cx);
|
||||
assert_eq!(current(&desk, cx), "Intro\n");
|
||||
press("secondary-{", cx);
|
||||
assert_eq!(current(&desk, cx), "1. Intro\n");
|
||||
press("secondary-}", cx);
|
||||
assert_eq!(current(&desk, cx), "- Intro\n");
|
||||
press("secondary-shift-q", cx);
|
||||
assert_eq!(
|
||||
current(&desk, cx),
|
||||
"- > Intro\n",
|
||||
"the item's text is quoted"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn format_keys_wrap_the_caret_word(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("word\n", cx);
|
||||
press("secondary-b", cx);
|
||||
assert_eq!(current(&desk, cx), "**word**\n");
|
||||
press("secondary-\\", cx);
|
||||
assert_eq!(current(&desk, cx), "word\n");
|
||||
press("alt-%", cx);
|
||||
assert_eq!(current(&desk, cx), "~~word~~\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn ctrl_slash_toggles_source_inside_the_code_editor(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a\n", cx);
|
||||
press("secondary-/", cx);
|
||||
assert_eq!(mode(&desk, cx), Mode::Source);
|
||||
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
|
||||
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
press("secondary-/", cx);
|
||||
assert_eq!(mode(&desk, cx), Mode::Visual);
|
||||
assert_eq!(current(&desk, cx), "a\n", "no comment marks");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_refused_command_says_why(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("```\ncode\n```\n", cx);
|
||||
press("secondary-1", cx);
|
||||
assert_eq!(current(&desk, cx), "```\ncode\n```\n");
|
||||
assert_eq!(toasted(&desk, "inside code", cx), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn alt_arrows_move_a_table_row(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("| A | B |\n| - | - |\n| 1 | 2 |\n", cx);
|
||||
press("secondary-/", cx);
|
||||
document(&desk, cx).update(cx, |document, cx| document.set_caret(2, 2, cx));
|
||||
press("alt-up", cx);
|
||||
assert_eq!(
|
||||
current(&desk, cx),
|
||||
"| 1 | 2 |\n| ---- | ---- |\n| A | B |\n"
|
||||
);
|
||||
press("alt-up", cx);
|
||||
assert_eq!(toasted(&desk, "Nothing lies past", cx), 1);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn view_keys_follow_typora(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a\n", cx);
|
||||
press("f8 f9 secondary-+", cx);
|
||||
let look = desk.read_with(cx, |desk, _| desk.look.clone());
|
||||
assert!(look.focus && look.typewriter && look.size > 100, "{look:?}");
|
||||
press("secondary-)", cx);
|
||||
assert_eq!(desk.read_with(cx, |desk, _| desk.look.size), 100);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn copy_as_markdown_and_paste_as_plain_text(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a **b**\n", cx);
|
||||
press("secondary-shift-c", cx);
|
||||
let copied = cx.read_from_clipboard().and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("a **b**\n"));
|
||||
cx.write_to_clipboard(ClipboardItem::new_string("*x*".into()));
|
||||
press("secondary-/", cx);
|
||||
press("secondary-shift-v", cx);
|
||||
assert_eq!(current(&desk, cx), "*x*a **b**\n");
|
||||
}
|
||||
|
||||
/// Row names; `-` a line, `▸` a submenu.
|
||||
fn rows(menu: &Menu) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for item in &menu.items {
|
||||
match item {
|
||||
MenuItem::Separator => out.push("-".into()),
|
||||
MenuItem::Submenu(sub) => {
|
||||
out.push(format!("▸ {}", sub.name));
|
||||
out.extend(rows(sub).into_iter().map(|row| format!(" {row}")));
|
||||
}
|
||||
MenuItem::Action { name, .. } => out.push(name.to_string()),
|
||||
MenuItem::SystemMenu(os) => out.push(os.name.to_string()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_paragraph_and_format_menus_follow_typora(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("a\n", cx);
|
||||
let menus = desk.read_with(cx, |desk, cx| crate::keys::menus(&desk.look, cx));
|
||||
let named: Vec<String> = menus.iter().map(|menu| menu.name.to_string()).collect();
|
||||
assert_eq!(named, ["Edit", "Paragraph", "Format", "View"]);
|
||||
let table = [
|
||||
"Insert Table",
|
||||
"-",
|
||||
"Add Row Above",
|
||||
"Add Row Below",
|
||||
"Add Column Before",
|
||||
"Add Column After",
|
||||
"-",
|
||||
"Move Row Up",
|
||||
"Move Row Down",
|
||||
"Move Column Left",
|
||||
"Move Column Right",
|
||||
"-",
|
||||
"Delete Row",
|
||||
"Delete Column",
|
||||
"-",
|
||||
"Prettify Source Code",
|
||||
"-",
|
||||
"Delete Table",
|
||||
];
|
||||
let mut paragraph: Vec<String> = [
|
||||
"Heading 1",
|
||||
"Heading 2",
|
||||
"Heading 3",
|
||||
"Heading 4",
|
||||
"Heading 5",
|
||||
"Heading 6",
|
||||
"-",
|
||||
"Paragraph",
|
||||
"-",
|
||||
"Increase Heading Level",
|
||||
"Decrease Heading Level",
|
||||
"-",
|
||||
"▸ Table",
|
||||
]
|
||||
.map(String::from)
|
||||
.into();
|
||||
paragraph.extend(table.map(|row| format!(" {row}")));
|
||||
paragraph.extend(
|
||||
[
|
||||
"Math Block",
|
||||
"Code Fences",
|
||||
"-",
|
||||
"Quote",
|
||||
"-",
|
||||
"Ordered List",
|
||||
"Unordered List",
|
||||
"Task List",
|
||||
"-",
|
||||
"Insert Paragraph Before",
|
||||
"Insert Paragraph After",
|
||||
"-",
|
||||
"Link Reference",
|
||||
"Footnotes",
|
||||
"-",
|
||||
"Horizontal Line",
|
||||
"Table of Contents",
|
||||
"YAML Front Matter",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
assert_eq!(rows(&menus[1]), paragraph);
|
||||
assert_eq!(
|
||||
rows(&menus[2]).join("|"),
|
||||
"Strong|Emphasis|Underline|Code|-|Strike|-|Hyperlink|Insert Image|-|Clear Format"
|
||||
);
|
||||
}
|
||||
|
||||
/// The keys a menu row shows for `action`.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn keys(action: &dyn Action, cx: &mut VisualTestContext) -> String {
|
||||
cx.update(|window, _| {
|
||||
let binding = window
|
||||
.highest_precedence_binding_for_action(action)
|
||||
.expect("a binding");
|
||||
let strokes: Vec<String> = binding
|
||||
.keystrokes()
|
||||
.iter()
|
||||
.map(|key| key.unparse())
|
||||
.collect();
|
||||
strokes.join(" ")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[gpui::test]
|
||||
fn every_command_takes_typoras_windows_key(cx: &mut TestAppContext) {
|
||||
use crate::{blocks::*, clip::*, keys::ToggleSource, look, tools::*};
|
||||
let (_desk, cx, _files) = opened("a\n", cx);
|
||||
let typora: [(&dyn Action, &str); 34] = [
|
||||
(&Heading1, "ctrl-1"),
|
||||
(&Heading6, "ctrl-6"),
|
||||
(&Paragraph, "ctrl-0"),
|
||||
(&Promote, "ctrl-="),
|
||||
(&Demote, "ctrl--"),
|
||||
(&MathBlock, "ctrl-shift-m"),
|
||||
(&CodeFences, "ctrl-shift-k"),
|
||||
(&Quote, "ctrl-shift-q"),
|
||||
(&OrderedList, "ctrl-{"),
|
||||
(&BulletList, "ctrl-}"),
|
||||
(&TaskList, "ctrl-shift-x"),
|
||||
(&Strong, "ctrl-b"),
|
||||
(&Emphasis, "ctrl-i"),
|
||||
(&Underline, "ctrl-u"),
|
||||
(&InlineCode, "ctrl-~"),
|
||||
(&Strike, "alt-%"),
|
||||
(&Hyperlink, "ctrl-k"),
|
||||
(&InsertImage, "ctrl-shift-i"),
|
||||
(&ClearFormat, "ctrl-\\"),
|
||||
(&InsertTable, "ctrl-t"),
|
||||
(&RowBelow, "ctrl-enter"),
|
||||
(&DeleteRow, "ctrl-shift-backspace"),
|
||||
(&MoveRowUp, "alt-up"),
|
||||
(&MoveRowDown, "alt-down"),
|
||||
(&MoveColumnLeft, "alt-left"),
|
||||
(&MoveColumnRight, "alt-right"),
|
||||
(&ToggleSource, "ctrl-/"),
|
||||
(&look::FocusMode, "f8"),
|
||||
(&look::Typewriter, "f9"),
|
||||
(&look::Larger, "ctrl-+"),
|
||||
(&look::Smaller, "ctrl-_"),
|
||||
(&look::ActualSize, "ctrl-("),
|
||||
(&CopyMarkdown, "ctrl-shift-c"),
|
||||
(&PastePlain, "ctrl-shift-v"),
|
||||
];
|
||||
for (action, want) in typora {
|
||||
assert_eq!(keys(action, cx), want, "{}", action.name());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Typora's Copy as Markdown and Paste as Plain Text.
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{
|
||||
App, ClipboardItem, Context, Focusable as _, InteractiveElement as _, KeyBinding, Window,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
|
||||
actions!(mark, [CopyMarkdown, PastePlain]);
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-shift-c", CopyMarkdown, at),
|
||||
KeyBinding::new("secondary-shift-v", PastePlain, at),
|
||||
]);
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Whether the document or nothing else holds focus.
|
||||
fn on_document(&self, window: &Window, cx: &App) -> bool {
|
||||
let Some(focused) = window.focused(cx) else {
|
||||
return true;
|
||||
};
|
||||
let held = (self.active_view()).is_some_and(|view| {
|
||||
let document = view.read(cx).document().read(cx);
|
||||
document.focus_handle(cx).contains_focused(window, cx)
|
||||
});
|
||||
held || focused == self.core.focus
|
||||
}
|
||||
|
||||
/// Hands a field outside the document its own `name` action.
|
||||
fn to_field(name: &str, window: &mut Window, cx: &mut App) {
|
||||
match cx.build_action(name, None) {
|
||||
Ok(action) => window.dispatch_action(action, cx),
|
||||
Err(error) => log::error!("mark: no action {name}: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected Markdown, or the whole text, as written.
|
||||
fn copy_markdown(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.on_document(window, cx) {
|
||||
return Self::to_field("ely_input::Copy", window, cx);
|
||||
}
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().read(cx);
|
||||
let selected = document.selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.text(cx),
|
||||
false => selected,
|
||||
};
|
||||
log::info!("mark: {} bytes copied as Markdown", markdown.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(markdown));
|
||||
}
|
||||
|
||||
/// The clipboard's text at the caret, pictures left out.
|
||||
fn paste_plain(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.on_document(window, cx) {
|
||||
return Self::to_field("ely_input::Paste", window, cx);
|
||||
}
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let Some(pasted) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
let said = text(cx, "mark.paste.none", &[]);
|
||||
return self.toast(said, Severity::Info, cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
if !document.update(cx, |document, cx| document.insert(&pasted, cx)) {
|
||||
let said = text(cx, "mark.preview.read_only", &[]);
|
||||
self.toast(said, Severity::Info, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clip_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
root.on_action(
|
||||
cx.listener(|desk, _: &CopyMarkdown, window, cx| desk.copy_markdown(window, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|desk, _: &PastePlain, window, cx| desk.paste_plain(window, cx)))
|
||||
}
|
||||
}
|
||||
+29
-31
@@ -23,7 +23,6 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::engine::Mark;
|
||||
use crate::keys::{CheckDisk, PreviewMode, SourceMode, VisualMode};
|
||||
use crate::view::{MarkView, Mode};
|
||||
|
||||
pub struct Desk {
|
||||
@@ -57,6 +56,8 @@ pub struct Desk {
|
||||
asked_print: Option<ely_files::Fingerprint>,
|
||||
/// The find box, while open.
|
||||
pub(crate) finding: Option<crate::finding::Find>,
|
||||
/// The copy being converted; a newer one drops it.
|
||||
pub(crate) copying: Option<gpui::Task<()>>,
|
||||
}
|
||||
|
||||
impl Host for Desk {
|
||||
@@ -206,6 +207,7 @@ pub(crate) fn open_in(
|
||||
asked: None,
|
||||
asked_print: None,
|
||||
finding: None,
|
||||
copying: None,
|
||||
}
|
||||
});
|
||||
let menus = crate::keys::menus(&look, cx);
|
||||
@@ -225,7 +227,12 @@ pub(crate) fn open_in(
|
||||
// Launch files stand alone; unsaved text still returns.
|
||||
if asked.is_empty() {
|
||||
for path in kept {
|
||||
// A restored tab does not rejoin the recent list.
|
||||
let (id, real) = (desk.core.next, dunce::canonicalize(&path).ok());
|
||||
desk.open_path(path, cx);
|
||||
if let Some(real) = real.filter(|_| desk.core.next > id) {
|
||||
desk.remembered.insert(id, real);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (at, name) in desk.keeping.untitled() {
|
||||
@@ -334,7 +341,7 @@ impl Desk {
|
||||
self.new_from_text("untitled.md", "", cx);
|
||||
}
|
||||
|
||||
fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
pub(crate) fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(view) = self.active_view() {
|
||||
view.update(cx, |view, cx| view.switch(mode, window, cx));
|
||||
}
|
||||
@@ -417,35 +424,26 @@ impl Render for Desk {
|
||||
.status(self.status(cx)),
|
||||
}
|
||||
.child(body);
|
||||
let root =
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(commands::CONTEXT)
|
||||
.track_focus(&self.core.focus)
|
||||
.on_action(cx.listener(Self::open_picked))
|
||||
.on_action(cx.listener(Self::on_new))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_rename))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.on_action(cx.listener(Self::on_find))
|
||||
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
|
||||
.on_action(cx.listener(|desk, _: &VisualMode, window, cx| {
|
||||
desk.switch(Mode::Visual, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &SourceMode, window, cx| {
|
||||
desk.switch(Mode::Source, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &PreviewMode, window, cx| {
|
||||
desk.switch(Mode::Preview, window, cx)
|
||||
}))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.naming_element(cx))
|
||||
.children(self.compare_dialog(cx))
|
||||
.children(self.find_box(cx))
|
||||
.children(self.export_dialog(cx));
|
||||
Self::output_actions(Self::table_actions(Self::look_actions(root, cx), cx), cx)
|
||||
let root = div()
|
||||
.size_full()
|
||||
.key_context(commands::CONTEXT)
|
||||
.track_focus(&self.core.focus)
|
||||
.on_action(cx.listener(Self::open_picked))
|
||||
.on_action(cx.listener(Self::on_new))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_rename))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.on_action(cx.listener(Self::on_find))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.naming_element(cx))
|
||||
.children(self.compare_dialog(cx))
|
||||
.children(self.find_box(cx))
|
||||
.children(self.export_dialog(cx));
|
||||
let root = Self::mode_actions(Self::look_actions(root, cx), cx);
|
||||
let root = Self::block_actions(Self::table_actions(root, cx), cx);
|
||||
Self::output_actions(Self::clip_actions(root, cx), cx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,26 @@ fn a_file_opens_with_its_outline_and_joins_the_recent_list(cx: &mut TestAppConte
|
||||
assert!(!kept.contains(r"\\\\?\\"), "plain paths only: {kept}");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_recent_list_clears_from_its_button(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("a.md");
|
||||
std::fs::write(&path, "Text.\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
assert!(desk.read_with(cx, |desk, _| !desk.recent.is_empty()));
|
||||
let button = cx.debug_bounds("mark-clear-recent").expect("the button");
|
||||
cx.simulate_click(button.center(), gpui::Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
assert!(desk.read_with(cx, |desk, _| desk.recent.is_empty()));
|
||||
let kept = std::fs::read_to_string(data.path().join("recent.json")).expect("the list");
|
||||
assert_eq!(kept, "[]");
|
||||
assert!(cx.debug_bounds("mark-clear-recent").is_none(), "nothing left to clear");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_outline_follows_typing(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
|
||||
@@ -8,11 +8,11 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_office::{Engine, Host, Load};
|
||||
use ely_office::{Engine, Host, Load, Tabs as _};
|
||||
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
|
||||
|
||||
use crate::text::Source;
|
||||
use crate::view::{Failed, MarkView, Reported};
|
||||
use crate::view::{Failed, MarkView, Refused, Reported};
|
||||
|
||||
/// A file as read, and its text now.
|
||||
pub struct Text {
|
||||
@@ -137,6 +137,11 @@ impl Engine for Mark {
|
||||
failed(host, &error.0, cx)
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(view, |host, _, refused: &Refused, cx| {
|
||||
let said = crate::blocks::refusal(refused.0, cx);
|
||||
host.toast(said, ely_gpui_component::primitives::Severity::Info, cx)
|
||||
})
|
||||
.detach();
|
||||
cx.subscribe(view, move |host, view, _: &Reported, cx| {
|
||||
let modified = view.read(cx).modified();
|
||||
reported(host, &view, modified, cx)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Rich, HTML code and plain text copies (R4.10).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{ClipboardItem, Context, Div, InteractiveElement as _, actions};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::output::{self, Page, Pictures};
|
||||
|
||||
actions!(mark, [CopyRich, CopyHtml, CopyPlain]);
|
||||
|
||||
/// What a copy turns the Markdown into.
|
||||
#[derive(Clone, Copy)]
|
||||
enum As {
|
||||
Rich,
|
||||
Html,
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// A converted copy, ready for the clipboard.
|
||||
enum Copied {
|
||||
Rich(Clip, Page),
|
||||
Html(String, Page),
|
||||
Plain(String),
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub(super) fn copy_actions(root: Div, cx: &mut Context<Self>) -> Div {
|
||||
root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy(As::Rich, cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy(As::Html, cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy(As::Plain, cx)))
|
||||
}
|
||||
|
||||
/// The selection or whole text, and the file's folder.
|
||||
fn copied_text(&mut self, cx: &mut Context<Self>) -> Option<(String, Option<PathBuf>)> {
|
||||
let Some(view) = self.active_view() else {
|
||||
self.no_document(cx);
|
||||
return None;
|
||||
};
|
||||
let document = view.read(cx).document().read(cx);
|
||||
let selected = document.selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.text(cx),
|
||||
false => selected,
|
||||
};
|
||||
let beside = (self.active_tab())
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| path.parent())
|
||||
.map(PathBuf::from);
|
||||
Some((markdown, beside))
|
||||
}
|
||||
|
||||
fn copy_failed(&mut self, error: &std::io::Error, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// Converts in the background, then writes the clipboard.
|
||||
fn copy(&mut self, kind: As, cx: &mut Context<Self>) {
|
||||
let Some((markdown, beside)) = self.copied_text(cx) else {
|
||||
return;
|
||||
};
|
||||
let before = witness(cx);
|
||||
self.copying = Some(cx.spawn(async move |desk, cx| {
|
||||
let converted = cx
|
||||
.background_executor()
|
||||
.spawn(async move { convert(kind, markdown, beside) })
|
||||
.await;
|
||||
let landed = desk.update(cx, |desk, cx| {
|
||||
desk.copying = None;
|
||||
// A later copy, here or elsewhere, wins.
|
||||
if witness(cx) != before {
|
||||
return log::info!("mark: the clipboard changed first; a copy is dropped");
|
||||
}
|
||||
desk.copied(converted, cx);
|
||||
});
|
||||
if landed.is_err() {
|
||||
log::warn!("mark: a copy finished after its window closed");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn copied(&mut self, converted: std::io::Result<Copied>, cx: &mut Context<Self>) {
|
||||
let written = converted.and_then(|copied| match copied {
|
||||
Copied::Rich(clip, page) => clipboard::write(&clip)
|
||||
.map(|()| Some(page))
|
||||
.map_err(|error| std::io::Error::other(format!("{error:#}"))),
|
||||
Copied::Html(html, page) => {
|
||||
log::info!("mark: {} bytes copied as HTML code", html.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(html));
|
||||
Ok(Some(page))
|
||||
}
|
||||
Copied::Plain(plain) => {
|
||||
log::info!("mark: {} bytes copied as plain text", plain.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(plain));
|
||||
Ok(None)
|
||||
}
|
||||
});
|
||||
match written {
|
||||
Ok(Some(page)) => self.left_out(&page, cx),
|
||||
Ok(None) => {}
|
||||
Err(error) => self.copy_failed(&error, cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the clipboard holds, its HTML included.
|
||||
fn witness(cx: &mut Context<Desk>) -> (Option<ClipboardItem>, Result<Option<String>, String>) {
|
||||
let html = clipboard::html().map_err(|error| {
|
||||
log::warn!("mark: the clipboard's HTML did not read: {error:#}");
|
||||
format!("{error:#}")
|
||||
});
|
||||
(cx.read_from_clipboard(), html)
|
||||
}
|
||||
|
||||
/// The copy as `kind`, converted off the window thread.
|
||||
fn convert(kind: As, markdown: String, beside: Option<PathBuf>) -> std::io::Result<Copied> {
|
||||
let beside = beside.as_deref();
|
||||
match kind {
|
||||
As::Rich => output::body(&markdown, beside, &Pictures::Inline).map(|mut page| {
|
||||
let html = std::mem::take(&mut page.html);
|
||||
let clip = Clip::Html {
|
||||
html,
|
||||
text: markdown,
|
||||
};
|
||||
Copied::Rich(clip, page)
|
||||
}),
|
||||
As::Html => output::body(&markdown, beside, &Pictures::Written)
|
||||
.map(|mut page| Copied::Html(std::mem::take(&mut page.html), page)),
|
||||
As::Plain => output::plain(&markdown).map(Copied::Plain),
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,20 @@ use ely_gpui_component::{
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
use ely_ui::{
|
||||
commands::{Export, Print},
|
||||
i18n::text,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::output::{self, Format, Page, Pictures, Target};
|
||||
use crate::output::{self, Format, Page, Target};
|
||||
|
||||
actions!(mark, [CopyRich]);
|
||||
mod copy;
|
||||
|
||||
pub use copy::{CopyHtml, CopyPlain, CopyRich};
|
||||
|
||||
const FORMATS: [(Format, &str, &str); 4] = [
|
||||
(Format::Page, "page", "html"),
|
||||
@@ -41,12 +41,13 @@ fn key(format: Format) -> &'static str {
|
||||
|
||||
impl Desk {
|
||||
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
|
||||
root.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
|
||||
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx)))
|
||||
let root = root
|
||||
.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
|
||||
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)));
|
||||
Self::copy_actions(root, cx)
|
||||
}
|
||||
|
||||
fn no_document(&self, cx: &mut Context<Self>) {
|
||||
pub(crate) fn no_document(&self, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "command.blocked.no_document", &[]);
|
||||
self.toast(said, Severity::Warning, cx);
|
||||
}
|
||||
@@ -275,39 +276,6 @@ impl Desk {
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// The selection, or the whole text, as HTML.
|
||||
fn copy_rich(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
let selected = document.read(cx).selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.read(cx).text(cx),
|
||||
false => selected,
|
||||
};
|
||||
let beside = self
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| path.parent());
|
||||
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|mut page| {
|
||||
let clip = Clip::Html {
|
||||
html: std::mem::take(&mut page.html),
|
||||
text: markdown,
|
||||
};
|
||||
clipboard::write(&clip)
|
||||
.map(|()| page)
|
||||
.map_err(|error| std::io::Error::other(format!("{error:#}")))
|
||||
});
|
||||
match copied {
|
||||
Ok(page) => self.left_out(&page, cx),
|
||||
Err(error) => {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file of its own for each print job.
|
||||
|
||||
@@ -25,7 +25,7 @@ fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
let html = std::fs::read_to_string(&out).expect("written");
|
||||
assert!(
|
||||
html.contains("<h1>Note</h1>") && html.contains("<svg"),
|
||||
html.contains("<h1 id=\"h-0\">Note</h1>") && html.contains("<svg"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
|
||||
});
|
||||
cx.dispatch_action(crate::export::CopyRich);
|
||||
cx.run_until_parked();
|
||||
let clip = ely_platform::clipboard::read()
|
||||
.expect("reads")
|
||||
.expect("a clip");
|
||||
|
||||
@@ -110,10 +110,11 @@ fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) {
|
||||
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
|
||||
cx.update(|window, cx| window.focus(&focus, cx));
|
||||
cx.dispatch_action(crate::tools::InsertTable);
|
||||
assert!(current_text(&desk, cx).contains("| --- | --- | --- |"));
|
||||
assert!(current_text(&desk, cx).contains("| ---- | ---- | ---- |"));
|
||||
cx.dispatch_action(crate::tools::DeleteColumn);
|
||||
cx.run_until_parked();
|
||||
assert!(
|
||||
current_text(&desk, cx).contains("| --- | --- |\n"),
|
||||
current_text(&desk, cx).contains("| ---- | ---- |\n"),
|
||||
"{}",
|
||||
current_text(&desk, cx)
|
||||
);
|
||||
@@ -175,3 +176,25 @@ fn preview_keeps_its_text_from_tables_and_replacement(cx: &mut TestAppContext) {
|
||||
replace_one(&desk, "x", cx);
|
||||
assert_eq!(current_text(&desk, cx), "a\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn clipboard_keys_in_find_reach_the_field(cx: &mut TestAppContext) {
|
||||
let (desk, cx, _files) = opened("alpha beta\n", cx);
|
||||
query(&desk, "beta", cx);
|
||||
let field = desk.read_with(cx, |desk, _| {
|
||||
desk.finding.as_ref().expect("open").query.clone()
|
||||
});
|
||||
field.update(cx, |field, cx| field.select(0..4, cx));
|
||||
cx.simulate_keystrokes("secondary-shift-c");
|
||||
cx.run_until_parked();
|
||||
let copied = cx.read_from_clipboard().and_then(|item| item.text());
|
||||
assert_eq!(copied.as_deref(), Some("beta"));
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string("alpha".into()));
|
||||
cx.simulate_keystrokes("secondary-shift-v");
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
field.read_with(cx, |field, _| field.text().to_string()),
|
||||
"alpha"
|
||||
);
|
||||
assert_eq!(current_text(&desk, cx), "alpha beta\n");
|
||||
}
|
||||
+78
-31
@@ -1,24 +1,34 @@
|
||||
//! Modes, menus and keys (MARK-01, MARK-02).
|
||||
//! Modes, menus and keys, as Typora's (MARK-01, MARK-02, MARK-15).
|
||||
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{App, KeyBinding, Menu, MenuItem, actions};
|
||||
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
|
||||
|
||||
use crate::view::Mode;
|
||||
use crate::{desk::Desk, view::Mode};
|
||||
|
||||
actions!(mark, [VisualMode, SourceMode, PreviewMode, CheckDisk]);
|
||||
actions!(
|
||||
mark,
|
||||
[VisualMode, SourceMode, PreviewMode, ToggleSource, CheckDisk]
|
||||
);
|
||||
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-1", VisualMode, at),
|
||||
KeyBinding::new("secondary-2", SourceMode, at),
|
||||
KeyBinding::new("secondary-3", PreviewMode, at),
|
||||
KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at),
|
||||
KeyBinding::new("secondary-=", crate::look::Larger, at),
|
||||
KeyBinding::new("secondary--", crate::look::Smaller, at),
|
||||
KeyBinding::new("secondary-0", crate::look::ActualSize, at),
|
||||
KeyBinding::new("secondary-/", ToggleSource, at),
|
||||
// Over the code editor's `//` toggle.
|
||||
KeyBinding::new("secondary-/", ToggleSource, Some("ElyEditor")),
|
||||
KeyBinding::new("f8", crate::look::FocusMode, at),
|
||||
KeyBinding::new("f9", crate::look::Typewriter, at),
|
||||
KeyBinding::new("secondary-+", crate::look::Larger, at),
|
||||
KeyBinding::new("secondary-_", crate::look::Smaller, at),
|
||||
KeyBinding::new("secondary-)", crate::look::ActualSize, at),
|
||||
KeyBinding::new("secondary-f", ely_office::FindReplace, at),
|
||||
]);
|
||||
// Windows' Chinese input keeps Ctrl+Shift+0 for itself.
|
||||
#[cfg(target_os = "windows")]
|
||||
cx.bind_keys([KeyBinding::new("ctrl-(", crate::look::ActualSize, at)]);
|
||||
crate::blocks::bind(cx);
|
||||
crate::tools::bind(cx);
|
||||
crate::clip::bind(cx);
|
||||
}
|
||||
|
||||
/// The label of `mode`.
|
||||
@@ -26,36 +36,39 @@ pub fn label(mode: Mode, cx: &App) -> gpui::SharedString {
|
||||
text(cx, &format!("mark.mode.{}", mode.name()), &[])
|
||||
}
|
||||
|
||||
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
use crate::look::{self, Font, Width};
|
||||
let checked = |key: &str, action: Box<dyn gpui::Action>, checked: bool| MenuItem::Action {
|
||||
/// A menu row named by `key`, ticked when `checked`.
|
||||
pub(crate) fn checked(key: &str, action: Box<dyn Action>, checked: bool, cx: &App) -> MenuItem {
|
||||
MenuItem::Action {
|
||||
name: text(cx, key, &[]),
|
||||
action,
|
||||
os_action: None,
|
||||
checked,
|
||||
disabled: false,
|
||||
};
|
||||
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
|
||||
use crate::tools::{
|
||||
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn item(key: &str, action: Box<dyn Action>, cx: &App) -> MenuItem {
|
||||
checked(key, action, false, cx)
|
||||
}
|
||||
|
||||
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
use crate::look::{self, Font, Width};
|
||||
let checked = |key: &str, action: Box<dyn Action>, on: bool| checked(key, action, on, cx);
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
vec![
|
||||
Menu::new(text(cx, "menu.edit", &[])).items([
|
||||
item("office.find.open", Box::new(ely_office::FindReplace)),
|
||||
item("mark.copy.plain", Box::new(crate::export::CopyPlain)),
|
||||
item("mark.copy.markdown", Box::new(crate::clip::CopyMarkdown)),
|
||||
item("mark.copy.html", Box::new(crate::export::CopyHtml)),
|
||||
item("mark.copy.rich", Box::new(crate::export::CopyRich)),
|
||||
]),
|
||||
Menu::new(text(cx, "mark.menu.table", &[])).items([
|
||||
item("mark.table.insert", Box::new(InsertTable)),
|
||||
item("mark.paste.plain", Box::new(crate::clip::PastePlain)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.row_above", Box::new(RowAbove)),
|
||||
item("mark.table.row_below", Box::new(RowBelow)),
|
||||
item("mark.table.column_left", Box::new(ColumnLeft)),
|
||||
item("mark.table.column_right", Box::new(ColumnRight)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.delete_row", Box::new(DeleteRow)),
|
||||
item("mark.table.delete_column", Box::new(DeleteColumn)),
|
||||
item("office.find.open", Box::new(ely_office::FindReplace)),
|
||||
]),
|
||||
crate::blocks::paragraph_menu(cx),
|
||||
crate::blocks::format_menu(cx),
|
||||
Menu::new(text(cx, "menu.view", &[])).items([
|
||||
item("mark.view.source", Box::new(ToggleSource)),
|
||||
MenuItem::action(label(Mode::Visual, cx), VisualMode),
|
||||
MenuItem::action(label(Mode::Source, cx), SourceMode),
|
||||
MenuItem::action(label(Mode::Preview, cx), PreviewMode),
|
||||
@@ -99,11 +112,45 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
look.font == Font::Mono,
|
||||
),
|
||||
MenuItem::separator(),
|
||||
item("mark.look.actual", Box::new(look::ActualSize)),
|
||||
item("mark.look.larger", Box::new(look::Larger)),
|
||||
item("mark.look.smaller", Box::new(look::Smaller)),
|
||||
item("mark.look.actual", Box::new(look::ActualSize)),
|
||||
MenuItem::separator(),
|
||||
item("mark.outside.check", Box::new(CheckDisk)),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
/// Source and visual, as Typora's Source Code Mode.
|
||||
fn toggle_source(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let mode = match view.read(cx).mode {
|
||||
Mode::Source => Mode::Visual,
|
||||
Mode::Visual | Mode::Preview => Mode::Source,
|
||||
};
|
||||
self.switch(mode, window, cx);
|
||||
}
|
||||
|
||||
pub fn mode_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
root.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &ToggleSource, window, cx| desk.toggle_source(window, cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &VisualMode, window, cx| {
|
||||
desk.switch(Mode::Visual, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &SourceMode, window, cx| {
|
||||
desk.switch(Mode::Source, window, cx)
|
||||
}),
|
||||
)
|
||||
.on_action(cx.listener(|desk, _: &PreviewMode, window, cx| {
|
||||
desk.switch(Mode::Preview, window, cx)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -196,8 +196,8 @@ impl Desk {
|
||||
mod tests {
|
||||
use ely_gpui_component::documents::DocumentLook;
|
||||
use gpui::{
|
||||
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext,
|
||||
point, rems,
|
||||
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point,
|
||||
rems,
|
||||
};
|
||||
|
||||
use crate::desk::{
|
||||
@@ -286,8 +286,11 @@ mod tests {
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("a.md");
|
||||
std::fs::write(&path, "a
|
||||
").expect("a file");
|
||||
std::fs::write(
|
||||
&path, "a
|
||||
",
|
||||
)
|
||||
.expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
|
||||
@@ -300,12 +303,22 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
let other = match cfg!(target_os = "macos") {
|
||||
true => Modifiers { control: true, ..Default::default() },
|
||||
false => Modifiers { platform: true, ..Default::default() },
|
||||
true => Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
},
|
||||
false => Modifiers {
|
||||
platform: true,
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
cx.simulate_event(wheel(other, 3.0));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(document_look(&desk, cx).scale, 1.0, "only Ctrl, or Cmd on macOS");
|
||||
assert_eq!(
|
||||
document_look(&desk, cx).scale,
|
||||
1.0,
|
||||
"only Ctrl, or Cmd on macOS"
|
||||
);
|
||||
cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(document_look(&desk, cx).scale, 1.1, "a notch is one step");
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
mod attach;
|
||||
mod autosave;
|
||||
mod blocks;
|
||||
mod clip;
|
||||
mod compare;
|
||||
mod desk;
|
||||
mod engine;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Addresses, read as browsers read them.
|
||||
|
||||
/// A plain, web or mail address, read as browsers do.
|
||||
pub(super) fn safe(url: &str) -> bool {
|
||||
// Browsers trim spaces and controls, then drop tabs and newlines.
|
||||
let read: String = url
|
||||
.trim_matches(|ch: char| ch <= ' ')
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
|
||||
.collect();
|
||||
match scheme(&read) {
|
||||
None => true,
|
||||
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An address's scheme, when it has one.
|
||||
pub(super) fn scheme(url: &str) -> Option<&str> {
|
||||
let end = url.find(':')?;
|
||||
let name = &url[..end];
|
||||
let plain = name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
|
||||
// A drive letter is a path, not a scheme.
|
||||
(plain && name.len() > 1).then_some(name)
|
||||
}
|
||||
+358
-156
@@ -1,84 +1,100 @@
|
||||
//! A syntax tree written as HTML, node by node.
|
||||
//! Markdown as HTML, as the drawing reads it.
|
||||
|
||||
use std::{collections::HashMap, io};
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{HashSet, VecDeque},
|
||||
io,
|
||||
ops::Range,
|
||||
};
|
||||
|
||||
use ely_gpui_component::{documents::diagram_svg, typography::formula_svg};
|
||||
use ely_gpui_component::{
|
||||
documents::{
|
||||
AlertKind, Inline, MarkdownBlock, MarkdownReader, Style, alert, diagram_svg, is_toc,
|
||||
},
|
||||
typography::formula_svg,
|
||||
};
|
||||
use markdown::mdast::{AlignKind, Node};
|
||||
|
||||
use super::{Page, attribute, escape, pictures::Placed};
|
||||
use super::{Page, address::safe, attribute, escape, pictures::Placed};
|
||||
|
||||
/// Writes `tree` into `page`; pictures go through `placed`.
|
||||
pub(super) fn write(tree: &Node, placed: &mut Placed, page: &mut Page) -> io::Result<()> {
|
||||
/// Writes `tree`, read from `source`, into `page`.
|
||||
pub(super) fn write(
|
||||
tree: &Node,
|
||||
source: &str,
|
||||
placed: &mut Placed,
|
||||
page: &mut Page,
|
||||
) -> io::Result<()> {
|
||||
let mut writer = Writer {
|
||||
placed,
|
||||
page,
|
||||
links: HashMap::new(),
|
||||
notes: HashMap::new(),
|
||||
called: Vec::new(),
|
||||
source,
|
||||
reader: MarkdownReader::joined(source, tree),
|
||||
anchored: HashSet::new(),
|
||||
};
|
||||
writer.gather(tree);
|
||||
writer.children(tree, false)?;
|
||||
writer.footnotes()
|
||||
writer.root(tree)
|
||||
}
|
||||
|
||||
struct Writer<'a, 'b, 'c> {
|
||||
placed: &'b mut Placed<'a>,
|
||||
page: &'c mut Page,
|
||||
/// Link definitions by identifier: address and title.
|
||||
links: HashMap<String, (String, Option<String>)>,
|
||||
notes: HashMap<String, Node>,
|
||||
/// Footnotes in the order first called.
|
||||
called: Vec<String>,
|
||||
source: &'c str,
|
||||
reader: MarkdownReader<'c>,
|
||||
/// Notes whose definition has its anchor.
|
||||
anchored: HashSet<usize>,
|
||||
}
|
||||
|
||||
/// What a read area leaves out, in reading order.
|
||||
#[derive(Default)]
|
||||
struct Atoms {
|
||||
/// Each formula's line.
|
||||
formulas: VecDeque<usize>,
|
||||
/// Each footnote reference's number.
|
||||
notes: VecDeque<usize>,
|
||||
}
|
||||
|
||||
/// A stretch of read text's mark.
|
||||
enum Mark<'a> {
|
||||
Style(Style),
|
||||
Link(&'a str),
|
||||
}
|
||||
|
||||
impl Writer<'_, '_, '_> {
|
||||
fn gather(&mut self, node: &Node) {
|
||||
match node {
|
||||
Node::Definition(definition) => {
|
||||
let target = (definition.url.clone(), definition.title.clone());
|
||||
self.links
|
||||
.entry(definition.identifier.clone())
|
||||
.or_insert(target);
|
||||
}
|
||||
Node::FootnoteDefinition(note) => {
|
||||
self.notes
|
||||
.entry(note.identifier.clone())
|
||||
.or_insert_with(|| node.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.gather(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, text: &str) {
|
||||
self.page.html.push_str(text);
|
||||
}
|
||||
|
||||
fn children(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.node(child, tight)?;
|
||||
/// Root blocks: alerts and `[toc]` live only here.
|
||||
fn root(&mut self, tree: &Node) -> io::Result<()> {
|
||||
for child in tree.children().into_iter().flatten() {
|
||||
match child {
|
||||
Node::Blockquote(quote) => match alert(quote) {
|
||||
Some((kind, body)) => self.alert(kind, &body)?,
|
||||
None => self.block(child, false)?,
|
||||
},
|
||||
_ if is_toc(child, self.source) => self.toc(),
|
||||
_ => self.block(child, false)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> {
|
||||
self.push(&format!("<{tag}>"));
|
||||
self.children(node, false)?;
|
||||
self.push(&format!("</{tag}>"));
|
||||
fn blocks(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.block(child, tight)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
fn block(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
match node {
|
||||
Node::Paragraph(_) if tight => self.children(node, false)?,
|
||||
Node::Paragraph(_) => self.wrapped("p", node)?,
|
||||
Node::Heading(heading) => self.wrapped(&format!("h{}", heading.depth), node)?,
|
||||
Node::Blockquote(_) => self.wrapped("blockquote", node)?,
|
||||
Node::Emphasis(_) => self.wrapped("em", node)?,
|
||||
Node::Strong(_) => self.wrapped("strong", node)?,
|
||||
Node::Delete(_) => self.wrapped("del", node)?,
|
||||
Node::Paragraph(_) | Node::Heading(_) | Node::Table(_) | Node::Html(_) => {
|
||||
self.area(node, tight)?
|
||||
}
|
||||
Node::Blockquote(_) => {
|
||||
self.push("<blockquote>");
|
||||
self.blocks(node, false)?;
|
||||
self.push("</blockquote>");
|
||||
}
|
||||
Node::List(list) => {
|
||||
let tag = if list.ordered { "ol" } else { "ul" };
|
||||
match list.start.filter(|start| list.ordered && *start != 1) {
|
||||
@@ -92,73 +108,145 @@ impl Writer<'_, '_, '_> {
|
||||
let mark = if checked { " checked" } else { "" };
|
||||
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
|
||||
}
|
||||
self.children(item, !list.spread && !each.spread)?;
|
||||
self.blocks(item, !list.spread && !each.spread)?;
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
Node::Table(table) => self.table(node, &table.align)?,
|
||||
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
|
||||
let line = line_of(node);
|
||||
match diagram_svg(&code.value) {
|
||||
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
|
||||
Err(_) => {
|
||||
self.page.diagrams.push(line);
|
||||
self.page.diagrams.push(line_of(node));
|
||||
self.code(Some("mermaid"), &code.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
|
||||
Node::Math(math) => self.math(node, &math.value, true),
|
||||
Node::InlineMath(math) => self.math(node, &math.value, false),
|
||||
Node::InlineCode(code) => self.push(&format!("<code>{}</code>", escape(&code.value))),
|
||||
Node::Text(text) => self.push(&escape(&text.value)),
|
||||
// HTML in the document shows as text, never runs.
|
||||
Node::Html(html) => self.push(&escape(&html.value)),
|
||||
Node::Break(_) => self.push("<br>"),
|
||||
Node::Math(math) => self.math(line_of(node), &math.value, true),
|
||||
Node::ThematicBreak(_) => self.push("<hr>"),
|
||||
Node::Link(link) => self.link(node, &link.url, link.title.as_deref())?,
|
||||
Node::LinkReference(link) => match self.links.get(&link.identifier).cloned() {
|
||||
Some((url, title)) => self.link(node, &url, title.as_deref())?,
|
||||
None => self.children(node, false)?,
|
||||
},
|
||||
Node::Image(image) => self.picture(&image.url, &image.alt, image.title.as_deref())?,
|
||||
Node::ImageReference(image) => match self.links.get(&image.identifier).cloned() {
|
||||
Some((url, title)) => self.picture(&url, &image.alt, title.as_deref())?,
|
||||
None => self.push(&escape(&image.alt)),
|
||||
},
|
||||
Node::FootnoteReference(call) => {
|
||||
let number = match self.called.iter().position(|id| *id == call.identifier) {
|
||||
Some(at) => at + 1,
|
||||
None => {
|
||||
self.called.push(call.identifier.clone());
|
||||
self.called.len()
|
||||
}
|
||||
};
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"));
|
||||
Node::FootnoteDefinition(note) => {
|
||||
// A reference reaches its note's first definition.
|
||||
let id = (self.reader.note(¬e.identifier))
|
||||
.filter(|number| self.anchored.insert(*number))
|
||||
.map_or_else(String::new, |number| format!(" id=\"fn-{number}\""));
|
||||
self.push(&format!(
|
||||
"<div{id} style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[{}]</span><div>",
|
||||
escape(&self.reader.label(note))
|
||||
));
|
||||
self.blocks(node, false)?;
|
||||
self.push("</div></div>");
|
||||
}
|
||||
// Written elsewhere, or never.
|
||||
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {}
|
||||
_ => self.children(node, false)?,
|
||||
// Never written.
|
||||
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) => {}
|
||||
other => unreachable!("{other:?} is no block outside MDX"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> {
|
||||
/// A paragraph, heading, table or HTML block, read as drawn.
|
||||
fn area(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
let mut read = Vec::new();
|
||||
self.reader.block(node, &mut read);
|
||||
if let Node::Table(_) = node {
|
||||
let [MarkdownBlock::Table { aligns, head, rows }] = read.as_slice() else {
|
||||
unreachable!("a table reads as one table");
|
||||
};
|
||||
return self.table(node, aligns, head, rows);
|
||||
}
|
||||
// A heading's pictures follow it whole.
|
||||
let split = !matches!(node, Node::Heading(_));
|
||||
let stretches = self.stretches(node, split);
|
||||
self.run(&read, node, tight, split, stretches)
|
||||
}
|
||||
|
||||
/// Pieces in order; with `split`, each picture ends a stretch.
|
||||
fn run(
|
||||
&mut self,
|
||||
pieces: &[MarkdownBlock],
|
||||
node: &Node,
|
||||
tight: bool,
|
||||
split: bool,
|
||||
mut stretches: VecDeque<Atoms>,
|
||||
) -> io::Result<()> {
|
||||
let mut written = false;
|
||||
for piece in pieces {
|
||||
match piece {
|
||||
MarkdownBlock::Image { alt, url, link, .. } => {
|
||||
self.picture(url, alt, link.as_deref())?;
|
||||
if split {
|
||||
retire(stretches.pop_front(), written);
|
||||
written = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let atoms = stretches.front_mut().expect("a stretch per text");
|
||||
self.piece(piece, node, tight, atoms);
|
||||
written = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
retire(stretches.pop_front(), written);
|
||||
assert!(stretches.is_empty(), "a stretch per picture");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Formulas and notes by stretch; pictures part them.
|
||||
fn stretches(&self, node: &Node, split: bool) -> VecDeque<Atoms> {
|
||||
let mut out = VecDeque::from([Atoms::default()]);
|
||||
self.gather(node, split, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn gather(&self, node: &Node, split: bool, out: &mut VecDeque<Atoms>) {
|
||||
match node {
|
||||
Node::InlineMath(_) => {
|
||||
(out.back_mut().expect("a stretch").formulas).push_back(line_of(node))
|
||||
}
|
||||
Node::FootnoteReference(call) => {
|
||||
let number = self
|
||||
.reader
|
||||
.note(&call.identifier)
|
||||
.expect("a reference names a note");
|
||||
out.back_mut().expect("a stretch").notes.push_back(number);
|
||||
}
|
||||
Node::Image(_) | Node::ImageReference(_) if split => out.push_back(Atoms::default()),
|
||||
_ => {}
|
||||
}
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.gather(child, split, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn table(
|
||||
&mut self,
|
||||
node: &Node,
|
||||
aligns: &[AlignKind],
|
||||
head: &[Vec<MarkdownBlock>],
|
||||
rows: &[Vec<Vec<MarkdownBlock>>],
|
||||
) -> io::Result<()> {
|
||||
let written = node.children().expect("a table holds rows");
|
||||
let read: Vec<&[Vec<MarkdownBlock>]> = std::iter::once(head)
|
||||
.chain(rows.iter().map(Vec::as_slice))
|
||||
.collect();
|
||||
assert_eq!(read.len(), written.len(), "a read row per row");
|
||||
self.push("<table>");
|
||||
for (at, row) in node.children().into_iter().flatten().enumerate() {
|
||||
let cell = if at == 0 { "th" } else { "td" };
|
||||
for (at, (row, cells)) in read.into_iter().zip(written).enumerate() {
|
||||
let tag = if at == 0 { "th" } else { "td" };
|
||||
let cells = cells.children().expect("a row holds cells");
|
||||
assert_eq!(row.len(), cells.len(), "a read cell per cell");
|
||||
self.push("<tr>");
|
||||
for (column, each) in row.children().into_iter().flatten().enumerate() {
|
||||
let side = match align.get(column) {
|
||||
for (column, (pieces, cell)) in row.iter().zip(cells).enumerate() {
|
||||
let side = match aligns.get(column) {
|
||||
Some(AlignKind::Left) => " style=\"text-align:left\"",
|
||||
Some(AlignKind::Right) => " style=\"text-align:right\"",
|
||||
Some(AlignKind::Center) => " style=\"text-align:center\"",
|
||||
_ => "",
|
||||
};
|
||||
self.push(&format!("<{cell}{side}>"));
|
||||
self.children(each, false)?;
|
||||
self.push(&format!("</{cell}>"));
|
||||
self.push(&format!("<{tag}{side}>"));
|
||||
let stretches = self.stretches(cell, true);
|
||||
self.run(pieces, cell, true, true, stretches)?;
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
self.push("</tr>");
|
||||
}
|
||||
@@ -166,6 +254,113 @@ impl Writer<'_, '_, '_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn piece(&mut self, piece: &MarkdownBlock, node: &Node, tight: bool, atoms: &mut Atoms) {
|
||||
match piece {
|
||||
MarkdownBlock::Paragraph(text) if tight => self.inline(text, atoms),
|
||||
MarkdownBlock::Paragraph(text) => {
|
||||
self.push("<p>");
|
||||
self.inline(text, atoms);
|
||||
self.push("</p>");
|
||||
}
|
||||
MarkdownBlock::Heading(depth, text) => {
|
||||
let at = node.position().expect("a parsed node has its place");
|
||||
self.push(&format!("<h{depth} id=\"h-{}\">", at.start.offset));
|
||||
self.inline(text, atoms);
|
||||
self.push(&format!("</h{depth}>"));
|
||||
}
|
||||
MarkdownBlock::Math(tex) => {
|
||||
let line = atoms.formulas.pop_front().expect("a formula's line");
|
||||
self.math(line, tex, true);
|
||||
}
|
||||
other => unreachable!("an area's text reads as text or math, not {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read text; its marks open and close as a stack.
|
||||
fn inline(&mut self, read: &Inline, atoms: &mut Atoms) {
|
||||
let mut marks: Vec<(&Range<usize>, Mark)> = (read.styles.iter())
|
||||
.filter(|(_, style)| *style != Style::Link)
|
||||
.map(|(range, style)| (range, Mark::Style(*style)))
|
||||
.chain(
|
||||
read.links
|
||||
.iter()
|
||||
.map(|(range, url)| (range, Mark::Link(url))),
|
||||
)
|
||||
.collect();
|
||||
// Outer first; a formula or note is its own text.
|
||||
marks.sort_by_key(|(range, mark)| {
|
||||
let whole = matches!(mark, Mark::Style(Style::Math | Style::Footnote));
|
||||
(range.start, Reverse(range.end), whole)
|
||||
});
|
||||
let (mut open, mut at): (Vec<(usize, &str)>, usize) = (Vec::new(), 0);
|
||||
let mut links = 0;
|
||||
for (range, mark) in marks {
|
||||
links -= self.close_to(read, range.start, &mut open, &mut at);
|
||||
assert!(
|
||||
range.start >= at && open.last().is_none_or(|(end, _)| range.end <= *end),
|
||||
"read marks nest"
|
||||
);
|
||||
self.text(&read.text[at..range.start]);
|
||||
at = range.start;
|
||||
match mark {
|
||||
Mark::Style(Style::Math) => {
|
||||
let line = atoms.formulas.pop_front().expect("a formula's line");
|
||||
self.math(line, &read.text[range.clone()], false);
|
||||
at = range.end;
|
||||
}
|
||||
Mark::Style(Style::Footnote) => {
|
||||
let number = atoms.notes.pop_front().expect("a note's number");
|
||||
// Links never nest.
|
||||
match links > 0 {
|
||||
true => self.push(&format!("<sup>{number}</sup>")),
|
||||
false => {
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"))
|
||||
}
|
||||
}
|
||||
at = range.end;
|
||||
}
|
||||
Mark::Style(style) => {
|
||||
let name = tag(style);
|
||||
self.push(&format!("<{name}>"));
|
||||
open.push((range.end, name));
|
||||
}
|
||||
Mark::Link(url) => {
|
||||
match safe(url) {
|
||||
true => self.push(&format!("<a href=\"{}\">", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
}
|
||||
open.push((range.end, "a"));
|
||||
links += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.close_to(read, read.text.len(), &mut open, &mut at);
|
||||
self.text(&read.text[at..]);
|
||||
}
|
||||
|
||||
/// Closes each mark ending by `to`; counts closed links.
|
||||
fn close_to(
|
||||
&mut self,
|
||||
read: &Inline,
|
||||
to: usize,
|
||||
open: &mut Vec<(usize, &str)>,
|
||||
at: &mut usize,
|
||||
) -> usize {
|
||||
let mut links = 0;
|
||||
while let Some(&(end, name)) = open.last().filter(|(end, _)| *end <= to) {
|
||||
self.text(&read.text[*at..end]);
|
||||
self.push(&format!("</{name}>"));
|
||||
*at = end;
|
||||
open.pop();
|
||||
links += usize::from(name == "a");
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
fn text(&mut self, text: &str) {
|
||||
self.push(&escape(text).replace('\n', "<br>"));
|
||||
}
|
||||
|
||||
fn code(&mut self, lang: Option<&str>, value: &str) {
|
||||
let class = lang.map_or_else(String::new, |lang| {
|
||||
format!(" class=\"language-{}\"", attribute(lang))
|
||||
@@ -173,12 +368,12 @@ impl Writer<'_, '_, '_> {
|
||||
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
|
||||
}
|
||||
|
||||
fn math(&mut self, node: &Node, value: &str, display: bool) {
|
||||
fn math(&mut self, line: usize, value: &str, display: bool) {
|
||||
match (formula_svg(value, display), display) {
|
||||
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
|
||||
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
|
||||
(Err(error), _) => {
|
||||
self.page.formulas.push((line_of(node), error.to_string()));
|
||||
self.page.formulas.push((line, error.to_string()));
|
||||
match display {
|
||||
true => self.code(Some("math"), value),
|
||||
false => self.push(&format!("<code>{}</code>", escape(value))),
|
||||
@@ -187,21 +382,37 @@ impl Writer<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn link(&mut self, node: &Node, url: &str, title: Option<&str>) -> io::Result<()> {
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
// Only web, mail and in-page addresses follow.
|
||||
match safe(url) {
|
||||
true => self.push(&format!("<a href=\"{}\"{title}>", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
/// A GitHub alert: its kind's title, then its blocks.
|
||||
fn alert(&mut self, kind: AlertKind, body: &[Node]) -> io::Result<()> {
|
||||
let (title, hue) = (kind.title(), hue(kind));
|
||||
self.push(&format!(
|
||||
"<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid {hue}\"><p style=\"font-weight:600;color:{hue}\">{title}</p>"
|
||||
));
|
||||
for node in body {
|
||||
self.block(node, false)?;
|
||||
}
|
||||
self.children(node, false)?;
|
||||
self.push("</a>");
|
||||
self.push("</div>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> {
|
||||
/// `[toc]`: each heading, linked to its place.
|
||||
fn toc(&mut self) {
|
||||
let items: String = (self.reader.headings.iter())
|
||||
.map(|heading| {
|
||||
format!(
|
||||
"<li style=\"margin-left:{}em\"><a href=\"#h-{}\">{}</a></li>",
|
||||
heading.depth - 1,
|
||||
heading.at,
|
||||
escape(&heading.title)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.push(&format!(
|
||||
"<ul style=\"list-style:none;padding-left:0\">{items}</ul>"
|
||||
));
|
||||
}
|
||||
|
||||
fn picture(&mut self, url: &str, alt: &str, link: Option<&str>) -> io::Result<()> {
|
||||
let src = self.placed.place(url)?;
|
||||
if src.is_none() {
|
||||
self.page.missing.push(if url.is_empty() {
|
||||
@@ -215,36 +426,52 @@ impl Writer<'_, '_, '_> {
|
||||
self.push(&format!("[{}]", escape(alt)));
|
||||
return Ok(());
|
||||
};
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
self.push(&format!(
|
||||
"<img src=\"{}\" alt=\"{}\"{title}>",
|
||||
let image = format!(
|
||||
"<img src=\"{}\" alt=\"{}\">",
|
||||
attribute(&src),
|
||||
attribute(alt)
|
||||
));
|
||||
);
|
||||
match link {
|
||||
Some(link) if safe(link) => {
|
||||
self.push(&format!("<a href=\"{}\">{image}</a>", attribute(link)))
|
||||
}
|
||||
Some(_) => self.push(&format!("<a>{image}</a>")),
|
||||
None => self.push(&image),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn footnotes(&mut self) -> io::Result<()> {
|
||||
if self.called.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.push("<section class=\"footnotes\"><hr><ol>");
|
||||
// A note may call another note.
|
||||
let mut at = 0;
|
||||
while at < self.called.len() {
|
||||
let note = self.notes.get(&self.called[at]).cloned();
|
||||
at += 1;
|
||||
self.push(&format!("<li id=\"fn-{at}\">"));
|
||||
match note {
|
||||
Some(note) => self.children(¬e, false)?,
|
||||
None => log::error!("mark: footnote {at} has no definition"),
|
||||
}
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push("</ol></section>");
|
||||
Ok(())
|
||||
/// A stretch done: all read, or blank and dropped.
|
||||
fn retire(atoms: Option<Atoms>, written: bool) {
|
||||
let atoms = atoms.expect("a stretch per picture");
|
||||
assert!(
|
||||
atoms.notes.is_empty() && (!written || atoms.formulas.is_empty()),
|
||||
"every formula and note is read"
|
||||
);
|
||||
}
|
||||
|
||||
/// GitHub's color for an alert kind.
|
||||
fn hue(kind: AlertKind) -> &'static str {
|
||||
match kind {
|
||||
AlertKind::Note => "#0969da",
|
||||
AlertKind::Tip => "#1a7f37",
|
||||
AlertKind::Important => "#8250df",
|
||||
AlertKind::Warning => "#9a6700",
|
||||
AlertKind::Caution => "#d1242f",
|
||||
}
|
||||
}
|
||||
|
||||
/// The tag a read style writes.
|
||||
fn tag(style: Style) -> &'static str {
|
||||
match style {
|
||||
Style::Strong => "strong",
|
||||
Style::Emphasis => "em",
|
||||
Style::Strike => "del",
|
||||
Style::Code => "code",
|
||||
Style::Underline => "u",
|
||||
Style::Mark => "mark",
|
||||
Style::Link | Style::Math | Style::Footnote => unreachable!("{style:?} writes no tag"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,28 +484,3 @@ fn line_of(node: &Node) -> usize {
|
||||
fn bare(svg: &str) -> &str {
|
||||
svg.find("<svg").map_or(svg, |at| &svg[at..])
|
||||
}
|
||||
|
||||
/// A plain, web or mail address, read as browsers do.
|
||||
fn safe(url: &str) -> bool {
|
||||
// Browsers trim spaces and controls, then drop tabs and newlines.
|
||||
let read: String = url
|
||||
.trim_matches(|ch: char| ch <= ' ')
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
|
||||
.collect();
|
||||
match scheme(&read) {
|
||||
None => true,
|
||||
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An address's scheme, when it has one.
|
||||
pub(super) fn scheme(url: &str) -> Option<&str> {
|
||||
let end = url.find(':')?;
|
||||
let name = &url[..end];
|
||||
let plain = name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
|
||||
// A drive letter is a path, not a scheme.
|
||||
(plain && name.len() > 1).then_some(name)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Markdown as an HTML page or plain text (MARK-11).
|
||||
|
||||
mod address;
|
||||
mod files;
|
||||
mod html;
|
||||
mod pictures;
|
||||
@@ -9,10 +10,10 @@ mod tests;
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
use ely_gpui_component::documents::markdown_tree;
|
||||
|
||||
pub use files::{Format, Target, write};
|
||||
pub use pictures::Pictures;
|
||||
pub use pictures::{Pictures, picture_file};
|
||||
pub use plain::plain;
|
||||
|
||||
/// A page and what it could not carry.
|
||||
@@ -42,10 +43,9 @@ pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Resul
|
||||
|
||||
/// `text`'s body; `placed` keeps what it copied.
|
||||
fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result<Page> {
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let tree = markdown_tree(text);
|
||||
let mut page = Page::default();
|
||||
html::write(&tree, placed, &mut page)?;
|
||||
html::write(&tree, text, placed, &mut page)?;
|
||||
log::info!(
|
||||
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
|
||||
page.html.len(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::{
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::html::scheme;
|
||||
use super::address::scheme;
|
||||
|
||||
/// Where pictures go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -19,6 +19,8 @@ pub enum Pictures {
|
||||
Folder(PathBuf),
|
||||
/// Copied into this folder, in formats MuPDF reads.
|
||||
Pdf(PathBuf),
|
||||
/// Addresses as the text writes them.
|
||||
Written,
|
||||
}
|
||||
|
||||
/// Picture formats MuPDF reads from files.
|
||||
@@ -40,6 +42,22 @@ pub(super) struct Placed<'a> {
|
||||
pub made: bool,
|
||||
}
|
||||
|
||||
/// A picture address's file; none for other schemes.
|
||||
pub fn picture_file(url: &str, beside: Option<&Path>) -> Option<PathBuf> {
|
||||
if url.to_lowercase().starts_with("file://") {
|
||||
return Some(file_path(&url["file://".len()..]));
|
||||
}
|
||||
if scheme(url).is_some() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(decoded(url));
|
||||
match (path.is_absolute(), beside) {
|
||||
(true, _) => Some(path),
|
||||
(false, Some(beside)) => Some(beside.join(path)),
|
||||
(false, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Placed<'a> {
|
||||
pub fn new(beside: Option<&'a Path>, pictures: &'a Pictures) -> Self {
|
||||
Self {
|
||||
@@ -56,6 +74,9 @@ impl<'a> Placed<'a> {
|
||||
|
||||
/// The `src` for `url`; none when it is not carried.
|
||||
pub fn place(&mut self, url: &str) -> io::Result<Option<String>> {
|
||||
if matches!(self.pictures, Pictures::Written) {
|
||||
return Ok(Some(url.to_owned()));
|
||||
}
|
||||
let lower = url.to_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
return Ok((!self.pdf()).then(|| url.to_owned()));
|
||||
@@ -67,20 +88,10 @@ impl<'a> Placed<'a> {
|
||||
};
|
||||
return Ok(readable.then(|| url.to_owned()));
|
||||
}
|
||||
let path = match (lower.starts_with("file://"), scheme(url)) {
|
||||
(true, _) => file_path(&url["file://".len()..]),
|
||||
// Other schemes are no picture of the document's.
|
||||
(false, Some(_)) => return Ok(None),
|
||||
(false, None) => {
|
||||
let path = PathBuf::from(decoded(url));
|
||||
match (path.is_absolute(), self.beside) {
|
||||
(true, _) => path,
|
||||
(false, Some(beside)) => beside.join(path),
|
||||
(false, None) => return Ok(None),
|
||||
}
|
||||
}
|
||||
};
|
||||
self.carried(&path)
|
||||
match picture_file(url, self.beside) {
|
||||
Some(path) => self.carried(&path),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
|
||||
@@ -98,6 +109,7 @@ impl<'a> Placed<'a> {
|
||||
}
|
||||
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return Ok(None),
|
||||
Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(),
|
||||
Pictures::Written => unreachable!("written addresses are not carried"),
|
||||
};
|
||||
let named = match self.copied.iter().find(|(from, _)| from == path) {
|
||||
Some((_, name)) => name.clone(),
|
||||
|
||||
@@ -2,21 +2,43 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
use ely_gpui_component::documents::{
|
||||
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_tree,
|
||||
};
|
||||
use markdown::mdast::Node;
|
||||
|
||||
/// `text` as plain text, a blank line between blocks.
|
||||
pub fn plain(text: &str) -> io::Result<String> {
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let tree = markdown_tree(text);
|
||||
let reader = MarkdownReader::joined(text, &tree);
|
||||
let mut blocks = Vec::new();
|
||||
// Alerts and `[toc]` live only at the root.
|
||||
for child in tree.children().into_iter().flatten() {
|
||||
block(child, &mut blocks);
|
||||
match child {
|
||||
Node::Blockquote(quote) => match alert(quote) {
|
||||
Some((kind, body)) => {
|
||||
blocks.push(kind.title().to_string());
|
||||
for node in &body {
|
||||
block(&reader, node, &mut blocks);
|
||||
}
|
||||
}
|
||||
None => block(&reader, child, &mut blocks),
|
||||
},
|
||||
_ if is_toc(child, text) => {
|
||||
let titles: Vec<&str> = (reader.headings.iter())
|
||||
.map(|heading| heading.title.as_str())
|
||||
.collect();
|
||||
if !titles.is_empty() {
|
||||
blocks.push(titles.join("\n"));
|
||||
}
|
||||
}
|
||||
_ => block(&reader, child, &mut blocks),
|
||||
}
|
||||
}
|
||||
Ok(blocks.join("\n\n") + "\n")
|
||||
}
|
||||
|
||||
fn block(node: &Node, blocks: &mut Vec<String>) {
|
||||
fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
|
||||
match node {
|
||||
Node::Code(code) => blocks.push(code.value.clone()),
|
||||
Node::Math(math) => blocks.push(math.value.clone()),
|
||||
@@ -24,7 +46,7 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
|
||||
let items = list.children.iter().map(|item| {
|
||||
let mut inner = Vec::new();
|
||||
for child in item.children().into_iter().flatten() {
|
||||
block(child, &mut inner);
|
||||
block(reader, child, &mut inner);
|
||||
}
|
||||
inner.join("\n")
|
||||
});
|
||||
@@ -32,32 +54,53 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
|
||||
}
|
||||
Node::Table(table) => {
|
||||
let rows = table.children.iter().map(|row| {
|
||||
let cells = row.children().into_iter().flatten().map(words);
|
||||
let cells = row.children().into_iter().flatten();
|
||||
let cells = cells.map(|cell| words(reader, cell));
|
||||
cells.collect::<Vec<_>>().join("\t")
|
||||
});
|
||||
blocks.push(rows.collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
Node::Blockquote(_) | Node::FootnoteDefinition(_) => {
|
||||
Node::FootnoteDefinition(note) => {
|
||||
let mut body = Vec::new();
|
||||
for child in ¬e.children {
|
||||
block(reader, child, &mut body);
|
||||
}
|
||||
// The label the drawing shows beside it.
|
||||
let label = format!("[{}]", reader.label(note));
|
||||
blocks.push([label, body.join("\n\n")].join(" ").trim_end().to_string());
|
||||
}
|
||||
Node::Blockquote(_) => {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
block(child, blocks);
|
||||
block(reader, child, blocks);
|
||||
}
|
||||
}
|
||||
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)),
|
||||
// Front matter, HTML, rules and definitions are not words.
|
||||
Node::Paragraph(_) => {
|
||||
let mut read = Vec::new();
|
||||
reader.block(node, &mut read);
|
||||
match read.as_slice() {
|
||||
[MarkdownBlock::Math(tex)] => blocks.push(tex.clone()),
|
||||
_ => blocks.push(words(reader, node)),
|
||||
}
|
||||
}
|
||||
Node::Heading(_) => blocks.push(words(reader, node)),
|
||||
Node::Html(_) => {
|
||||
let mut read = Vec::new();
|
||||
reader.block(node, &mut read);
|
||||
for piece in read {
|
||||
match piece {
|
||||
MarkdownBlock::Paragraph(text) => blocks.push(text.text),
|
||||
other => unreachable!("an HTML block reads as text, not {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Front matter, rules and definitions are not words.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The words of an inline run.
|
||||
fn words(node: &Node) -> String {
|
||||
match node {
|
||||
Node::Text(text) => text.value.clone(),
|
||||
Node::InlineCode(code) => code.value.clone(),
|
||||
Node::InlineMath(math) => math.value.clone(),
|
||||
Node::Break(_) => "\n".into(),
|
||||
Node::Image(image) => image.alt.clone(),
|
||||
Node::ImageReference(image) => image.alt.clone(),
|
||||
Node::Html(_) => String::new(),
|
||||
_ => node.children().into_iter().flatten().map(words).collect(),
|
||||
}
|
||||
/// An inline run's words, as the drawing reads them.
|
||||
fn words(reader: &MarkdownReader, node: &Node) -> String {
|
||||
reader
|
||||
.inline(node.children().expect("an inline run holds inlines"))
|
||||
.text
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use std::path::Path;
|
||||
|
||||
use super::{Pictures, body, document, plain};
|
||||
|
||||
mod visual;
|
||||
|
||||
fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
|
||||
body(text, beside, pictures).expect("a page")
|
||||
}
|
||||
@@ -51,17 +53,17 @@ fn markdown_writes_as_html() {
|
||||
let text = "# T\n\n- [x] done\n- b\n\n3. c\n\n| l | r |\n|:--|--:|\n| 1 | 2 |\n\n~~gone~~ [a](https://x.org \"t\") [r][ref] [bad](javascript:alert(1))\n\nNote[^1].\n\n[^1]: The note.\n\n[ref]: /docs\n";
|
||||
let html = page(text, None, &Pictures::Inline).html;
|
||||
for part in [
|
||||
"<h1>T</h1>",
|
||||
"<h1 id=\"h-0\">T</h1>",
|
||||
"<li><input type=\"checkbox\" disabled checked> done</li>",
|
||||
"<ol start=\"3\">",
|
||||
"<th style=\"text-align:left\">l</th>",
|
||||
"<td style=\"text-align:right\">2</td>",
|
||||
"<del>gone</del>",
|
||||
"<a href=\"https://x.org\" title=\"t\">a</a>",
|
||||
"<a href=\"https://x.org\">a</a>",
|
||||
"<a href=\"/docs\">r</a>",
|
||||
"<a>bad</a>",
|
||||
"<sup><a href=\"#fn-1\">1</a></sup>",
|
||||
"<li id=\"fn-1\"><p>The note.</p></li>",
|
||||
"<div id=\"fn-1\" style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[1]</span><div><p>The note.</p></div></div>",
|
||||
] {
|
||||
assert!(html.contains(part), "{part} in {html}");
|
||||
}
|
||||
@@ -458,9 +460,15 @@ fn a_formula_letter_no_font_has_stays_as_tex() {
|
||||
fn a_large_document_exports_whole() {
|
||||
// Long paragraphs: markdown-rs slows with the block count.
|
||||
let words = "word ".repeat(200);
|
||||
let mut text: String = (0..2_200).map(|n| format!("Paragraph {n}: {words}\n\n")).collect();
|
||||
let mut text: String = (0..2_200)
|
||||
.map(|n| format!("Paragraph {n}: {words}\n\n"))
|
||||
.collect();
|
||||
text.push_str("The last line.\n");
|
||||
assert!(text.len() > ely_gpui_component::documents::LARGEST_READ);
|
||||
assert!(plain(&text).expect("text").ends_with("The last line.\n"));
|
||||
assert!(page(&text, None, &Pictures::Inline).html.ends_with("<p>The last line.</p>"));
|
||||
assert!(
|
||||
page(&text, None, &Pictures::Inline)
|
||||
.html
|
||||
.ends_with("<p>The last line.</p>")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Exports read the file as the visual mode does (R4.4).
|
||||
|
||||
use super::{Pictures, page, plain};
|
||||
use crate::output::{Page, html::write, markdown_tree, pictures::Placed};
|
||||
|
||||
fn html(text: &str) -> String {
|
||||
page(text, None, &Pictures::Inline).html
|
||||
}
|
||||
|
||||
fn text(text: &str) -> String {
|
||||
plain(text).expect("plain text")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcodes_turn_as_github_turns_them() {
|
||||
let page = html("Go :smile: x:smile:\n\nx:smile: :smile:\n\na <code>:smile:</code> :smile:\n");
|
||||
for part in [
|
||||
"<p>Go 😄 x😄</p>",
|
||||
"<p>x:smile: :smile:</p>",
|
||||
"<p>a <code>:smile:</code> 😄</p>",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert_eq!(
|
||||
text("Go :smile:\n\nx:smile: :smile:\n"),
|
||||
"Go 😄\n\nx:smile: :smile:\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_tags_keep_what_they_mean_and_nothing_runs() {
|
||||
let source = "a <u>u</u> <mark>m</mark> <kbd>k</kbd> <span>s</span> <script>x</script> \
|
||||
<a href=\"javascript:x\">j</a> <a href=\"https://e.test\">e</a>\n";
|
||||
let page = html(source);
|
||||
for part in [
|
||||
"<u>u</u>",
|
||||
"<mark>m</mark>",
|
||||
"<code>k</code>",
|
||||
" s ",
|
||||
"<script>x</script>",
|
||||
"<a>j</a>",
|
||||
"<a href=\"https://e.test\">e</a>",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert!(
|
||||
!page.contains("<span") && !page.contains("<script"),
|
||||
"{page}"
|
||||
);
|
||||
assert_eq!(
|
||||
text("a <b>b</b> <script>x</script>\n"),
|
||||
"a b <script>x</script>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_html_block_shows_its_text() {
|
||||
let source = "<div align=\"center\">\n <b>Bold</b> and :smile:\n</div>\n";
|
||||
let page = html(source);
|
||||
assert!(
|
||||
page.contains("<p><strong>Bold</strong> and 😄</p>"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(!page.contains("<div"), "{page}");
|
||||
assert_eq!(text(source), "Bold and 😄\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_writes_its_kind_and_title() {
|
||||
let page = html("> [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n");
|
||||
let alert = "<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid #0969da\"><p style=\"font-weight:600;color:#0969da\">Note</p><p>Read this.</p></div>";
|
||||
assert!(page.contains(alert), "{page}");
|
||||
assert!(!page.contains("#1a7f37"), "only root quotes alert: {page}");
|
||||
assert_eq!(text("> [!WARNING]\n> Hot.\n"), "Warning\n\nHot.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_toc_lists_the_headings_and_links_them() {
|
||||
let page = html("[toc]\n\n# A\n\n## B :smile:\n");
|
||||
let toc = "<ul style=\"list-style:none;padding-left:0\"><li style=\"margin-left:0em\"><a href=\"#h-7\">A</a></li><li style=\"margin-left:1em\"><a href=\"#h-12\">B 😄</a></li></ul>";
|
||||
assert!(page.contains(toc), "{page}");
|
||||
assert!(page.contains("<h1 id=\"h-7\">A</h1>") && page.contains("<h2 id=\"h-12\">B 😄</h2>"));
|
||||
assert_eq!(text("[toc]\n\n# A\n\n## B\n"), "A\nB\n\nA\n\nB\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_crossing_containers_read_as_drawn() {
|
||||
for (source, shown) in [
|
||||
("*a <code>b* :smile:</code> :smile:\n", "a b :smile: 😄"),
|
||||
("a <code>*b</code>* :smile:\n", "a b 😄"),
|
||||
] {
|
||||
assert_eq!(text(source), format!("{shown}\n"));
|
||||
let page = html(source);
|
||||
assert_eq!(page.matches("😄").count(), 1, "{page}");
|
||||
assert_eq!(
|
||||
page.matches(":smile:").count(),
|
||||
shown.matches(":smile:").count(),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_cell_and_block_reads_on_its_own() {
|
||||
let page = html("| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |\n");
|
||||
assert_eq!(page.matches("😄").count(), 3, "{page}");
|
||||
let page = html("# a <code>x\n\n:smile:\n");
|
||||
assert!(page.contains("<p>😄</p>"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_closes_the_tags_left_open_in_it() {
|
||||
let page = html("a <b>bold\n\nnormal\n");
|
||||
assert!(
|
||||
page.contains("<p>a <strong>bold</strong></p><p>normal</p>"),
|
||||
"{page}"
|
||||
);
|
||||
let page = html("a <a href=\"https://example.test\">link\n\nnormal\n");
|
||||
assert!(page.ends_with("<p>normal</p>"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_toc_entry_has_its_heading() {
|
||||
let page = html("[toc]\n\n# Main\n\n[^n]:\n ## Unused\n");
|
||||
let targets: Vec<&str> = page
|
||||
.split("href=\"#")
|
||||
.skip(1)
|
||||
.map(|rest| &rest[..rest.find('"').expect("a quote")])
|
||||
.collect();
|
||||
assert_eq!(targets.len(), 2, "{page}");
|
||||
for target in targets {
|
||||
assert!(
|
||||
page.contains(&format!("id=\"{target}\"")),
|
||||
"{target} in {page}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_html_block_writes_in_linear_time() {
|
||||
let took = |count: usize| {
|
||||
let source = format!("<div>{}</div>\n", "<b>x</b> ".repeat(count));
|
||||
let start = std::time::Instant::now();
|
||||
html(&source);
|
||||
start.elapsed()
|
||||
};
|
||||
let (small, large) = (took(8_000), took(32_000));
|
||||
// Four times the input; squared would be sixteen.
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_under_deep_marks_write_in_linear_time() {
|
||||
let took = |count: usize| {
|
||||
let source = format!(
|
||||
"start {}{}
|
||||
|
||||
[^n]: note
|
||||
",
|
||||
"<b>".repeat(count),
|
||||
"x[^n] ".repeat(count)
|
||||
);
|
||||
let tree = markdown_tree(&source);
|
||||
let mut placed = Placed::new(None, &Pictures::Inline);
|
||||
let start = std::time::Instant::now();
|
||||
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
|
||||
start.elapsed()
|
||||
};
|
||||
// Parsing grows squared upstream; untimed.
|
||||
let (small, large) = (took(4_000), took(16_000));
|
||||
assert!(large < small * 8, "{small:?} then {large:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_formula_is_dropped_with_its_text() {
|
||||
for source in [
|
||||
"$ $\n",
|
||||
"$ $ \n",
|
||||
"| $ $ | a |\n| - | - |\n| b | c |\n",
|
||||
] {
|
||||
html(source);
|
||||
}
|
||||
let page = page("$ $ \nnext $\\sqrt{$\n", None, &Pictures::Inline);
|
||||
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
|
||||
assert_eq!(lines, [2], "{}", page.html);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_in_a_link_keeps_the_link_whole() {
|
||||
let page = html("a <a href=\"https://example.test\">b[^n] c</a> after\n\n[^n]: note\n");
|
||||
assert!(
|
||||
page.contains("<a href=\"https://example.test\">b<sup>1</sup> c</a>"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reference_reaches_its_own_note() {
|
||||
let page = html("[^1]: unused\n\nCall[^used].\n\n[^used]: right target\n");
|
||||
assert_eq!(page.matches("id=\"fn-1\"").count(), 1, "{page}");
|
||||
let at = page.find("id=\"fn-1\"").expect("an anchor");
|
||||
assert!(
|
||||
page[at..].contains("right target") && !page[at..].contains("unused"),
|
||||
"{page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_notes_keep_the_labels_drawn_beside_them() {
|
||||
assert_eq!(
|
||||
text("A[^b] B[^a].\n\n[^a]: Alpha\n\n[^b]: Beta\n"),
|
||||
"A\u{b9} B\u{b2}.\n\n[2] Alpha\n\n[1] Beta\n"
|
||||
);
|
||||
assert_eq!(text("x\n\n[^n]: note\n"), "x\n\n[n] note\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_fragment_carries_its_look() {
|
||||
let page = html("> [!NOTE]\n> Read this.\n\n[toc]\n\n# Title\n\nCall[^n].\n\n[^n]: note\n");
|
||||
for part in [
|
||||
"border-left:.25em solid #0969da",
|
||||
"font-weight:600",
|
||||
"list-style:none",
|
||||
"display:flex",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert!(
|
||||
!page.contains("class="),
|
||||
"the look needs no page style: {page}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_soft_break_joins_in_exports_as_typora_exports_it() {
|
||||
let page = html("你好\n测试 \nend\n");
|
||||
assert!(page.contains("<p>你好 测试<br>end</p>"), "{page}");
|
||||
assert_eq!(text("你好\n测试\n"), "你好 测试\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tex_delimiters_export_as_formulas() {
|
||||
let source = r"a \(x^2\) b
|
||||
|
||||
\[
|
||||
y+1
|
||||
\]
|
||||
";
|
||||
let page = page(source, None, &Pictures::Inline);
|
||||
assert_eq!(page.html.matches("<svg").count(), 2, "{}", page.html);
|
||||
assert!(page.formulas.is_empty(), "{:?}", page.formulas);
|
||||
assert_eq!(text(source), "a x^2 b\n\ny+1\n");
|
||||
}
|
||||
@@ -293,7 +293,13 @@ impl Desk {
|
||||
}
|
||||
|
||||
/// A new file with the saved text; edits stay.
|
||||
fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context<Self>) {
|
||||
fn keep_edits(
|
||||
&mut self,
|
||||
id: u64,
|
||||
disk: Source,
|
||||
fingerprint: Fingerprint,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
@@ -398,7 +404,10 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
|
||||
let (sender, mut changes) = mpsc::unbounded();
|
||||
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
|
||||
// Reads, ours included, are no change.
|
||||
if event.as_ref().is_ok_and(|event| ely_ui::watching::only_read(&event.kind)) {
|
||||
if event
|
||||
.as_ref()
|
||||
.is_ok_and(|event| ely_ui::watching::only_read(&event.kind))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
|
||||
|
||||
@@ -13,9 +13,14 @@ 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),
|
||||
if path.is_dir() {
|
||||
return self.open_folder(path, cx);
|
||||
}
|
||||
// A shown file only takes focus; the list still learns.
|
||||
let shown = self.core.sessions.is_open(&path).unwrap_or(false);
|
||||
self.open_file(path.clone(), cx);
|
||||
if shown {
|
||||
self.remember(&path, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +113,22 @@ impl Desk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties the recent list.
|
||||
pub fn clear_recent(&mut self, cx: &mut Context<Self>) {
|
||||
match ely_files::recent::clear(&self.recent_file) {
|
||||
Ok(()) => {
|
||||
log::info!("mark: the recent list is cleared");
|
||||
self.recent.clear();
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("mark: the recent list did not clear: {error}");
|
||||
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Line and column `at` in a tab or file.
|
||||
pub fn open_at(
|
||||
&mut self,
|
||||
|
||||
@@ -141,8 +141,11 @@ impl Keeping {
|
||||
/// The untitled tabs to make: their place here and name.
|
||||
pub fn untitled(&self) -> Vec<(usize, String)> {
|
||||
let waiting = self.waiting.iter().enumerate();
|
||||
let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
|
||||
untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect()
|
||||
let untitled =
|
||||
waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
|
||||
untitled
|
||||
.map(|(at, waiting)| (at, waiting.kept.name.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Entry `at` belongs to untitled tab `tab`.
|
||||
@@ -152,11 +155,14 @@ impl Keeping {
|
||||
|
||||
/// What tab `id` takes: its file or its copy.
|
||||
fn take(&mut self, real: Option<&PathBuf>, id: u64) -> Option<Waiting> {
|
||||
let at = self.waiting.iter().position(|waiting| match (&waiting.real, real) {
|
||||
(Some(kept), Some(open)) => kept == open,
|
||||
(None, None) => waiting.tab == Some(id),
|
||||
_ => false,
|
||||
})?;
|
||||
let at = self
|
||||
.waiting
|
||||
.iter()
|
||||
.position(|waiting| match (&waiting.real, real) {
|
||||
(Some(kept), Some(open)) => kept == open,
|
||||
(None, None) => waiting.tab == Some(id),
|
||||
_ => false,
|
||||
})?;
|
||||
Some(self.waiting.remove(at))
|
||||
}
|
||||
|
||||
@@ -243,7 +249,9 @@ impl Desk {
|
||||
// Modes take focus: in the window, after this update.
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let shown = ely_office::in_window(&desk, cx, |_, window, cx| {
|
||||
view.update(cx, |view, cx| view.reopen(mode, kept.place, back, window, cx))
|
||||
view.update(cx, |view, cx| {
|
||||
view.reopen(mode, kept.place, back, window, cx)
|
||||
})
|
||||
});
|
||||
if let Err(error) = shown {
|
||||
log::warn!("mark: tab {id} did not restore: {error:#}");
|
||||
@@ -285,7 +293,8 @@ impl Desk {
|
||||
.spawn(async move {
|
||||
let now = SystemTime::now();
|
||||
let limits = ely_platform::storage::limits(ely_platform::AppId::Mark)?;
|
||||
let mut recovery = Recovery::new(folder).with_limits(limits.keep, limits.megabytes * 1024 * 1024);
|
||||
let mut recovery = Recovery::new(folder)
|
||||
.with_limits(limits.keep, limits.megabytes * 1024 * 1024);
|
||||
recovery.write_once(current.as_bytes(), None, now)
|
||||
})
|
||||
.await;
|
||||
@@ -350,7 +359,11 @@ impl Desk {
|
||||
None => text(cx, "office.stopped.never", &[]).to_string(),
|
||||
};
|
||||
let name = tab.name.to_string();
|
||||
let said = text(cx, "mark.recovery.failed", &[("name", &name), ("error", &error), ("at", &at)]);
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.recovery.failed",
|
||||
&[("name", &name), ("error", &error), ("at", &at)],
|
||||
);
|
||||
self.toast_unlogged(said, ely_gpui_component::primitives::Severity::Danger, cx);
|
||||
}
|
||||
|
||||
|
||||
@@ -441,4 +441,50 @@ mod window {
|
||||
"the session"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_cleared_recent_list_stays_cleared_with_tabs_restored(
|
||||
first: &mut TestAppContext,
|
||||
second: &mut TestAppContext,
|
||||
) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let (a, b) = (files.path().join("a.md"), files.path().join("b.md"));
|
||||
std::fs::write(&a, "A
|
||||
").expect("a");
|
||||
std::fs::write(&b, "B
|
||||
").expect("b");
|
||||
let (desk, cx) = start(first, data.path());
|
||||
open(&desk, &a, cx);
|
||||
let button = cx.debug_bounds("mark-clear-recent").expect("the button");
|
||||
cx.simulate_click(button.center(), gpui::Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.leave(cx);
|
||||
desk.keeping.close();
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let (desk, cx) = start(second, data.path());
|
||||
assert_eq!(names(&desk, cx), ["a.md"]);
|
||||
assert!(desk.read_with(cx, |desk, _| desk.recent.is_empty()));
|
||||
let list = data.path().join("recent.json");
|
||||
assert_eq!(std::fs::read_to_string(&list).expect("the list"), "[]");
|
||||
// Explicit opens still join it: the restored file, a link.
|
||||
let alias = files.path().join("alias.md");
|
||||
std::fs::hard_link(&a, &alias).expect("a hard link");
|
||||
open(&desk, &a, cx);
|
||||
open(&desk, &alias, cx);
|
||||
open(&desk, &b, cx);
|
||||
assert_eq!(names(&desk, cx), ["a.md", "b.md"]);
|
||||
let real = |path: &std::path::Path| dunce::canonicalize(path).expect("real");
|
||||
let want = [real(&b), real(&alias), real(&a)];
|
||||
assert_eq!(desk.read_with(cx, |desk, _| desk.recent.clone()), want);
|
||||
let kept: Vec<std::path::PathBuf> =
|
||||
serde_json::from_str(&std::fs::read_to_string(&list).expect("the list"))
|
||||
.expect("a list");
|
||||
assert_eq!(kept, want);
|
||||
}
|
||||
}
|
||||
+65
-25
@@ -15,8 +15,7 @@ use ely_office::prelude::*;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{
|
||||
AnyElement, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render,
|
||||
SharedString, Styled as _, WeakEntity, Window, div,
|
||||
ParentElement as _, Render, SharedString, Styled as _, WeakEntity, Window, div,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
@@ -201,7 +200,7 @@ impl Side {
|
||||
.action(open)
|
||||
.into_any_element();
|
||||
}
|
||||
let opener = desk.downgrade();
|
||||
let (opener, clearer) = (desk.downgrade(), desk.downgrade());
|
||||
let rows = read.recent.iter().map(|path| {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -215,15 +214,32 @@ impl Side {
|
||||
);
|
||||
(key, item)
|
||||
});
|
||||
rows.fold(
|
||||
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|
||||
|list, (key, item)| list.row(key, item),
|
||||
)
|
||||
.on_activate(move |key, _, cx| {
|
||||
let path = PathBuf::from(key.as_ref());
|
||||
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
||||
})
|
||||
.into_any_element()
|
||||
let list = rows
|
||||
.fold(
|
||||
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|
||||
|list, (key, item)| list.row(key, item),
|
||||
)
|
||||
.on_activate(move |key, _, cx| {
|
||||
let path = PathBuf::from(key.as_ref());
|
||||
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
|
||||
});
|
||||
let clear = Button::new("mark-clear-recent", text(cx, "mark.recent.clear", &[]))
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
clearer.update(cx, |desk, cx| desk.clear_recent(cx)).ok();
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.size_full()
|
||||
.child(div().flex_1().min_h_0().child(list))
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "mark-clear-recent".into())
|
||||
.child(clear),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn outline(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
|
||||
@@ -251,17 +267,21 @@ impl Side {
|
||||
},
|
||||
);
|
||||
let outline = outline.on_select(move |key, window, cx| {
|
||||
let Some(heading) = key.parse::<usize>().ok().and_then(|at| headings.get(at)) else {
|
||||
return log::error!("mark: the outline chose no heading");
|
||||
};
|
||||
// The heading in view, the caret on it.
|
||||
let line = heading.line;
|
||||
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
|
||||
});
|
||||
let Some(heading) = key.parse::<usize>().ok().and_then(|at| headings.get(at)) else {
|
||||
return log::error!("mark: the outline chose no heading");
|
||||
};
|
||||
// The heading in view, the caret on it.
|
||||
let line = heading.line;
|
||||
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
|
||||
});
|
||||
ScrollArea::new("mark-outline-scroll")
|
||||
.vertical()
|
||||
.size_full()
|
||||
.child(div().debug_selector(|| "mark-outline".into()).child(outline))
|
||||
.child(
|
||||
div()
|
||||
.debug_selector(|| "mark-outline".into())
|
||||
.child(outline),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -269,9 +289,17 @@ impl Side {
|
||||
let this = cx.entity();
|
||||
let scope = SegmentedControl::new("mark-scope", if self.wide { "folder" } else { "file" })
|
||||
.size(ControlSize::Sm)
|
||||
.segment("file", text(cx, "mark.search.file", &[]), Some(IconName::File));
|
||||
.segment(
|
||||
"file",
|
||||
text(cx, "mark.search.file", &[]),
|
||||
Some(IconName::File),
|
||||
);
|
||||
let scope = match desk.read(cx).folder {
|
||||
Some(_) => scope.segment("folder", text(cx, "mark.search.folder", &[]), Some(IconName::Folder)),
|
||||
Some(_) => scope.segment(
|
||||
"folder",
|
||||
text(cx, "mark.search.folder", &[]),
|
||||
Some(IconName::Folder),
|
||||
),
|
||||
None => scope,
|
||||
};
|
||||
let scope = scope.on_change(move |key, _, cx| {
|
||||
@@ -350,9 +378,21 @@ impl Render for Side {
|
||||
};
|
||||
let panes = SegmentedControl::new("mark-pane", key)
|
||||
.size(ControlSize::Sm)
|
||||
.segment("files", text(cx, "mark.pane.files", &[]), Some(IconName::Folder))
|
||||
.segment("outline", text(cx, "mark.pane.outline", &[]), Some(IconName::List))
|
||||
.segment("search", text(cx, "mark.pane.search", &[]), Some(IconName::Search))
|
||||
.segment(
|
||||
"files",
|
||||
text(cx, "mark.pane.files", &[]),
|
||||
Some(IconName::Folder),
|
||||
)
|
||||
.segment(
|
||||
"outline",
|
||||
text(cx, "mark.pane.outline", &[]),
|
||||
Some(IconName::List),
|
||||
)
|
||||
.segment(
|
||||
"search",
|
||||
text(cx, "mark.pane.search", &[]),
|
||||
Some(IconName::Search),
|
||||
)
|
||||
.on_change(move |key, _, cx| {
|
||||
this.update(cx, |side, cx| {
|
||||
side.pane = match key.as_ref() {
|
||||
|
||||
+69
-56
@@ -1,14 +1,10 @@
|
||||
//! The table menu's work on the shown file (MARK-06).
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{TableEdit, TableRefusal},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{Context, InteractiveElement as _, actions};
|
||||
use ely_gpui_component::documents::TableEdit;
|
||||
use ely_ui::{commands, i18n::text};
|
||||
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::{blocks::edits, desk::Desk, keys::item};
|
||||
|
||||
actions!(
|
||||
mark,
|
||||
@@ -18,58 +14,75 @@ actions!(
|
||||
RowBelow,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
MoveRowUp,
|
||||
MoveRowDown,
|
||||
MoveColumnLeft,
|
||||
MoveColumnRight,
|
||||
DeleteRow,
|
||||
DeleteColumn
|
||||
DeleteColumn,
|
||||
TidyTable,
|
||||
DeleteTable
|
||||
]
|
||||
);
|
||||
|
||||
impl Desk {
|
||||
fn table(&mut self, edit: Option<TableEdit>, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
let said = text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(said, Severity::Warning, cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
let done = document.update(cx, |document, cx| match edit {
|
||||
Some(edit) => document.edit_table(edit, cx),
|
||||
None => document.insert_table(cx),
|
||||
});
|
||||
let Err(refusal) = done else {
|
||||
return;
|
||||
};
|
||||
let key = match refusal {
|
||||
TableRefusal::Outside => "mark.table.none",
|
||||
TableRefusal::Kept => "mark.table.kept",
|
||||
TableRefusal::Beyond => "mark.table.beyond",
|
||||
TableRefusal::Mixed => "mark.table.mixed",
|
||||
TableRefusal::Preview => "mark.preview.read_only",
|
||||
};
|
||||
let said = text(cx, key, &[]);
|
||||
self.toast(said, Severity::Info, cx);
|
||||
}
|
||||
pub fn bind(cx: &mut App) {
|
||||
let at = Some(commands::CONTEXT);
|
||||
// An open block's input takes Ctrl+Enter as Submit.
|
||||
let open = Some("MarkdownDocument > ElyInput");
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("secondary-t", InsertTable, at),
|
||||
KeyBinding::new("secondary-enter", RowBelow, at),
|
||||
KeyBinding::new("secondary-enter", RowBelow, open),
|
||||
KeyBinding::new("alt-up", MoveRowUp, at),
|
||||
KeyBinding::new("alt-down", MoveRowDown, at),
|
||||
KeyBinding::new("alt-left", MoveColumnLeft, at),
|
||||
KeyBinding::new("alt-right", MoveColumnRight, at),
|
||||
KeyBinding::new("secondary-shift-backspace", DeleteRow, at),
|
||||
]);
|
||||
}
|
||||
|
||||
pub fn table_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
element
|
||||
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table(None, cx)))
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &RowAbove, _, cx| desk.table(Some(TableEdit::RowAbove), cx)),
|
||||
)
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &RowBelow, _, cx| desk.table(Some(TableEdit::RowBelow), cx)),
|
||||
)
|
||||
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| {
|
||||
desk.table(Some(TableEdit::ColumnLeft), cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| {
|
||||
desk.table(Some(TableEdit::ColumnRight), cx)
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|desk, _: &DeleteRow, _, cx| {
|
||||
desk.table(Some(TableEdit::DeleteRow), cx)
|
||||
}),
|
||||
)
|
||||
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| {
|
||||
desk.table(Some(TableEdit::DeleteColumn), cx)
|
||||
}))
|
||||
/// Typora's Paragraph ▸ Table.
|
||||
pub fn table_menu(cx: &App) -> Menu {
|
||||
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
|
||||
Menu::new(text(cx, "mark.menu.table", &[])).items([
|
||||
item("mark.table.insert", Box::new(InsertTable)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.row_above", Box::new(RowAbove)),
|
||||
item("mark.table.row_below", Box::new(RowBelow)),
|
||||
item("mark.table.column_left", Box::new(ColumnLeft)),
|
||||
item("mark.table.column_right", Box::new(ColumnRight)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.move_row_up", Box::new(MoveRowUp)),
|
||||
item("mark.table.move_row_down", Box::new(MoveRowDown)),
|
||||
item("mark.table.move_column_left", Box::new(MoveColumnLeft)),
|
||||
item("mark.table.move_column_right", Box::new(MoveColumnRight)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.delete_row", Box::new(DeleteRow)),
|
||||
item("mark.table.delete_column", Box::new(DeleteColumn)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.tidy", Box::new(TidyTable)),
|
||||
MenuItem::separator(),
|
||||
item("mark.table.delete", Box::new(DeleteTable)),
|
||||
])
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub fn table_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
|
||||
use TableEdit as T;
|
||||
edits!(root, cx,
|
||||
InsertTable => |d, cx| d.insert_table(cx),
|
||||
RowAbove => |d, cx| d.edit_table(T::RowAbove, cx),
|
||||
RowBelow => |d, cx| d.edit_table(T::RowBelow, cx),
|
||||
ColumnLeft => |d, cx| d.edit_table(T::ColumnLeft, cx),
|
||||
ColumnRight => |d, cx| d.edit_table(T::ColumnRight, cx),
|
||||
MoveRowUp => |d, cx| d.edit_table(T::MoveRowUp, cx),
|
||||
MoveRowDown => |d, cx| d.edit_table(T::MoveRowDown, cx),
|
||||
MoveColumnLeft => |d, cx| d.edit_table(T::MoveColumnLeft, cx),
|
||||
MoveColumnRight => |d, cx| d.edit_table(T::MoveColumnRight, cx),
|
||||
DeleteRow => |d, cx| d.edit_table(T::DeleteRow, cx),
|
||||
DeleteColumn => |d, cx| d.edit_table(T::DeleteColumn, cx),
|
||||
TidyTable => |d, cx| d.edit_table(T::Tidy, cx),
|
||||
DeleteTable => |d, cx| d.edit_table(T::Delete, cx),
|
||||
)
|
||||
}
|
||||
}
|
||||
+98
-24
@@ -1,13 +1,10 @@
|
||||
//! Whether Git sees the file changed; ElyTree does the rest.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_gpui_component::{primitives::Severity, shell::StatusBarItem};
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::AppId;
|
||||
use ely_platform::{AppId, git::NoGit};
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::Context;
|
||||
|
||||
@@ -23,15 +20,22 @@ pub enum Tracked {
|
||||
Untracked,
|
||||
Ignored,
|
||||
Conflicted,
|
||||
/// Ely's Git sits beside ElyTree, which is not installed.
|
||||
NoGit,
|
||||
/// 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()),
|
||||
status(file).unwrap_or_else(failed)
|
||||
}
|
||||
|
||||
/// A failure: no Git at all, else why.
|
||||
fn failed(error: io::Error) -> Tracked {
|
||||
match error.get_ref().is_some_and(|inner| inner.is::<NoGit>()) {
|
||||
true => Tracked::NoGit,
|
||||
false => Tracked::Unknown(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +126,16 @@ impl Desk {
|
||||
Tracked::Untracked => "mark.git.untracked",
|
||||
Tracked::Ignored => "mark.git.ignored",
|
||||
Tracked::Conflicted => "mark.git.conflicted",
|
||||
Tracked::NoGit => {
|
||||
let this = cx.entity();
|
||||
let item = StatusBarItem::new("mark-git")
|
||||
.label(text(cx, "mark.git.none", &[]))
|
||||
.tooltip(text(cx, "mark.git.needs", &[]));
|
||||
return Some(item.on_click(move |_, _, cx| {
|
||||
let said = text(cx, "mark.git.needs", &[]);
|
||||
this.update(cx, |desk, cx| desk.toast(said, Severity::Warning, cx));
|
||||
}));
|
||||
}
|
||||
Tracked::Unknown(why) => {
|
||||
let label = text(cx, "mark.git.unknown", &[]);
|
||||
return Some(
|
||||
@@ -145,7 +159,10 @@ impl Desk {
|
||||
}
|
||||
let (this, file) = (this.clone(), file.clone());
|
||||
cx.spawn(async move |cx| {
|
||||
let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await;
|
||||
let opened = cx
|
||||
.background_executor()
|
||||
.spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) })
|
||||
.await;
|
||||
if let Err(error) = opened {
|
||||
log::error!("mark: ElyTree did not open: {error}");
|
||||
cx.update(|cx| {
|
||||
@@ -172,11 +189,21 @@ mod tests {
|
||||
assert!(done.expect("git runs").status.success(), "git {args:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_git_asks_for_elytree_and_other_failures_say_why() {
|
||||
let missing = io::Error::new(io::ErrorKind::NotFound, NoGit);
|
||||
assert_eq!(failed(missing), Tracked::NoGit);
|
||||
let other = io::Error::new(io::ErrorKind::NotFound, "gone");
|
||||
assert_eq!(failed(other), Tracked::Unknown("gone".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_stay_and_broken_repositories_say_why() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
// Windows names end in no space.
|
||||
let repo = dir.path().join(if cfg!(windows) { "re po" } else { "repo " });
|
||||
let repo = dir
|
||||
.path()
|
||||
.join(if cfg!(windows) { "re po" } else { "repo " });
|
||||
std::fs::create_dir(&repo).expect("the folder");
|
||||
git(&repo, &["init", "-q"]);
|
||||
std::fs::write(repo.join("t.md"), "a").expect("a file");
|
||||
@@ -193,11 +220,28 @@ mod tests {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let repo = dir.path();
|
||||
git(repo, &["init", "-q"]);
|
||||
std::fs::write(repo.join(".gitignore"), "skip.md
|
||||
").expect("ignores");
|
||||
std::fs::write(
|
||||
repo.join(".gitignore"),
|
||||
"skip.md
|
||||
",
|
||||
)
|
||||
.expect("ignores");
|
||||
std::fs::write(repo.join("t.md"), "a").expect("a file");
|
||||
git(repo, &["add", "."]);
|
||||
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=T",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"one",
|
||||
],
|
||||
);
|
||||
assert_eq!(ask(&repo.join("t.md")), Tracked::Unchanged);
|
||||
std::fs::write(repo.join("t.md"), "b").expect("an edit");
|
||||
assert_eq!(ask(&repo.join("t.md")), Tracked::Changed);
|
||||
@@ -208,30 +252,60 @@ mod tests {
|
||||
#[gpui::test]
|
||||
fn the_status_item_follows_a_save(cx: &mut gpui::TestAppContext) {
|
||||
use ely_office::prelude::*;
|
||||
let (data, dir) = (tempfile::tempdir().expect("data"), tempfile::tempdir().expect("repo"));
|
||||
let (data, dir) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("repo"),
|
||||
);
|
||||
let repo = dir.path();
|
||||
git(repo, &["init", "-q"]);
|
||||
std::fs::write(repo.join("t.md"), "a
|
||||
").expect("a file");
|
||||
std::fs::write(
|
||||
repo.join("t.md"),
|
||||
"a
|
||||
",
|
||||
)
|
||||
.expect("a file");
|
||||
git(repo, &["add", "."]);
|
||||
git(repo, &["-c", "user.name=T", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", "commit", "-qm", "one"]);
|
||||
git(
|
||||
repo,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=T",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-qm",
|
||||
"one",
|
||||
],
|
||||
);
|
||||
let (desk, cx) = crate::desk::tests::start(cx, data.path());
|
||||
crate::desk::tests::open(&desk, &repo.join("t.md"), cx);
|
||||
let said = |cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
|
||||
let said =
|
||||
|cx: &mut gpui::VisualTestContext| desk.read_with(cx, |desk, _| desk.tracked.clone());
|
||||
assert_eq!(said(cx), Some(Tracked::Unchanged));
|
||||
let (id, view) = desk.read_with(cx, |desk, _| {
|
||||
let tab = desk.active_tab().expect("a tab");
|
||||
(tab.id, tab.view.clone())
|
||||
});
|
||||
view.update(cx, |view, cx| view.adopt("b
|
||||
".into(), cx));
|
||||
cx.executor().advance_clock(std::time::Duration::from_secs(1));
|
||||
view.update(cx, |view, cx| {
|
||||
view.adopt(
|
||||
"b
|
||||
"
|
||||
.into(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.executor()
|
||||
.advance_clock(std::time::Duration::from_secs(1));
|
||||
cx.run_until_parked();
|
||||
desk.update_in(cx, |desk, window, cx| desk.save(id, None, window, cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(std::fs::read_to_string(repo.join("t.md")).expect("saved"), "b
|
||||
");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(repo.join("t.md")).expect("saved"),
|
||||
"b
|
||||
"
|
||||
);
|
||||
assert_eq!(said(cx), Some(Tracked::Changed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::documents::{DocumentEvent, LARGEST_READ, MarkdownDocument, MarkdownMode};
|
||||
use ely_gpui_component::documents::{
|
||||
DocumentEvent, EditRefusal, LARGEST_READ, MarkdownDocument, MarkdownMode,
|
||||
};
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable as _,
|
||||
InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div,
|
||||
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Render, Styled as _,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::engine::{Doc, mark};
|
||||
@@ -16,6 +19,9 @@ pub struct Reported;
|
||||
/// What went wrong, for the shell to say.
|
||||
pub struct Failed(pub String);
|
||||
|
||||
/// An edit asked did not happen, and why.
|
||||
pub struct Refused(pub EditRefusal);
|
||||
|
||||
/// How a document shows (MARK-02).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -81,11 +87,14 @@ pub struct MarkView {
|
||||
pub(crate) shown: bool,
|
||||
/// Trackpad pixels toward the next size step.
|
||||
wheel: f32,
|
||||
/// The last right-click menu asked.
|
||||
offer: Option<ely_office::menu::Offer<menu::Asked>>,
|
||||
pub focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl EventEmitter<Reported> for MarkView {}
|
||||
impl EventEmitter<Failed> for MarkView {}
|
||||
impl EventEmitter<Refused> for MarkView {}
|
||||
|
||||
impl MarkView {
|
||||
pub fn new(doc: Doc, saved: String, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
@@ -126,6 +135,7 @@ impl MarkView {
|
||||
folder: None,
|
||||
shown: false,
|
||||
wheel: 0.0,
|
||||
offer: None,
|
||||
focus,
|
||||
}
|
||||
}
|
||||
@@ -269,6 +279,13 @@ impl MarkView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let DocumentEvent::EditRefused(why) = event {
|
||||
return cx.emit(Refused(*why));
|
||||
}
|
||||
if let DocumentEvent::Pointed(target) = event {
|
||||
let at = window.mouse_position();
|
||||
return self.ask_menu(menu::Asked::Text(target.clone()), at, cx);
|
||||
}
|
||||
if *event == DocumentEvent::Changed {
|
||||
let text = document.read(cx).text(cx);
|
||||
self.doc.lock().expect("the text lock").current = text;
|
||||
@@ -304,7 +321,7 @@ impl MarkView {
|
||||
}
|
||||
|
||||
impl Render for MarkView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
// Ctrl and the wheel size text, as Ctrl+= does.
|
||||
let zoom = ely_ui::wheel::zoom_layer(move |event, window, cx| {
|
||||
@@ -320,10 +337,16 @@ impl Render for MarkView {
|
||||
.relative()
|
||||
.size_full()
|
||||
.track_focus(&self.focus)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(|view, event, _, cx| view.pressed(event, cx)),
|
||||
)
|
||||
.child(self.document.clone())
|
||||
.child(zoom)
|
||||
.children(self.context_menu(window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
mod menu;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,463 @@
|
||||
//! The right-click menu, as Typora's (R4.10).
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{DocumentTarget, TargetBlock},
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::IconName,
|
||||
};
|
||||
use ely_office::menu::Offer;
|
||||
use ely_ui::i18n::text;
|
||||
use gpui::{
|
||||
Action, App, ClipboardItem, Context, Keystroke, MouseDownEvent, Pixels, Point, SharedString,
|
||||
Window,
|
||||
};
|
||||
|
||||
use super::{Failed, MarkView, Mode};
|
||||
use crate::{blocks as b, clip, export, tools};
|
||||
|
||||
/// What a right press asked a menu for.
|
||||
pub(crate) enum Asked {
|
||||
/// The visual mode's target.
|
||||
Text(DocumentTarget),
|
||||
Source,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl MarkView {
|
||||
pub(super) fn ask_menu(&mut self, asked: Asked, at: Point<Pixels>, cx: &mut Context<Self>) {
|
||||
self.offer = Some(Offer::new(self.offer.as_ref(), 0, asked, at));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A right press the visual blocks left alone.
|
||||
pub(super) fn pressed(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
|
||||
let asked = match self.mode {
|
||||
Mode::Visual => return,
|
||||
Mode::Source => Asked::Source,
|
||||
Mode::Preview => Asked::Preview,
|
||||
};
|
||||
log::info!("mark: a right press in {}", self.mode.name());
|
||||
self.ask_menu(asked, event.position, cx);
|
||||
}
|
||||
|
||||
pub(super) fn context_menu(
|
||||
&self,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<ContextMenu> {
|
||||
let offer = self.offer.as_ref()?;
|
||||
let selected = !self.document.read(cx).selected(cx).is_empty();
|
||||
let menu = match &offer.what {
|
||||
Asked::Text(target) => self.text_menu(target, selected, window, cx),
|
||||
Asked::Source => Rows::default()
|
||||
.buttons(clipboard(selected, cx))
|
||||
.row(MenuItem::submenu(
|
||||
said("mark.menu.copy_as", cx),
|
||||
copy_as(window, cx),
|
||||
))
|
||||
.menu(),
|
||||
Asked::Preview => copies(window, cx).into_iter().fold(Menu::new(), Menu::item),
|
||||
};
|
||||
Some(offer.host("mark-menu", menu))
|
||||
}
|
||||
|
||||
/// Typora's groups, shown as the target allows.
|
||||
fn text_menu(
|
||||
&self,
|
||||
target: &DocumentTarget,
|
||||
selected: bool,
|
||||
window: &Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Menu {
|
||||
use TargetBlock as K;
|
||||
let view = cx.entity();
|
||||
let block = target.block;
|
||||
let is = |kinds: &[K]| block.is_some_and(|block| kinds.contains(&block));
|
||||
let inline = is(&[
|
||||
K::Paragraph,
|
||||
K::Quote,
|
||||
K::Alert,
|
||||
K::List,
|
||||
K::Table,
|
||||
K::Footnote,
|
||||
]) || matches!(block, Some(K::Heading(_)));
|
||||
let mut rows = Rows::default();
|
||||
if let Some(url) = &target.image {
|
||||
let url = url.clone();
|
||||
let key = match web(&url) {
|
||||
true => "mark.image.browser",
|
||||
false => "mark.image.location",
|
||||
};
|
||||
let (shown, dropped, at) = (view.clone(), view.clone(), target.at);
|
||||
rows =
|
||||
rows.row(MenuItem::new(said(key, cx)).on_click(move |_, cx| {
|
||||
shown.update(cx, |view, cx| view.show_picture(&url, cx))
|
||||
}))
|
||||
.row(MenuItem::new(said("mark.image.delete", cx)).on_click(move |_, cx| {
|
||||
dropped.update(cx, |view, cx| view.delete_image(at, cx))
|
||||
}))
|
||||
.line();
|
||||
}
|
||||
if let Some(url) = &target.link {
|
||||
let (url, view) = (url.clone(), view.clone());
|
||||
rows = rows
|
||||
.row(
|
||||
MenuItem::new(said("mark.link.open", cx))
|
||||
.on_click(move |_, cx| view.update(cx, |view, cx| view.follow(&url, cx))),
|
||||
)
|
||||
.line();
|
||||
}
|
||||
if is(&[K::Math, K::Html]) {
|
||||
let (at, view) = (target.at, view.clone());
|
||||
rows = rows.row(MenuItem::new(said("mark.block.edit", cx)).on_click(
|
||||
move |window, cx| {
|
||||
let document = view.read(cx).document.clone();
|
||||
document.update(cx, |document, cx| document.show_match(at..at, window, cx));
|
||||
},
|
||||
));
|
||||
}
|
||||
rows = rows.buttons(clipboard(selected, cx));
|
||||
if let Some(url) = &target.link {
|
||||
rows = rows.row(copy_text(said("mark.link.copy", cx), url));
|
||||
}
|
||||
rows = rows.row(MenuItem::submenu(
|
||||
said("mark.menu.copy_as", cx),
|
||||
copy_as(window, cx),
|
||||
));
|
||||
if let Some(code) = &target.code {
|
||||
rows = rows.row(copy_text(said("mark.code.copy", cx), code));
|
||||
}
|
||||
rows = rows.line();
|
||||
if inline {
|
||||
rows = rows.buttons(formats(window, cx));
|
||||
}
|
||||
if !is(&[K::Math, K::Html, K::FrontMatter]) {
|
||||
rows = rows.buttons(wraps(window, cx));
|
||||
}
|
||||
rows = rows.line();
|
||||
if target.in_table {
|
||||
rows = rows.row(MenuItem::submenu(
|
||||
said("mark.menu.table", cx),
|
||||
table(window, cx),
|
||||
));
|
||||
} else if block.is_some() && !is(&[K::Math, K::Html]) {
|
||||
let name = kind(block, cx);
|
||||
rows = rows.row(MenuItem::submenu(name, paragraph(window, cx)));
|
||||
}
|
||||
rows = rows.row(MenuItem::submenu(
|
||||
said("mark.menu.insert", cx),
|
||||
insert(inline, window, cx),
|
||||
));
|
||||
if block.is_some() && !is(&[K::Paragraph]) && !matches!(block, Some(K::Heading(_))) {
|
||||
let name = kind(block, cx);
|
||||
let label = text(cx, "mark.block.delete", &[("block", &name)]);
|
||||
rows = rows
|
||||
.line()
|
||||
.row(run(label, Box::new(b::DeleteBlock), window));
|
||||
}
|
||||
rows.menu()
|
||||
}
|
||||
|
||||
/// Deletes the picture at byte `at`, or says why not.
|
||||
fn delete_image(&mut self, at: usize, cx: &mut Context<Self>) {
|
||||
let deleted = (self.document).update(cx, |document, cx| document.delete_image(at, cx));
|
||||
if let Err(why) = deleted {
|
||||
cx.emit(super::Refused(why));
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a local picture's file, or opens a web one.
|
||||
fn show_picture(&mut self, url: &str, cx: &mut Context<Self>) {
|
||||
if web(url) {
|
||||
log::info!("mark: a picture opens in the browser");
|
||||
return cx.open_url(url);
|
||||
}
|
||||
match crate::output::picture_file(url, self.folder.as_deref()) {
|
||||
Some(path) if path.is_file() => cx.reveal_path(&path),
|
||||
_ => {
|
||||
log::info!("mark: a picture with no file here");
|
||||
cx.emit(Failed(said("mark.image.no_file", cx).to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn web(url: &str) -> bool {
|
||||
let lower = url.to_lowercase();
|
||||
lower.starts_with("http://") || lower.starts_with("https://")
|
||||
}
|
||||
|
||||
fn said(key: &str, cx: &App) -> SharedString {
|
||||
text(cx, key, &[])
|
||||
}
|
||||
|
||||
/// Rows with lines only between groups.
|
||||
#[derive(Default)]
|
||||
struct Rows {
|
||||
menu: Menu,
|
||||
filled: bool,
|
||||
line: bool,
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
fn row(self, item: MenuItem) -> Self {
|
||||
self.with(|menu| menu.item(item))
|
||||
}
|
||||
|
||||
fn buttons(self, items: Vec<MenuItem>) -> Self {
|
||||
self.with(|menu| menu.buttons(items))
|
||||
}
|
||||
|
||||
fn with(mut self, add: impl FnOnce(Menu) -> Menu) -> Self {
|
||||
if self.line && self.filled {
|
||||
self.menu = self.menu.separator();
|
||||
}
|
||||
self.menu = add(self.menu);
|
||||
(self.filled, self.line) = (true, false);
|
||||
self
|
||||
}
|
||||
|
||||
fn line(mut self) -> Self {
|
||||
self.line = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn menu(self) -> Menu {
|
||||
self.menu
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that runs `action`, its keys shown.
|
||||
fn run(label: SharedString, action: Box<dyn Action>, window: &Window) -> MenuItem {
|
||||
let keys = (window.highest_precedence_binding_for_action(action.as_ref())).map(|binding| {
|
||||
let strokes: Vec<String> = binding.keystrokes().iter().map(|k| k.unparse()).collect();
|
||||
strokes.join(" ")
|
||||
});
|
||||
let action: Rc<Box<dyn Action>> = Rc::new(action);
|
||||
let row = MenuItem::new(label)
|
||||
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx));
|
||||
match keys {
|
||||
Some(keys) => row.keys(keys),
|
||||
None => row,
|
||||
}
|
||||
}
|
||||
|
||||
/// A button that presses `stroke` where focus went back.
|
||||
fn press(label: SharedString, icon: IconName, stroke: &str, shown: bool) -> MenuItem {
|
||||
let keystroke = Keystroke::parse(stroke).expect("a fixed keystroke");
|
||||
let button = MenuItem::new(label).icon(icon);
|
||||
let button = match shown {
|
||||
true => button.keys(keystroke.unparse()),
|
||||
false => button,
|
||||
};
|
||||
button.on_click(move |window, cx| {
|
||||
let keystroke = keystroke.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
window.dispatch_keystroke(keystroke, cx);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn clipboard(selected: bool, cx: &App) -> Vec<MenuItem> {
|
||||
vec![
|
||||
press(
|
||||
said("mark.edit.cut", cx),
|
||||
IconName::Scissors,
|
||||
"secondary-x",
|
||||
true,
|
||||
)
|
||||
.disabled(!selected),
|
||||
press(
|
||||
said("mark.edit.copy", cx),
|
||||
IconName::Copy,
|
||||
"secondary-c",
|
||||
true,
|
||||
)
|
||||
.disabled(!selected),
|
||||
press(
|
||||
said("mark.edit.paste", cx),
|
||||
IconName::Clipboard,
|
||||
"secondary-v",
|
||||
true,
|
||||
),
|
||||
press(
|
||||
said("mark.edit.delete", cx),
|
||||
IconName::Trash2,
|
||||
"backspace",
|
||||
false,
|
||||
)
|
||||
.disabled(!selected),
|
||||
]
|
||||
}
|
||||
|
||||
fn copy_text(label: SharedString, copied: &str) -> MenuItem {
|
||||
let copied = copied.to_string();
|
||||
MenuItem::new(label).on_click(move |_, cx| {
|
||||
log::info!("mark: {} bytes copied from the menu", copied.len());
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(copied.clone()));
|
||||
})
|
||||
}
|
||||
|
||||
/// Typora's Copy / Paste As rows.
|
||||
fn copies(window: &Window, cx: &App) -> Vec<MenuItem> {
|
||||
vec![
|
||||
run(
|
||||
said("mark.copy.plain", cx),
|
||||
Box::new(export::CopyPlain),
|
||||
window,
|
||||
),
|
||||
run(
|
||||
said("mark.copy.markdown", cx),
|
||||
Box::new(clip::CopyMarkdown),
|
||||
window,
|
||||
),
|
||||
run(
|
||||
said("mark.copy.html", cx),
|
||||
Box::new(export::CopyHtml),
|
||||
window,
|
||||
),
|
||||
run(
|
||||
said("mark.copy.rich", cx),
|
||||
Box::new(export::CopyRich),
|
||||
window,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn copy_as(window: &Window, cx: &App) -> Menu {
|
||||
let paste = run(
|
||||
said("mark.paste.plain", cx),
|
||||
Box::new(clip::PastePlain),
|
||||
window,
|
||||
);
|
||||
copies(window, cx)
|
||||
.into_iter()
|
||||
.fold(Menu::new(), Menu::item)
|
||||
.separator()
|
||||
.item(paste)
|
||||
}
|
||||
|
||||
fn formats(window: &Window, cx: &App) -> Vec<MenuItem> {
|
||||
let row = |key, action: Box<dyn Action>, name| run(said(key, cx), action, window).icon(name);
|
||||
vec![
|
||||
row("mark.format.strong", Box::new(b::Strong), IconName::Bold),
|
||||
row(
|
||||
"mark.format.emphasis",
|
||||
Box::new(b::Emphasis),
|
||||
IconName::Italic,
|
||||
),
|
||||
row("mark.format.code", Box::new(b::InlineCode), IconName::Code),
|
||||
row("mark.format.link", Box::new(b::Hyperlink), IconName::Link),
|
||||
]
|
||||
}
|
||||
|
||||
fn wraps(window: &Window, cx: &App) -> Vec<MenuItem> {
|
||||
let row = |key, action: Box<dyn Action>, name| run(said(key, cx), action, window).icon(name);
|
||||
vec![
|
||||
row("mark.block.quote", Box::new(b::Quote), IconName::Quote),
|
||||
row(
|
||||
"mark.block.ordered",
|
||||
Box::new(b::OrderedList),
|
||||
IconName::ListOrdered,
|
||||
),
|
||||
row("mark.block.bullet", Box::new(b::BulletList), IconName::List),
|
||||
row(
|
||||
"mark.block.task",
|
||||
Box::new(b::TaskList),
|
||||
IconName::ListChecks,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn paragraph(window: &Window, cx: &App) -> Menu {
|
||||
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
|
||||
Menu::new()
|
||||
.item(row("mark.block.h1", Box::new(b::Heading1)))
|
||||
.item(row("mark.block.h2", Box::new(b::Heading2)))
|
||||
.item(row("mark.block.h3", Box::new(b::Heading3)))
|
||||
.item(row("mark.block.h4", Box::new(b::Heading4)))
|
||||
.item(row("mark.block.h5", Box::new(b::Heading5)))
|
||||
.item(row("mark.block.h6", Box::new(b::Heading6)))
|
||||
.separator()
|
||||
.item(row("mark.block.paragraph", Box::new(b::Paragraph)))
|
||||
}
|
||||
|
||||
fn table(window: &Window, cx: &App) -> Menu {
|
||||
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
|
||||
Menu::new()
|
||||
.item(row("mark.table.row_above", Box::new(tools::RowAbove)))
|
||||
.item(row("mark.table.row_below", Box::new(tools::RowBelow)))
|
||||
.item(row("mark.table.column_left", Box::new(tools::ColumnLeft)))
|
||||
.item(row("mark.table.column_right", Box::new(tools::ColumnRight)))
|
||||
.separator()
|
||||
.item(row("mark.table.move_row_up", Box::new(tools::MoveRowUp)))
|
||||
.item(row(
|
||||
"mark.table.move_row_down",
|
||||
Box::new(tools::MoveRowDown),
|
||||
))
|
||||
.item(row(
|
||||
"mark.table.move_column_left",
|
||||
Box::new(tools::MoveColumnLeft),
|
||||
))
|
||||
.item(row(
|
||||
"mark.table.move_column_right",
|
||||
Box::new(tools::MoveColumnRight),
|
||||
))
|
||||
.separator()
|
||||
.item(row("mark.table.delete_row", Box::new(tools::DeleteRow)))
|
||||
.item(row(
|
||||
"mark.table.delete_column",
|
||||
Box::new(tools::DeleteColumn),
|
||||
))
|
||||
.separator()
|
||||
.item(row("mark.table.tidy", Box::new(tools::TidyTable)))
|
||||
.separator()
|
||||
.item(row("mark.table.delete", Box::new(tools::DeleteTable)))
|
||||
}
|
||||
|
||||
fn insert(inline: bool, window: &Window, cx: &App) -> Menu {
|
||||
let row = |key, action: Box<dyn Action>| run(said(key, cx), action, window);
|
||||
let menu = match inline {
|
||||
true => Menu::new()
|
||||
.item(row("mark.insert.image", Box::new(b::InsertImage)))
|
||||
.separator(),
|
||||
false => Menu::new(),
|
||||
};
|
||||
menu.item(row("mark.block.footnote", Box::new(b::Footnote)))
|
||||
.item(row("mark.block.link_reference", Box::new(b::LinkReference)))
|
||||
.item(row("mark.block.rule", Box::new(b::Rule)))
|
||||
.item(row("mark.menu.table", Box::new(tools::InsertTable)))
|
||||
.item(row("mark.block.code", Box::new(b::CodeFences)))
|
||||
.item(row("mark.block.math", Box::new(b::MathBlock)))
|
||||
.item(row("mark.block.toc", Box::new(b::Toc)))
|
||||
.item(row("mark.block.front_matter", Box::new(b::FrontMatter)))
|
||||
.separator()
|
||||
.item(row("mark.insert.before", Box::new(b::ParagraphBefore)))
|
||||
.item(row("mark.insert.after", Box::new(b::ParagraphAfter)))
|
||||
}
|
||||
|
||||
/// A block kind's name, as Typora's menus say it.
|
||||
fn kind(block: Option<TargetBlock>, cx: &App) -> SharedString {
|
||||
use TargetBlock as K;
|
||||
let key = match block {
|
||||
None | Some(K::Paragraph) => "mark.block.paragraph",
|
||||
Some(K::Heading(level)) => {
|
||||
return said(&format!("mark.block.h{}", level.clamp(1, 6)), cx);
|
||||
}
|
||||
Some(K::Quote) => "mark.block.quote",
|
||||
Some(K::Alert) => "mark.kind.alert",
|
||||
Some(K::List) => "mark.kind.list",
|
||||
Some(K::Code) => "mark.block.code",
|
||||
Some(K::Math) => "mark.block.math",
|
||||
Some(K::Table) => "mark.menu.table",
|
||||
Some(K::Rule) => "mark.block.rule",
|
||||
Some(K::Html) => "mark.kind.html",
|
||||
Some(K::Footnote) => "mark.block.footnote",
|
||||
Some(K::Definition) => "mark.block.link_reference",
|
||||
Some(K::FrontMatter) => "mark.block.front_matter",
|
||||
Some(K::Toc) => "mark.block.toc",
|
||||
};
|
||||
said(key, cx)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ fn fraction(key: &TileKey, size: (f32, f32)) -> [f32; 4] {
|
||||
|
||||
impl PdfView {
|
||||
/// The pages in view and the tiles each needs.
|
||||
pub fn showing(&self, sf: f32, dark: bool) -> Vec<Showing> {
|
||||
pub fn showing(&self, sf: f32) -> Vec<Showing> {
|
||||
let laid = self.laid_out();
|
||||
let (room_w, room_h) = (f32::from(self.room.size.width), f32::from(self.room.size.height));
|
||||
let (left, top) = (-f32::from(self.scroll.offset().x), -f32::from(self.scroll.offset().y));
|
||||
@@ -50,7 +50,7 @@ impl PdfView {
|
||||
let keys = ys
|
||||
.flat_map(|ty| xs.clone().map(move |tx| (tx, ty)))
|
||||
.map(|(tx, ty)| {
|
||||
let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, tx, ty };
|
||||
let key = TileKey { page: place.page, scale, rotate: self.rotate, tx, ty };
|
||||
(key, [tx * TILE, ty * TILE, TILE.min(dw - tx * TILE), TILE.min(dh - ty * TILE)])
|
||||
})
|
||||
.collect();
|
||||
@@ -123,7 +123,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_tile_keeps_its_place_as_a_fraction_of_its_page() {
|
||||
let key = TileKey { page: 0, scale: 2000, rotate: 0, dark: false, tx: 1, ty: 0 };
|
||||
let key = TileKey { page: 0, scale: 2000, rotate: 0, tx: 1, ty: 0 };
|
||||
// A 612 by 792 page at 2x: 1224 by 1584.
|
||||
assert_eq!(fraction(&key, (612.0, 792.0)), [512.0 / 1224.0, 0.0, 512.0 / 1224.0, 512.0 / 1584.0]);
|
||||
let turned = TileKey { rotate: 90, ..key };
|
||||
|
||||
@@ -16,14 +16,13 @@ use crate::view::{PdfView, Reported};
|
||||
/// A tile's side in device pixels.
|
||||
pub const TILE: i32 = 512;
|
||||
|
||||
/// A page tile at one scale, turn and theme.
|
||||
/// A page tile at one scale and turn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct TileKey {
|
||||
pub page: usize,
|
||||
/// Device pixels a point, times a thousand.
|
||||
pub scale: u32,
|
||||
pub rotate: i32,
|
||||
pub dark: bool,
|
||||
pub tx: i32,
|
||||
pub ty: i32,
|
||||
}
|
||||
@@ -123,17 +122,14 @@ impl PdfView {
|
||||
|
||||
/// Pages in view and their tiles; missing ones asked.
|
||||
pub fn page_view(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let (sf, dark) = (window.scale_factor(), cx.theme().is_dark());
|
||||
let (sunken, paper) = {
|
||||
let colors = &cx.theme().colors;
|
||||
(colors.sunken, if dark { colors.surface } else { colors.paper })
|
||||
};
|
||||
let sf = window.scale_factor();
|
||||
let (sunken, paper) = (cx.theme().colors.sunken, gpui::white());
|
||||
let laid = self.laid_out();
|
||||
if let Some((x, y)) = self.go_to.take() {
|
||||
self.scroll.set_offset(point(px(-x), px(-y)));
|
||||
}
|
||||
let top = -f32::from(self.scroll.offset().y);
|
||||
let showing = self.showing(sf, dark);
|
||||
let showing = self.showing(sf);
|
||||
// The current page: first showing more than a sliver.
|
||||
if let Some(first) = showing.iter().map(|s| s.place).find(|p| p.y + p.h > top + GAP * 3.0) {
|
||||
let paired = showing.iter().any(|s| s.place.page == self.current) && first.page / 2 == self.current / 2;
|
||||
@@ -266,7 +262,7 @@ impl PdfView {
|
||||
self.painting = true;
|
||||
let (doc, scale, version) = (self.doc, key.scale as f32 / 1000.0, self.version);
|
||||
self.off(
|
||||
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect, key.dark),
|
||||
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect),
|
||||
move |view, painted, cx| {
|
||||
view.painting = false;
|
||||
if view.version != version {
|
||||
|
||||
+13
-13
@@ -100,11 +100,11 @@ impl PdfView {
|
||||
}
|
||||
|
||||
/// Asks for the first missing picture in `rows`.
|
||||
fn ask_thumb(&mut self, rows: Range<usize>, (sf, dark): (f32, bool), cx: &mut Context<Self>) {
|
||||
fn ask_thumb(&mut self, rows: Range<usize>, sf: f32, cx: &mut Context<Self>) {
|
||||
if self.thumbing {
|
||||
return;
|
||||
}
|
||||
let due = |page: &usize| !self.thumbs.contains_key(&(*page, dark)) || self.thumbs_stale.contains(&(*page, dark));
|
||||
let due = |page: &usize| !self.thumbs.contains_key(page) || self.thumbs_stale.contains(page);
|
||||
let Some(page) = rows.clone().find(|page| due(page) && !self.thumbs_failed.contains(page)) else {
|
||||
return;
|
||||
};
|
||||
@@ -114,7 +114,7 @@ impl PdfView {
|
||||
let (doc, version) = (self.doc, self.version);
|
||||
self.thumbing = true;
|
||||
self.off(
|
||||
move |worker| worker.render(doc, page, (scale, 0), tile, dark),
|
||||
move |worker| worker.render(doc, page, (scale, 0), tile),
|
||||
move |view, painted, cx| {
|
||||
view.thumbing = false;
|
||||
if view.version != version {
|
||||
@@ -128,8 +128,8 @@ impl PdfView {
|
||||
});
|
||||
match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) {
|
||||
Ok(Some(image)) => {
|
||||
view.thumbs_stale.remove(&(page, dark));
|
||||
if let Some(old) = view.thumbs.insert((page, dark), image) {
|
||||
view.thumbs_stale.remove(&page);
|
||||
if let Some(old) = view.thumbs.insert(page, image) {
|
||||
view.retired.push(old);
|
||||
}
|
||||
}
|
||||
@@ -149,18 +149,18 @@ impl PdfView {
|
||||
|
||||
impl PdfView {
|
||||
/// The picture a row draws; an old one until repainted.
|
||||
pub fn picture(&self, page: usize, dark: bool) -> Option<Arc<RenderImage>> {
|
||||
self.thumbs.get(&(page, dark)).cloned()
|
||||
pub fn picture(&self, page: usize) -> Option<Arc<RenderImage>> {
|
||||
self.thumbs.get(&page).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// One row: the page number and its picture.
|
||||
fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyElement {
|
||||
fn row(view: &Entity<PdfView>, page: usize, cx: &App) -> gpui::AnyElement {
|
||||
let read = view.read(cx);
|
||||
let colors = &cx.theme().colors;
|
||||
let (w, h) = read.sizes[page];
|
||||
let picked = read.picked.contains(&page);
|
||||
let picture = read.picture(page, dark);
|
||||
let picture = read.picture(page);
|
||||
let count = read.sizes.len();
|
||||
let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
|
||||
let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone());
|
||||
@@ -210,7 +210,7 @@ fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyEl
|
||||
.h(px(height))
|
||||
.border_1()
|
||||
.border_color(if picked { colors.focus } else { colors.border })
|
||||
.bg(if dark { colors.surface } else { colors.paper })
|
||||
.bg(gpui::white())
|
||||
.when_some(picture, |frame, picture| frame.child(img(picture).size_full())),
|
||||
)
|
||||
.child(div().text_xs().text_color(colors.fg_subtle).child((page + 1).to_string()))
|
||||
@@ -258,12 +258,12 @@ impl Render for Side {
|
||||
Pane::Notes => crate::notelist::list(&view, cx),
|
||||
Pane::Files => crate::attach::list(&view, cx),
|
||||
Pane::Pages => {
|
||||
let shown = (window.scale_factor(), cx.theme().is_dark());
|
||||
let sf = window.scale_factor();
|
||||
let count = view.read(cx).sizes.len();
|
||||
let rows = view.clone();
|
||||
let list = uniform_list("pdf-thumbs", count, move |range, _, cx| {
|
||||
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), shown, cx));
|
||||
range.map(|page| row(&rows, page, shown.1, cx)).collect()
|
||||
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), sf, cx));
|
||||
range.map(|page| row(&rows, page, cx)).collect()
|
||||
})
|
||||
.size_full();
|
||||
div()
|
||||
|
||||
@@ -188,13 +188,13 @@ fn tiles_beside_the_view_are_neither_asked_nor_kept() {
|
||||
|
||||
/// Flags any notify leaving a shown page with nothing drawn.
|
||||
fn never_bare(cx: &mut VisualTestContext, view: &Entity<PdfView>) -> (gpui::Subscription, std::rc::Rc<std::cell::Cell<bool>>) {
|
||||
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
|
||||
let sf = cx.update(|window, _| window.scale_factor());
|
||||
let bare = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let seen = bare.clone();
|
||||
let watch = cx.update(|_, app| {
|
||||
app.observe(view, move |view, app| {
|
||||
let read = view.read(app);
|
||||
if read.showing(sf, dark).iter().any(|page| read.pieces(page).is_empty()) {
|
||||
if read.showing(sf).iter().any(|page| read.pieces(page).is_empty()) {
|
||||
seen.set(true);
|
||||
}
|
||||
})
|
||||
@@ -248,10 +248,10 @@ fn a_new_scale_keeps_the_old_tiles_until_new_ones_land() {
|
||||
cx.simulate_resize(gpui::size(px(700.), px(800.)));
|
||||
// The new size lays out before any new tile lands.
|
||||
cx.run_until_parked();
|
||||
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
|
||||
let sf = cx.update(|window, _| window.scale_factor());
|
||||
let now = cx.update(|_, app| {
|
||||
let read = view.read(app);
|
||||
read.showing(sf, dark).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
|
||||
read.showing(sf).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
|
||||
});
|
||||
assert!(now.iter().all(|count| *count > 0), "drawn right after the resize: {now:?}");
|
||||
settle(cx, |app| view.read(app).laid_out().scale != scale && repainted(view.read(app)));
|
||||
@@ -315,7 +315,7 @@ fn old_tiles_are_painted_and_freed_with_their_tab() {
|
||||
let shown = cx.update(|window, app| {
|
||||
let read = view.read(app);
|
||||
let room = read.room;
|
||||
read.showing(window.scale_factor(), false).iter().map(|s| {
|
||||
read.showing(window.scale_factor()).iter().map(|s| {
|
||||
let (left, top) = (-f32::from(read.scroll.offset().x), -f32::from(read.scroll.offset().y));
|
||||
let w = (s.place.x + s.place.w).min(left + f32::from(room.size.width)) - s.place.x.max(left);
|
||||
let h = (s.place.y + s.place.h).min(top + f32::from(room.size.height)) - s.place.y.max(top);
|
||||
@@ -333,9 +333,8 @@ fn an_edit_keeps_page_pictures_until_they_repaint() {
|
||||
let mut cx = TestAppContext::single();
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
|
||||
let view = shown(cx, &viewer);
|
||||
let dark = cx.update(|_, app| ely_gpui_component::theme::ActiveTheme::theme(app).is_dark());
|
||||
settle(cx, |app| view.read(app).picture(0, dark).is_some() && repainted(view.read(app)));
|
||||
let old = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
|
||||
settle(cx, |app| view.read(app).picture(0).is_some() && repainted(view.read(app)));
|
||||
let old = cx.update(|_, app| view.read(app).picture(0).expect("a picture"));
|
||||
// A picture on its way holds back the repaint.
|
||||
cx.update(|_, app| view.update(app, |view, _| view.thumbing = true));
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
|
||||
@@ -349,7 +348,7 @@ fn an_edit_keeps_page_pictures_until_they_repaint() {
|
||||
cx.notify()
|
||||
}));
|
||||
settle(cx, |app| view.read(app).thumbs_stale.is_empty());
|
||||
let new = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
|
||||
let new = cx.update(|_, app| view.read(app).picture(0).expect("a picture"));
|
||||
assert!(!std::sync::Arc::ptr_eq(&new, &old), "the new picture replaces it");
|
||||
super::frames(cx);
|
||||
assert!(cx.update(|window, _| window.has_image_atlas_entry(&new)), "and is drawn");
|
||||
|
||||
@@ -158,7 +158,7 @@ fn scanned(dir: &std::path::Path) -> std::path::PathBuf {
|
||||
let doc = worker.open(&source).expect("opens").doc;
|
||||
let s: f32 = 200.0 / 72.0;
|
||||
let (w, h) = ((612.0 * s).ceil() as i32, (792.0 * s).ceil() as i32);
|
||||
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h], false).expect("draws").bgra;
|
||||
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h]).expect("draws").bgra;
|
||||
let gray: Vec<u8> = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect();
|
||||
let path = dir.join("scan.pdf");
|
||||
std::fs::write(&path, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Password prompts, zoomed pairs, jumps across (C11.1).
|
||||
|
||||
use ely_office::{Dialog, Dialogs as _, Tabs as _};
|
||||
use ely_office::{Dialog, Dialogs as _, Opening as _, Tabs as _};
|
||||
use gpui::{Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{file, frames, settle, shown, windowed};
|
||||
@@ -144,3 +144,21 @@ fn ctrl_and_a_wheel_notch_step_the_zoom_once() {
|
||||
frames(cx);
|
||||
assert_eq!(read(cx).0, Zoom::Scale(crate::keys::stepped(1.0, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dark_app_shows_the_page_as_the_file_paints_it() {
|
||||
use ely_gpui_component::theme::{Mode, Theme};
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[], true);
|
||||
cx.update(|_, app| Theme::set_mode_now(Mode::Dark, app));
|
||||
let path = file(dir.path(), "one.pdf", &[(612.0, 792.0, &["alpha"])]);
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.open_file(path, cx)));
|
||||
settle(cx, |app| viewer.read(app).active_view().is_some());
|
||||
let view = shown(cx, &viewer);
|
||||
let corner = |app: &gpui::App| view.read(app).tiles.iter().find(|(key, _)| key.tx == 0 && key.ty == 0).map(|(_, image)| image.clone());
|
||||
settle(cx, |app| corner(app).is_some());
|
||||
let tile = cx.update(|_, app| corner(app)).expect("the first tile");
|
||||
let bytes = tile.as_bytes(0).expect("its pixels");
|
||||
assert_eq!(&bytes[..3], &[255, 255, 255], "the paper stays white");
|
||||
}
|
||||
@@ -87,11 +87,11 @@ pub struct PdfView {
|
||||
pub saved: String,
|
||||
/// Saves so far: a read from before one is stale.
|
||||
pub saves: u64,
|
||||
/// Page pictures for the side panel, by page and darkness.
|
||||
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
|
||||
/// Page pictures for the side panel, by page.
|
||||
pub thumbs: HashMap<usize, Arc<RenderImage>>,
|
||||
pub thumbs_failed: HashSet<usize>,
|
||||
/// Pictures from before an edit; shown until repainted.
|
||||
pub thumbs_stale: HashSet<(usize, bool)>,
|
||||
pub thumbs_stale: HashSet<usize>,
|
||||
/// A picture is on its way from the worker.
|
||||
pub thumbing: bool,
|
||||
/// Pages picked in the side panel, and the anchor.
|
||||
|
||||
@@ -57,6 +57,8 @@ pub struct Deck {
|
||||
pub remarks_pane: Entity<crate::remarks::Pane>,
|
||||
/// What the menus last checked: grid, guides, comments.
|
||||
pub snap_menus: Option<(bool, bool, bool)>,
|
||||
/// The canvas's right-click menu.
|
||||
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
|
||||
/// Pictures of closed tabs and ended previews, freed later.
|
||||
pub retired: ely_gpui_component::primitives::Retired,
|
||||
}
|
||||
@@ -93,16 +95,20 @@ impl Host for Deck {
|
||||
&& !tab.extra.fitted
|
||||
{
|
||||
tab.extra.fitted = true;
|
||||
view.update(cx, |view, cx| view.fit_page(cx));
|
||||
view.update(cx, |view, cx| {
|
||||
view.fit_page(cx);
|
||||
view.turn_parts();
|
||||
});
|
||||
}
|
||||
// Pictures follow the kernel's light or dark scheme.
|
||||
let dark = view.read(cx).dark();
|
||||
// Pictures before the scheme landed are repainted.
|
||||
let lit = view.read(cx).lit();
|
||||
if let Some(tab) = self.tab_mut(id)
|
||||
&& tab.extra.dark != dark
|
||||
&& tab.extra.lit != lit
|
||||
{
|
||||
tab.extra.dark = dark;
|
||||
tab.extra.lit = lit;
|
||||
self.repaint_all(id, cx);
|
||||
}
|
||||
self.offer_menu(id, view, cx);
|
||||
self.reread(id, view, cx);
|
||||
}
|
||||
}
|
||||
@@ -172,6 +178,7 @@ impl Deck {
|
||||
reading_fonts: false,
|
||||
guide_drag: None,
|
||||
snap_menus: Some((false, false, false)),
|
||||
menu: None,
|
||||
notes: Default::default(),
|
||||
remarks_open: false,
|
||||
preview: None,
|
||||
@@ -259,7 +266,12 @@ impl Deck {
|
||||
Ok(((slides, at), (design, objects, notes, remarks, (motion, media)), size, history)) => {
|
||||
tab.extra.read_at = Some(at);
|
||||
log::info!("show: tab {id} has {} parts", slides.list.len());
|
||||
view.update(cx, |view, _| view.set_part(slides.current));
|
||||
// A queued turn or part outranks an earlier read.
|
||||
view.update(cx, |view, _| {
|
||||
if view.settled() {
|
||||
view.set_part(slides.current)
|
||||
}
|
||||
});
|
||||
let names: HashSet<&String> =
|
||||
slides.list.iter().map(|slide| &slide.name).collect();
|
||||
tab.extra.keep_pictures(&names);
|
||||
@@ -379,7 +391,7 @@ impl Deck {
|
||||
self.paint_next(id, cx);
|
||||
}
|
||||
|
||||
fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let title = match self.core.loading.first() {
|
||||
Some((_, name)) => Self::text(cx, "office.loading", &[("name", name)]),
|
||||
@@ -447,7 +459,8 @@ impl Render for Deck {
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.dialog_element(cx));
|
||||
.children(self.dialog_element(cx))
|
||||
.children(self.context_menu(cx));
|
||||
let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx);
|
||||
Self::bind_zoom(root, cx)
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ pub struct Extra {
|
||||
pub painting: bool,
|
||||
/// The parts are masters.
|
||||
pub masters: bool,
|
||||
/// The scheme the pictures were painted with.
|
||||
pub dark: Option<bool>,
|
||||
/// The pictures were painted with Ely's scheme.
|
||||
pub lit: bool,
|
||||
/// The view was fitted to the slide once.
|
||||
pub fitted: bool,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod guides;
|
||||
mod insert;
|
||||
mod list;
|
||||
mod media;
|
||||
mod menu;
|
||||
mod motion;
|
||||
mod new;
|
||||
mod notes;
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
//! The canvas's right-click menus, laid out as PowerPoint's (SHOW-03).
|
||||
|
||||
use ely_gpui_component::{
|
||||
menus::{ContextMenu, Menu, MenuItem},
|
||||
primitives::Severity,
|
||||
};
|
||||
use ely_lok::{
|
||||
LokView,
|
||||
menu::{Runs, Target, runs},
|
||||
show::LAYOUTS,
|
||||
};
|
||||
use ely_office::menu::{self, Entry, Offer};
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{App, Context, Entity, Window};
|
||||
|
||||
use crate::deck::Deck;
|
||||
|
||||
/// What was right-clicked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Kind {
|
||||
/// The slide, nothing selected.
|
||||
Slide,
|
||||
/// Text being edited.
|
||||
Text,
|
||||
/// A shape or a text box.
|
||||
Object,
|
||||
Picture,
|
||||
Table,
|
||||
}
|
||||
|
||||
/// A menu's kind and what the kernel lets run.
|
||||
pub struct What {
|
||||
pub kind: Kind,
|
||||
runs: Runs,
|
||||
}
|
||||
|
||||
/// One thing a menu does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Pick {
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
/// An index into `LAYOUTS`.
|
||||
Layout(usize),
|
||||
Comment,
|
||||
Bullets,
|
||||
Numbering,
|
||||
Group,
|
||||
Ungroup,
|
||||
Front,
|
||||
Forward,
|
||||
Back,
|
||||
Backward,
|
||||
Replace,
|
||||
Crop,
|
||||
RowAbove,
|
||||
RowBelow,
|
||||
ColumnLeft,
|
||||
ColumnRight,
|
||||
DeleteRows,
|
||||
DeleteColumns,
|
||||
DeleteTable,
|
||||
Merge,
|
||||
}
|
||||
|
||||
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
|
||||
|
||||
const LAYOUT_PICKS: [Pick; 12] = [
|
||||
Pick::Layout(0),
|
||||
Pick::Layout(1),
|
||||
Pick::Layout(2),
|
||||
Pick::Layout(3),
|
||||
Pick::Layout(4),
|
||||
Pick::Layout(5),
|
||||
Pick::Layout(6),
|
||||
Pick::Layout(7),
|
||||
Pick::Layout(8),
|
||||
Pick::Layout(9),
|
||||
Pick::Layout(10),
|
||||
Pick::Layout(11),
|
||||
];
|
||||
|
||||
const SLIDE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub("show.design.layout", &LAYOUT_PICKS),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const TEXT: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Bullets),
|
||||
Entry::Row(Pick::Numbering),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const OBJECT: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
|
||||
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
|
||||
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const PICTURE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Replace),
|
||||
Entry::Row(Pick::Crop),
|
||||
Entry::Line,
|
||||
Entry::Sub("show.object.grouping", &[Pick::Group, Pick::Ungroup]),
|
||||
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
|
||||
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
const TABLE: &[Entry<Pick>] = &[
|
||||
Entry::Line,
|
||||
Entry::Sub(
|
||||
"doc.menu.insert",
|
||||
&[Pick::RowAbove, Pick::RowBelow, Pick::ColumnLeft, Pick::ColumnRight],
|
||||
),
|
||||
Entry::Sub("doc.menu.delete", &[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable]),
|
||||
Entry::Row(Pick::Merge),
|
||||
Entry::Line,
|
||||
Entry::Row(Pick::Comment),
|
||||
];
|
||||
|
||||
/// Each layout's label, in `LAYOUTS` order.
|
||||
const LAYOUT_KEYS: [&str; 12] = [
|
||||
"show.layout.20",
|
||||
"show.layout.0",
|
||||
"show.layout.1",
|
||||
"show.layout.3",
|
||||
"show.layout.19",
|
||||
"show.layout.32",
|
||||
"show.layout.12",
|
||||
"show.layout.15",
|
||||
"show.layout.14",
|
||||
"show.layout.16",
|
||||
"show.layout.18",
|
||||
"show.layout.34",
|
||||
];
|
||||
|
||||
impl menu::Pick for Pick {
|
||||
fn row(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
|
||||
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
|
||||
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
|
||||
Pick::Layout(index) => (LAYOUT_KEYS[index], ".uno:AssignLayout"),
|
||||
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
|
||||
Pick::Bullets => ("doc.props.bullets", ".uno:DefaultBullet"),
|
||||
Pick::Numbering => ("doc.props.numbering", ".uno:DefaultNumbering"),
|
||||
Pick::Group => ("show.object.group", ".uno:FormatGroup"),
|
||||
Pick::Ungroup => ("show.object.ungroup", ".uno:FormatUngroup"),
|
||||
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
|
||||
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
|
||||
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
|
||||
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
|
||||
Pick::Replace => ("show.object.replace", ".uno:ChangePicture"),
|
||||
Pick::Crop => ("show.object.crop", ".uno:Crop"),
|
||||
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
|
||||
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
|
||||
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
|
||||
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
|
||||
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
|
||||
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
|
||||
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
|
||||
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl What {
|
||||
/// The kernel lets the pick's own command run now.
|
||||
pub(crate) fn on(&self, pick: Pick) -> bool {
|
||||
self.runs.allows(menu::Pick::row(pick).1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn of(target: Target) -> Option<Kind> {
|
||||
match target {
|
||||
Target::Slide => Some(Kind::Slide),
|
||||
Target::Text => Some(Kind::Text),
|
||||
Target::Object => Some(Kind::Object),
|
||||
Target::Picture => Some(Kind::Picture),
|
||||
Target::Table => Some(Kind::Table),
|
||||
Target::Frame | Target::Cell => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Its rows under the clipboard row.
|
||||
fn rows(self) -> &'static [Entry<Pick>] {
|
||||
match self {
|
||||
Kind::Slide => SLIDE,
|
||||
Kind::Text => TEXT,
|
||||
Kind::Object => OBJECT,
|
||||
Kind::Picture => PICTURE,
|
||||
Kind::Table => TABLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command its rows run.
|
||||
fn commands(self) -> Vec<&'static str> {
|
||||
let rows = self.rows().iter().flat_map(|entry| match entry {
|
||||
Entry::Row(pick) => std::slice::from_ref(pick),
|
||||
Entry::Sub(_, picks) => picks,
|
||||
Entry::Line => &[],
|
||||
});
|
||||
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
|
||||
commands.sort_unstable();
|
||||
commands.dedup();
|
||||
commands
|
||||
}
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Turns the kernel's context menu into Ely's.
|
||||
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
let (offered, at) = match menu::take(view, cx) {
|
||||
None => return,
|
||||
Some(Ok(taken)) => taken,
|
||||
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
|
||||
};
|
||||
let Some(kind) = Kind::of(offered.target) else {
|
||||
let error = format!("a {:?} menu in a presentation", offered.target);
|
||||
return self.toast(Deck::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
|
||||
};
|
||||
let commands = kind.commands();
|
||||
self.ask_in(
|
||||
id,
|
||||
move |worker, doc| runs(worker, doc, &commands),
|
||||
move |deck, read, _, cx| match read {
|
||||
Ok(runs) => {
|
||||
deck.menu = Some(Offer::new(deck.menu.as_ref(), id, What { kind, runs }, at));
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => deck.engine_failed(&error.to_string(), cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// The menu's host, for the active deck.
|
||||
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
|
||||
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
|
||||
Some(offer.host("show-menu", menu_of(&cx.entity(), &offer.what, cx)))
|
||||
}
|
||||
|
||||
/// Runs one menu row.
|
||||
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("show: the menu picks {pick:?}");
|
||||
let id = match pick {
|
||||
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
|
||||
Pick::Comment => return self.ask_remark(None, window, cx),
|
||||
Pick::Replace => return self.pick_picture(true, cx),
|
||||
Pick::Layout(index) => {
|
||||
return self.design_command("slide.layout", &[&LAYOUTS[index].to_string()], cx);
|
||||
}
|
||||
Pick::Bullets => "list.bullets",
|
||||
Pick::Numbering => "list.numbering",
|
||||
Pick::Group => "object.group",
|
||||
Pick::Ungroup => "object.ungroup",
|
||||
Pick::Front => "image.front",
|
||||
Pick::Forward => "image.forward",
|
||||
Pick::Back => "image.back",
|
||||
Pick::Backward => "image.backward",
|
||||
Pick::Crop => "image.crop",
|
||||
Pick::RowAbove => "table.row_above",
|
||||
Pick::RowBelow => "table.row_below",
|
||||
Pick::ColumnLeft => "table.column_left",
|
||||
Pick::ColumnRight => "table.column_right",
|
||||
Pick::DeleteRows => "table.delete_rows",
|
||||
Pick::DeleteColumns => "table.delete_columns",
|
||||
Pick::DeleteTable => "table.delete",
|
||||
Pick::Merge => "table.merge",
|
||||
};
|
||||
self.object_command(id, &[], cx);
|
||||
}
|
||||
|
||||
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
|
||||
log::warn!("show: {pick:?} with no deck");
|
||||
return;
|
||||
};
|
||||
view.update(cx, |view, _| match pick {
|
||||
Pick::Paste => view.paste(),
|
||||
_ => view.copy(pick == Pick::Cut),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard row, then the target's rows.
|
||||
pub(crate) fn menu_of(this: &Entity<Deck>, what: &What, cx: &App) -> Menu {
|
||||
let item = |pick: Pick| {
|
||||
let this = this.clone();
|
||||
MenuItem::new(Deck::text(cx, menu::Pick::row(pick).0, &[]))
|
||||
.disabled(!what.on(pick))
|
||||
.on_click(move |window, cx| this.update(cx, |deck, cx| deck.choose(pick, window, cx)))
|
||||
};
|
||||
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
|
||||
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ impl Deck {
|
||||
let range = output.pages();
|
||||
let (w, h) = tab.extra.size;
|
||||
let size = (PICTURE, (f64::from(PICTURE) * h as f64 / w.max(1) as f64).round() as u32);
|
||||
let (copy, light) = (tab.work.join("output.odp"), ely_lok::scheme(false));
|
||||
let (copy, light) = (tab.work.join("output.odp"), ely_lok::SCHEME);
|
||||
let (shown, opened) = (folder.display().to_string(), folder.clone());
|
||||
log::info!("show: writes slide pictures");
|
||||
self.read_in(
|
||||
|
||||
@@ -252,7 +252,7 @@ impl Deck {
|
||||
// A show starts from black; effects play on the page.
|
||||
let behind = match &preview.playing {
|
||||
Playing::Transition { .. } => gpui::black(),
|
||||
Playing::Effects { .. } => ely_lok::paper(cx),
|
||||
Playing::Effects { .. } => ely_lok::paper(),
|
||||
};
|
||||
let content = match &preview.playing {
|
||||
Playing::Effects {
|
||||
@@ -266,7 +266,7 @@ impl Deck {
|
||||
} else {
|
||||
(t / preview.length) as f32
|
||||
};
|
||||
let paper = ely_lok::paper(cx);
|
||||
let paper = ely_lok::paper();
|
||||
// Exits are not read; the old slide shows everything.
|
||||
let from = (from.as_deref())
|
||||
.map(|from| compose::slide(from, |_| true, frame, paper).into_any_element());
|
||||
|
||||
@@ -234,6 +234,7 @@ impl Render for Strip {
|
||||
let label = format!("{} {}", part + 1, slide.name);
|
||||
div()
|
||||
.id(("show-slide", part))
|
||||
.debug_selector(move || format!("show-slide-{part}"))
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.flex()
|
||||
@@ -301,6 +302,7 @@ impl Render for Strip {
|
||||
.children(slides);
|
||||
div()
|
||||
.size_full()
|
||||
.child(ContextMenu::new("show-strip-menu", popup(cx)).child(list))
|
||||
// A sized host lets the list overflow and scroll.
|
||||
.child(ContextMenu::new("show-strip-menu", popup(cx)).size_full().child(list))
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ impl Render for Presenter {
|
||||
let big = stage::fit(show.slide, (room.width * 0.6, room.height * 0.8));
|
||||
let small = stage::fit(show.slide, (room.width * 0.3, room.height * 0.35));
|
||||
let next = match show.upcoming() {
|
||||
Some(part) => stage::still(show, part, small, cx),
|
||||
Some(part) => stage::still(show, part, small),
|
||||
None => div().w(small.0).h(small.1).into_any_element(),
|
||||
};
|
||||
let notes = show.part().map(|part| show.slides[part].notes.clone());
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement {
|
||||
let Some(stack) = show.stack(part) else {
|
||||
return black((w, h));
|
||||
};
|
||||
let paper = ely_lok::paper(cx);
|
||||
let paper = ely_lok::paper();
|
||||
let t = show.entered.elapsed().as_secs_f64();
|
||||
let slide = &show.slides[part];
|
||||
let shown = match &show.leaving {
|
||||
@@ -76,9 +76,9 @@ pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement {
|
||||
}
|
||||
|
||||
/// A slide as it first shows, still.
|
||||
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels), cx: &App) -> AnyElement {
|
||||
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels)) -> AnyElement {
|
||||
match show.stack(part) {
|
||||
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper(cx))
|
||||
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper())
|
||||
.into_any_element(),
|
||||
None => black(size),
|
||||
}
|
||||
|
||||
@@ -59,16 +59,14 @@ pub fn discard(worker: Arc<Mutex<Worker>>, doc: Doc, copy: PathBuf, cx: &App) {
|
||||
fn copy(
|
||||
worker: &mut Worker,
|
||||
doc: &Doc,
|
||||
(copy, scheme): (&std::path::Path, Option<&'static str>),
|
||||
copy: &std::path::Path,
|
||||
) -> io::Result<(usize, Doc, Vec<ShowSlide>)> {
|
||||
let current = doc.sheets(worker)?.current;
|
||||
let url = ely_lok::file_url(copy);
|
||||
doc.save_as(worker, &url, "odp")?;
|
||||
let shown = worker.load(&url)?;
|
||||
let read = |worker: &mut Worker| -> io::Result<Vec<ShowSlide>> {
|
||||
if let Some(scheme) = scheme {
|
||||
worker.run(&shown, "view.theme", &[scheme])?;
|
||||
}
|
||||
worker.run(&shown, "view.theme", &[ely_lok::SCHEME])?;
|
||||
ely_lok::show::slide_show(&worker.values(&shown, ely_lok::show::SLIDE_SHOW)?)
|
||||
};
|
||||
match read(worker) {
|
||||
@@ -161,14 +159,13 @@ impl Deck {
|
||||
self.show_starting = false;
|
||||
return;
|
||||
};
|
||||
let scheme = tab.view.read(cx).dark().map(ely_lok::scheme);
|
||||
let path = tab.work.join(format!("show-{}.odp", self.shows));
|
||||
self.shows += 1;
|
||||
let into = path.clone();
|
||||
let worker = self.core.worker.clone();
|
||||
self.read_in(
|
||||
id,
|
||||
move |worker, doc| copy(worker, doc, (&into, scheme)),
|
||||
move |worker, doc| copy(worker, doc, &into),
|
||||
move |deck, read, _, cx| {
|
||||
deck.show_starting = false;
|
||||
let (current, doc, slides) = match read {
|
||||
@@ -382,7 +379,7 @@ mod tests {
|
||||
let deck = deck.read(app);
|
||||
let tab = deck.active_tab().expect("a tab");
|
||||
let mut worker = deck.core.worker.lock().expect("the worker lock");
|
||||
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy");
|
||||
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, &path).expect("a copy");
|
||||
super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false }
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
|
||||
@@ -8,6 +8,7 @@ use gpui::{AppContext as _, Entity, TestAppContext};
|
||||
|
||||
use crate::deck::Deck;
|
||||
|
||||
mod menu;
|
||||
mod motion;
|
||||
mod notes;
|
||||
mod objects;
|
||||
@@ -17,6 +18,8 @@ mod play_more;
|
||||
mod play_time;
|
||||
mod review;
|
||||
mod slides;
|
||||
mod strip;
|
||||
mod turn;
|
||||
|
||||
/// An unrendered Deck over a fresh worker, if LOK exists.
|
||||
pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>> {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
use ely_gpui_component::theme::Theme;
|
||||
use ely_lok::show::Objects;
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::objects::{act, blank, objects};
|
||||
use super::settle;
|
||||
use crate::deck::Deck;
|
||||
use crate::menu::{Kind, Pick};
|
||||
|
||||
/// The canvas with the menu's host over it.
|
||||
struct Host(Entity<Deck>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
let body = self.0.update(cx, |deck, cx| deck.body(window, cx));
|
||||
let menu = self.0.update(cx, |deck, cx| deck.context_menu(cx));
|
||||
gpui::div().relative().size_full().flex().flex_col().child(body).children(menu)
|
||||
}
|
||||
}
|
||||
|
||||
fn frames(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
|
||||
/// A slide point, in 1/100 mm, in window pixels.
|
||||
fn spot(cx: &mut VisualTestContext, deck: &Entity<Deck>, (x, y): (i64, i64)) -> Point<Pixels> {
|
||||
frames(cx);
|
||||
cx.update(|_, app| {
|
||||
let read = deck.read(app).active_tab().expect("a tab").view.read(app);
|
||||
let twips = |mm100| crate::object::twips(mm100) as i32;
|
||||
read.origin() + read.place((twips(x), twips(y)))
|
||||
})
|
||||
}
|
||||
|
||||
/// A point `1/part` into the first object of `kind`.
|
||||
fn within(cx: &mut VisualTestContext, deck: &Entity<Deck>, kind: &str, part: i64) -> Point<Pixels> {
|
||||
let (x, y, w, h) = cx.update(|_, app| {
|
||||
let objects = objects(app, deck).expect("objects");
|
||||
objects.objects.iter().find(|object| object.kind == kind).expect("the object").rect
|
||||
});
|
||||
spot(cx, deck, (x + w / part, y + h / part))
|
||||
}
|
||||
|
||||
fn click(cx: &mut VisualTestContext, at: Point<Pixels>, button: MouseButton) {
|
||||
cx.simulate_mouse_move(at, None, Modifiers::none());
|
||||
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||
}
|
||||
|
||||
/// Right-clicks and waits for a menu of `kind`.
|
||||
fn right_click(cx: &mut VisualTestContext, deck: &Entity<Deck>, at: Point<Pixels>, kind: Kind) {
|
||||
cx.update(|_, app| deck.update(app, |deck, _| deck.menu = None));
|
||||
click(cx, at, MouseButton::Right);
|
||||
for _ in 0..500 {
|
||||
cx.run_until_parked();
|
||||
if cx.update(|_, app| deck.read(app).menu.as_ref().is_some_and(|offer| offer.what.kind == kind)) {
|
||||
return frames(cx);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
let got = cx.update(|_, app| deck.read(app).menu.as_ref().map(|offer| offer.what.kind));
|
||||
panic!("no {kind:?} menu, got {got:?}");
|
||||
}
|
||||
|
||||
fn on(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) -> bool {
|
||||
cx.update(|_, app| deck.read(app).menu.as_ref().expect("a menu").what.on(pick))
|
||||
}
|
||||
|
||||
fn choose(cx: &mut VisualTestContext, deck: &Entity<Deck>, pick: Pick) {
|
||||
cx.update(|window, app| deck.update(app, |deck, cx| deck.choose(pick, window, cx)));
|
||||
}
|
||||
|
||||
fn table_height(objects: &Objects) -> Option<i64> {
|
||||
objects.objects.iter().find(|object| object.kind == "table").map(|object| object.rect.3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_clicks_open_powerpoint_rows_on_each_target() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
cx.update(|cx| Theme::update(cx, |theme| theme.reduced_motion = true));
|
||||
settle(&cx, |app| objects(app, &deck).is_some_and(|o| !o.objects.is_empty()));
|
||||
let window = cx.add_window(|_, _| Host(deck.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
let corner = spot(&mut shown, &deck, (27_000, 15_000));
|
||||
right_click(&mut shown, &deck, corner, Kind::Slide);
|
||||
let clipboard = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
|
||||
assert!((clipboard.origin - corner).x < gpui::px(40.0), "at the click");
|
||||
assert!(on(&mut shown, &deck, Pick::Layout(0)) && on(&mut shown, &deck, Pick::Comment));
|
||||
// Typing in the title: list rows.
|
||||
let title = within(&mut shown, &deck, "text", 2);
|
||||
click(&mut shown, title, MouseButton::Left);
|
||||
right_click(&mut shown, &deck, title, Kind::Text);
|
||||
assert!(on(&mut shown, &deck, Pick::Bullets) && on(&mut shown, &deck, Pick::Numbering));
|
||||
let rectangle = ("draw.shape", &["rectangle", "true"][..]);
|
||||
act(&mut cx, &deck, rectangle, |o| o.selection.as_ref().is_some_and(|s| s.kind == "shape"));
|
||||
let shape = within(&mut shown, &deck, "shape", 2);
|
||||
right_click(&mut shown, &deck, shape, Kind::Object);
|
||||
assert!(on(&mut shown, &deck, Pick::Back) && on(&mut shown, &deck, Pick::Forward));
|
||||
assert!(!on(&mut shown, &deck, Pick::Group), "one shape does not group");
|
||||
choose(&mut shown, &deck, Pick::Back);
|
||||
settle(&cx, |app| objects(app, &deck).is_some_and(|o| o.objects.first().is_some_and(|first| first.kind == "shape")));
|
||||
let path = dir.path().join("red.png");
|
||||
image::RgbaImage::from_pixel(300, 200, image::Rgba([255, 0, 0, 255])).save(&path).expect("the png writes");
|
||||
let image = ("image.insert", &[ely_lok::file_url(&path)][..]);
|
||||
let image: (&str, Vec<&str>) = (image.0, image.1.iter().map(String::as_str).collect());
|
||||
act(&mut cx, &deck, (image.0, &image.1), |o| o.selection.as_ref().is_some_and(|s| s.kind == "image"));
|
||||
let picture = within(&mut shown, &deck, "image", 2);
|
||||
right_click(&mut shown, &deck, picture, Kind::Picture);
|
||||
assert!(on(&mut shown, &deck, Pick::Replace) && on(&mut shown, &deck, Pick::Crop));
|
||||
act(&mut cx, &deck, ("table.insert", &["2", "2"]), |o| table_height(o).is_some());
|
||||
// A quarter in: the middle is where the borders cross.
|
||||
let cell = within(&mut shown, &deck, "table", 4);
|
||||
right_click(&mut shown, &deck, cell, Kind::Table);
|
||||
assert!(on(&mut shown, &deck, Pick::RowAbove), "inserting needs no cell");
|
||||
// Deleting rows needs the cursor in a cell.
|
||||
click(&mut shown, cell, MouseButton::Left);
|
||||
right_click(&mut shown, &deck, cell, Kind::Table);
|
||||
assert!(on(&mut shown, &deck, Pick::DeleteRows) && on(&mut shown, &deck, Pick::Merge));
|
||||
let before = cx.update(|app| objects(app, &deck).and_then(|o| table_height(&o))).expect("a table");
|
||||
choose(&mut shown, &deck, Pick::RowBelow);
|
||||
settle(&cx, |app| objects(app, &deck).and_then(|o| table_height(&o)).is_some_and(|h| h > before));
|
||||
// A right click that ends an edit gets no menu.
|
||||
click(&mut shown, corner, MouseButton::Left);
|
||||
right_click(&mut shown, &deck, corner, Kind::Slide);
|
||||
choose(&mut shown, &deck, Pick::Layout(0));
|
||||
let layout = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.design.as_ref().map(|d| d.layout));
|
||||
settle(&cx, |app| layout(app) == Some(20));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use gpui::{Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::objects::blank;
|
||||
use super::{counts, settle, update};
|
||||
use crate::slides::Strip;
|
||||
|
||||
/// The slide strip alone in a window.
|
||||
struct Host(Entity<Strip>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
gpui::div().size_full().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_scrolls_the_strip_to_its_last_slide() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
return eprintln!("SKIP: no LOK build");
|
||||
};
|
||||
for n in 2..=24 {
|
||||
update(&mut cx, &deck, |deck, cx| deck.object_command("slide.insert", &[], cx));
|
||||
settle(&cx, |app| counts(app, &deck).0 == n);
|
||||
}
|
||||
let strip = cx.update(|app| deck.read(app).strip.clone());
|
||||
let window = cx.add_window(|_, _| Host(strip));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
shown.run_until_parked();
|
||||
let height = shown.update(|window, _| window.viewport_size().height);
|
||||
let last = |shown: &mut VisualTestContext| shown.debug_bounds("show-slide-23").expect("the last slide is laid out");
|
||||
let before = last(&mut shown);
|
||||
assert!(before.bottom() > height, "the strip is longer than the window");
|
||||
let at = point(before.left() + px(20.0), height / 2.0);
|
||||
for _ in 0..20 {
|
||||
shown.simulate_event(ScrollWheelEvent {
|
||||
position: at,
|
||||
delta: ScrollDelta::Pixels(point(px(0.0), px(-1000.0))),
|
||||
..Default::default()
|
||||
});
|
||||
shown.run_until_parked();
|
||||
}
|
||||
let after = last(&mut shown);
|
||||
assert!(after.bottom() <= height, "the last slide is in view: {after:?}");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use ely_lok::LokView;
|
||||
use ely_office::prelude::*;
|
||||
use gpui::{Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point};
|
||||
|
||||
use super::objects::{blank, selection};
|
||||
use super::{counts, quiet, settle, update};
|
||||
use crate::deck::Deck;
|
||||
|
||||
/// The slide canvas alone in a window.
|
||||
struct Host(Entity<LokView>);
|
||||
|
||||
impl gpui::Render for Host {
|
||||
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||
use gpui::{ParentElement as _, Styled as _};
|
||||
gpui::div().size_full().child(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// The current slide once every turn landed.
|
||||
fn current(shown: &mut VisualTestContext, deck: &Entity<Deck>) -> usize {
|
||||
quiet(shown, deck);
|
||||
shown.update(|_, app| deck.read(app).active_tab().map(|tab| tab.extra.slides.current).expect("a tab"))
|
||||
}
|
||||
|
||||
/// Whole notches, in this platform's lines.
|
||||
fn wheel(shown: &mut VisualTestContext, notches: f32) {
|
||||
let middle = shown.update(|window, _| window.viewport_size()).center();
|
||||
let delta = ScrollDelta::Lines(point(0.0, notches * ely_lok::notch()));
|
||||
shown.simulate_event(ScrollWheelEvent { position: middle.into(), delta, ..Default::default() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_and_up_down_turn_slides() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
return eprintln!("SKIP: no LOK build");
|
||||
};
|
||||
for n in 2..=3 {
|
||||
update(&mut cx, &deck, |deck, cx| deck.object_command("slide.insert", &[], cx));
|
||||
settle(&cx, |app| counts(app, &deck).0 == n);
|
||||
}
|
||||
let view = cx.update(|app| deck.read(app).active_tab().expect("a tab").view.clone());
|
||||
cx.update(|app| view.update(app, |view, _| view.show_part(0)));
|
||||
let window = cx.add_window(|_, _| Host(view.clone()));
|
||||
let mut shown = VisualTestContext::from_window(window.into(), &cx);
|
||||
shown.run_until_parked();
|
||||
assert_eq!(current(&mut shown, &deck), 0);
|
||||
wheel(&mut shown, -2.0);
|
||||
assert_eq!(current(&mut shown, &deck), 2, "two notches in one event turn two");
|
||||
wheel(&mut shown, 1.0);
|
||||
wheel(&mut shown, 1.0);
|
||||
assert_eq!(current(&mut shown, &deck), 0, "rapid notches each turn");
|
||||
wheel(&mut shown, -1.0);
|
||||
assert_eq!(current(&mut shown, &deck), 1, "the wheel turns down");
|
||||
shown.update(|window, app| {
|
||||
let focus = view.read(app).focus_handle().clone();
|
||||
window.focus(&focus, app)
|
||||
});
|
||||
shown.simulate_keystrokes("down");
|
||||
assert_eq!(current(&mut shown, &deck), 2, "Down turns");
|
||||
shown.simulate_keystrokes("down");
|
||||
assert_eq!(current(&mut shown, &deck), 2, "the last slide stays");
|
||||
shown.simulate_keystrokes("up");
|
||||
assert_eq!(current(&mut shown, &deck), 1, "Up turns back");
|
||||
wheel(&mut shown, 1.0);
|
||||
assert_eq!(current(&mut shown, &deck), 0, "the wheel turns up");
|
||||
// Down queued behind Tab moves the object Tab picks.
|
||||
shown.simulate_keystrokes("tab down");
|
||||
assert_eq!(current(&mut shown, &deck), 0, "Down after Tab moves, not turns");
|
||||
assert!(shown.update(|_, app| selection(app, &deck)).is_some(), "Tab picked an object");
|
||||
// A turn lands before the keys queued after it.
|
||||
shown.simulate_keystrokes("escape down tab down");
|
||||
assert_eq!(current(&mut shown, &deck), 1, "Down turns, then moves");
|
||||
assert!(shown.update(|_, app| selection(app, &deck)).is_some(), "Tab picked on the new slide");
|
||||
}
|
||||
@@ -23,6 +23,7 @@ ely-ui.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely-ui = { workspace = true, features = ["test-support"] }
|
||||
ely-gpui-component = { workspace = true, features = ["test-support"] }
|
||||
tempfile.workspace = true
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ pub struct Book {
|
||||
pub drag: Option<Drag>,
|
||||
/// The sheet menu's request: number and place.
|
||||
pub sheet_menu: Option<(u64, gpui::Point<gpui::Pixels>)>,
|
||||
/// The grid or header menu.
|
||||
pub menu: Option<ely_office::menu::Offer<crate::menu::What>>,
|
||||
pub panel: Entity<Panel>,
|
||||
pub panel_shown: bool,
|
||||
pub choices: Option<crate::rules::Choices>,
|
||||
@@ -95,6 +97,7 @@ impl Host for Book {
|
||||
self.freeze(id, view, cx);
|
||||
self.found(id, view, cx);
|
||||
self.requested(id, view, cx);
|
||||
self.offer_menu(id, view, cx);
|
||||
self.follow_rule(id, view, cx);
|
||||
self.read_links(id, view, cx);
|
||||
if self.core.active == Some(id) {
|
||||
@@ -190,6 +193,7 @@ impl Book {
|
||||
bars: Bars::new(window, cx),
|
||||
drag: None,
|
||||
sheet_menu: None,
|
||||
menu: None,
|
||||
panel,
|
||||
panel_shown: true,
|
||||
choices: None,
|
||||
@@ -388,7 +392,8 @@ impl Render for Book {
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.dialog_element(cx));
|
||||
.children(self.dialog_element(cx))
|
||||
.children(self.context_menu(cx));
|
||||
let root = Self::bind_zoom(crate::cells::bind(crate::sheets::bind(root, cx), cx), cx);
|
||||
let root = crate::data::bind(crate::formulas::bind(root, cx), cx);
|
||||
let root = crate::page::bind(crate::protect::bind(root, cx), cx);
|
||||
|
||||
Loaded 100 of 546 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user