1 Commits
Author SHA1 Message Date
雷电芽衣 eb231635d2 Relicense Ely Office's own code from AGPL-3.0 to MIT
release / macos (push) Canceled after 0s
release / windows (push) Canceled after 0s
release / linux (push) Canceled after 0s
Applied on top of the released v0.1.4 so the tag says MIT, as main does
(271468d). Bundled engines keep their licences: LibreOffice (MPL-2.0),
Git (GPL-2.0) and MuPDF (AGPL-3.0).
2026-10-20 01:33:33 -04:00
197 changed files with 779 additions and 8726 deletions

No files matched your search

-3
View File
@@ -15,9 +15,6 @@ jobs:
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
# Repository variables; both live in the runner's keychain.
ELY_SIGN_IDENTITY: ${{ vars.ELY_SIGN_IDENTITY }}
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
run: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
-1
View File
@@ -18,4 +18,3 @@ node_modules/
/assets/papirus_icons-master.zip
/Gitea.md
/.work/
/TyporaRef
Generated
+16 -20
View File
@@ -1875,7 +1875,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "ely-agent"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"ely-platform",
"ely-ui",
@@ -1884,7 +1884,7 @@ dependencies = [
[[package]]
name = "ely-doc"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"anyhow",
"csv",
@@ -1906,7 +1906,7 @@ dependencies = [
[[package]]
name = "ely-files"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"aes-gcm",
"dunce",
@@ -1923,7 +1923,7 @@ dependencies = [
[[package]]
name = "ely-gpui-component"
version = "0.1.1"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=2b6bd1d36951713e61763bcd9572bbd42464dd7d#2b6bd1d36951713e61763bcd9572bbd42464dd7d"
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=8fd3e431db3c57c67328869cffdcd43d5f7fd056#8fd3e431db3c57c67328869cffdcd43d5f7fd056"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -1960,7 +1960,6 @@ dependencies = [
"similar",
"smallvec",
"unicode-segmentation",
"unicode-width",
"usvg",
"vte",
"web-time",
@@ -1969,14 +1968,14 @@ dependencies = [
[[package]]
name = "ely-ipc"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"log",
]
[[package]]
name = "ely-lok"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"base64 0.23.1",
"dunce",
@@ -1997,7 +1996,7 @@ dependencies = [
[[package]]
name = "ely-mail"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"ely-platform",
"ely-ui",
@@ -2006,7 +2005,7 @@ dependencies = [
[[package]]
name = "ely-mark"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"anyhow",
"base64 0.23.1",
@@ -2031,7 +2030,7 @@ dependencies = [
[[package]]
name = "ely-office"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"anyhow",
"directories",
@@ -2052,7 +2051,7 @@ dependencies = [
[[package]]
name = "ely-pdf"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"dunce",
"ely-files",
@@ -2070,7 +2069,7 @@ dependencies = [
[[package]]
name = "ely-pdf-engine"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"cc",
"ely-ipc",
@@ -2086,7 +2085,7 @@ dependencies = [
[[package]]
name = "ely-platform"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"anyhow",
"arboard",
@@ -2095,7 +2094,6 @@ dependencies = [
"directories",
"ely-ipc",
"env_logger",
"getrandom 0.4.3",
"image",
"libc",
"log",
@@ -2121,7 +2119,7 @@ dependencies = [
[[package]]
name = "ely-show"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"ely-files",
"ely-gpui-component",
@@ -2141,7 +2139,7 @@ dependencies = [
[[package]]
name = "ely-table"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"ely-gpui-component",
"ely-lok",
@@ -2158,7 +2156,7 @@ dependencies = [
[[package]]
name = "ely-tree"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"dunce",
"ely-files",
@@ -2178,7 +2176,7 @@ dependencies = [
[[package]]
name = "ely-ui"
version = "0.1.5"
version = "0.1.4"
dependencies = [
"anyhow",
"directories",
@@ -2187,7 +2185,6 @@ dependencies = [
"futures",
"gpui-pre",
"gpui-pre-platform",
"gpui-pre-windows",
"log",
"notify",
"semver",
@@ -2982,7 +2979,6 @@ dependencies = [
"waker-fn",
"web-time",
"windows 0.62.2",
"zed-font-kit",
"zed-scap",
]
+3 -3
View File
@@ -5,10 +5,10 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.5"
version = "0.1.4"
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 = "2b6bd1d36951713e61763bcd9572bbd42464dd7d" }
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "8fd3e431db3c57c67328869cffdcd43d5f7fd056" }
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"] }
+21 -661
View File
@@ -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.
+2 -9
View File
@@ -478,8 +478,6 @@ 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 功能要求
| 编号 | 功能 | 完成要求 |
@@ -487,7 +485,7 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
| MARK-01 | 本地文件 | 新建、打开、保存、重命名和另存;支持直接打开文件或文件夹 |
| MARK-02 | 编辑模式 | 可视化、源码和只读预览可切换;切换本身不修改文件内容 |
| MARK-03 | Markdown | 支持 CommonMark 与 GFM 常用语法:标题、列表、任务、引用、链接、图片、表格和代码块 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;照 Typora 默认显示 `:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块与 `[toc]` 目录;`==`、`^`、单个 `~` 照 GFM,不作高亮、上下标;Typora 与 GitHub 读法不同处随 GitHub(表情名与转义、标签与强调交叉);资源随包提供 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;资源随包提供 |
| MARK-05 | 导航 | 提供文件树、文档大纲、多标签、最近文件和按标题跳转 |
| MARK-06 | 编辑工具 | 支持查找替换、源码正则搜索、缩进、列表续写、表格行列操作和代码高亮 |
| MARK-07 | 附件 | 支持拖放和粘贴图片,默认复制到文档旁的资源目录并写入相对路径 |
@@ -498,8 +496,6 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
| MARK-12 | 会话恢复 | 恢复打开标签、未保存内容、光标、滚动和视图设置 |
| MARK-13 | Git 配合 | 在仓库内显示文件是否有改动,并可调用 ElyTree 查看;不内置第二套 Git 客户端 |
| MARK-14 | 大文件 | 大文件采用延迟渲染或源码视图,说明限制,不截断保存内容 |
| MARK-15 | 格式命令 | 段落与格式菜单照 Typora 的项与快捷键:标题与段落、引用与列表、插入块、行内格式与清除格式;每条命令一步撤销,用不了的地方说明原因 |
| MARK-16 | 右键菜单 | 右键处的块、链接、图片、表格或代码决定菜单内容,照 Typora 的布局(剪贴板与格式为图标行);预览只读时只留复制类 |
数学与 Mermaid 默认仅渲染文档中已保存的源代码,不执行任意本地命令。语法错误显示原文与错误位置,不清空代码块。
@@ -756,7 +752,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 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
@@ -937,6 +933,3 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S24] MailCore 2 官方 LICENSE 文件。
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
`https://support.typora.io/Shortcut-Keys/`
+1 -1
View File
@@ -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`.
+8 -19
View File
@@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
## 组件映射
依赖 `ely-gpui-component`(pin 2b6bd1d,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
依赖 `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` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -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, MarkdownReader, MarkdownBlock, alert, is_toc, BlockCommand, InlineFormat, TableEdit, EditRefusal, TableRefusal, DocumentTarget, TargetBlock}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法;段落、格式与表格菜单经文档的 `block`、`format`、`edit_table`;右键菜单听 `DocumentEvent::Pointed` |
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode}` `editor::{CodeEditor, FindWidget}` |
| Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` |
| 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` |
| 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 |
@@ -84,7 +84,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
| 组件内部文案未接 i18n(Ely 用到的组件已接:提示、默认占位、补全空行、字号示例,6df9ab0;查找框、排序与筛选、密码开关,a4bf696;`i18n/tests/shipped.rs` 防回退;其余组件之外:欢迎页标题、菜单项等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
| 组件内部文案未接 i18n(面板族之外:欢迎页标题、菜单项、`SortBuilder` 与 `FilterBuilder` 等) | 面板族文案已接 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 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6-astra,effort max,fast)
scripts/review.sh ID 轮次 路径 审查一条任务
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
@@ -159,7 +159,7 @@ tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
tests/scripts/one-instance.sh 第二份 ElyDoc 把文件交给第一份后退出,第一份开出它、工作目录不动
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限)
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
@@ -173,7 +173,7 @@ tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
cargo check --workspace
@@ -197,7 +197,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案 a4bf696 起接 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` 的文案未接 i18n,中文界面下仍为英文(上游待办)。
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
@@ -265,7 +265,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。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-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
@@ -286,14 +286,3 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
- 2026-10-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进),保存后交给会话与 `Saved` 的路径以 `dunce::simplified` 整体判定后为普通写法;ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。
- 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。
- 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。
- 2026-10-14(用户定):Typora 是参照,不求一一相同;ElyMark 的验收与评审只看每项功能是否可用,缺 Typora 的某行、次序或措辞不同不算缺陷。
- 2026-10-14(用户要求照 Word):ElyDoc 的右键与表格(R5)。右键菜单由 Ely 按目标自建(`apps/ely-doc/src/menu.rs`),开在所点之处(`LokView` 记下右键点,`take_menu` 一并交出):目标、各项可否与拼写建议都取自内核这次给的菜单(`ely_lok::menu`:有 `WrapMenu` 为图片,有 `TableInsertMenu` 为表格,否则正文;缺 `enabled` 即报错),命令只经 `COMMANDS`。剪切、复制、粘贴为图标行(组件库 `Menu::buttons`);正文为链接…、新建批注;表格另有插入、删除、合并、拆分、平均分布;图片为文字环绕(嵌入、四周、上下、浮于文字上方;内核无紧密、穿越、衬于文字下方的对应项,不列)、置于顶层、置于底层、插入题注…,新建批注灰显(内核的图片菜单无批注)。链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显(Word 未选中也可用;照做须改内核,未做)。拖动表格边线:worker 把 `LOK_CALLBACK_TABLE_SELECTED` 读成 `ely_lok::table::Table`(列与行边线、上下限),视图在边线上显示调整指针(命中范围不超过与邻线距离的四分之一,矮行的中间仍落在文档),按住时记下读到它的批次的修订与按住处的偏移,拖动时画参考线(出视图照跟),松开以 `.uno:TableChangeCurrentBorderPosition` 发一条命令(一步撤销),原地松开不发;其间文档变了即以过期拒绝(`view/borders.rs`)。内核在 LOK 下不处理边线的指针与拖动(依赖 LOK 没有的标尺)。属性栏在光标进表格时把表格组排在最前(组件库 9ff403d 起按标题记折叠)。Windows 真窗口逐项核对过(右键三种目标、插入行列、拖动列宽行高、撤销、保存后重开);拼写建议在 Windows 上内核不给(P3-01),未核对。
- 2026-10-14(用户要求核对 ElyTable 与 ElyShow):ElyTable 的右键(R5.5)。菜单机制移入 `ely_office::menu`(取内核这次给的菜单、开在所点之处、剪贴板图标行、子菜单可否随其中各项),ElyDoc 与 ElyTable 共用;`ely_lok::menu` 的目标另有单元格(有 `.uno:InsertCell`)与 Writer 之外的绘图对象(有 `.uno:ArrangeMenu`)。网格照 Excel:插入 ▸、删除 ▸、清除内容(`.uno:ClearContents`,同 Delete 键,不弹内核的删除内容对话框)、合并、拆分、设置单元格格式(打开格式面板);对象为置于顶层、置于底层;编辑单元格时只有剪贴板行。行列表头由 Ely 自画,内核不给菜单:右键先选中该行或列(已选的整行整列含它即不动,同 Excel)。选中对象或正编辑图形里的文字时,绘图外壳取代了单元格外壳,`.uno:SelectRow`/`.uno:SelectColumn` 原无处执行而被丢弃;补丁 `0046-lok-calc-headers-leave-drawings.patch` 让 LOK 下视图外壳接下这两条:先结束图形编辑(键入的文字留下)并取消选中,再选行列,同 Calc 自己的表头(`DrawDeselectAll`),菜单为插入、删除、清除内容、行高或列宽、隐藏、显示。ElyTable 每一项的可否都取自内核对它所发的那条命令此刻的状态(内核的菜单只定目标):补丁 `0045-lok-command-states.patch` 加 `getCommandValues(".uno:CommandStates?commands=…")`,先冲出调度器、再逐条问调度器(同菜单栏),未知的命令即报错(`ely_lok::menu::runs`);LOK 推送的状态只有固定的一组,且在空闲时才更新。因此受保护而许插入行列的工作表照样可插;行高、列宽只在文档只读时禁用(内核的规则),受保护时由内核拒绝并报出其错误。ElyTable 无批注界面,不列批注。
- 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。
- 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0;R4.11 真窗口查出 Windows 的中文输入法把 Ctrl+Shift+0 留作热键(注册表 `HKCU\Control Panel\Input Method\Hot Keys\00000104`),应用收不到,Windows 另绑 Ctrl+Shift+9(菜单显示它)。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点与数字一律写所出的字符(`secondary-{`、`secondary-+`、`alt-%`):三端的 gpui 都把 Shift 加该键报成字符并去掉 Shift(Windows 见 `gpui-pre-windows` 的 `get_keystroke_key`);R4.9 原在 Windows 写键名,真键盘按不出(R4.11 真窗口查出)。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。第 2 轮后:转换完成时剪贴板已不是开始时的内容(此后有任何复制,含编辑器自己的 Ctrl+C)即丢弃这次结果,同内容的再次写入分不出;打开的块里只认最后一次右键(组件 2b8bd9f),所点字节对回所画字节的读法逐段只对齐一次(原逐字重读,8000 字的段落要近 2 秒)。第 3 轮后:剪贴板的比对另含其 HTML(gpui 读剪贴板不含 HTML,纯文字相同而富文本不同的后来复制原被盖掉;`ely_platform::clipboard::html`);组件的计时测试改用 `web_time::Instant`(组件 e07fd34)。
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。
- 2026-10-15(用户报:ElyMark 点表格整个表格变成源码,应在格内编辑;用户定:格内显示该格原文):表格单元格编辑(R6.1)。组件库 2594b3f:可视模式按下表格只打开所按的那一格,在格内原位以单行输入框显示该格的 Markdown 原文(`**粗体**` 带记号),表格其余照常画出;按在格内文字上即落在所按的字,按在格内空白落在格末;以表格边缘、键盘或上下键从邻块进入的表格块打开其首格或末格;Tab、Shift+Tab 到下一格、上一格(跨行),上下键到上下一格,越出表格即到邻块;回车与 Esc 写回,写回为一步撤销;格内键入的 `|` 写回时转义为 `\|`(表格编辑读到的也是转义后的文字);格开着时做表格编辑(插删行列、整理等)先写回该格再执行,格随之关闭。列表、引用里的表格同此。表格绘制移入组件 `render/table.rs`,按格的空白处也带出所按的格(`Place::Cell`)。第 1 轮后(组件库 6a3f348):开着的格里的按下只给其输入框,插入点落在所按处、仍是一步撤销;格末单数的反斜杠写回时补成一对,不吞下一格的竖线;查找在可视模式里打开匹配所在的格并选中,跨格的匹配转到其行;格开着时右键所按的字节随格内键入的竖线换算;在未开的格的空白处右键,目标为该格末尾。第 2 轮后(组件库 2b6bd1d):格末的反斜杠只在紧接竖线时补成一对(`| a\ |` 的反斜杠后是空格,原样写回,只打开不键入不改一个字节);改首格后仍认得所在表格(无外侧竖线的表格首格始于块首,原被当作写掉了块),Tab 与按邻格照常;开着的格的空白处左键把插入点移到格末、右键以格末为目标,都不写回,仍是一步撤销。
+1 -1
View File
@@ -48,7 +48,7 @@ impl Docs {
.size_full()
.child(tab.view.clone())
.children(self.find_box(cx))
.children(self.context_menu(cx))
.children(self.spelling_menu(cx))
.into_any_element();
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
let fonts = tab.view.read(cx).missing_fonts().join(", ");
+2 -2
View File
@@ -37,7 +37,7 @@ pub struct Docs {
pub review: Entity<Review>,
/// What the right column shows.
pub side: Side,
pub offer: Option<ely_office::menu::Offer<ely_lok::menu::Offered>>,
pub offer: Option<crate::spell::Offer>,
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_menu(id, view, cx);
self.offer_spelling(id, view, cx);
}
/// A dialog about a closed tab goes with it.
+1 -1
View File
@@ -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;
-238
View File
@@ -1,238 +0,0 @@
//! 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;
-117
View File
@@ -1,117 +0,0 @@
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");
}
+7 -7
View File
@@ -250,15 +250,15 @@ impl Render for Props {
let grid = if graphic {
grid.group(self.object_group(cx))
} else {
// 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))
let grid = grid
.group(self.text_group(window, cx))
.group(self.paragraph_group(cx))
.group(self.style_group(cx))
.group(self.page_group(cx))
.group(self.page_group(cx));
match self.state(".uno:DeleteTable", cx).as_deref() {
Some("enabled") => grid.group(self.table_group(cx)),
_ => grid,
}
};
div()
.size_full()
+2 -7
View File
@@ -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, InteractiveElement, ParentElement, SharedString, Window, div};
use gpui::{App, Context, SharedString, Window};
use super::Props;
use crate::docs::Docs;
@@ -77,12 +77,7 @@ 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"),
div()
.debug_selector(|| "doc-props-text".into())
.child(effects),
)
.row(tip("doc.props.effects"), effects)
.row(
tip("doc.props.color"),
palette(&this, "format.color", (color, AUTOMATIC), None, cx),
+6 -8
View File
@@ -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, InteractiveElement, IntoElement, ParentElement, SharedString, div};
use gpui::{AnyElement, Context, IntoElement, SharedString};
use super::Props;
use crate::docs::Docs;
@@ -14,13 +14,11 @@ impl Props {
PropertyGroup::new(tip("doc.props.table"))
.row(
tip("doc.props.rows"),
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([
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"),
-80
View File
@@ -1,6 +1,5 @@
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};
@@ -297,82 +296,3 @@ 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"
);
}
+85
View File
@@ -0,0 +1,85 @@
//! Spelling suggestions from the kernel's context menu (DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::LokView;
use gpui::{Context, Entity, IntoElement, Pixels, Point};
use crate::docs::Docs;
use ely_office::prelude::*;
/// A misspelled word's menu, asked for at a point.
pub struct Offer {
tab: u64,
pub(crate) suggestions: Vec<(String, String)>,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
}
impl Docs {
/// Turns the kernel's context menu into a spelling offer.
pub(crate) fn offer_spelling(
&mut self,
id: u64,
view: &Entity<LokView>,
cx: &mut Context<Self>,
) {
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
return;
};
let spelling = match ely_lok::review::spelling(&json) {
Ok(spelling) => spelling,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let Some(spelling) = spelling else {
log::info!("doc: the kernel menu has no spelling");
return;
};
let Some(caret) = view.read(cx).cursor_bounds() else {
log::warn!("doc: a spelling menu without a caret");
return;
};
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
self.offer = Some(Offer {
tab: id,
suggestions: spelling.suggestions,
request: (number, caret.bottom_left()),
});
cx.notify();
}
/// The spelling menu's host, for the active tab.
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
let this = cx.entity();
let mut menu = Menu::new();
for (label, rule) in &offer.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if offer.suggestions.is_empty() {
let none = Docs::text(cx, "doc.spell.none", &[]);
menu = menu.item(MenuItem::new(none).disabled(true));
}
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
let menu = menu
.separator()
.item(MenuItem::new(ignore).on_click(move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
}));
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
}
}
+1 -2
View File
@@ -126,8 +126,7 @@ 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 spelling = offer.what.spelling.as_ref().expect("a spelling menu");
let (_, rule) = spelling
let (_, rule) = offer
.suggestions
.iter()
.find(|(label, _)| label == "The")
-234
View File
@@ -1,234 +0,0 @@
//! 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;
-251
View File
@@ -1,251 +0,0 @@
//! 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![pic](missing.png)\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("![pic](missing.png) 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");
}
-91
View File
@@ -1,91 +0,0 @@
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);
}
-316
View File
@@ -1,316 +0,0 @@
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());
}
}
-87
View File
@@ -1,87 +0,0 @@
//! 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)))
}
}
+31 -24
View File
@@ -23,6 +23,7 @@ use gpui::{
};
use crate::engine::Mark;
use crate::keys::{CheckDisk, PreviewMode, SourceMode, VisualMode};
use crate::view::{MarkView, Mode};
pub struct Desk {
@@ -56,8 +57,6 @@ 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 {
@@ -207,7 +206,6 @@ pub(crate) fn open_in(
asked: None,
asked_print: None,
finding: None,
copying: None,
}
});
let menus = crate::keys::menus(&look, cx);
@@ -336,7 +334,7 @@ impl Desk {
self.new_from_text("untitled.md", "", cx);
}
pub(crate) fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
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));
}
@@ -419,26 +417,35 @@ 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))
.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)
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)
}
}
-20
View File
@@ -82,26 +82,6 @@ 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) = (
+2 -7
View File
@@ -8,11 +8,11 @@ use std::{
sync::{Arc, Mutex},
};
use ely_office::{Engine, Host, Load, Tabs as _};
use ely_office::{Engine, Host, Load};
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
use crate::text::Source;
use crate::view::{Failed, MarkView, Refused, Reported};
use crate::view::{Failed, MarkView, Reported};
/// A file as read, and its text now.
pub struct Text {
@@ -137,11 +137,6 @@ 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)
-136
View File
@@ -1,136 +0,0 @@
//! 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),
}
}
+41 -9
View File
@@ -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, Target};
use crate::output::{self, Format, Page, Pictures, Target};
mod copy;
pub use copy::{CopyHtml, CopyPlain, CopyRich};
actions!(mark, [CopyRich]);
const FORMATS: [(Format, &str, &str); 4] = [
(Format::Page, "page", "html"),
@@ -41,13 +41,12 @@ fn key(format: Format) -> &'static str {
impl Desk {
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
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)
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)))
}
pub(crate) fn no_document(&self, cx: &mut Context<Self>) {
fn no_document(&self, cx: &mut Context<Self>) {
let said = text(cx, "command.blocked.no_document", &[]);
self.toast(said, Severity::Warning, cx);
}
@@ -276,6 +275,39 @@ 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.
+1 -2
View File
@@ -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 id=\"h-0\">Note</h1>") && html.contains("<svg"),
html.contains("<h1>Note</h1>") && html.contains("<svg"),
"{html}"
);
}
@@ -74,7 +74,6 @@ 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");
+2 -25
View File
@@ -110,11 +110,10 @@ 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)
);
@@ -176,25 +175,3 @@ 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");
}
+32 -79
View File
@@ -1,34 +1,24 @@
//! Modes, menus and keys, as Typora's (MARK-01, MARK-02, MARK-15).
//! Modes, menus and keys (MARK-01, MARK-02).
use ely_ui::{commands, i18n::text};
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
use gpui::{App, KeyBinding, Menu, MenuItem, actions};
use crate::{desk::Desk, view::Mode};
use crate::view::Mode;
actions!(
mark,
[VisualMode, SourceMode, PreviewMode, ToggleSource, CheckDisk]
);
actions!(mark, [VisualMode, SourceMode, PreviewMode, CheckDisk]);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
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-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-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`.
@@ -36,39 +26,36 @@ pub fn label(mode: Mode, cx: &App) -> gpui::SharedString {
text(cx, &format!("mark.mode.{}", mode.name()), &[])
}
/// 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 {
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
use crate::look::{self, Font, Width};
let checked = |key: &str, action: Box<dyn gpui::Action>, checked: bool| MenuItem::Action {
name: text(cx, key, &[]),
action,
os_action: None,
checked,
disabled: false,
}
}
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);
};
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
use crate::tools::{
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
};
vec![
Menu::new(text(cx, "menu.edit", &[])).items([
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)),
item("mark.paste.plain", Box::new(crate::clip::PastePlain)),
MenuItem::separator(),
item("office.find.open", Box::new(ely_office::FindReplace)),
item("mark.copy.rich", Box::new(crate::export::CopyRich)),
]),
Menu::new(text(cx, "mark.menu.table", &[])).items([
item("mark.table.insert", Box::new(InsertTable)),
MenuItem::separator(),
item("mark.table.row_above", Box::new(RowAbove)),
item("mark.table.row_below", Box::new(RowBelow)),
item("mark.table.column_left", Box::new(ColumnLeft)),
item("mark.table.column_right", Box::new(ColumnRight)),
MenuItem::separator(),
item("mark.table.delete_row", Box::new(DeleteRow)),
item("mark.table.delete_column", Box::new(DeleteColumn)),
]),
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),
@@ -112,45 +99,11 @@ 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)
}))
}
}
+7 -20
View File
@@ -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,11 +286,8 @@ 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());
@@ -303,22 +300,12 @@ 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");
-2
View File
@@ -3,8 +3,6 @@
mod attach;
mod autosave;
mod blocks;
mod clip;
mod compare;
mod desk;
mod engine;
-26
View File
@@ -1,26 +0,0 @@
//! 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)
}
+158 -360
View File
@@ -1,100 +1,84 @@
//! Markdown as HTML, as the drawing reads it.
//! A syntax tree written as HTML, node by node.
use std::{
cmp::Reverse,
collections::{HashSet, VecDeque},
io,
ops::Range,
};
use std::{collections::HashMap, io};
use ely_gpui_component::{
documents::{
AlertKind, Inline, MarkdownBlock, MarkdownReader, Style, alert, diagram_svg, is_toc,
},
typography::formula_svg,
};
use ely_gpui_component::{documents::diagram_svg, typography::formula_svg};
use markdown::mdast::{AlignKind, Node};
use super::{Page, address::safe, attribute, escape, pictures::Placed};
use super::{Page, attribute, escape, pictures::Placed};
/// Writes `tree`, read from `source`, into `page`.
pub(super) fn write(
tree: &Node,
source: &str,
placed: &mut Placed,
page: &mut Page,
) -> io::Result<()> {
/// Writes `tree` into `page`; pictures go through `placed`.
pub(super) fn write(tree: &Node, placed: &mut Placed, page: &mut Page) -> io::Result<()> {
let mut writer = Writer {
placed,
page,
source,
reader: MarkdownReader::new(source, tree),
anchored: HashSet::new(),
links: HashMap::new(),
notes: HashMap::new(),
called: Vec::new(),
};
writer.root(tree)
writer.gather(tree);
writer.children(tree, false)?;
writer.footnotes()
}
struct Writer<'a, 'b, 'c> {
placed: &'b mut Placed<'a>,
page: &'c mut Page,
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),
/// 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>,
}
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);
}
/// 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 blocks(&mut self, node: &Node, tight: bool) -> io::Result<()> {
fn children(&mut self, node: &Node, tight: bool) -> io::Result<()> {
for child in node.children().into_iter().flatten() {
self.block(child, tight)?;
self.node(child, tight)?;
}
Ok(())
}
fn block(&mut self, node: &Node, tight: bool) -> io::Result<()> {
fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> {
self.push(&format!("<{tag}>"));
self.children(node, false)?;
self.push(&format!("</{tag}>"));
Ok(())
}
fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> {
match 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::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::List(list) => {
let tag = if list.ordered { "ol" } else { "ul" };
match list.start.filter(|start| list.ordered && *start != 1) {
@@ -108,145 +92,73 @@ impl Writer<'_, '_, '_> {
let mark = if checked { " checked" } else { "" };
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
}
self.blocks(item, !list.spread && !each.spread)?;
self.children(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_of(node));
self.page.diagrams.push(line);
self.code(Some("mermaid"), &code.value);
}
}
}
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
Node::Math(math) => self.math(line_of(node), &math.value, true),
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::ThematicBreak(_) => self.push("<hr>"),
Node::FootnoteDefinition(note) => {
// A reference reaches its note's first definition.
let id = (self.reader.note(&note.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>");
}
// Never written.
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) => {}
other => unreachable!("{other:?} is no block outside MDX"),
}
Ok(())
}
/// 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::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 = self
.reader
.note(&call.identifier)
.expect("a reference names a note");
out.back_mut().expect("a stretch").notes.push_back(number);
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::Image(_) | Node::ImageReference(_) if split => out.push_back(Atoms::default()),
_ => {}
}
for child in node.children().into_iter().flatten() {
self.gather(child, split, out);
// Written elsewhere, or never.
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {}
_ => self.children(node, false)?,
}
Ok(())
}
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");
fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> {
self.push("<table>");
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");
for (at, row) in node.children().into_iter().flatten().enumerate() {
let cell = if at == 0 { "th" } else { "td" };
self.push("<tr>");
for (column, (pieces, cell)) in row.iter().zip(cells).enumerate() {
let side = match aligns.get(column) {
for (column, each) in row.children().into_iter().flatten().enumerate() {
let side = match align.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!("<{tag}{side}>"));
let stretches = self.stretches(cell, true);
self.run(pieces, cell, true, true, stretches)?;
self.push(&format!("</{tag}>"));
self.push(&format!("<{cell}{side}>"));
self.children(each, false)?;
self.push(&format!("</{cell}>"));
}
self.push("</tr>");
}
@@ -254,113 +166,6 @@ 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))
@@ -368,12 +173,12 @@ impl Writer<'_, '_, '_> {
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
}
fn math(&mut self, line: usize, value: &str, display: bool) {
fn math(&mut self, node: &Node, 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, error.to_string()));
self.page.formulas.push((line_of(node), error.to_string()));
match display {
true => self.code(Some("math"), value),
false => self.push(&format!("<code>{}</code>", escape(value))),
@@ -382,37 +187,21 @@ impl Writer<'_, '_, '_> {
}
}
/// 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)?;
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>"),
}
self.push("</div>");
self.children(node, false)?;
self.push("</a>");
Ok(())
}
/// `[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<()> {
fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> {
let src = self.placed.place(url)?;
if src.is_none() {
self.page.missing.push(if url.is_empty() {
@@ -426,52 +215,36 @@ impl Writer<'_, '_, '_> {
self.push(&format!("[{}]", escape(alt)));
return Ok(());
};
let image = format!(
"<img src=\"{}\" alt=\"{}\">",
let title = title.map_or_else(String::new, |title| {
format!(" title=\"{}\"", attribute(title))
});
self.push(&format!(
"<img src=\"{}\" alt=\"{}\"{title}>",
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(())
}
}
/// 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"),
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(&note, false)?,
None => log::error!("mark: footnote {at} has no definition"),
}
self.push("</li>");
}
self.push("</ol></section>");
Ok(())
}
}
@@ -484,3 +257,28 @@ 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)
}
+2 -3
View File
@@ -1,6 +1,5 @@
//! Markdown as an HTML page or plain text (MARK-11).
mod address;
mod files;
mod html;
mod pictures;
@@ -13,7 +12,7 @@ use std::{io, path::Path};
use ely_gpui_component::documents::markdown_options;
pub use files::{Format, Target, write};
pub use pictures::{Pictures, picture_file};
pub use pictures::Pictures;
pub use plain::plain;
/// A page and what it could not carry.
@@ -46,7 +45,7 @@ fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result<Page> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let mut page = Page::default();
html::write(&tree, text, placed, &mut page)?;
html::write(&tree, placed, &mut page)?;
log::info!(
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
page.html.len(),
+15 -27
View File
@@ -8,7 +8,7 @@ use std::{
use base64::Engine as _;
use super::address::scheme;
use super::html::scheme;
/// Where pictures go.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -19,8 +19,6 @@ 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.
@@ -42,22 +40,6 @@ 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 {
@@ -74,9 +56,6 @@ 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()));
@@ -88,10 +67,20 @@ impl<'a> Placed<'a> {
};
return Ok(readable.then(|| url.to_owned()));
}
match picture_file(url, self.beside) {
Some(path) => self.carried(&path),
None => Ok(None),
}
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)
}
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
@@ -109,7 +98,6 @@ 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(),
+21 -57
View File
@@ -2,44 +2,21 @@
use std::io;
use ely_gpui_component::documents::{
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_options,
};
use ely_gpui_component::documents::markdown_options;
use markdown::mdast::Node;
/// `text` as plain text, a blank line between blocks.
pub fn plain(text: &str) -> io::Result<String> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let reader = MarkdownReader::new(text, &tree);
let mut blocks = Vec::new();
// Alerts and `[toc]` live only at the root.
for child in tree.children().into_iter().flatten() {
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),
}
block(child, &mut blocks);
}
Ok(blocks.join("\n\n") + "\n")
}
fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
fn block(node: &Node, blocks: &mut Vec<String>) {
match node {
Node::Code(code) => blocks.push(code.value.clone()),
Node::Math(math) => blocks.push(math.value.clone()),
@@ -47,7 +24,7 @@ fn block(reader: &MarkdownReader, 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(reader, child, &mut inner);
block(child, &mut inner);
}
inner.join("\n")
});
@@ -55,45 +32,32 @@ fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
}
Node::Table(table) => {
let rows = table.children.iter().map(|row| {
let cells = row.children().into_iter().flatten();
let cells = cells.map(|cell| words(reader, cell));
let cells = row.children().into_iter().flatten().map(words);
cells.collect::<Vec<_>>().join("\t")
});
blocks.push(rows.collect::<Vec<_>>().join("\n"));
}
Node::FootnoteDefinition(note) => {
let mut body = Vec::new();
for child in &note.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(_) => {
Node::Blockquote(_) | Node::FootnoteDefinition(_) => {
for child in node.children().into_iter().flatten() {
block(reader, child, blocks);
block(child, blocks);
}
}
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)),
Node::Html(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
for piece in read {
match piece {
MarkdownBlock::Paragraph(text) => blocks.push(text.text),
other => unreachable!("an HTML block reads as text, not {other:?}"),
}
}
}
// Front matter, rules and definitions are not words.
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)),
// Front matter, HTML, rules and definitions are not words.
_ => {}
}
}
/// 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
/// 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(),
}
}
+5 -13
View File
@@ -2,8 +2,6 @@ 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")
}
@@ -53,17 +51,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 id=\"h-0\">T</h1>",
"<h1>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\">a</a>",
"<a href=\"https://x.org\" title=\"t\">a</a>",
"<a href=\"/docs\">r</a>",
"<a>bad</a>",
"<sup><a href=\"#fn-1\">1</a></sup>",
"<div id=\"fn-1\" style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[1]</span><div><p>The note.</p></div></div>",
"<li id=\"fn-1\"><p>The note.</p></li>",
] {
assert!(html.contains(part), "{part} in {html}");
}
@@ -460,15 +458,9 @@ 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>"));
}
-232
View File
@@ -1,232 +0,0 @@
//! Exports read the file as the visual mode does (R4.4).
use super::{Pictures, page, plain};
use crate::output::{Page, html::write, markdown_options, pictures::Placed};
fn html(text: &str) -> String {
page(text, None, &Pictures::Inline).html
}
fn text(text: &str) -> String {
plain(text).expect("plain text")
}
#[test]
fn shortcodes_turn_as_github_turns_them() {
let page = html("Go :smile: x:smile:\n\nx:smile: :smile:\n\na <code>:smile:</code> :smile:\n");
for part in [
"<p>Go 😄 x😄</p>",
"<p>x:smile: :smile:</p>",
"<p>a <code>:smile:</code> 😄</p>",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert_eq!(
text("Go :smile:\n\nx:smile: :smile:\n"),
"Go 😄\n\nx:smile: :smile:\n"
);
}
#[test]
fn inline_tags_keep_what_they_mean_and_nothing_runs() {
let source = "a <u>u</u> <mark>m</mark> <kbd>k</kbd> <span>s</span> <script>x</script> \
<a href=\"javascript:x\">j</a> <a href=\"https://e.test\">e</a>\n";
let page = html(source);
for part in [
"<u>u</u>",
"<mark>m</mark>",
"<code>k</code>",
" s ",
"&lt;script&gt;x&lt;/script&gt;",
"<a>j</a>",
"<a href=\"https://e.test\">e</a>",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert!(
!page.contains("<span") && !page.contains("<script"),
"{page}"
);
assert_eq!(
text("a <b>b</b> <script>x</script>\n"),
"a b <script>x</script>\n"
);
}
#[test]
fn an_html_block_shows_its_text() {
let source = "<div align=\"center\">\n <b>Bold</b> and :smile:\n</div>\n";
let page = html(source);
assert!(
page.contains("<p><strong>Bold</strong> and 😄</p>"),
"{page}"
);
assert!(!page.contains("<div"), "{page}");
assert_eq!(text(source), "Bold and 😄\n");
}
#[test]
fn an_alert_writes_its_kind_and_title() {
let page = html("> [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n");
let alert = "<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid #0969da\"><p style=\"font-weight:600;color:#0969da\">Note</p><p>Read this.</p></div>";
assert!(page.contains(alert), "{page}");
assert!(!page.contains("#1a7f37"), "only root quotes alert: {page}");
assert_eq!(text("> [!WARNING]\n> Hot.\n"), "Warning\n\nHot.\n");
}
#[test]
fn a_toc_lists_the_headings_and_links_them() {
let page = html("[toc]\n\n# A\n\n## B :smile:\n");
let toc = "<ul style=\"list-style:none;padding-left:0\"><li style=\"margin-left:0em\"><a href=\"#h-7\">A</a></li><li style=\"margin-left:1em\"><a href=\"#h-12\">B 😄</a></li></ul>";
assert!(page.contains(toc), "{page}");
assert!(page.contains("<h1 id=\"h-7\">A</h1>") && page.contains("<h2 id=\"h-12\">B 😄</h2>"));
assert_eq!(text("[toc]\n\n# A\n\n## B\n"), "A\nB\n\nA\n\nB\n");
}
#[test]
fn tags_crossing_containers_read_as_drawn() {
for (source, shown) in [
("*a <code>b* :smile:</code> :smile:\n", "a b :smile: 😄"),
("a <code>*b</code>* :smile:\n", "a b 😄"),
] {
assert_eq!(text(source), format!("{shown}\n"));
let page = html(source);
assert_eq!(page.matches("😄").count(), 1, "{page}");
assert_eq!(
page.matches(":smile:").count(),
shown.matches(":smile:").count(),
"{page}"
);
}
}
#[test]
fn each_cell_and_block_reads_on_its_own() {
let page = html("| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |\n");
assert_eq!(page.matches("😄").count(), 3, "{page}");
let page = html("# a <code>x\n\n:smile:\n");
assert!(page.contains("<p>😄</p>"), "{page}");
}
#[test]
fn a_block_closes_the_tags_left_open_in_it() {
let page = html("a <b>bold\n\nnormal\n");
assert!(
page.contains("<p>a <strong>bold</strong></p><p>normal</p>"),
"{page}"
);
let page = html("a <a href=\"https://example.test\">link\n\nnormal\n");
assert!(page.ends_with("<p>normal</p>"), "{page}");
}
#[test]
fn every_toc_entry_has_its_heading() {
let page = html("[toc]\n\n# Main\n\n[^n]:\n ## Unused\n");
let targets: Vec<&str> = page
.split("href=\"#")
.skip(1)
.map(|rest| &rest[..rest.find('"').expect("a quote")])
.collect();
assert_eq!(targets.len(), 2, "{page}");
for target in targets {
assert!(
page.contains(&format!("id=\"{target}\"")),
"{target} in {page}"
);
}
}
#[test]
fn a_long_html_block_writes_in_linear_time() {
let took = |count: usize| {
let source = format!("<div>{}</div>\n", "<b>x</b> ".repeat(count));
let start = std::time::Instant::now();
html(&source);
start.elapsed()
};
let (small, large) = (took(8_000), took(32_000));
// Four times the input; squared would be sixteen.
assert!(large < small * 8, "{small:?} then {large:?}");
}
#[test]
fn notes_under_deep_marks_write_in_linear_time() {
let took = |count: usize| {
let source = format!(
"start {}{}
[^n]: note
",
"<b>".repeat(count),
"x[^n] ".repeat(count)
);
let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed");
let mut placed = Placed::new(None, &Pictures::Inline);
let start = std::time::Instant::now();
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
start.elapsed()
};
// Parsing grows squared upstream; untimed.
let (small, large) = (took(4_000), took(16_000));
assert!(large < small * 8, "{small:?} then {large:?}");
}
#[test]
fn a_blank_formula_is_dropped_with_its_text() {
for source in [
"$ $\n",
"$ $ ![p](p.png)\n",
"| $ $ | a |\n| - | - |\n| b | c |\n",
] {
html(source);
}
let page = page("$ $ ![p](p.png)\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}"
);
}
+2 -11
View File
@@ -293,13 +293,7 @@ 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;
};
@@ -404,10 +398,7 @@ 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()));
-16
View File
@@ -108,22 +108,6 @@ 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,
+10 -23
View File
@@ -141,11 +141,8 @@ 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`.
@@ -155,14 +152,11 @@ 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))
}
@@ -249,9 +243,7 @@ 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:#}");
@@ -293,8 +285,7 @@ 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;
@@ -359,11 +350,7 @@ 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);
}
+25 -65
View File
@@ -15,7 +15,8 @@ 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;
@@ -200,7 +201,7 @@ impl Side {
.action(open)
.into_any_element();
}
let (opener, clearer) = (desk.downgrade(), desk.downgrade());
let opener = desk.downgrade();
let rows = read.recent.iter().map(|path| {
let name = path
.file_name()
@@ -214,32 +215,15 @@ impl Side {
);
(key, item)
});
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()
rows.fold(
SelectableList::new("mark-recent").label(text(cx, "mark.recent", &[])),
|list, (key, item)| list.row(key, item),
)
.on_activate(move |key, _, cx| {
let path = PathBuf::from(key.as_ref());
opener.update(cx, |desk, cx| desk.open_path(path, cx)).ok();
})
.into_any_element()
}
fn outline(&self, desk: &Entity<Desk>, cx: &mut Context<Self>) -> AnyElement {
@@ -267,21 +251,17 @@ 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()
}
@@ -289,17 +269,9 @@ 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| {
@@ -378,21 +350,9 @@ 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() {
+56 -69
View File
@@ -1,10 +1,14 @@
//! The table menu's work on the shown file (MARK-06).
use ely_gpui_component::documents::TableEdit;
use ely_ui::{commands, i18n::text};
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
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 crate::{blocks::edits, desk::Desk, keys::item};
use crate::desk::Desk;
actions!(
mark,
@@ -14,75 +18,58 @@ actions!(
RowBelow,
ColumnLeft,
ColumnRight,
MoveRowUp,
MoveRowDown,
MoveColumnLeft,
MoveColumnRight,
DeleteRow,
DeleteColumn,
TidyTable,
DeleteTable
DeleteColumn
]
);
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),
]);
}
/// 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),
)
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 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)
}))
}
}
+20 -69
View File
@@ -1,6 +1,9 @@
//! 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::*;
@@ -142,10 +145,7 @@ impl Desk {
}
let (this, file) = (this.clone(), file.clone());
cx.spawn(async move |cx| {
let opened = cx
.background_executor()
.spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) })
.await;
let opened = cx.background_executor().spawn(async move { ely_platform::apps::open(AppId::Tree, &[file]) }).await;
if let Err(error) = opened {
log::error!("mark: ElyTree did not open: {error}");
cx.update(|cx| {
@@ -176,9 +176,7 @@ mod tests {
fn spaces_stay_and_broken_repositories_say_why() {
let dir = tempfile::tempdir().expect("a temp dir");
// Windows names end in no space.
let repo = dir
.path()
.join(if cfg!(windows) { "re po" } else { "repo " });
let repo = dir.path().join(if cfg!(windows) { "re po" } else { "repo " });
std::fs::create_dir(&repo).expect("the folder");
git(&repo, &["init", "-q"]);
std::fs::write(repo.join("t.md"), "a").expect("a file");
@@ -195,28 +193,11 @@ 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);
@@ -227,60 +208,30 @@ 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));
}
}
+3 -26
View File
@@ -2,13 +2,10 @@
use std::path::PathBuf;
use ely_gpui_component::documents::{
DocumentEvent, EditRefusal, LARGEST_READ, MarkdownDocument, MarkdownMode,
};
use ely_gpui_component::documents::{DocumentEvent, LARGEST_READ, MarkdownDocument, MarkdownMode};
use gpui::{
AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable as _,
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Render, Styled as _,
Window, div,
InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _, Window, div,
};
use crate::engine::{Doc, mark};
@@ -19,9 +16,6 @@ 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")]
@@ -87,14 +81,11 @@ 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 {
@@ -135,7 +126,6 @@ impl MarkView {
folder: None,
shown: false,
wheel: 0.0,
offer: None,
focus,
}
}
@@ -279,13 +269,6 @@ 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;
@@ -321,7 +304,7 @@ impl MarkView {
}
impl Render for MarkView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _: &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| {
@@ -337,16 +320,10 @@ 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;
-463
View File
@@ -1,463 +0,0 @@
//! 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)
}
+2 -7
View File
@@ -57,8 +57,6 @@ 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,
}
@@ -105,7 +103,6 @@ impl Host for Deck {
tab.extra.dark = dark;
self.repaint_all(id, cx);
}
self.offer_menu(id, view, cx);
self.reread(id, view, cx);
}
}
@@ -175,7 +172,6 @@ impl Deck {
reading_fonts: false,
guide_drag: None,
snap_menus: Some((false, false, false)),
menu: None,
notes: Default::default(),
remarks_open: false,
preview: None,
@@ -383,7 +379,7 @@ impl Deck {
self.paint_next(id, cx);
}
pub(crate) fn body(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
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)]),
@@ -451,8 +447,7 @@ 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.context_menu(cx));
.children(self.dialog_element(cx));
let root = crate::slideshow::bind(crate::insert::bind(crate::slides::bind(root, cx), cx), cx);
Self::bind_zoom(root, cx)
}
-1
View File
@@ -11,7 +11,6 @@ mod guides;
mod insert;
mod list;
mod media;
mod menu;
mod motion;
mod new;
mod notes;
-307
View File
@@ -1,307 +0,0 @@
//! 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)
}
+1 -3
View File
@@ -234,7 +234,6 @@ 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()
@@ -302,7 +301,6 @@ impl Render for Strip {
.children(slides);
div()
.size_full()
// A sized host lets the list overflow and scroll.
.child(ContextMenu::new("show-strip-menu", popup(cx)).size_full().child(list))
.child(ContextMenu::new("show-strip-menu", popup(cx)).child(list))
}
}
-2
View File
@@ -8,7 +8,6 @@ use gpui::{AppContext as _, Entity, TestAppContext};
use crate::deck::Deck;
mod menu;
mod motion;
mod notes;
mod objects;
@@ -18,7 +17,6 @@ mod play_more;
mod play_time;
mod review;
mod slides;
mod strip;
/// An unrendered Deck over a fresh worker, if LOK exists.
pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>> {
-138
View File
@@ -1,138 +0,0 @@
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));
}
-47
View File
@@ -1,47 +0,0 @@
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:?}");
}
-1
View File
@@ -23,7 +23,6 @@ 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"] }
+1 -6
View File
@@ -61,8 +61,6 @@ 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>,
@@ -97,7 +95,6 @@ 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) {
@@ -193,7 +190,6 @@ impl Book {
bars: Bars::new(window, cx),
drag: None,
sheet_menu: None,
menu: None,
panel,
panel_shown: true,
choices: None,
@@ -392,8 +388,7 @@ 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.context_menu(cx));
.children(self.dialog_element(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);
+1 -1
View File
@@ -144,7 +144,7 @@ impl Book {
self.run_in(Some(tab), id, &[&size.to_string()], cx);
}
pub(crate) fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
fn ask_size(&mut self, column: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
let text = Book::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
+6 -14
View File
@@ -140,12 +140,6 @@ impl Book {
}
}
/// Selects a header's row or column.
pub(crate) fn select_header(&mut self, column: bool, index: u32, modifier: &str, cx: &mut Context<Self>) {
let id = if column { "select.column" } else { "select.row" };
self.run_active(id, &[&index.to_string(), modifier], cx);
}
/// One row or column header with its sizing edge.
fn header(
&self,
@@ -207,15 +201,13 @@ impl Book {
.border_color(transparent_black())
.focus_ring(cx)
.tab_index(0)
.on_mouse_down(
MouseButton::Right,
cx.listener(move |book, event: &MouseDownEvent, _, cx| {
cx.stop_propagation();
book.header_menu(column, index, event.position, cx);
}),
)
.on_click(cx.listener(move |book, event: &ClickEvent, _, cx| {
book.select_header(column, index, &modifier(&event.modifiers()), cx);
let id = if column {
"select.column"
} else {
"select.row"
};
book.run_active(id, &[&index.to_string(), &modifier(&event.modifiers())], cx);
}))
.child(label);
let cell = div()
-1
View File
@@ -11,7 +11,6 @@ mod figures;
mod format;
mod formulas;
mod grid;
mod menu;
mod output;
mod page;
mod protect;
-326
View File
@@ -1,326 +0,0 @@
//! The right-click menus, laid out as Excel's (TABLE-02).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::{
LokView, Rect,
menu::{Runs, Target, runs},
sheet::Geometry,
};
use ely_office::menu::{self, Entry, Offer};
use ely_office::prelude::*;
use gpui::{App, Context, Entity, Pixels, Point, Window};
use crate::book::Book;
/// What was right-clicked.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Kind {
Cell,
Object,
/// A cell's text, being edited.
Text,
Rows,
Columns,
}
/// A menu's kind and what the kernel lets run.
pub struct What {
pub kind: Kind,
runs: Runs,
}
/// One thing a menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
RowsAbove,
RowsBelow,
ColumnsBefore,
ColumnsAfter,
InsertDown,
InsertRight,
DeleteRows,
DeleteColumns,
DeleteUp,
DeleteLeft,
Clear,
Merge,
Split,
Format,
RowHeight,
ColumnWidth,
HideRows,
ShowRows,
HideColumns,
ShowColumns,
Front,
Forward,
Back,
Backward,
}
const CLIPBOARD: [Pick; 3] = [Pick::Cut, Pick::Copy, Pick::Paste];
const CELL: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"table.menu.insert",
&[
Pick::RowsAbove,
Pick::RowsBelow,
Pick::ColumnsBefore,
Pick::ColumnsAfter,
Pick::InsertDown,
Pick::InsertRight,
],
),
Entry::Sub(
"table.menu.delete",
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteUp, Pick::DeleteLeft],
),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::Merge),
Entry::Row(Pick::Split),
Entry::Line,
Entry::Row(Pick::Format),
];
const OBJECT: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
];
const ROWS: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::RowsAbove),
Entry::Row(Pick::DeleteRows),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::RowHeight),
Entry::Row(Pick::HideRows),
Entry::Row(Pick::ShowRows),
];
const COLUMNS: &[Entry<Pick>] = &[
Entry::Line,
Entry::Row(Pick::ColumnsBefore),
Entry::Row(Pick::DeleteColumns),
Entry::Row(Pick::Clear),
Entry::Line,
Entry::Row(Pick::ColumnWidth),
Entry::Row(Pick::HideColumns),
Entry::Row(Pick::ShowColumns),
];
impl menu::Pick for Pick {
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
Pick::RowsAbove => ("table.cells.rows_above", ".uno:InsertRowsBefore"),
Pick::RowsBelow => ("table.cells.rows_below", ".uno:InsertRowsAfter"),
Pick::ColumnsBefore => ("table.cells.columns_before", ".uno:InsertColumnsBefore"),
Pick::ColumnsAfter => ("table.cells.columns_after", ".uno:InsertColumnsAfter"),
Pick::InsertDown => ("table.cells.insert_down", ".uno:InsertCell"),
Pick::InsertRight => ("table.cells.insert_right", ".uno:InsertCell"),
Pick::DeleteRows => ("table.cells.delete_rows", ".uno:DeleteRows"),
Pick::DeleteColumns => ("table.cells.delete_columns", ".uno:DeleteColumns"),
Pick::DeleteUp => ("table.cells.delete_up", ".uno:DeleteCell"),
Pick::DeleteLeft => ("table.cells.delete_left", ".uno:DeleteCell"),
Pick::Clear => ("table.menu.clear", ".uno:ClearContents"),
Pick::Merge => ("table.cells.merge", ".uno:MergeCells"),
Pick::Split => ("table.cells.split", ".uno:SplitCell"),
Pick::Format => ("table.menu.format", ".uno:FormatCellDialog"),
Pick::RowHeight => ("table.cells.row_height", ".uno:RowHeight"),
Pick::ColumnWidth => ("table.cells.column_width", ".uno:ColumnWidth"),
Pick::HideRows => ("table.cells.hide_rows", ".uno:HideRow"),
Pick::ShowRows => ("table.cells.show_rows", ".uno:ShowRow"),
Pick::HideColumns => ("table.cells.hide_columns", ".uno:HideColumn"),
Pick::ShowColumns => ("table.cells.show_columns", ".uno:ShowColumn"),
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
}
}
}
impl What {
/// The kernel lets the pick's own command run now.
pub(crate) fn on(&self, pick: Pick) -> bool {
self.runs.allows(menu::Pick::row(pick).1)
}
}
impl Kind {
/// Its rows under the clipboard row.
fn rows(self) -> &'static [Entry<Pick>] {
match self {
Kind::Cell => CELL,
Kind::Object => OBJECT,
Kind::Text => &[],
Kind::Rows => ROWS,
Kind::Columns => COLUMNS,
}
}
/// Every command its rows run.
fn commands(self) -> Vec<&'static str> {
let rows = self.rows().iter().flat_map(|entry| match entry {
Entry::Row(pick) => std::slice::from_ref(pick),
Entry::Sub(_, picks) => picks,
Entry::Line => &[],
});
let mut commands: Vec<_> = CLIPBOARD.iter().chain(rows).map(|pick| menu::Pick::row(*pick).1).collect();
commands.sort_unstable();
commands.dedup();
commands
}
}
/// A whole selected row or column covers the header.
pub(crate) fn held(selection: &[Rect], geometry: &Geometry, column: bool, index: u32) -> bool {
let (along, across) = if column {
(&geometry.columns, &geometry.rows)
} else {
(&geometry.rows, &geometry.columns)
};
let (from, to) = (along.start(index), along.start(index + 1));
let end = across.start(across.count());
selection.iter().any(|&(x, y, w, h)| {
let ((start, size), (side, span)) = if column { ((x, w), (y, h)) } else { ((y, h), (x, w)) };
side == 0 && span + 1 >= end && start <= from && to <= start + size + 1
})
}
impl Book {
/// Turns the kernel's context menu into Ely's.
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => return self.engine_failed(&error.to_string(), cx),
};
let kind = match offered.target {
Target::Cell => Kind::Cell,
Target::Object | Target::Picture => Kind::Object,
Target::Text => Kind::Text,
Target::Table | Target::Frame | Target::Slide => {
let error = format!("a {:?} menu in a spreadsheet", offered.target);
return self.toast(Book::text(cx, "office.error.engine", &[("error", &error)]), Severity::Danger, cx);
}
};
self.offer(id, kind, at, cx);
}
/// Selects a header, then offers its menu.
pub(crate) fn header_menu(&mut self, column: bool, index: u32, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let id = tab.id;
// A selected object keeps nothing held.
let read = tab.view.read(cx);
if read.graphic().is_some() || !held(read.selection(), &tab.extra.geometry, column, index) {
self.select_header(column, index, "0", cx);
}
log::info!("table: a header menu, column {column}");
self.menu = None;
let kind = if column { Kind::Columns } else { Kind::Rows };
match self.settled(id, cx) {
true => self.offer(id, kind, at, cx),
false => self.when_settled(id, move |book, _, cx| book.offer(id, kind, at, cx)),
}
cx.notify();
}
/// Asks what may run now, then opens the menu.
fn offer(&mut self, id: u64, kind: Kind, at: Point<Pixels>, cx: &mut Context<Self>) {
let commands = kind.commands();
self.ask_in(
id,
move |worker, doc| runs(worker, doc, &commands),
move |book, read, _, cx| match read {
Ok(runs) => {
book.menu = Some(Offer::new(book.menu.as_ref(), id, What { kind, runs }, at));
cx.notify();
}
Err(error) => book.engine_failed(&error.to_string(), cx),
},
cx,
);
}
/// The menu's host, for the active workbook.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self.menu.as_ref().filter(|offer| Some(offer.tab) == self.core.active)?;
Some(offer.host("table-menu", menu_of(&cx.entity(), &offer.what, cx)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("table: the menu picks {pick:?}");
let (id, values): (&str, &[&str]) = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Merge => return self.merge(self.core.active, cx),
Pick::RowHeight => return self.ask_size(false, window, cx),
Pick::ColumnWidth => return self.ask_size(true, window, cx),
Pick::Format => {
self.panel_shown = true;
return cx.notify();
}
Pick::RowsAbove => ("rows.insert_above", &[]),
Pick::RowsBelow => ("rows.insert_below", &[]),
Pick::ColumnsBefore => ("columns.insert_before", &[]),
Pick::ColumnsAfter => ("columns.insert_after", &[]),
Pick::InsertDown => ("cells.insert", &["V"]),
Pick::InsertRight => ("cells.insert", &[">"]),
Pick::DeleteRows => ("rows.delete", &[]),
Pick::DeleteColumns => ("columns.delete", &[]),
Pick::DeleteUp => ("cells.delete", &["U"]),
Pick::DeleteLeft => ("cells.delete", &["L"]),
Pick::Clear => ("cells.clear", &[]),
Pick::Split => ("cells.split", &[]),
Pick::HideRows => ("rows.hide", &[]),
Pick::ShowRows => ("rows.show", &[]),
Pick::HideColumns => ("columns.hide", &[]),
Pick::ShowColumns => ("columns.show", &[]),
Pick::Front => ("image.front", &[]),
Pick::Forward => ("image.forward", &[]),
Pick::Back => ("image.back", &[]),
Pick::Backward => ("image.backward", &[]),
};
self.run_active(id, values, cx);
}
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
log::warn!("table: {pick:?} with no workbook");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// The clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Book>, what: &What, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Book::text(cx, menu::Pick::row(pick).0, &[]))
.disabled(!what.on(pick))
.on_click(move |window, cx| this.update(cx, |book, cx| book.choose(pick, window, cx)))
};
let menu = menu::clipboard(Menu::new(), CLIPBOARD, item);
menu::entries(menu, what.kind.rows(), cx, |pick| what.on(pick), item)
}
-1
View File
@@ -395,7 +395,6 @@ fn a_delete_stays_with_its_workbook() {
mod analysis;
mod data;
mod menu;
mod names;
mod numbers;
mod output;
-260
View File
@@ -1,260 +0,0 @@
use ely_gpui_component::theme::Theme;
use ely_lok::{LokView, sheet};
use gpui::{Modifiers, MouseButton, Pixels, Point, VisualTestContext};
use super::data::{cells, open, run};
use super::*;
use crate::menu::{Kind, Pick, What, held};
type Shows = fn(&What) -> bool;
/// The grid with the menu's host over it.
struct Host(Entity<Book>);
impl gpui::Render for Host {
fn render(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let grid = self.0.update(cx, |book, cx| book.grid(window, cx));
let menu = self.0.update(cx, |book, cx| book.context_menu(cx));
gpui::div().relative().size_full().flex().flex_col().child(grid).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
fn view(cx: &mut VisualTestContext, book: &Entity<Book>) -> Entity<LokView> {
cx.update(|_, app| book.read(app).active_tab().expect("a tab").view.clone())
}
/// A cell's middle or its header's, in window pixels.
fn spot(cx: &mut VisualTestContext, book: &Entity<Book>, column: u32, row: u32, header: Option<bool>) -> Point<Pixels> {
frames(cx);
let view = view(cx, book);
cx.update(|_, app| {
let geometry = &book.read(app).active_tab().expect("a tab").extra.geometry;
let x = geometry.columns.start(column) + geometry.columns.size(column) / 2;
let y = geometry.rows.start(row) + geometry.rows.size(row) / 2;
let read = view.read(app);
let at = read.origin() + read.place((x, y));
match header {
None => at,
Some(false) => gpui::point(read.origin().x - gpui::px(24.0), at.y),
Some(true) => gpui::point(at.x, read.origin().y - gpui::px(12.0)),
}
})
}
/// Right-clicks and waits until `shown` holds for the menu.
fn right_click(cx: &mut VisualTestContext, book: &Entity<Book>, at: Point<Pixels>, shown: Shows) {
cx.update(|_, app| book.update(app, |book, _| book.menu = None));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| book.read(app).menu.as_ref().is_some_and(|offer| shown(&offer.what))) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("no menu");
}
fn on(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) -> bool {
cx.update(|_, app| book.read(app).menu.as_ref().expect("a menu").what.on(pick))
}
fn choose(cx: &mut VisualTestContext, book: &Entity<Book>, pick: Pick) {
cx.update(|window, app| book.update(app, |book, cx| book.choose(pick, window, cx)));
}
/// A column range's cells, empty ones kept.
fn column(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -> Vec<String> {
cells(cx, book, range).lines().map(|line| line.trim().to_string()).collect()
}
fn cell(what: &What) -> bool {
what.kind == Kind::Cell
}
fn rows(what: &What) -> bool {
what.kind == Kind::Rows
}
fn columns(what: &What) -> bool {
what.kind == Kind::Columns
}
/// A new, selected rectangle over A1.
fn corner_rectangle(cx: &mut TestAppContext, book: &Entity<Book>) {
run(cx, book, "draw.shape", &["rectangle", "true"]);
run(cx, book, "object.x", &["0"]);
run(cx, book, "object.y", &["0"]);
let selected = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
assert!(selected.is_some_and(|(x, y, ..)| x < 100 && y < 100), "selected at A1: {selected:?}");
}
/// Protects the sheet, allowing selection and `more`.
fn protect(cx: &mut TestAppContext, book: &Entity<Book>, more: &[&str]) {
let allow: Vec<String> = ["selectLocked", "selectUnlocked"].iter().chain(more).map(|key| key.to_string()).collect();
run(cx, book, "sheet.protect", &[&sheet::protect_json("pw", &allow)]);
}
#[test]
fn right_clicks_open_excel_rows_on_cells_and_headers() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
open(&mut cx, &book);
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.extra.geometry.rows.count() > 0));
let window = cx.add_window(|_, _| Host(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let a2 = spot(&mut shown, &book, 0, 1, None);
right_click(&mut shown, &book, a2, cell);
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
let off = cut.origin - a2;
assert!(off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0), "at the click: {off:?}");
assert!(on(&mut shown, &book, Pick::RowsBelow) && on(&mut shown, &book, Pick::Clear));
assert!(!on(&mut shown, &book, Pick::Merge), "one cell does not merge");
choose(&mut shown, &book, Pick::RowsBelow);
assert_eq!(column(&mut cx, &book, "A2:A4"), ["a", "", "b"], "a row below A2");
// The read left A2:A4 selected; a click outside it moves.
run(&mut cx, &book, "cell.goto", &["D9"]);
let a2 = spot(&mut shown, &book, 0, 1, None);
right_click(&mut shown, &book, a2, cell);
choose(&mut shown, &book, Pick::Clear);
assert_eq!(column(&mut cx, &book, "A1:A4"), ["Name", "", "", "b"], "cleared");
assert_eq!(cells(&mut cx, &book, "B2"), "3", "one cell only");
let row5 = spot(&mut shown, &book, 0, 4, Some(false));
right_click(&mut shown, &book, row5, rows);
assert!(on(&mut shown, &book, Pick::HideRows) && on(&mut shown, &book, Pick::DeleteRows));
choose(&mut shown, &book, Pick::DeleteRows);
assert_eq!(cells(&mut cx, &book, "A5"), "c", "row 5 went, not row 2");
// A selected rectangle over A1 hides no header menu.
corner_rectangle(&mut cx, &book);
let row1 = spot(&mut shown, &book, 0, 0, Some(false));
right_click(&mut shown, &book, row1, rows);
assert!(on(&mut shown, &book, Pick::RowsAbove) && on(&mut shown, &book, Pick::HideRows));
choose(&mut shown, &book, Pick::RowsAbove);
assert_eq!(cells(&mut cx, &book, "A2"), "Name", "a row above row 1");
corner_rectangle(&mut cx, &book);
let a = spot(&mut shown, &book, 0, 0, Some(true));
right_click(&mut shown, &book, a, columns);
assert!(on(&mut shown, &book, Pick::ColumnsBefore) && on(&mut shown, &book, Pick::DeleteColumns));
protect(&mut cx, &book, &[]);
let headers: [(Point<Pixels>, Shows, [Pick; 4]); 2] = [
(spot(&mut shown, &book, 0, 2, Some(false)), rows, [Pick::RowsAbove, Pick::DeleteRows, Pick::HideRows, Pick::ShowRows]),
(spot(&mut shown, &book, 1, 0, Some(true)), columns, [Pick::ColumnsBefore, Pick::DeleteColumns, Pick::HideColumns, Pick::ShowColumns]),
];
for (at, shown_as, picks) in headers {
right_click(&mut shown, &book, at, shown_as);
assert!(on(&mut shown, &book, Pick::Copy), "copy stays on");
for pick in picks.into_iter().chain([Pick::Cut, Pick::Clear]) {
assert!(!on(&mut shown, &book, pick), "{pick:?} is off on a protected sheet");
}
}
// Protection that lets rows and columns in.
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
protect(&mut cx, &book, &["insertRows", "insertColumns"]);
let b = spot(&mut shown, &book, 1, 0, Some(true));
right_click(&mut shown, &book, b, columns);
assert!(on(&mut shown, &book, Pick::ColumnsBefore), "inserting columns is allowed");
assert!(!on(&mut shown, &book, Pick::DeleteColumns), "deleting columns is not");
let row2 = spot(&mut shown, &book, 0, 1, Some(false));
right_click(&mut shown, &book, row2, rows);
assert!(on(&mut shown, &book, Pick::RowsAbove), "inserting rows is allowed");
assert!(!on(&mut shown, &book, Pick::DeleteRows), "deleting rows is not");
choose(&mut shown, &book, Pick::RowsAbove);
// Protected cells read as nothing.
run(&mut cx, &book, "sheet.protect", &[&sheet::unprotect_json("pw")]);
assert_eq!(cells(&mut cx, &book, "A3"), "Name", "a row above row 2");
}
#[test]
fn a_header_keeps_a_whole_selection_that_holds_it() {
let geometry = sheet::geometry(
r#"{"rows":{"sizes":"256:1048575","hidden":"0:1048575"},"columns":{"sizes":"1280:16383","hidden":"0:16383"}}"#,
)
.expect("parses");
// As Calc reports column B, then row 3.
let column = [(1280, 0, 1279, 268435455)];
assert!(held(&column, &geometry, true, 1));
assert!(!held(&column, &geometry, true, 0));
assert!(!held(&column, &geometry, false, 0), "a column is no row");
let row = [(0, 512, 20971519, 255)];
assert!(held(&row, &geometry, false, 2));
assert!(!held(&row, &geometry, true, 0));
let one = [(1280, 256, 1279, 255)];
assert!(!held(&one, &geometry, true, 1), "one cell is no column");
assert!(!held(&[], &geometry, false, 0));
}
/// Starts typing into the selected shape's text.
fn type_in_shape(cx: &mut TestAppContext, book: &Entity<Book>) {
cx.update(|app| {
let book = book.read(app);
let tab = book.active_tab().expect("a tab");
let mut worker = book.core.worker.lock().expect("the worker");
worker.post_key(&tab.doc, 0, 769).expect("F2");
worker.post_key(&tab.doc, 'z' as u32, 537).expect("a key");
});
fresh(cx, book);
let graphic = cx.update(|app| book.read(app).active_tab().expect("a tab").view.read(app).graphic());
assert!(graphic.is_none(), "editing the shape's text: {graphic:?}");
}
#[test]
fn a_header_ends_shape_text_editing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = super::book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
open(&mut cx, &book);
fresh(&cx, &book);
let window = cx.add_window(|_, _| Host(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let headers: [(bool, u32, Shows, Pick); 2] =
[(false, 2, rows, Pick::RowsAbove), (true, 1, columns, Pick::ColumnsBefore)];
for (column, index, shown_as, insert) in headers {
corner_rectangle(&mut cx, &book);
type_in_shape(&mut cx, &book);
let at = if column { spot(&mut shown, &book, index, 2, Some(true)) } else { spot(&mut shown, &book, 0, index, Some(false)) };
right_click(&mut shown, &book, at, shown_as);
let picked = cx.update(|app| {
let tab = book.read(app).active_tab().expect("a tab");
held(tab.view.read(app).selection(), &tab.extra.geometry, column, index)
});
assert!(picked, "the header's line is selected, column {column}");
assert!(on(&mut shown, &book, insert), "{insert:?} is on");
choose(&mut shown, &book, insert);
}
assert_eq!(cells(&mut cx, &book, "A4"), "b", "a row above row 3");
assert_eq!(cells(&mut cx, &book, "C2"), "3", "a column before B");
let path = dir.path().join("shapes.fods");
cx.update(|app| {
let book = book.read(app);
let mut worker = book.core.worker.lock().expect("the worker");
let doc = &book.active_tab().expect("a tab").doc;
doc.save_as(&mut worker, &ely_lok::file_url(&path), "fods").expect("the save");
});
let xml = std::fs::read_to_string(&path).expect("the save");
assert_eq!(xml.matches(">z</text:p>").count(), 2, "each shape keeps its typed text");
}
+1 -11
View File
@@ -39,14 +39,6 @@ pub fn forget(file: &Path, root: &Path) -> io::Result<()> {
.map(drop)
}
/// Empties the list.
pub fn clear(file: &Path) -> io::Result<()> {
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)?;
}
crate::save_atomic(file, b"[]", 0).map(drop)
}
/// Puts `root` first in the list and writes it.
pub fn remember(file: &Path, root: &Path) -> io::Result<Vec<PathBuf>> {
let mut list = recent(file)?;
@@ -68,7 +60,7 @@ pub fn remember(file: &Path, root: &Path) -> io::Result<Vec<PathBuf>> {
mod tests {
use std::path::{Path, PathBuf};
use super::{clear, forget, recent, remember};
use super::{forget, recent, remember};
#[test]
fn the_list_puts_the_newest_first_and_keeps_twenty() {
@@ -90,8 +82,6 @@ mod tests {
let plain = [PathBuf::from(r"C:\r\old"), PathBuf::from(r"C:\r\new")];
assert_eq!(recent(&file).expect("reads"), plain);
}
clear(&file).expect("clears");
assert!(recent(&file).expect("reads").is_empty());
std::fs::write(&file, "not json").expect("writes");
assert!(
remember(&file, Path::new("/r/1")).is_err(),
+2 -7
View File
@@ -53,11 +53,6 @@ pub fn check_reply(asked: u64, got: u64) -> io::Result<()> {
/// Reads (tag, id, payload); None at a clean stream end.
pub fn read_frame(input: &mut impl Read) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
read_capped(input, MAX_FRAME)
}
/// As `read_frame`, refusing payloads over `cap` before allocating.
pub fn read_capped(input: &mut impl Read, cap: usize) -> io::Result<Option<(u8, u64, Vec<u8>)>> {
let mut len = [0u8; 4];
// Only a stream ending between frames ends cleanly.
let mut got = 0;
@@ -71,10 +66,10 @@ pub fn read_capped(input: &mut impl Read, cap: usize) -> io::Result<Option<(u8,
}
}
let len = u32::from_le_bytes(len) as usize;
if len > cap {
if len > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("a {len} byte frame is over the {cap} byte cap"),
"frame over the cap",
));
}
let mut tag = [0u8; 1];
-1
View File
@@ -32,7 +32,6 @@ windows-sys.workspace = true
tempfile.workspace = true
ely-ui.workspace = true
gpui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
[[example]]
name = "lokview"
-2
View File
@@ -48,7 +48,6 @@ fn unpack_pushed(body: &[u8]) -> io::Result<(u64, Events)> {
selection: marked,
};
let cell = crate::events::Cell::unpack(&mut unpack)?;
let table = crate::table::Table::unpack(&mut unpack)?;
let events = Events {
seq,
revision,
@@ -73,7 +72,6 @@ fn unpack_pushed(body: &[u8]) -> io::Result<(u64, Events)> {
a11y,
tabs,
cell,
table,
};
Ok((id, events))
}
-2
View File
@@ -3,7 +3,6 @@ use std::path::PathBuf;
use super::*;
mod analysis;
mod borders;
mod charts;
mod check;
mod commands;
@@ -16,7 +15,6 @@ pub(crate) mod input;
mod latency;
mod layout;
mod media;
mod menus;
mod motion;
mod output;
mod paint;
@@ -1,58 +0,0 @@
use super::input::open;
use super::menus::{DOC, find};
use super::*;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// The first inner column line, once it differs from `from`.
fn column(worker: &mut Worker, doc: &Doc, seen: &Events, from: i32) -> Events {
poll_until(worker, doc, seen, WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.inner[0].position != from)
})
}
#[test]
fn a_border_reports_and_moves_in_one_step() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(DOC);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
let (x, y) = find(&mut worker, &doc, &mut seen, "left");
click(&mut worker, &doc, x, y);
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| seen.table.is_some());
let table = seen.table.clone().expect("the cursor's table");
let (columns, rows) = (&table.columns, &table.rows);
assert_eq!(columns.inner.len(), 1, "two columns, one inner line");
assert_eq!(rows.inner.len(), 1, "two rows, one inner line");
let caret = seen.cursor.expect("a caret");
let across = (columns.offset + columns.left, columns.offset + columns.inner[0].position);
let down = (rows.offset + rows.left, rows.offset + rows.inner[0].position);
assert!(across.0 < caret.0 && caret.0 < across.1, "the caret is in column one: {across:?} {caret:?}");
assert!(down.0 <= caret.1 && caret.1 < down.1, "the caret is in row one: {down:?} {caret:?}");
let read = worker.now(&doc).expect("the revision");
let line = columns.inner[0].position;
worker.run(&read, "table.border", &["column-middle", "0", "500"]).expect("the line moves");
seen = column(&mut worker, &doc, &seen, line);
let moved = seen.table.clone().expect("still in the table");
assert_eq!(moved.columns.inner[0].position, line + 500, "500 twips right");
let stale = worker.run(&read, "table.border", &["column-middle", "0", "500"]);
assert!(stale.is_err_and(|error| crate::is_stale(&error)), "an old read is refused");
worker.run(&doc, "edit.undo", &[]).expect("undo");
seen = column(&mut worker, &doc, &seen, line + 500);
let undone = seen.table.clone().expect("still in the table");
assert_eq!(undone.columns.inner[0].position, line, "one undo restores it");
let bottom = undone.rows.right;
let read = worker.now(&doc).expect("the revision");
worker.run(&read, "table.border", &["row-right", "0", "400"]).expect("the bottom moves");
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.rows.right != bottom)
});
let taller = seen.table.expect("still in the table");
assert_eq!(taller.rows.right, bottom + 400, "the last row grows");
}
+1 -1
View File
@@ -143,7 +143,7 @@ fn every_mapped_command_reaches_the_kernel_and_reports_state() {
assert!(text.contains("fo:font-weight=\"bold\""), "bold landed");
assert_eq!(
commands::all().count(),
248,
246,
"the rows of text, layout, refs, tools, sheet, calc, data, analysis, page, show and draw"
);
}
-116
View File
@@ -1,116 +0,0 @@
use super::input::open;
use super::layout::png;
use super::*;
use crate::menu::{self, Target};
pub(super) const DOC: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><text:p>The cat ran.</text:p>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>down</text:p></table:table-cell><table:table-cell><text:p>more</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Right-clicks at a point and reads the kernel's menu.
fn right(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
click(worker, doc, x, y);
press(worker, doc, seen, (x, y))
}
/// Right-clicks without moving the selection first.
fn press(worker: &mut Worker, doc: &Doc, seen: &mut Events, (x, y): (i32, i32)) -> menu::Offered {
for kind in [0, 1] {
let event = Mouse { kind, x, y, count: 1, buttons: 4, modifiers: 0 };
worker.post_mouse(doc, event).expect("the click posts");
}
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.menu.is_some());
menu::read(&seen.menu.take().expect("a menu")).expect("the menu parses")
}
/// The middle of the first match of `word`.
pub(super) fn find(worker: &mut Worker, doc: &Doc, seen: &mut Events, word: &str) -> (i32, i32) {
let before = seen.searched;
worker
.run(doc, "find.run", &[word, "false", "0", "0", "0", "1"])
.expect("the search runs");
*seen = poll_until(worker, doc, seen, WAIT, |seen| seen.searched > before);
let (x, y, _, h) = seen.found[0];
(x + 60, y + h / 2)
}
#[test]
fn a_right_click_names_its_target() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = open(DOC);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
let at = find(&mut worker, &doc, &mut seen, "cat");
let text = right(&mut worker, &doc, &mut seen, at);
assert_eq!(text.target, Target::Text);
assert!(text.allows(".uno:InsertAnnotation") && text.allows(".uno:Paste"));
assert!(!text.allows(".uno:Cut"), "nothing is selected");
let at = find(&mut worker, &doc, &mut seen, "left");
let table = right(&mut worker, &doc, &mut seen, at);
assert_eq!(table.target, Target::Table);
for uno in [
".uno:InsertRowsBefore",
".uno:InsertColumnsAfter",
".uno:DeleteTable",
".uno:SplitCell",
] {
assert!(table.allows(uno), "{uno} runs in a cell");
}
assert!(!table.allows(".uno:MergeCells"), "one cell does not merge");
let at = find(&mut worker, &doc, &mut seen, "after");
click(&mut worker, &doc, at.0, at.1);
let blue = png(dir.path(), "blue.png", [0, 0, 255, 255]);
worker.run(&doc, "image.insert", &[&blue]).expect("the image lands");
seen = poll_until(&mut worker, &doc, &seen, WAIT, |seen| seen.graphic.is_some());
let (x, y, w, h) = seen.graphic.expect("the image is selected");
let frame = right(&mut worker, &doc, &mut seen, (x + w / 2, y + h / 2));
assert_eq!(frame.target, Target::Frame);
for uno in [".uno:Copy", ".uno:WrapOn", ".uno:BringToFront", ".uno:InsertCaptionDialog"] {
assert!(frame.allows(uno), "{uno} runs on an image");
}
assert!(!frame.allows(".uno:InsertAnnotation"), "no comment on an image");
}
const LOCKED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text><text:p>open words</text:p>
<text:section text:name="S" text:protected="true"><text:p>sealed words</text:p></text:section>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell table:protected="true"><text:p>fixed</text:p></table:table-cell><table:table-cell><text:p>free</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
#[test]
fn protected_text_offers_no_link() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open(LOCKED);
click(&mut worker, &doc, 150, 150);
let mut seen = Events::default();
for (word, target, link) in [
("open", Target::Text, true),
("sealed", Target::Text, false),
("free", Target::Table, true),
("fixed", Target::Table, false),
] {
let at = find(&mut worker, &doc, &mut seen, word);
let offered = press(&mut worker, &doc, &mut seen, at);
assert_eq!(offered.target, target, "{word}");
assert_eq!(offered.allows(".uno:InsertHyperlink"), link, "{word} selected");
}
let at = find(&mut worker, &doc, &mut seen, "open");
let caret = right(&mut worker, &doc, &mut seen, at);
assert!(!caret.allows(".uno:InsertHyperlink"), "the kernel links a selection");
}
+4 -5
View File
@@ -1,6 +1,6 @@
use super::input::{open, saved};
use super::*;
use crate::{menu, review};
use crate::review;
const TEXT: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
@@ -125,9 +125,8 @@ fn spelling_suggests_and_ignores() {
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
let spelling = menu::read(&json)
let spelling = review::spelling(&json)
.expect("the menu parses")
.spelling
.expect("a spelling menu");
let (_, rule) = spelling
.suggestions
@@ -146,13 +145,13 @@ fn spelling_suggests_and_ignores() {
);
let (x, y, _, h) = seen.found[0];
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert!(menu::read(&json).expect("parses").spelling.is_some());
assert!(review::spelling(&json).expect("parses").is_some());
worker
.run(&doc, "spell.ignore_all", &["Spelling"])
.expect("ignore all");
let json = menu(&mut worker, &doc, &mut seen, (x + 60, y + h / 2)).expect("a menu");
assert_eq!(
menu::read(&json).expect("parses").spelling,
review::spelling(&json).expect("parses"),
None,
"an ignored word is no longer wrong"
);
-1
View File
@@ -153,7 +153,6 @@ pub const QUERIES: &[&str] = &[
crate::sheet::NUMBER,
crate::sheet::FILTER,
crate::sheet::LIST,
crate::menu::STATES,
];
/// The kernel's font families, from `.uno:CharFontName`.
-10
View File
@@ -159,16 +159,6 @@ pub(super) const ROWS: &[Command] = &[
&[],
Check::Undoable,
),
command(
"table.border",
".uno:TableChangeCurrentBorderPosition",
&[
Arg::Text("BorderType"),
Arg::Int("Index", "unsigned short", 0, 999),
Arg::Int("Offset", "long", -600_000, 600_000),
],
Check::Undoable,
),
command(
"table.borders_all",
".uno:SetBorderStyle",
-1
View File
@@ -100,7 +100,6 @@ pub(super) const ROWS: &[Command] = &[
Check::Undoable,
),
command("cells.split", ".uno:SplitCell", &[], Check::Undoable),
command("cells.clear", ".uno:ClearContents", &[], Check::Undoable),
command("view.freeze", ".uno:FreezePanes", &[], Check::Finished),
command("rows.hide", ".uno:HideRow", &[], Check::Undoable),
command("rows.show", ".uno:ShowRow", &[], Check::Undoable),
+1 -5
View File
@@ -54,7 +54,6 @@ const CELL_ADDRESS: i32 = 34;
const REFERENCE_MARKS: i32 = 45;
const FUNCTION_LIST: i32 = 47;
const TOOLTIP: i32 = 71;
const TABLE: i32 = 44;
/// A mixed state; kernel values never hold NUL.
pub const MIXED: &str = "\u{0}mixed";
@@ -104,7 +103,6 @@ pub struct Events {
/// The paragraph's own tab stops, in 1/100 mm.
pub tabs: Vec<i32>,
pub cell: Cell,
pub table: Option<crate::table::Table>,
}
#[derive(Default)]
@@ -146,8 +144,7 @@ fn signal() -> &'static Condvar {
SIGNAL.get_or_init(Condvar::new)
}
const KINDS: [i32; 35] = [
TABLE,
const KINDS: [i32; 34] = [
MOUSE_POINTER,
REFERENCE_MARKS,
FUNCTION_LIST,
@@ -269,7 +266,6 @@ fn record(slot: &mut Slot, kind: i32, text: &str) -> Option<u32> {
.push(format!("an unreadable search result: {text}")),
},
MENU => batch.menu = Some(text.to_string()),
TABLE => crate::table::read(text).map_or_else(|fault| batch.faults.push(fault), |table| batch.table = table),
CELL_CURSOR => batch.cell.cursor = rect_in(batch, "cell cursor", text),
CELL_FORMULA => batch.cell.formula = text.to_string(),
CELL_ADDRESS => batch.cell.address = text.to_string(),
-2
View File
@@ -7,11 +7,9 @@ mod ffi;
mod handshake;
pub mod history;
mod ipc;
pub mod menu;
pub mod review;
pub mod sheet;
pub mod show;
mod table;
mod worker;
pub use client::{
-156
View File
@@ -1,156 +0,0 @@
//! The kernel's context menu: target and flags (DOC-02).
use std::{collections::HashMap, io};
use crate::{
Doc, Worker,
review::{entries, invalid, parse},
};
/// Where a right click landed, per the kernel's menu.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Target {
Text,
Table,
/// A Writer graphic, object or frame.
Frame,
/// A spreadsheet cell.
Cell,
/// A drawing object outside Writer.
Object,
/// A picture outside Writer.
Picture,
/// An Impress slide, nothing selected.
Slide,
}
/// A misspelled word's menu: suggestions as the kernel's rules.
#[derive(Clone, Debug, PartialEq)]
pub struct Spelling {
/// Label and `ApplyRule` value for each suggestion.
pub suggestions: Vec<(String, String)>,
}
/// One menu the kernel offered.
#[derive(Clone, Debug, PartialEq)]
pub struct Offered {
pub target: Target,
pub spelling: Option<Spelling>,
/// Each command, at any depth, and whether it runs now.
enabled: HashMap<String, bool>,
}
impl Offered {
/// The kernel offers `uno` and it runs now.
pub fn allows(&self, uno: &str) -> bool {
self.enabled.get(uno) == Some(&true)
}
}
/// Which asked commands run now, per the kernel's dispatcher.
#[derive(Clone, Debug, PartialEq)]
pub struct Runs(HashMap<String, bool>);
impl Runs {
/// The kernel lets `uno` run now.
pub fn allows(&self, uno: &str) -> bool {
self.0.get(uno) == Some(&true)
}
}
/// The read of which commands run now (patch 0045).
pub const STATES: &str = ".uno:CommandStates";
/// Asks the kernel which of `commands` run now.
pub fn runs(worker: &mut Worker, doc: &Doc, commands: &[&str]) -> io::Result<Runs> {
let json = worker.values(doc, &format!("{STATES}?commands={}", commands.join(",")))?;
read_runs(&json, commands)
}
/// Reads the answer; every asked command must be in it.
fn read_runs(json: &str, commands: &[&str]) -> io::Result<Runs> {
let value = parse(json)?;
let states = value["commandStates"]
.as_object()
.ok_or_else(|| invalid(format!("no command states: {json}")))?;
let read = |command: &&str| match states.get(*command).and_then(serde_json::Value::as_bool) {
Some(on) => Ok((command.to_string(), on)),
None => Err(invalid(format!("no state for {command}: {json}"))),
};
Ok(Runs(commands.iter().map(read).collect::<io::Result<_>>()?))
}
/// Reads a `LOK_CALLBACK_CONTEXT_MENU` payload.
pub fn read(json: &str) -> io::Result<Offered> {
let list = parse(json)?;
let mut enabled = HashMap::new();
let mut suggestions = Vec::new();
walk(entries(&list, "menu")?, &mut enabled, &mut suggestions)?;
let spelling = enabled
.keys()
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
.then_some(Spelling { suggestions });
let target = if enabled.contains_key(".uno:TableInsertMenu") {
Target::Table
} else if enabled.contains_key(".uno:WrapMenu") {
Target::Frame
} else if enabled.contains_key(".uno:InsertCell") {
Target::Cell
} else if enabled.contains_key(".uno:ChangePicture") {
Target::Picture
} else if enabled.contains_key(".uno:ArrangeMenu") {
Target::Object
} else if enabled.contains_key(".uno:SlideLayoutMenu") {
Target::Slide
} else {
Target::Text
};
Ok(Offered {
target,
spelling,
enabled,
})
}
fn walk(
items: &[serde_json::Value],
enabled: &mut HashMap<String, bool>,
suggestions: &mut Vec<(String, String)>,
) -> io::Result<()> {
for item in items {
if item["type"] == "separator" {
continue;
}
let on = match item["enabled"].as_str() {
Some("true") => true,
Some("false") => false,
_ => return Err(invalid(format!("a menu item has no enabled flag: {item}"))),
};
// A submenu may have no command.
match (item["command"].as_str(), item["type"].as_str()) {
(Some(command), _) => {
enabled.insert(command.to_string(), on);
if let Some(rule) =
command.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")
{
let label = item["text"]
.as_str()
.ok_or_else(|| invalid(format!("a suggestion has no text: {item}")))?;
suggestions.push((label.to_string(), rule.to_string()));
}
}
(None, Some("menu")) => {}
(None, _) => return Err(invalid(format!("a menu item has no command: {item}"))),
}
if let Some(sub) = item.get("menu") {
let sub = sub
.as_array()
.ok_or_else(|| invalid(format!("a submenu is no list: {item}")))?;
walk(sub, enabled, suggestions)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests;
-64
View File
@@ -1,64 +0,0 @@
use super::*;
fn item(command: &str, enabled: bool) -> String {
format!(r#"{{"text":"x","type":"command","command":"{command}","enabled":"{enabled}"}}"#)
}
#[test]
fn spelling_rows_come_first_class() {
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The","enabled":"true"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling","enabled":"true"}]}"#;
let offered = read(menu).expect("the menu parses");
let spelling = offered.spelling.expect("a spelling menu");
assert_eq!(spelling.suggestions, [("The".into(), "Spelling_The".into())]);
assert_eq!(offered.target, Target::Text);
let other = format!(r#"{{"menu":[{}]}}"#, item(".uno:Cut", true));
assert_eq!(read(&other).expect("the menu parses").spelling, None);
}
#[test]
fn the_menu_names_its_target_and_flags() {
let table = format!(
r#"{{"menu":[{},{{"text":"Insert","type":"menu","command":".uno:TableInsertMenu","menu":[{}],"enabled":"true"}}]}}"#,
item(".uno:MergeCells", false),
item(".uno:InsertRowsBefore", true),
);
let table = read(&table).expect("the table menu parses");
assert_eq!(table.target, Target::Table);
assert!(table.allows(".uno:InsertRowsBefore"), "submenu rows count");
assert!(!table.allows(".uno:MergeCells"), "a disabled row");
assert!(!table.allows(".uno:DeleteTable"), "an absent row");
let frame = format!(
r#"{{"menu":[{{"type":"menu","menu":[{}],"enabled":"true"}}]}}"#,
item(".uno:WrapMenu", true)
);
assert_eq!(read(&frame).expect("a commandless submenu").target, Target::Frame);
let cell = format!(r#"{{"menu":[{}]}}"#, item(".uno:InsertCell", false));
assert_eq!(read(&cell).expect("parses").target, Target::Cell);
let object = format!(r#"{{"menu":[{}]}}"#, item(".uno:ArrangeMenu", true));
assert_eq!(read(&object).expect("parses").target, Target::Object);
let text = format!(r#"{{"menu":[{}]}}"#, item(".uno:Paste", true));
assert_eq!(read(&text).expect("parses").target, Target::Text);
// Impress: a picture also arranges; a bare slide has layouts.
let picture = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:ChangePicture", true), item(".uno:ArrangeMenu", true));
assert_eq!(read(&picture).expect("parses").target, Target::Picture);
let slide = format!(r#"{{"menu":[{},{}]}}"#, item(".uno:Paste", true), item(".uno:SlideLayoutMenu", true));
assert_eq!(read(&slide).expect("parses").target, Target::Slide);
}
#[test]
fn a_malformed_menu_fails() {
assert!(read(r#"{"menu":[{"type":"command","command":".uno:Cut"}]}"#).is_err());
assert!(read(r#"{"menu":[{"type":"command","enabled":"true"}]}"#).is_err());
assert!(read(r#"{"items":[]}"#).is_err());
}
#[test]
fn command_states_answer_each_asked_command() {
let json = r#"{"commandStates":{".uno:DeleteRows":false,".uno:InsertRowsBefore":true}}"#;
let runs = read_runs(json, &[".uno:InsertRowsBefore", ".uno:DeleteRows"]).expect("parses");
assert!(runs.allows(".uno:InsertRowsBefore"));
assert!(!runs.allows(".uno:DeleteRows"));
assert!(read_runs(json, &[".uno:HideRow"]).is_err(), "an unanswered command");
let text = r#"{"commandStates":{".uno:Cut":"true"}}"#;
assert!(read_runs(text, &[".uno:Cut"]).is_err(), "a state is a boolean");
}
+43 -3
View File
@@ -38,6 +38,13 @@ pub struct Counts {
pub selected_characters: u64,
}
/// A misspelled word's menu: suggestions as the kernel's rules.
#[derive(Clone, Debug, PartialEq)]
pub struct Spelling {
/// Label and `ApplyRule` value for each suggestion.
pub suggestions: Vec<(String, String)>,
}
/// The counts from `.uno:WordCount`.
pub fn counts(json: &str) -> io::Result<Counts> {
let list = parse(json)?;
@@ -50,6 +57,32 @@ pub fn counts(json: &str) -> io::Result<Counts> {
})
}
/// A context menu's spelling part, if it has one.
pub fn spelling(json: &str) -> io::Result<Option<Spelling>> {
let list = parse(json)?;
let items = entries(&list, "menu")?;
let commands: Vec<&str> = items
.iter()
.filter_map(|item| item["command"].as_str())
.collect();
if !commands
.iter()
.any(|command| command.starts_with(".uno:SpellCheckIgnoreAll"))
{
return Ok(None);
}
let suggestions = items
.iter()
.filter_map(|item| {
let rule = item["command"]
.as_str()?
.strip_prefix(".uno:SpellCheckApplySuggestion?ApplyRule:string=")?;
Some((item["text"].as_str()?.to_string(), rule.to_string()))
})
.collect();
Ok(Some(Spelling { suggestions }))
}
/// A heading from `.uno:Outline`.
#[derive(Clone, Debug, PartialEq)]
pub struct Heading {
@@ -157,11 +190,11 @@ pub fn formula(json: &str) -> io::Result<Option<String>> {
}
}
pub(crate) fn parse(json: &str) -> io::Result<serde_json::Value> {
fn parse(json: &str) -> io::Result<serde_json::Value> {
serde_json::from_str(json).map_err(|error| invalid(format!("a bad kernel list: {error}")))
}
pub(crate) fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
fn entries<'a>(list: &'a serde_json::Value, key: &str) -> io::Result<&'a Vec<serde_json::Value>> {
list[key]
.as_array()
.ok_or_else(|| invalid(format!("the list has no {key}")))
@@ -224,7 +257,7 @@ fn plain(html: &str) -> String {
.replace("&amp;", "&")
}
pub(crate) fn invalid(message: String) -> io::Error {
fn invalid(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
@@ -255,5 +288,12 @@ mod tests {
let counts = counts(r#"{"document":{"words":11,"characters":41,"charactersNoSpaces":31},"selection":{"words":2,"characters":7,"charactersNoSpaces":6}}"#)
.expect("counts parse");
assert_eq!((counts.words, counts.selected_characters), (11, 7));
let menu = r#"{"menu":[{"text":"The","type":"command","command":".uno:SpellCheckApplySuggestion?ApplyRule:string=Spelling_The"},{"type":"separator"},{"text":"Ignore All","type":"command","command":".uno:SpellCheckIgnoreAll?Type:string=Spelling"}]}"#;
let found = spelling(menu)
.expect("the menu parses")
.expect("a spelling menu");
assert_eq!(found.suggestions, [("The".into(), "Spelling_The".into())]);
let other = r#"{"menu":[{"text":"Cut","type":"command","command":".uno:Cut"}]}"#;
assert_eq!(spelling(other).expect("the menu parses"), None);
}
}
-196
View File
@@ -1,196 +0,0 @@
//! A table's borders, as Writer and Impress report them (DOC-07).
use std::io;
use serde_json::Value;
use crate::ipc::{Pack, Unpack};
/// One axis in twips: columns run across, rows down.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Lines {
/// The first line, from `offset`.
pub left: i32,
/// The last line, from `offset`.
pub right: i32,
pub offset: i32,
pub inner: Vec<Line>,
/// Bounds of the first and last lines, when sent.
pub ends: Option<[(i32, i32); 2]>,
}
/// An inner line and the kernel's bounds for it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Line {
pub position: i32,
pub min: i32,
pub max: i32,
pub hidden: bool,
}
/// The table holding the cursor.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Table {
pub columns: Lines,
pub rows: Lines,
}
/// Reads a `LOK_CALLBACK_TABLE_SELECTED` payload; `{}` is no table.
pub fn read(text: &str) -> Result<Option<Table>, String> {
let fault = || format!("an unreadable table: {text}");
let json: Value = serde_json::from_str(text).map_err(|_| fault())?;
let object = json.as_object().ok_or_else(fault)?;
if object.is_empty() {
return Ok(None);
}
// Impress writes every value as text (boost's property tree).
let int = |value: Option<&Value>| match value? {
Value::Number(number) => i32::try_from(number.as_i64()?).ok(),
Value::String(text) => text.parse().ok(),
_ => None,
};
let flag = |value: Option<&Value>| match value? {
Value::Bool(on) => Some(*on),
Value::String(text) => text.parse().ok(),
_ => None,
};
let lines = |key: &str| -> Option<Lines> {
let axis = object.get(key)?;
// Boost writes an empty list as an empty string.
let entries = match axis.get("entries")? {
Value::Array(entries) => entries.as_slice(),
Value::String(text) if text.is_empty() => &[],
_ => return None,
};
let inner = entries.iter().map(|entry| {
Some(Line {
position: int(entry.get("position"))?,
min: int(entry.get("min"))?,
max: int(entry.get("max"))?,
hidden: flag(entry.get("hidden"))?,
})
});
let bound = |key: &str| int(axis.get(key));
let ends = match ["leftMin", "leftMax", "rightMin", "rightMax"].map(|key| axis.get(key).is_some()) {
[false, false, false, false] => None,
[true, true, true, true] => {
Some([(bound("leftMin")?, bound("leftMax")?), (bound("rightMin")?, bound("rightMax")?)])
}
_ => return None,
};
Some(Lines {
left: int(axis.get("left"))?,
right: int(axis.get("right"))?,
offset: int(axis.get("tableOffset"))?,
inner: inner.collect::<Option<_>>()?,
ends,
})
};
let columns = lines("columns").ok_or_else(fault)?;
let rows = lines("rows").ok_or_else(fault)?;
Ok(Some(Table { columns, rows }))
}
impl Table {
pub(crate) fn pack(table: Option<&Table>, pack: Pack) -> Pack {
let Some(table) = table else {
return pack.u64(0);
};
[&table.columns, &table.rows].into_iter().fold(pack.u64(1), |pack, axis| {
let pack = pack
.i64(axis.left.into())
.i64(axis.right.into())
.i64(axis.offset.into())
.u64(axis.inner.len() as u64);
let pack = match axis.ends {
None => pack.u64(0),
Some(ends) => ends.iter().fold(pack.u64(1), |pack, &(low, high)| pack.i64(low.into()).i64(high.into())),
};
axis.inner.iter().fold(pack, |pack, line| {
pack.i64(line.position.into())
.i64(line.min.into())
.i64(line.max.into())
.u64(line.hidden as u64)
})
})
}
pub(crate) fn unpack(unpack: &mut Unpack) -> io::Result<Option<Table>> {
if unpack.u64()? == 0 {
return Ok(None);
}
let mut lines = || -> io::Result<Lines> {
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
let (left, right, offset) = (int()?, int()?, int()?);
let count = unpack.u64()?;
let ends = match unpack.u64()? {
0 => None,
_ => {
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
Some([(int()?, int()?), (int()?, int()?)])
}
};
let inner = (0..count)
.map(|_| {
let mut int = || i32::try_from(unpack.i64()?).map_err(io::Error::other);
let (position, min, max) = (int()?, int()?, int()?);
Ok(Line { position, min, max, hidden: unpack.u64()? != 0 })
})
.collect::<io::Result<_>>()?;
Ok(Lines { left, right, offset, inner, ends })
};
let columns = lines()?;
let rows = lines()?;
Ok(Some(Table { columns, rows }))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SEEN: &str = r#"{ "columns": { "left": 0, "right": 9638, "tableOffset": 1135, "entries": [ { "position": 4819, "min": 300, "max": 9338, "hidden": false } ] }, "rows": { "left": 0, "right": 552, "tableOffset": 1418, "entries": [ { "position": 276, "min": 100, "max": 552, "hidden": false } ] } }"#;
/// As Impress sent it for a new 2x2 table.
const IMPRESS: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "entries": [ { "position": "3996", "min": "340", "max": "7653", "hidden": "false" } ] }, "rows": { "tableOffset": "4340", "left": "0", "right": "1091", "entries": [ { "position": "545", "min": "340", "max": "152183880", "hidden": "false" } ] }, "rectangle": { "x": "3940", "y": "4351", "width": "7993", "height": "1091" } }"#;
#[test]
fn impress_writes_numbers_as_text() {
let table = read(IMPRESS).expect("readable").expect("a table");
assert_eq!(table.columns.offset, 3929);
assert_eq!(table.columns.inner, [Line { position: 3996, min: 340, max: 7653, hidden: false }]);
assert_eq!(table.rows.right, 1091);
assert!(read(&IMPRESS.replace("\"false\"", "\"no\"")).is_err(), "a flag is true or false");
assert_eq!(table.rows.ends, None, "an unpatched kernel sends no ends");
}
/// One row, as Impress sends it with patch 0047.
const ONE_ROW: &str = r#"{ "columns": { "tableOffset": "3929", "left": "0", "right": "7993", "leftMin": "-152183880", "leftMax": "7823", "rightMin": "170", "rightMax": "152183880", "entries": "" }, "rows": { "tableOffset": "4340", "left": "0", "right": "545", "leftMin": "-152183880", "leftMax": "28", "rightMin": "517", "rightMax": "152183880", "entries": "" } }"#;
#[test]
fn one_line_has_no_inner_lines_and_its_ends_cross_the_wire() {
let table = read(ONE_ROW).expect("readable").expect("a table");
assert!(table.columns.inner.is_empty() && table.rows.inner.is_empty());
assert_eq!(table.rows.ends, Some([(-152_183_880, 28), (517, 152_183_880)]));
assert!(read(&ONE_ROW.replace("\"entries\": \"\"", "\"entries\": \"x\"")).is_err(), "text is no list");
assert!(read(&ONE_ROW.replace("\"leftMin\": \"-152183880\", ", "")).is_err(), "ends come in fours");
let bytes = Table::pack(Some(&table), Pack::new()).bytes();
assert_eq!(Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks"), Some(table));
}
#[test]
fn a_table_reads_and_crosses_the_wire() {
assert_eq!(read("{ }"), Ok(None), "no table");
let table = read(SEEN).expect("readable").expect("a table");
assert_eq!(table.columns.offset, 1135);
assert_eq!(table.rows.inner[0].position, 276);
let text = SEEN.replace("\"left\": 0,", "\"left\": \"zero\",");
assert!(read(&text).is_err(), "a word is no number");
assert!(read("[]").is_err());
let bytes = Table::pack(Some(&table), Pack::new()).bytes();
let back = Table::unpack(&mut Unpack::new(&bytes)).expect("unpacks");
assert_eq!(back, Some(table));
let none = Table::pack(None, Pack::new()).bytes();
assert_eq!(Table::unpack(&mut Unpack::new(&none)).expect("unpacks"), None);
}
}
+5 -7
View File
@@ -14,7 +14,6 @@ use image::{Frame, ImageBuffer, Rgba};
use crate::{Doc, Events, Rect, Worker};
mod borders;
mod clip;
mod clock;
mod formula;
@@ -131,7 +130,6 @@ pub struct LokView {
marked: Option<ime::Marked>,
dragging: bool,
grip: Option<handles::Grip>,
borders: borders::Borders,
handle_size: Pixels,
states: HashMap<String, String>,
graphic: Option<Rect>,
@@ -139,7 +137,6 @@ pub struct LokView {
/// The last search outcome: its count and matches.
search: (u64, Vec<Rect>),
menu: Option<String>,
right_at: Option<Point<Pixels>>,
fonts: Vec<String>,
a11y: crate::A11y,
tabs: Vec<i32>,
@@ -166,6 +163,7 @@ pub fn layout(worker: &mut Worker, doc: &Doc) -> io::Result<Layout> {
Ok((doc.size(worker)?, doc.pages(worker)?))
}
impl EventEmitter<LokError> for LokView {}
impl EventEmitter<Reported> for LokView {}
@@ -193,7 +191,10 @@ impl LokView {
let mut view = Self {
worker,
doc,
geom: Geom { zoom: 1.0, dpi: 1.0 },
geom: Geom {
zoom: 1.0,
dpi: 1.0,
},
scroll: (0, 0),
doc_size: (width as i32, height as i32),
generation: 0,
@@ -232,14 +233,12 @@ impl LokView {
marked: None,
dragging: false,
grip: None,
borders: Default::default(),
handle_size: px(6.0),
states: HashMap::new(),
graphic: None,
review: 0,
search: (0, Vec::new()),
menu: None,
right_at: None,
fonts: Vec::new(),
a11y: crate::A11y::default(),
tabs: Vec::new(),
@@ -399,7 +398,6 @@ impl LokView {
}
self.cell = batch.cell;
self.graphic = batch.graphic;
self.read_borders(batch.table, batch.revision);
self.review = batch.review;
self.search = (batch.searched, batch.found);
self.fonts = batch.fonts;
-293
View File
@@ -1,293 +0,0 @@
//! Dragging the cursor's table borders (DOC-07).
use gpui::{Bounds, Context, CursorStyle, Pixels, Point, px, size};
use super::LokView;
use crate::table::{Lines, Table};
use crate::{Doc, LokError};
/// Least gap the kernel keeps between lines, in twips.
const GAP: i32 = 40;
/// A border, as `.uno:TableChangeCurrentBorderPosition` names it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Side {
ColumnLeft,
Column(u16),
ColumnRight,
Row(u16),
RowBottom,
}
impl Side {
/// Column lines stand upright and move across.
fn upright(self) -> bool {
matches!(self, Side::ColumnLeft | Side::Column(_) | Side::ColumnRight)
}
fn kind(self) -> &'static str {
match self {
Side::ColumnLeft => "column-left",
Side::Column(_) => "column-middle",
Side::ColumnRight => "column-right",
Side::Row(_) => "row-middle",
Side::RowBottom => "row-right",
}
}
fn index(self) -> u16 {
match self {
Side::Column(index) | Side::Row(index) => index,
_ => 0,
}
}
}
/// A held border, with the table read it came from.
#[derive(Clone)]
pub(super) struct Held {
side: Side,
/// The pointer's offset from the line when pressed.
grab: i32,
from: i32,
to: i32,
table: Table,
doc: Doc,
}
/// The cursor's table as last read, and a held border.
#[derive(Default)]
pub(super) struct Borders {
table: Option<(Table, Doc)>,
/// The revision of the batch the table came from.
read: u64,
held: Option<Held>,
hover: Option<CursorStyle>,
}
/// Each border: side, place, span and room, in twips.
fn lines(table: &Table) -> Vec<(Side, i32, (i32, i32), i32)> {
let (columns, rows) = (&table.columns, &table.rows);
let down = (rows.offset + rows.left, rows.offset + rows.right);
let across = (columns.offset + columns.left, columns.offset + columns.right);
let mut upright = vec![(Side::ColumnLeft, across.0)];
upright.extend(inner(columns).map(|(index, at)| (Side::Column(index), at)));
upright.push((Side::ColumnRight, across.1));
let mut level: Vec<_> = inner(rows).map(|(index, at)| (Side::Row(index), at)).collect();
level.push((Side::RowBottom, down.1));
let mut lines = spaced(&upright, None, down);
lines.extend(spaced(&level, Some(down.0), across));
lines
}
/// Gives each line its span and nearest neighbour's distance.
fn spaced(stops: &[(Side, i32)], before: Option<i32>, span: (i32, i32)) -> Vec<(Side, i32, (i32, i32), i32)> {
let places: Vec<i32> = before.into_iter().chain(stops.iter().map(|&(_, at)| at)).collect();
let skip = places.len() - stops.len();
let room = |i: usize| {
let prev = i.checked_sub(1).map(|j| places[i] - places[j]);
prev.into_iter().chain(places.get(i + 1).map(|next| next - places[i])).min().unwrap_or(i32::MAX)
};
stops.iter().enumerate().map(|(i, &(side, at))| (side, at, span, room(i + skip))).collect()
}
fn inner(lines: &Lines) -> impl Iterator<Item = (u16, i32)> + '_ {
lines.inner.iter().enumerate().filter(|(_, line)| !line.hidden).map(|(index, line)| (index as u16, lines.offset + line.position))
}
/// `offset` as the kernel will clamp it.
fn clamp(table: &Table, side: Side, offset: i32) -> i32 {
let axis = if side.upright() { &table.columns } else { &table.rows };
let (first, last) = (axis.inner.first(), axis.inner.last());
// Impress sends its outer bounds; Writer leaves a gap.
let (at, low, high) = match (side, axis.ends) {
(Side::ColumnLeft, Some([(low, high), _])) => (axis.left, low, high),
(Side::ColumnRight | Side::RowBottom, Some([_, (low, high)])) => (axis.right, low, high),
(Side::ColumnLeft, None) => (axis.left, i32::MIN, first.map_or(axis.right, |line| line.position) - GAP),
(Side::ColumnRight | Side::RowBottom, None) => {
(axis.right, last.map_or(axis.left, |line| line.position) + GAP, i32::MAX)
}
(Side::Column(index) | Side::Row(index), ends) => {
let line = axis.inner[usize::from(index)];
let high = if ends.is_some() { line.max } else { (line.max - GAP).max(line.min) };
(line.position, line.min, high)
}
};
let moved = (at.saturating_add(offset)).clamp(low, high) - at;
// A line past its bound never moves against the drag.
if moved.signum() == offset.signum() { moved } else { 0 }
}
impl LokView {
/// Takes the cursor's table from a batch at `revision`.
pub(super) fn read_borders(&mut self, table: Option<Table>, revision: u64) {
if revision < self.borders.read {
log::debug!("lok: table read at {revision} is older than {}", self.borders.read);
return;
}
if table.is_none() {
self.borders.hover = None;
}
let mut doc = self.doc.clone();
doc.rev = revision;
self.borders.read = revision;
self.borders.table = table.map(|table| (table, doc));
}
/// The border under `position`; short cells keep their middles.
fn border_at(&self, position: Point<Pixels>) -> Option<(Side, i32)> {
let (table, _) = self.borders.table.as_ref()?;
lines(table).into_iter().find_map(|(side, at, (start, end), room)| {
let reach = self.handle_size.min(px(self.geom.px_of(room) / 4.0));
let (from, to) = if side.upright() {
(self.window_point((at, start)), self.window_point((at, end)))
} else {
(self.window_point((start, at)), self.window_point((end, at)))
};
let (near, along, low, high) = if side.upright() {
((from.x - position.x).abs(), position.y, from.y, to.y)
} else {
((from.y - position.y).abs(), position.x, from.x, to.x)
};
(near <= reach && along >= low - reach && along <= high + reach).then_some((side, at))
})
}
/// Holds the border under `position`, if any.
pub(super) fn hold_border(&mut self, position: Point<Pixels>) -> bool {
let Some(((side, at), (table, doc))) = self.border_at(position).zip(self.borders.table.clone()) else {
return false;
};
let (x, y) = self.doc_point(position);
let grab = if side.upright() { x } else { y } - at;
log::info!("lok: table border {} {} held", side.kind(), side.index());
self.borders.held = Some(Held { side, grab, from: at, to: at, table, doc });
true
}
/// A held border follows the pointer; a hovered one shows.
pub(super) fn move_border(&mut self, position: Point<Pixels>) -> bool {
let Some(held) = &self.borders.held else {
let side = self.border_at(position).map(|(side, _)| side);
self.borders.hover = side.map(|side| if side.upright() { CursorStyle::ResizeColumn } else { CursorStyle::ResizeRow });
return false;
};
let (x, y) = self.doc_point(position);
let want = if held.side.upright() { x } else { y } - held.grab;
let to = held.from + clamp(&held.table, held.side, want - held.from);
if let Some(held) = self.borders.held.as_mut() {
held.to = to;
}
true
}
/// Lets go at `position`: one command at the read's revision.
pub(super) fn drop_border(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) -> bool {
if !self.move_border(position) {
return false;
}
let Some(held) = self.borders.held.take() else {
return false;
};
let offset = held.to - held.from;
if offset == 0 {
log::info!("lok: table border let go in place");
return true;
}
log::info!("lok: table border {} {} moves {offset}", held.side.kind(), held.side.index());
let (index, offset) = (held.side.index().to_string(), offset.to_string());
if let Err(error) = self.run_at(&held.doc, "table.border", &[held.side.kind(), &index, &offset]) {
cx.emit(LokError(format!("the table border did not move: {error}")));
}
true
}
pub(super) fn holds_border(&self) -> bool {
self.borders.held.is_some()
}
/// The pointer a border asks for, while held or hovered.
pub(super) fn border_pointer(&self) -> Option<CursorStyle> {
match &self.borders.held {
Some(held) if held.side.upright() => Some(CursorStyle::ResizeColumn),
Some(_) => Some(CursorStyle::ResizeRow),
None => self.borders.hover,
}
}
/// The guide line of a held border, in window pixels.
pub(super) fn guide(&self) -> Option<Bounds<Pixels>> {
let held = self.borders.held.as_ref()?;
let (_, _, (start, end), _) = lines(&held.table).into_iter().find(|&(side, ..)| side == held.side)?;
Some(if held.side.upright() {
let (top, bottom) = (self.window_point((held.to, start)), self.window_point((held.to, end)));
Bounds { origin: top, size: size(px(1.0), bottom.y - top.y) }
} else {
let (left, right) = (self.window_point((start, held.to)), self.window_point((end, held.to)));
Bounds { origin: left, size: size(right.x - left.x, px(1.0)) }
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::table::Line;
fn table() -> Table {
let line = |position, min, max| Line { position, min, max, hidden: false };
Table {
columns: Lines { left: 0, right: 9000, offset: 1000, inner: vec![line(3000, 300, 6000), line(6000, 3300, 8700)], ends: None },
rows: Lines { left: 0, right: 800, offset: 2000, inner: vec![line(400, 100, 800)], ends: None },
}
}
#[test]
fn borders_lie_where_the_kernel_puts_them() {
let sides: Vec<_> = lines(&table()).into_iter().map(|(side, at, _, room)| (side, at, room)).collect();
assert_eq!(
sides,
[
(Side::ColumnLeft, 1000, 3000),
(Side::Column(0), 4000, 3000),
(Side::Column(1), 7000, 3000),
(Side::ColumnRight, 10_000, 3000),
(Side::Row(0), 2400, 400),
(Side::RowBottom, 2800, 400),
]
);
}
#[test]
fn a_drag_clamps_as_the_kernel_does() {
let table = table();
assert_eq!(clamp(&table, Side::Column(0), 500), 500, "inside its bounds");
assert_eq!(clamp(&table, Side::Column(0), 9000), 2960, "up to max less the gap");
assert_eq!(clamp(&table, Side::Column(1), -9000), -2700, "down to min");
assert_eq!(clamp(&table, Side::ColumnLeft, 5000), 2960, "short of the first line");
assert_eq!(clamp(&table, Side::ColumnRight, -8000), -2960, "past the last line");
assert_eq!(clamp(&table, Side::RowBottom, -800), -360, "a row keeps the gap");
assert_eq!(clamp(&table, Side::Row(0), 500), 360, "max less the gap");
}
#[test]
fn sent_ends_bound_the_outer_lines() {
let mut table = table();
table.columns.ends = Some([(-50_000, 2800), (6200, 50_000)]);
table.rows.ends = Some([(-50_000, 380), (717, 50_000)]);
assert_eq!(clamp(&table, Side::ColumnLeft, 5000), 2800, "to the first column's minimum");
assert_eq!(clamp(&table, Side::ColumnLeft, -900), -900, "outward too");
assert_eq!(clamp(&table, Side::ColumnRight, -8000), -2800, "to the last column's minimum");
assert_eq!(clamp(&table, Side::RowBottom, -800), -83, "to the last row's minimum");
assert_eq!(clamp(&table, Side::Column(0), 9000), 3000, "Impress's max needs no gap");
}
#[test]
fn a_line_at_or_past_its_bound_stays_put() {
let mut table = table();
table.columns.inner[0].max = 3000;
assert_eq!(clamp(&table, Side::Column(0), 0), 0, "a still press sends nothing");
assert_eq!(clamp(&table, Side::Column(0), 200), 0, "no room toward the bound");
assert_eq!(clamp(&table, Side::Column(0), -200), -200, "away from it is free");
}
}
+5 -24
View File
@@ -313,8 +313,7 @@ impl LokView {
return;
};
window.focus(&self.focus, cx);
self.right_at = (event.button == MouseButton::Right).then_some(event.position);
if event.button == MouseButton::Left && (self.grab(event.position) || self.hold_border(event.position)) {
if event.button == MouseButton::Left && self.grab(event.position) {
cx.notify();
return;
}
@@ -334,10 +333,6 @@ impl LokView {
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.drop_border(event.position, cx) {
cx.notify();
return;
}
if let Some(grip) = self.grip.take() {
let to = self.doc_point(event.position);
log::info!("lok: handle released");
@@ -370,7 +365,7 @@ impl LokView {
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.dragging || self.grip.is_some() || self.holds_border() {
if self.dragging || self.grip.is_some() {
self.on_mouse_up(event, window, cx);
}
}
@@ -387,10 +382,6 @@ impl LokView {
cx.notify();
return;
}
if self.move_border(event.position) {
cx.notify();
return;
}
if self.dragging && event.pressed_button == Some(MouseButton::Left) {
self.mouse(2, event.position, 1, 1, &event.modifiers);
} else if event.pressed_button.is_none() {
@@ -400,17 +391,7 @@ impl LokView {
}
pub(super) fn on_copy(&mut self, _: &Copy, _: &mut Window, _: &mut Context<Self>) {
self.copy(false);
}
/// Copies the selection out, or cuts it.
pub fn copy(&mut self, cut: bool) {
self.send(Input::Copy { cut });
}
/// Pastes the system clipboard.
pub fn paste(&mut self) {
self.send(Input::Paste);
self.send(Input::Copy { cut: false });
}
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
@@ -429,11 +410,11 @@ impl LokView {
}
pub(super) fn on_cut(&mut self, _: &Cut, _: &mut Window, _: &mut Context<Self>) {
self.copy(true);
self.send(Input::Copy { cut: true });
}
pub(super) fn on_paste(&mut self, _: &Paste, _: &mut Window, _: &mut Context<Self>) {
self.paste();
self.send(Input::Paste);
}
/// Pastes values or formulas alone (TABLE-08).
+4 -5
View File
@@ -1,6 +1,6 @@
//! What the view knows, for apps to read.
use gpui::{FocusHandle, Pixels, Point};
use gpui::FocusHandle;
use super::LokView;
use crate::Rect;
@@ -91,10 +91,9 @@ impl LokView {
&self.fonts
}
/// Takes the kernel's last menu and the click that asked.
pub fn take_menu(&mut self) -> Option<(String, Option<Point<Pixels>>)> {
let menu = self.menu.take()?;
Some((menu, self.right_at.take()))
/// Takes the context menu the kernel last offered.
pub fn take_menu(&mut self) -> Option<String> {
self.menu.take()
}
/// The paragraph's own tab stops, in 1/100 mm.
+3 -22
View File
@@ -2,8 +2,8 @@
use ely_gpui_component::theme::ActiveTheme;
use gpui::{
BorderStyle, Bounds, ContentMask, Context, Corners, CursorStyle, DispatchPhase, Edges,
ElementInputHandler, InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, Render, Role,
BorderStyle, Bounds, ContentMask, Context, Corners, CursorStyle, Edges, ElementInputHandler,
InteractiveElement, IntoElement, MouseButton, ParentElement, Render, Role,
StatefulInteractiveElement, Styled, Window, canvas, div, fill, outline, px, quad, size,
transparent_black,
};
@@ -59,7 +59,7 @@ impl Render for LokView {
.aria_value(self.a11y.text.clone())
.key_context(CONTEXT)
.track_focus(&self.focus)
.cursor(self.border_pointer().unwrap_or(self.pointer))
.cursor(self.pointer)
.size_full()
.overflow_hidden()
.bg(canvas_color)
@@ -164,9 +164,6 @@ impl Render for LokView {
));
}
}
if let Some(guide) = painted.guide {
window.paint_quad(fill(guide, frame_color));
}
if let Some(caret) = painted.caret.filter(|_| focus.is_focused(window))
{
let caret = Bounds {
@@ -176,19 +173,6 @@ impl Render for LokView {
window.paint_quad(fill(caret, caret_color));
}
});
if painted.guide.is_some() {
// A held border follows the pointer out of the view.
let this = this.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, app| {
if phase == DispatchPhase::Bubble {
this.update(app, |view, cx| {
if view.move_border(event.position) {
cx.notify();
}
});
}
});
}
window.handle_input(&focus, ElementInputHandler::new(bounds, this), app);
},
)
@@ -212,8 +196,6 @@ pub(super) struct Painted {
pub(super) frame: Option<(Bounds<Pixels>, Vec<Bounds<Pixels>>)>,
/// The references of a formula being typed.
pub(super) marks: Vec<(Bounds<Pixels>, gpui::Hsla)>,
/// A held table border's guide line.
pub(super) guide: Option<Bounds<Pixels>>,
}
impl LokView {
@@ -375,7 +357,6 @@ impl LokView {
cell: self.cell.cursor.map(rect),
frame,
marks: self.marks(),
guide: self.guide(),
}
}
}
-2
View File
@@ -1,12 +1,10 @@
use super::*;
mod borders;
mod clip;
mod commands;
mod fit;
mod input;
mod large;
mod show_borders;
mod theme;
use gpui::{App, Bounds, Entity, point, size};
-301
View File
@@ -1,301 +0,0 @@
use super::*;
use crate::client::tests::poll_until;
use gpui::{CursorStyle, MouseButton, MouseDownEvent, MouseUpEvent, TestAppContext};
const TABLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:body><office:text>
<table:table table:name="T"><table:table-column table:number-columns-repeated="2"/>
<table:table-row><table:table-cell><text:p>left</text:p></table:table-cell><table:table-cell><text:p>right</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>down</text:p></table:table-cell><table:table-cell><text:p>more</text:p></table:table-cell></table:table-row>
</table:table><text:p>after</text:p></office:text></office:body>
</office:document>"#;
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// The view takes the cursor's table the kernel reports.
fn borders_read(view: &mut LokView) -> crate::table::Table {
let doc = view.doc.clone();
let mut worker = view.worker.lock().expect("the worker lock");
worker.post_key(&doc, 0, 1027).expect("Right moves the cursor");
let seen = poll_until(&mut worker, &doc, &Events::default(), WAIT, |seen| seen.table.is_some());
let table = seen.table.expect("the cursor's table");
let now = worker.now(&doc).expect("the revision");
drop(worker);
view.doc = now;
view.read_borders(Some(table.clone()), view.doc.rev);
table
}
#[test]
fn a_dragged_border_moves_once_where_its_guide_shows() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let (line, moved) = cx.update(|app| {
view.update(app, |view, cx| {
let table = borders_read(view);
let (columns, rows) = (&table.columns, &table.rows);
let line = columns.offset + columns.inner[0].position;
let middle = rows.offset + (rows.left + rows.right) / 2;
let on = view.window_point((line, middle));
view.move_border(on);
assert_eq!(view.border_pointer(), Some(CursorStyle::ResizeColumn), "a hovered line asks to resize");
assert!(view.hold_border(on), "the line is held");
assert!(view.drop_border(on, cx), "let go in place");
assert!(view.hold_border(on));
let end = on + gpui::point(px(40.0), px(3.0));
assert!(view.move_border(end));
let guide = view.guide().expect("a guide line");
let to = view.doc_point(guide.origin).0;
assert!(to > line, "the guide follows the pointer: {line} to {to}");
assert!(view.drop_border(end, cx), "the drop runs");
assert!(!view.holds_border());
(line, to - line)
})
});
for _ in 0..80 {
cx.executor().tick();
}
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.offset + table.columns.inner[0].position != line)
});
let table = seen.table.expect("still in the table");
let landed = table.columns.offset + table.columns.inner[0].position;
assert!((landed - line - moved).abs() <= 1, "the kernel lands it at the guide: {line} + {moved} vs {landed}");
});
}
#[test]
fn a_held_border_keeps_its_table_and_a_newer_read_refuses_it() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let line = cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| errors.borrow_mut().push(error.0.clone())).detach();
view.update(app, |view, cx| {
let table = borders_read(view);
let (columns, rows) = (&table.columns, &table.rows);
let line = columns.offset + columns.inner[0].position;
let on = view.window_point((line, rows.offset + (rows.left + rows.right) / 2));
assert!(view.hold_border(on), "the line is held");
let doc = view.doc.clone();
let mut worker = view.worker.lock().expect("the worker lock");
worker.post_key(&doc, 'a' as u32, 512).expect("a types");
view.doc = worker.now(&doc).expect("the revision");
drop(worker);
let mut gone = table.clone();
gone.columns.inner.clear();
view.read_borders(Some(gone), view.doc.rev);
let end = on + gpui::point(px(40.0), px(0.0));
assert!(view.move_border(end), "it moves by its own table");
assert!(view.guide().is_some(), "its guide still shows");
assert!(view.drop_border(end, cx), "the drop runs");
line
})
});
for _ in 0..60 {
for _ in 0..20 {
cx.executor().tick();
}
if !errors.borrow().is_empty() {
break;
}
}
assert!(errors.borrow().iter().any(|error| error.contains("stale")), "{:?}", errors.borrow());
cx.update(|app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
worker.post_key(&view.doc, 0, 1027).expect("Right moves the cursor");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| seen.table.is_some());
let table = seen.table.expect("still in the table");
assert_eq!(table.columns.offset + table.columns.inner[0].position, line, "the old drag never lands");
});
}
#[test]
fn a_click_in_a_short_row_reaches_the_document() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let window = cx.add_window(|_, _| super::input::Blank);
cx.update_window(window.into(), |_, window, app| {
view.update(app, |view, cx| {
view.set_zoom(0.5, cx);
let table = borders_read(view);
let (columns, rows) = (&table.columns, &table.rows);
let x = columns.offset + (columns.left + columns.inner[0].position) / 2;
let middle = view.window_point((x, rows.offset + (rows.inner[0].position + rows.right) / 2));
let line = view.window_point((x, rows.offset + rows.inner[0].position));
assert!(f32::from(line.y - middle.y).abs() < 6.0, "the row is shorter than two reaches");
let down = |position| MouseDownEvent {
button: MouseButton::Left,
position,
modifiers: Default::default(),
click_count: 1,
first_mouse: false,
};
let up = MouseUpEvent { button: MouseButton::Left, position: middle, modifiers: Default::default(), click_count: 1 };
view.on_mouse_down(&down(middle), window, cx);
assert!(!view.holds_border(), "the row's middle goes to the document");
view.on_mouse_up(&up, window, cx);
view.on_mouse_down(&down(line), window, cx);
assert!(view.holds_border(), "the line itself is held");
assert!(view.drop_border(line, cx));
})
})
.expect("the window lives");
}
/// The batch whose table has `count` inner columns.
fn table_batch(view: &LokView, count: usize) -> Events {
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.inner.len() == count)
});
assert_eq!(seen.table.as_ref().map(|table| table.columns.inner.len()), Some(count));
seen
}
#[test]
fn a_late_batch_keeps_its_own_revision() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let errors = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let before = cx.update(|app| {
let errors = errors.clone();
app.subscribe(&view, move |_, error: &LokError, _| errors.borrow_mut().push(error.0.clone())).detach();
view.update(app, |view, cx| {
borders_read(view);
let old = table_batch(view, 1);
let table = old.table.clone().expect("the old table");
let now = {
let mut worker = view.worker.lock().expect("the worker lock");
worker.run(&view.doc, "table.column_right", &[]).expect("a column goes in");
worker.now(&view.doc).expect("the revision")
};
let before = table_batch(view, 2).table.expect("the new table").columns.inner[0].position;
view.seen_revision(now.rev);
view.apply_batch(old, None, cx);
let on = view.window_point((table.columns.offset + table.columns.inner[0].position, table.rows.offset + 100));
assert!(view.hold_border(on), "the old table's line is held");
let end = on + gpui::point(px(20.0), px(0.0));
assert!(view.drop_border(end, cx), "the drop runs");
before
})
});
for _ in 0..60 {
for _ in 0..20 {
cx.executor().tick();
}
if !errors.borrow().is_empty() {
break;
}
}
assert!(errors.borrow().iter().any(|error| error.contains("stale")), "{:?}", errors.borrow());
let after = cx.update(|app| view.update(app, |view, _| table_batch(view, 2).table.expect("the table").columns.inner[0].position));
assert_eq!(after, before, "the old read never moves the new table");
}
/// The real view, narrower than its window.
struct Narrow(gpui::Entity<LokView>);
impl gpui::Render for Narrow {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement, Styled};
gpui::div().w(px(450.0)).h(px(300.0)).child(self.0.clone())
}
}
#[test]
fn a_border_let_go_outside_the_view_lands_there() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let (_, vcx) = cx.add_window_view(|_, _| Narrow(view.clone()));
vcx.run_until_parked();
let (line, on) = vcx.update(|_, app| {
view.update(app, |view, cx| {
let table = borders_read(view);
cx.notify();
let line = table.columns.offset + table.columns.inner[0].position;
(line, view.window_point((line, table.rows.offset + 100)))
})
});
vcx.run_until_parked();
assert!(on.x > px(380.0) && on.x < px(440.0), "the line sits near the view's edge: {on:?}");
vcx.simulate_mouse_down(on, MouseButton::Left, Default::default());
assert!(vcx.update(|_, app| view.read(app).holds_border()), "the line is held");
let inside = gpui::point(px(445.0), on.y);
vcx.simulate_mouse_move(inside, MouseButton::Left, Default::default());
let at = |vcx: &mut gpui::VisualTestContext| vcx.update(|_, app| view.read(app).guide().expect("a guide").origin.x);
let held = at(vcx);
let outside = gpui::point(px(490.0), on.y);
vcx.simulate_mouse_move(outside, MouseButton::Left, Default::default());
let out = at(vcx);
assert!(out > held, "the guide follows out of the view: {held:?} to {out:?}");
let wanted = vcx.update(|_, app| view.read(app).doc_point(gpui::point(out, on.y)).0);
vcx.simulate_mouse_up(outside, MouseButton::Left, Default::default());
assert!(!vcx.update(|_, app| view.read(app).holds_border()), "let go");
vcx.run_until_parked();
let landed = vcx.update(|_, app| {
let view = view.read(app);
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| table.columns.offset + table.columns.inner[0].position != line)
});
let table = seen.table.expect("still in the table");
table.columns.offset + table.columns.inner[0].position
});
assert!((landed - wanted).abs() <= 1, "it lands at the release: {landed} vs {wanted}");
}
#[test]
fn a_still_click_beside_a_border_sends_nothing() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| live_view_of(app, &install, "table.fodt", TABLE));
let (_, vcx) = cx.add_window_view(|_, _| Narrow(view.clone()));
vcx.run_until_parked();
let on = vcx.update(|_, app| {
view.update(app, |view, cx| {
let table = borders_read(view);
cx.notify();
let line = table.columns.offset + table.columns.inner[0].position;
view.window_point((line, table.rows.offset + 100)) + gpui::point(px(3.0), px(0.0))
})
});
vcx.run_until_parked();
let sent = vcx.update(|_, app| view.read(app).sent());
vcx.simulate_mouse_down(on, MouseButton::Left, Default::default());
assert!(vcx.update(|_, app| view.read(app).holds_border()), "the line is held off its centre");
vcx.simulate_mouse_up(on, MouseButton::Left, Default::default());
assert!(!vcx.update(|_, app| view.read(app).holds_border()), "let go");
assert_eq!(vcx.update(|_, app| view.read(app).sent()), sent, "a still click sends no command");
}
+1 -1
View File
@@ -60,7 +60,7 @@ fn typing_and_composing_reach_the_document() {
assert_eq!(saved(&view, &mut cx), ["ab你", "Hello 世界"]);
}
pub(super) struct Blank;
struct Blank;
impl gpui::Render for Blank {
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
@@ -1,172 +0,0 @@
use super::*;
use crate::client::tests::{poll_until, show::DECK};
use crate::table::Table;
use gpui::{Pixels, Point, TestAppContext};
const WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// A line's place along its axis, in twips.
type Place = fn(&Table) -> i32;
/// A new table on the slide, read into the view.
fn table_in(view: &mut LokView, (columns, rows): (&str, &str)) -> Table {
let doc = view.doc.clone();
let mut worker = view.worker.lock().expect("the worker lock");
worker.run(&doc, "table.insert", &[columns, rows]).expect("a table");
let seen = poll_until(&mut worker, &doc, &Events::default(), WAIT, |seen| seen.table.is_some());
assert!(seen.faults.is_empty(), "{:?}", seen.faults);
let table = seen.table.expect("the selected table");
view.doc = worker.now(&doc).expect("the revision");
drop(worker);
view.read_borders(Some(table.clone()), view.doc.rev);
table
}
/// The view's table once `place` moves off `from`.
fn moved(view: &mut LokView, place: Place, from: i32) -> Table {
let mut worker = view.worker.lock().expect("the worker lock");
let seen = poll_until(&mut worker, &view.doc, &Events::default(), WAIT, |seen| {
seen.table.as_ref().is_some_and(|table| place(table) != from)
});
let table = seen.table.expect("still the table");
view.doc = worker.now(&view.doc).expect("the revision");
drop(worker);
view.read_borders(Some(table.clone()), view.doc.rev);
table
}
/// The last drag's place: before, guide, landed, undone.
fn drag(size: (&str, &str), place: Place, upright: bool, bys: &[Point<Pixels>]) -> Option<[i32; 4]> {
let install = crate::client::tests::install()?;
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, size)));
let mut last = [0; 3];
for &by in bys {
let (before, guide) = cx.update(|app| {
view.update(app, |view, cx| {
let at = place(&table);
let across = if upright { &table.rows } else { &table.columns };
// A quarter along: halfway may cross another line.
let along = across.offset + across.left + (across.right - across.left) / 4;
let on = view.window_point(if upright { (at, along) } else { (along, at) });
assert!(view.hold_border(on), "the line is held");
assert!(view.move_border(on + by));
let guide = view.doc_point(view.guide().expect("a guide").origin);
assert!(view.drop_border(on + by, cx), "the drop runs");
(at, if upright { guide.0 } else { guide.1 })
})
});
for _ in 0..80 {
cx.executor().tick();
}
table = cx.update(|app| view.update(app, |view, _| moved(view, place, before)));
last = [before, guide, place(&table)];
}
let undone = cx.update(|app| {
view.update(app, |view, _| {
view.worker.lock().expect("the worker lock").run(&view.doc, "edit.undo", &[]).expect("undoes");
place(&moved(view, place, last[2]))
})
});
Some([last[0], last[1], last[2], undone])
}
/// Lands at its guide; one undo puts it back.
#[track_caller]
fn lands([before, guide, landed, undone]: [i32; 4]) {
assert!((landed - guide).abs() <= 2, "{before} to the guide {guide}, landed {landed}");
assert!((undone - before).abs() <= 1, "one undo: {undone} vs {before}");
}
#[test]
fn an_inner_line_lands_at_its_guide() {
let place: Place = |t| t.columns.offset + t.columns.inner[0].position;
let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0], "the guide went right");
lands(moved);
}
#[test]
fn the_left_edge_narrows_the_first_column_and_moves_the_table() {
let place: Place = |t| t.columns.offset + t.columns.left;
let Some(moved) = drag(("2", "2"), place, true, &[gpui::point(px(40.0), px(0.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0], "the guide went right");
lands(moved);
}
#[test]
fn a_one_row_bottom_grows() {
let place: Place = |t| t.rows.offset + t.rows.right;
let Some(moved) = drag(("2", "1"), place, false, &[gpui::point(px(0.0), px(40.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0], "the guide went down");
lands(moved);
}
#[test]
fn a_grown_bottom_stops_at_the_last_rows_minimum() {
let place: Place = |t| t.rows.offset + t.rows.right;
let Some(moved) = drag(("2", "2"), place, false, &[gpui::point(px(0.0), px(60.0)), gpui::point(px(0.0), px(-200.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] < moved[0] && moved[1] > moved[0] - 3000, "the guide stops at the minimum: {moved:?}");
lands(moved);
}
#[test]
fn a_one_column_right_edge_stops_at_the_columns_minimum() {
let place: Place = |t| t.columns.offset + t.columns.right;
let Some(moved) = drag(("1", "2"), place, true, &[gpui::point(px(-600.0), px(0.0))]) else {
return eprintln!("SKIP: no pinned LOK build");
};
assert!(moved[1] > moved[0] - 9000, "the guide stops at the minimum: {moved:?}");
lands(moved);
}
#[test]
fn a_still_press_beside_a_minimum_column_sends_nothing() {
let Some(install) = crate::client::tests::install() else {
return eprintln!("SKIP: no pinned LOK build");
};
let cx = TestAppContext::single();
let (view, _) = cx.update(|app| live_view_of(app, &install, "deck.fodp", DECK));
let right: Place = |t| t.columns.offset + t.columns.right;
let quarter = |t: &Table| t.rows.offset + t.rows.left + (t.rows.right - t.rows.left) / 4;
let mut table = cx.update(|app| view.update(app, |view, _| table_in(view, ("2", "2"))));
// Its minimum width drops once it narrows; shrink twice.
for _ in 0..2 {
let before = cx.update(|app| {
view.update(app, |view, cx| {
let on = view.window_point((right(&table), quarter(&table)));
let to = on + gpui::point(px(-600.0), px(0.0));
assert!(view.hold_border(on) && view.move_border(to) && view.drop_border(to, cx));
right(&table)
})
});
for _ in 0..80 {
cx.executor().tick();
}
table = cx.update(|app| view.update(app, |view, _| moved(view, right, before)));
}
let sent = cx.update(|app| {
view.update(app, |view, cx| {
let line = table.columns.inner[0];
assert_eq!(line.position, line.max, "the right column is at its minimum");
let on = view.window_point((table.columns.offset + line.position, quarter(&table)));
let sent = view.sent();
assert!(view.hold_border(on));
assert!(view.drop_border(on, cx));
sent
})
});
for _ in 0..80 {
cx.executor().tick();
}
assert_eq!(cx.update(|app| view.read(app).sent()), sent, "a still press sends no command");
}
+1 -1
View File
@@ -82,5 +82,5 @@ fn pack_events(batch: Events) -> Vec<u8> {
.i64(signed(a11y.caret))
.i64(signed(start))
.i64(signed(end));
crate::table::Table::pack(batch.table.as_ref(), batch.cell.pack(pack)).bytes()
batch.cell.pack(pack).bytes()
}
-1
View File
@@ -9,7 +9,6 @@ pub mod engine;
pub mod files;
mod find;
pub mod lok;
pub mod menu;
mod open;
mod recover;
pub mod panel;
-89
View File
@@ -1,89 +0,0 @@
//! Ely's right-click menus, gated by the kernel's (DOC-02).
use std::{cell::Cell, io};
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::IconName,
};
use ely_lok::{LokView, menu::Offered};
use gpui::{App, Entity, Pixels, Point};
/// One menu, asked for at a point in one tab.
pub struct Offer<W> {
pub tab: u64,
pub what: W,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
/// Handed to a host; a remounted host must not reopen.
served: Cell<bool>,
}
impl<W> Offer<W> {
/// The next menu after `last`.
pub fn new(last: Option<&Offer<W>>, tab: u64, what: W, at: Point<Pixels>) -> Self {
let number = last.map_or(1, |last| last.request.0 + 1);
Offer { tab, what, request: (number, at), served: Cell::new(false) }
}
/// Its host; only the first render opens it.
pub fn host(&self, id: &'static str, menu: Menu) -> ContextMenu {
let request = (!self.served.replace(true)).then_some(self.request);
ContextMenu::new(id, menu).manual(request)
}
}
/// The view's last kernel menu and where it opens.
pub fn take(view: &Entity<LokView>, cx: &mut App) -> Option<io::Result<(Offered, Point<Pixels>)>> {
let (json, at) = view.update(cx, |view, _| view.take_menu())?;
let offered = match ely_lok::menu::read(&json) {
Ok(offered) => offered,
Err(error) => return Some(Err(error)),
};
// Without a click it opens at the caret.
let caret = || view.read(cx).cursor_bounds().map(|caret| caret.bottom_left());
let Some(at) = at.or_else(caret) else {
log::warn!("office: a menu with no click and no caret");
return None;
};
log::info!("office: a {:?} menu", offered.target);
Some(Ok((offered, at)))
}
/// A menu row: its label key and its gating command.
pub trait Pick: Copy + 'static {
fn row(self) -> (&'static str, &'static str);
}
/// A row, a submenu or a line.
pub enum Entry<P: 'static> {
Row(P),
Sub(&'static str, &'static [P]),
Line,
}
/// Cut, copy and paste as one row of icons.
pub fn clipboard<P: Pick>(menu: Menu, picks: [P; 3], item: impl Fn(P) -> MenuItem) -> Menu {
let icons = [IconName::Scissors, IconName::Copy, IconName::Clipboard];
let row = picks.into_iter().zip(icons).map(|(pick, icon)| item(pick).icon(icon));
menu.buttons(row)
}
/// Adds entries; a submenu is on when any row is.
pub fn entries<P: Pick>(
menu: Menu,
entries: &[Entry<P>],
cx: &App,
on: impl Fn(P) -> bool,
item: impl Fn(P) -> MenuItem,
) -> Menu {
entries.iter().fold(menu, |menu, entry| match entry {
Entry::Row(pick) => menu.item(item(*pick)),
Entry::Sub(key, picks) => {
let sub = picks.iter().fold(Menu::new(), |sub, pick| sub.item(item(*pick)));
let any = picks.iter().any(|pick| on(*pick));
menu.item(MenuItem::submenu(crate::text(cx, key, &[]), sub).disabled(!any))
}
Entry::Line => menu.separator(),
})
}
-1
View File
@@ -12,7 +12,6 @@ arboard.workspace = true
directories.workspace = true
ely-ipc.workspace = true
env_logger.workspace = true
getrandom.workspace = true
image.workspace = true
log.workspace = true
semver.workspace = true
-5
View File
@@ -74,11 +74,6 @@ pub fn read() -> Result<Option<Clip>> {
})
}
/// The clipboard's HTML, if any; gpui's reads omit it.
pub fn html() -> Result<Option<String>> {
with_board(|board| absent_ok(board.get().html()))
}
/// Empty or other-format clipboards read as None.
fn absent_ok<T>(outcome: Result<T, Error>) -> Result<Option<T>> {
match outcome {
+8 -75
View File
@@ -1,56 +1,27 @@
//! One running copy per app; two would wipe shared data.
mod door;
use std::{
fs::File,
io,
path::{Path, PathBuf},
sync::OnceLock,
time::Duration,
};
pub use door::Door;
use std::{fs::File, io, path::Path, sync::OnceLock};
use crate::AppId;
/// The lock this process holds while it runs.
static HELD: OnceLock<File> = OnceLock::new();
/// How long a second copy waits for the door.
const WAIT: Duration = Duration::from_secs(10);
/// What starting a copy found.
pub enum Start {
/// This copy runs; its door takes later copies' files.
First(Door),
/// A running copy took the files; this one ends.
Handed,
}
/// Takes `app`'s lock, or hands `files` to its holder.
pub fn hold(app: AppId, files: &[PathBuf]) -> io::Result<Start> {
/// Takes `app`'s instance lock; a running copy refuses this one.
pub fn hold(app: AppId) -> io::Result<()> {
let dir = crate::data_dir(app).map_err(io::Error::other)?;
std::fs::create_dir_all(&dir)?;
start(app, &dir, files)
}
fn start(app: AppId, dir: &Path, files: &[PathBuf]) -> io::Result<Start> {
match take(app, dir) {
Ok(()) => Ok(Start::First(Door::open(dir)?)),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => match door::hand(dir, files, WAIT) {
Ok(()) => Ok(Start::Handed),
Err(handing) => Err(io::Error::new(handing.kind(), format!("{error} and did not take the files: {handing}"))),
},
Err(error) => Err(error),
}
take(app, &dir)
}
fn take(app: AppId, dir: &Path) -> io::Result<()> {
let file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(dir.join("instance.lock"))?;
match lock(&file)? {
None => HELD.set(file).map_err(|_| io::Error::other("this process took its instance lock twice")),
Some(holder) => Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} is already running ({holder})", app.name()))),
Some(holder) => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} is already running ({holder}); open files in its window", app.name()),
)),
}
}
@@ -130,44 +101,6 @@ mod tests {
}
}
#[test]
fn a_second_copy_hands_its_files_to_the_first() {
use std::io::BufRead;
let dir = tempfile::tempdir().expect("a temp dir");
let exe = std::env::current_exe().expect("this test");
let mut first = std::process::Command::new(&exe)
.args(["--exact", "instance::tests::first_copy", "--nocapture"])
.env("ELY_FIRST_DIR", dir.path())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("the first copy");
let mut said = std::io::BufReader::new(first.stdout.take().expect("its output")).lines().map(|line| line.expect("a line"));
assert!(said.by_ref().any(|line| line.contains("HELD")), "the first copy did not start");
let file = std::path::PathBuf::from("C:/notes/a b.md");
let second = start(AppId::Doc, dir.path(), std::slice::from_ref(&file)).expect("handed over");
assert!(matches!(second, Start::Handed));
let got = said.find(|line| line.contains("GOT"));
assert_eq!(got.as_deref().and_then(|line| line.split("GOT ").nth(1)), Some(file.to_str().expect("text")));
assert!(first.wait().expect("ends").success());
}
/// The first copy: holds, then reports one hand-over.
#[test]
fn first_copy() {
let Ok(dir) = std::env::var("ELY_FIRST_DIR") else {
return;
};
let Ok(Start::First(door)) = start(AppId::Doc, Path::new(&dir), &[]) else {
panic!("the first copy did not hold");
};
let (sender, heard) = std::sync::mpsc::channel();
let sender = std::sync::Mutex::new(sender);
door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok()).expect("served");
println!("HELD");
let files = heard.recv_timeout(std::time::Duration::from_secs(20)).expect("a hand-over");
println!("GOT {}", files[0].display());
}
/// One copy, in a process of its own.
#[test]
fn one_copy() {
-425
View File
@@ -1,425 +0,0 @@
//! The running copy's door: a second copy hands it files.
use std::{
ffi::OsString,
fs,
io::{self, Read, Write},
net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream},
path::{Path, PathBuf},
sync::Arc,
thread,
time::{Duration, Instant},
};
const FILE: &str = "instance.door";
const TOKEN: u8 = 1;
const PATH: u8 = 2;
const DONE: u8 = 3;
const REFUSED: u8 = 4;
const OPEN: u8 = 5;
/// How long one caller may take, start to end.
const PATIENCE: Duration = Duration::from_secs(5);
/// The largest frame before the token is shown.
const SHOWN: usize = 64;
/// The largest path frame: a whole Windows path.
const LONGEST: usize = 128 * 1024;
/// The running copy's listener; its token keeps others out.
pub struct Door {
listener: TcpListener,
token: [u8; 32],
}
impl Door {
/// Opens a local door; its address goes in `dir`.
pub(super) fn open(dir: &Path) -> io::Result<Door> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let mut token = [0; 32];
getrandom::fill(&mut token).map_err(|_| io::Error::other("system randomness failed"))?;
let port = listener.local_addr()?.port();
let text = format!("{port} {} {}\n", std::process::id(), hex(&token));
// Whole or absent to a reader.
let fresh = dir.join("instance.door.new");
write_private(&fresh, text.as_bytes())?;
fs::rename(&fresh, dir.join(FILE))?;
Ok(Door { listener, token })
}
/// Hands each caller's files to `deliver`; false means not taken.
pub fn serve(
self,
deliver: impl Fn(Vec<PathBuf>) -> bool + Send + Sync + 'static,
) -> io::Result<()> {
let deliver = Arc::new(deliver);
thread::Builder::new()
.name("instance door".into())
.spawn(move || {
for stream in self.listener.incoming() {
let (token, deliver) = (self.token, deliver.clone());
// A stalled caller must not hold up the next.
let answered = stream.and_then(|stream| {
thread::Builder::new()
.name("instance caller".into())
.spawn(move || {
if let Err(error) = answer(&stream, &token, &*deliver) {
log::warn!("instance: a hand-over failed: {error}");
}
})
.map(drop)
});
if let Err(error) = answered {
log::warn!("instance: a caller was not answered: {error}");
}
}
})?;
Ok(())
}
}
fn answer(
stream: &TcpStream,
token: &[u8],
deliver: &dyn Fn(Vec<PathBuf>) -> bool,
) -> io::Result<()> {
let mut timed = Timed {
stream,
until: Instant::now() + PATIENCE,
};
let (tag, shown) = next(&mut timed, SHOWN)?;
if tag != TOKEN || !same(&shown, token) {
ely_ipc::write_frame(&mut &*stream, REFUSED, 0, &[])?;
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"a caller without the token",
));
}
ely_ipc::write_frame(&mut &*stream, OPEN, 0, &[])?;
let mut files = Vec::new();
loop {
match next(&mut timed, LONGEST)? {
(PATH, bytes) => files.push(path(bytes)?),
(DONE, _) => break,
(tag, _) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("frame {tag} is not a hand-over's"),
));
}
}
}
log::info!("instance: another copy handed over {} files", files.len());
// No receipt: the caller reports the files untaken.
if !deliver(files) {
return Err(io::Error::other("the app is quitting and took no files"));
}
ely_ipc::write_frame(&mut &*stream, DONE, 0, &[])
}
/// A stream whose reads all end by one deadline.
struct Timed<'a> {
stream: &'a TcpStream,
until: Instant,
}
impl Read for Timed<'_> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let left = self.until.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"a caller held the door too long",
));
}
self.stream.set_read_timeout(Some(left))?;
(&mut &*self.stream).read(buf)
}
}
/// Hands `files` to `dir`'s door, waiting up to `wait`.
pub(super) fn hand(dir: &Path, files: &[PathBuf], wait: Duration) -> io::Result<()> {
let deadline = Instant::now() + wait;
loop {
// Nothing handed yet, so every failure here may retry.
match knock(dir).and_then(|(stream, token)| Ok((admit(&stream, &token)?, stream))) {
Ok((true, stream)) => return give(&stream, files),
// An old door file; the new one is coming.
Ok((false, _)) if Instant::now() < deadline => {}
Ok((false, _)) => {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"it refused the door's token",
));
}
Err(_) if Instant::now() < deadline => {}
Err(error) => return Err(error),
}
thread::sleep(Duration::from_millis(100));
}
}
/// Connects to `dir`'s door; nothing sent, so retries are safe.
fn knock(dir: &Path) -> io::Result<(TcpStream, Vec<u8>)> {
let text = fs::read_to_string(dir.join(FILE))?;
let bad = || io::Error::new(io::ErrorKind::InvalidData, format!("an unreadable {FILE}"));
let mut words = text.split_whitespace();
let port: u16 = words
.next()
.and_then(|word| word.parse().ok())
.ok_or_else(bad)?;
let pid: u32 = words
.next()
.and_then(|word| word.parse().ok())
.ok_or_else(bad)?;
let token = words.next().and_then(unhex).ok_or_else(bad)?;
let stream =
TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), PATIENCE)?;
stream.set_read_timeout(Some(PATIENCE))?;
lend_foreground(pid);
Ok((stream, token))
}
/// Shows the token; false when the door refuses it.
fn admit(stream: &TcpStream, token: &[u8]) -> io::Result<bool> {
ely_ipc::write_frame(&mut &*stream, TOKEN, 0, token)?;
match next(&mut &*stream, SHOWN)? {
(OPEN, _) => Ok(true),
(REFUSED, _) => Ok(false),
(tag, _) => Err(unexpected(tag)),
}
}
/// Sends the files once admitted; never retried.
fn give(stream: &TcpStream, files: &[PathBuf]) -> io::Result<()> {
for file in files {
ely_ipc::write_frame(&mut &*stream, PATH, 0, &bytes(file))?;
}
ely_ipc::write_frame(&mut &*stream, DONE, 0, &[])?;
// The app may answer after the door's own wait.
stream.set_read_timeout(Some(PATIENCE * 2))?;
match next(&mut &*stream, SHOWN)? {
(DONE, _) => Ok(()),
(tag, _) => Err(unexpected(tag)),
}
}
fn unexpected(tag: u8) -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("it answered frame {tag}"),
)
}
fn next(stream: &mut impl Read, cap: usize) -> io::Result<(u8, Vec<u8>)> {
match ely_ipc::read_capped(stream, cap)? {
Some((tag, _, payload)) => Ok((tag, payload)),
None => Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"the other copy hung up",
)),
}
}
/// Lets the running copy bring its window to the front.
#[cfg(windows)]
fn lend_foreground(pid: u32) {
// Refused when not in front; then nothing to lend.
unsafe { windows_sys::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow(pid) };
}
#[cfg(not(windows))]
fn lend_foreground(_: u32) {}
fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
options.open(path)?.write_all(bytes)
}
/// Equal tokens, compared in constant time.
fn same(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0, |diff, (x, y)| diff | (x ^ y)) == 0
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn unhex(text: &str) -> Option<Vec<u8>> {
let pairs = text.as_bytes().chunks(2);
pairs
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok())
.collect()
}
#[cfg(unix)]
fn bytes(path: &Path) -> Vec<u8> {
std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str()).to_vec()
}
#[cfg(unix)]
fn path(bytes: Vec<u8>) -> io::Result<PathBuf> {
Ok(<OsString as std::os::unix::ffi::OsStringExt>::from_vec(bytes).into())
}
/// UTF-16 units, little-endian, so any Windows name survives.
#[cfg(windows)]
fn bytes(path: &Path) -> Vec<u8> {
std::os::windows::ffi::OsStrExt::encode_wide(path.as_os_str())
.flat_map(u16::to_le_bytes)
.collect()
}
#[cfg(windows)]
fn path(bytes: Vec<u8>) -> io::Result<PathBuf> {
if !bytes.len().is_multiple_of(2) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"a cut UTF-16 path",
));
}
let wide: Vec<u16> = bytes
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();
Ok(<OsString as std::os::windows::ffi::OsStringExt>::from_wide(&wide).into())
}
#[cfg(test)]
mod tests {
use super::*;
fn opened(dir: &Path) -> std::sync::mpsc::Receiver<Vec<PathBuf>> {
let (sender, heard) = std::sync::mpsc::channel();
let sender = std::sync::Mutex::new(sender);
let door = Door::open(dir).expect("a door");
door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok())
.expect("served");
heard
}
/// Writes a door file whose token is wrong.
fn forge(dir: &Path) -> String {
let door = dir.join(FILE);
let text = fs::read_to_string(&door).expect("the door");
let (head, _) = text.rsplit_once(' ').expect("a token");
fs::write(&door, format!("{head} {}\n", hex(&[7; 32]))).expect("a forged door");
text
}
#[test]
fn a_hand_over_brings_every_path_whole() {
let dir = tempfile::tempdir().expect("a temp dir");
let heard = opened(dir.path());
let files = vec![
PathBuf::from("C:/a b/笔记 #1.md"),
PathBuf::from("/tmp/x.md"),
];
hand(dir.path(), &files, Duration::from_secs(2)).expect("handed");
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
hand(dir.path(), &[], Duration::from_secs(2)).expect("an empty hand-over raises");
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(vec![]));
}
#[test]
fn a_wrong_token_hands_nothing_over() {
for _ in 0..12 {
let dir = tempfile::tempdir().expect("a temp dir");
let heard = opened(dir.path());
forge(dir.path());
let refused = hand(
dir.path(),
&[PathBuf::from("x.md")],
Duration::from_millis(300),
);
assert_eq!(
refused.map_err(|error| error.kind()),
Err(io::ErrorKind::PermissionDenied)
);
assert!(heard.try_recv().is_err(), "nothing reached the copy");
}
}
#[test]
fn an_old_door_file_is_waited_out() {
for _ in 0..5 {
let dir = tempfile::tempdir().expect("a temp dir");
let heard = opened(dir.path());
let real = forge(dir.path());
let door = dir.path().join(FILE);
let fixer = thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
fs::write(door, real).expect("the real door");
});
let files = vec![PathBuf::from("x.md")];
hand(dir.path(), &files, Duration::from_secs(3))
.expect("handed once the door is right");
fixer.join().expect("fixed");
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
}
}
#[test]
fn a_dripping_caller_neither_blocks_nor_stays() {
let dir = tempfile::tempdir().expect("a temp dir");
let heard = opened(dir.path());
let text = fs::read_to_string(dir.path().join(FILE)).expect("the door");
let port: u16 = text
.split_whitespace()
.next()
.and_then(|word| word.parse().ok())
.expect("a port");
let mut drip = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).expect("connected");
let started = Instant::now();
let dripper = thread::spawn(move || {
// A token frame's header, a byte at a time.
for byte in [32u8, 0, 0, 0, TOKEN, 0, 0, 0, 0, 0, 0, 0, 0]
.into_iter()
.chain([0; 32])
{
if drip.write_all(&[byte]).is_err() {
return;
}
thread::sleep(Duration::from_millis(300));
}
});
let files = vec![PathBuf::from("x.md")];
hand(dir.path(), &files, Duration::from_secs(2)).expect("handed past the drip");
assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files));
dripper.join().expect("dripped");
let held = started.elapsed();
assert!(
held < PATIENCE + Duration::from_secs(2),
"the drip was cut at the deadline, not after {held:?}"
);
}
#[test]
fn files_the_app_did_not_take_are_reported() {
let dir = tempfile::tempdir().expect("a temp dir");
Door::open(dir.path())
.expect("a door")
.serve(|_| false)
.expect("served");
let started = Instant::now();
let untaken = hand(dir.path(), &[PathBuf::from("x.md")], Duration::from_secs(3));
assert!(untaken.is_err(), "untaken files are no success");
assert!(
started.elapsed() < Duration::from_secs(1),
"sent paths are not sent again"
);
}
#[test]
fn no_door_fails_after_the_wait() {
let dir = tempfile::tempdir().expect("a temp dir");
let started = Instant::now();
let missing = hand(dir.path(), &[], Duration::from_millis(300));
assert_eq!(
missing.map_err(|error| error.kind()),
Err(io::ErrorKind::NotFound)
);
assert!(started.elapsed() >= Duration::from_millis(300));
}
}
-3
View File
@@ -19,9 +19,6 @@ log.workspace = true
notify.workspace = true
serde_json.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
gpui_windows = { package = "gpui-pre-windows", version = "=0.3.8" }
[features]
test-support = []
-5
View File
@@ -5,7 +5,6 @@ mod calc;
mod data;
mod doc;
mod mark;
mod mark_menus;
mod motion;
mod office;
mod output;
@@ -147,8 +146,6 @@ pub fn install(cx: &mut App) {
/// Installs the catalogs speaking `locale` (en-US or zh-CN).
pub fn install_as(locale: &'static str, cx: &mut App) {
#[cfg(target_os = "windows")]
gpui_windows::set_text_language(locale);
let (en, zh) = catalogs();
cx.set_global(
I18n::new(locale)
@@ -182,7 +179,6 @@ fn catalogs() -> (Catalog, Catalog) {
.chain(tree_ops::EN)
.chain(tree_places::EN)
.chain(mark::EN)
.chain(mark_menus::EN)
.chain(settings::EN)
.copied()
.collect();
@@ -206,7 +202,6 @@ fn catalogs() -> (Catalog, Catalog) {
.chain(tree_ops::ZH_CN)
.chain(tree_places::ZH_CN)
.chain(mark::ZH_CN)
.chain(mark_menus::ZH_CN)
.chain(settings::ZH_CN)
.copied()
.collect();
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more