Compare commits
1
Commits
aad589a6ab
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba357ffd6 |
No files matched your search
+1
-1
@@ -8,7 +8,7 @@ exclude = ["engines", "reference"]
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "AGPL-3.0-or-later"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -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.
|
||||
@@ -757,7 +757,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 的声明。最终发行须按实际链接、进程调用、修改和打包方式审查,进程隔离不作为免除许可义务的依据。
|
||||
|
||||
|
||||
@@ -44,4 +44,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`.
|
||||
@@ -81,8 +81,10 @@ impl Docs {
|
||||
let path = source.work.join("print.pdf");
|
||||
let printing = path.clone();
|
||||
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
|
||||
Ok(()) => ely_office::print_later(printing, cx),
|
||||
Err(error) => docs.print_failed(&error.to_string(), cx),
|
||||
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
|
||||
docs.output_failed(&error, cx)
|
||||
}),
|
||||
Err(error) => docs.output_failed(&error.to_string(), cx),
|
||||
});
|
||||
}
|
||||
let Some(dir) = ely_office::folder(source) else {
|
||||
|
||||
@@ -343,7 +343,9 @@ impl Desk {
|
||||
if !job.lost.whole() {
|
||||
self.warn_lost(&job.lost, None, cx);
|
||||
}
|
||||
return ely_office::print_later(pdf, cx);
|
||||
return ely_office::print_later(pdf, cx, |desk, why, cx| {
|
||||
desk.exported(Err(io::Error::other(why)), cx)
|
||||
});
|
||||
};
|
||||
// Never over the open file, not even through a link.
|
||||
let moved = not_open(self, &to)
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, ca
|
||||
use crate::content::{Aim, Tool};
|
||||
use crate::layout::Placed;
|
||||
use crate::pages::{rect_on, to_view};
|
||||
use crate::view::{PdfView, Unread};
|
||||
use crate::view::PdfView;
|
||||
|
||||
/// How far a click may move, in window pixels.
|
||||
pub(crate) const CLICK: f32 = 3.0;
|
||||
@@ -77,49 +77,31 @@ impl PdfView {
|
||||
if self.reading_notes {
|
||||
return;
|
||||
}
|
||||
let wanted: Vec<usize> = (pages.into_iter())
|
||||
.filter(|page| !self.notes.contains_key(page) && !self.unread.contains(&Unread::Notes(*page)))
|
||||
.take(BATCH)
|
||||
.collect();
|
||||
let wanted: Vec<usize> = pages.into_iter().filter(|page| !self.notes.contains_key(page)).take(BATCH).collect();
|
||||
if wanted.is_empty() {
|
||||
return;
|
||||
}
|
||||
let asked = wanted.clone();
|
||||
self.reading_notes = true;
|
||||
let (doc, version) = (self.doc, self.version);
|
||||
self.off(
|
||||
// Page by page: one bad page spares the rest.
|
||||
move |worker| {
|
||||
let read = (wanted.into_iter())
|
||||
.map(|page| (page, worker.marks(doc, page).and_then(|marks| Ok((marks, worker.links(doc, page)?)))))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(read)
|
||||
(wanted.into_iter())
|
||||
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?)))
|
||||
.collect::<std::io::Result<Vec<_>>>()
|
||||
},
|
||||
move |view, read: std::io::Result<Vec<_>>, cx| {
|
||||
move |view, read, cx| {
|
||||
view.reading_notes = false;
|
||||
if view.version != version {
|
||||
return cx.notify();
|
||||
}
|
||||
// No worker at all: every page asked failed.
|
||||
let read = read.unwrap_or_else(|error| {
|
||||
let why = error.to_string();
|
||||
asked.into_iter().map(|page| (page, Err(std::io::Error::other(why.clone())))).collect()
|
||||
});
|
||||
let mut first = None;
|
||||
for (page, page_read) in read {
|
||||
match page_read {
|
||||
Ok((marks, links)) => {
|
||||
match read {
|
||||
Ok(read) => {
|
||||
for (page, marks, links) in read {
|
||||
view.notes.insert(page, marks);
|
||||
view.links.insert(page, links);
|
||||
}
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Notes(page));
|
||||
first.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(error) = first {
|
||||
view.fail("reading annotations", &error, cx);
|
||||
Err(error) => view.fail("reading annotations", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
@@ -129,7 +111,7 @@ impl PdfView {
|
||||
|
||||
/// Reads every field name in the form, once an edit.
|
||||
pub fn ask_names(&mut self, cx: &mut Context<Self>) {
|
||||
if self.form_names.is_some() || self.reading_names || self.unread.contains(&Unread::Names) {
|
||||
if self.form_names.is_some() || self.reading_names {
|
||||
return;
|
||||
}
|
||||
self.reading_names = true;
|
||||
@@ -143,10 +125,7 @@ impl PdfView {
|
||||
}
|
||||
match read {
|
||||
Ok(names) => view.form_names = Some(names),
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Names);
|
||||
view.fail("reading field names", &error, cx)
|
||||
}
|
||||
Err(error) => view.fail("reading field names", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
@@ -275,11 +254,11 @@ impl PdfView {
|
||||
return;
|
||||
}
|
||||
let author = ely_ui::settings::current(cx).author.clone();
|
||||
self.edit_at(what, move |worker, doc| {
|
||||
self.settled_edit(what, cx, move |worker, doc| {
|
||||
let name = ely_platform::user::named(&author)?;
|
||||
annots.iter_mut().for_each(|annot| annot.author = name.clone());
|
||||
worker.annotate(doc, &annots)
|
||||
}, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// A press with a drawing or form tool.
|
||||
|
||||
@@ -15,7 +15,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use crate::document::{RemoveAttachment, SaveAttachment};
|
||||
use crate::view::{PdfView, Unread};
|
||||
use crate::view::PdfView;
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
/// A size as people read it.
|
||||
@@ -30,7 +30,7 @@ pub fn size(bytes: u64) -> String {
|
||||
impl PdfView {
|
||||
/// Reads the attachments once an edit.
|
||||
pub fn ask_files(&mut self, cx: &mut Context<Self>) {
|
||||
if self.files.is_some() || self.reading_files || self.unread.contains(&Unread::Files) {
|
||||
if self.files.is_some() || self.reading_files {
|
||||
return;
|
||||
}
|
||||
self.reading_files = true;
|
||||
@@ -44,10 +44,7 @@ impl PdfView {
|
||||
}
|
||||
match read {
|
||||
Ok(files) => view.files = Some(files),
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Files);
|
||||
view.fail("reading attachments", &error, cx)
|
||||
}
|
||||
Err(error) => view.fail("reading attachments", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, di
|
||||
|
||||
use crate::layout::Placed;
|
||||
use crate::pages::rect_on;
|
||||
use crate::view::{PdfView, Unread};
|
||||
use crate::view::PdfView;
|
||||
|
||||
/// What a press on a page does.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -139,9 +139,7 @@ impl PdfView {
|
||||
if !self.tool.edits() || self.reading_layer {
|
||||
return;
|
||||
}
|
||||
let Some(page) = (pages.iter().copied())
|
||||
.find(|page| !self.layers.contains_key(page) && !self.unread.contains(&Unread::Layer(*page)))
|
||||
else {
|
||||
let Some(page) = pages.iter().copied().find(|page| !self.layers.contains_key(page)) else {
|
||||
return;
|
||||
};
|
||||
self.reading_layer = true;
|
||||
@@ -158,10 +156,7 @@ impl PdfView {
|
||||
Ok(layer) => {
|
||||
view.layers.insert(page, layer);
|
||||
}
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Layer(page));
|
||||
view.fail("reading what the page holds", &error, cx)
|
||||
}
|
||||
Err(error) => view.fail("reading what the page holds", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
|
||||
@@ -19,8 +19,6 @@ pub struct Queued {
|
||||
doc: Doc,
|
||||
/// The edit may add, drop, move or reshape pages.
|
||||
pages: bool,
|
||||
/// Aimed at a point: runs on the state it meets.
|
||||
follow: bool,
|
||||
}
|
||||
|
||||
/// The document as an edit left it.
|
||||
@@ -43,23 +41,6 @@ impl PdfView {
|
||||
self.queue_edit(what, None, false, op, cx);
|
||||
}
|
||||
|
||||
/// Queues a point-aimed edit; only moving pages refuse it.
|
||||
pub fn edit_at(
|
||||
&mut self,
|
||||
what: &'static str,
|
||||
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.moving || self.queue.iter().any(|queued| queued.pages) {
|
||||
let text = crate::viewer::Viewer::text(cx, "pdf.pages.changed", &[]);
|
||||
cx.emit(crate::view::Failed(text.to_string()));
|
||||
return cx.notify();
|
||||
}
|
||||
log::info!("pdf: queues {what}");
|
||||
self.queue.push_back(Queued { what, op: Box::new(op), pick: None, doc: self.doc, pages: false, follow: true });
|
||||
self.next_edit(cx);
|
||||
}
|
||||
|
||||
/// Queues an edit to the pages themselves.
|
||||
pub fn edit_pages(
|
||||
&mut self,
|
||||
@@ -81,7 +62,7 @@ impl PdfView {
|
||||
) {
|
||||
log::info!("pdf: queues {what}");
|
||||
// Aimed at what is shown now; later states refuse it.
|
||||
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages, follow: false });
|
||||
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc, pages });
|
||||
self.next_edit(cx);
|
||||
}
|
||||
|
||||
@@ -89,12 +70,10 @@ impl PdfView {
|
||||
if self.editing {
|
||||
return;
|
||||
}
|
||||
let Some(Queued { what, op, pick, doc, pages, follow }) = self.queue.pop_front() else {
|
||||
let Some(Queued { what, op, pick, doc, pages }) = self.queue.pop_front() else {
|
||||
return;
|
||||
};
|
||||
// Earlier edits have landed; a point still means the same.
|
||||
let doc = if follow { self.doc } else { doc };
|
||||
(self.editing, self.moving) = (true, pages);
|
||||
self.editing = true;
|
||||
self.off(
|
||||
move |worker| {
|
||||
let mark = op(worker, doc)?;
|
||||
@@ -104,7 +83,7 @@ impl PdfView {
|
||||
Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? })
|
||||
},
|
||||
move |view, fresh, cx| {
|
||||
(view.editing, view.moving) = (false, false);
|
||||
view.editing = false;
|
||||
match fresh {
|
||||
Ok(fresh) => {
|
||||
view.refresh(fresh, pages, cx);
|
||||
@@ -149,7 +128,6 @@ impl PdfView {
|
||||
}
|
||||
self.marks = fresh.marks;
|
||||
self.failed.clear();
|
||||
self.unread.clear();
|
||||
self.text.clear();
|
||||
self.asking_text.clear();
|
||||
self.thumbs_failed.clear();
|
||||
@@ -173,10 +151,8 @@ impl PdfView {
|
||||
/// The file now holds history `mark`.
|
||||
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
|
||||
self.saved = mark.to_string();
|
||||
self.saves += 1;
|
||||
// The file changed; read it again.
|
||||
self.signatures = None;
|
||||
self.unread.retain(|unread| !matches!(unread, crate::view::Unread::Signatures(_)));
|
||||
cx.emit(Reported);
|
||||
}
|
||||
|
||||
|
||||
@@ -165,12 +165,10 @@ impl Viewer {
|
||||
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
|
||||
return self.toast(summary, Severity::Info, cx);
|
||||
};
|
||||
let laid = self.on_same(&view, run.version, cx, |view, cx| {
|
||||
self.on_same(&view, run.version, cx, |view, cx| {
|
||||
view.edit("adding recognised text", move |worker, doc| worker.text_layer(doc, &pages, replace), cx)
|
||||
});
|
||||
if laid {
|
||||
self.toast(summary, Severity::Success, cx);
|
||||
}
|
||||
self.toast(summary, Severity::Success, cx);
|
||||
}
|
||||
|
||||
/// The OCR dialog: the ask, then the progress.
|
||||
|
||||
@@ -95,7 +95,7 @@ impl Viewer {
|
||||
id,
|
||||
move |worker, doc| worker.get()?.print_copy(*doc, &out),
|
||||
move |viewer, done, _, cx| match done {
|
||||
Ok(()) => ely_office::print_later(printing, cx),
|
||||
Ok(()) => ely_office::print_later(printing, cx, |viewer, error, cx| viewer.panel_failed(&error, cx)),
|
||||
Err(error) => viewer.failed(&error, cx),
|
||||
},
|
||||
cx,
|
||||
|
||||
@@ -17,7 +17,7 @@ use ely_platform::trust::{self, Trust};
|
||||
use gpui::{AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, Styled as _, TaskExt as _, Window, div};
|
||||
|
||||
use crate::redact::beside;
|
||||
use crate::view::{PdfView, Unread};
|
||||
use crate::view::PdfView;
|
||||
use crate::viewer::Viewer;
|
||||
|
||||
/// A signature field and the system's word on its certificate.
|
||||
@@ -47,13 +47,11 @@ pub struct Signer {
|
||||
impl PdfView {
|
||||
/// Reads and checks the signatures of the file at `path`.
|
||||
pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
||||
let failed = self.unread.contains(&Unread::Signatures(path.clone()));
|
||||
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures || failed {
|
||||
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures {
|
||||
return;
|
||||
}
|
||||
self.reading_signatures = true;
|
||||
// A save in between makes this read stale: dropped.
|
||||
let (doc, asked, saves) = (self.doc, path.clone(), self.saves);
|
||||
let (doc, asked) = (self.doc, path.clone());
|
||||
self.off(
|
||||
move |worker| {
|
||||
let fields = worker.signatures(doc, &path)?;
|
||||
@@ -66,16 +64,9 @@ impl PdfView {
|
||||
},
|
||||
move |view, read: io::Result<Vec<Checked>>, cx| {
|
||||
view.reading_signatures = false;
|
||||
if view.saves != saves {
|
||||
log::info!("pdf: a signature read from before a save is dropped");
|
||||
return cx.notify();
|
||||
}
|
||||
match read {
|
||||
Ok(read) => view.signatures = Some((asked, read)),
|
||||
Err(error) => {
|
||||
view.unread.insert(Unread::Signatures(asked));
|
||||
view.fail("reading signatures", &error, cx)
|
||||
}
|
||||
Err(error) => view.fail("reading signatures", &error, cx),
|
||||
}
|
||||
cx.notify();
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ mod notes;
|
||||
mod organize;
|
||||
mod output;
|
||||
mod protect;
|
||||
mod reads;
|
||||
mod removal;
|
||||
mod sign;
|
||||
mod structure;
|
||||
|
||||
@@ -397,33 +397,3 @@ fn a_small_picture_on_text_under_a_page_form_still_aims() {
|
||||
click(cx, &view, (140.0, 66.0));
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_overtaken_by_an_edit_says_so() {
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (viewer, cx, view) = editing(&mut cx, dir.path());
|
||||
// The window's toasts live on its root.
|
||||
let root = cx.update(|window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
|
||||
root
|
||||
});
|
||||
click(cx, &view, (80.0, 66.0));
|
||||
type_in(cx, &viewer, "Hi world\nSecond line");
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| {
|
||||
// A new font asks for a preview first.
|
||||
viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into());
|
||||
viewer.write_text(false, cx);
|
||||
// An edit lands before the preview answers.
|
||||
if let Some(view) = viewer.active_view() {
|
||||
view.update(cx, |view, _| view.version += 1);
|
||||
}
|
||||
}));
|
||||
let changed = cx.update(|_, app| Viewer::text(app, "pdf.object.changed", &[]));
|
||||
settle(cx, |app| root.read(app).toasts(app).iter().any(|(text, severity)| *severity == Some(Severity::Warning) && *text == changed));
|
||||
frames(cx);
|
||||
assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "no stale preview");
|
||||
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "nothing written");
|
||||
}
|
||||
@@ -315,48 +315,3 @@ fn a_default_name_avoids_a_field_with_no_widget() {
|
||||
send(cx, &viewer, Change::Design(typed, Vec::new()));
|
||||
assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_strokes_queue_behind_each_other() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("one.pdf");
|
||||
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
|
||||
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
let note = |y: f32| ely_pdf_engine::NewAnnot { page: 0, shape: ely_pdf_engine::Shape::Note((100.0, y)), rgb: None, contents: "n".into(), author: String::new() };
|
||||
// The second lands while the first still runs.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.annotate("a first note", vec![note(500.0)], cx);
|
||||
view.annotate("a second note", vec![note(400.0)], cx);
|
||||
}));
|
||||
settle(cx, |app| view.read(app).settled());
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| {
|
||||
side.pane = crate::side::Pane::Notes;
|
||||
cx.notify();
|
||||
})));
|
||||
let count = |cx: &mut gpui::VisualTestContext| {
|
||||
settle(cx, |app| view.read(app).settled() && view.read(app).notes.contains_key(&0));
|
||||
cx.update(|_, app| view.read(app).notes[&0].annots.len())
|
||||
};
|
||||
assert_eq!(count(cx), 2, "both notes landed");
|
||||
let rotate = |view: &mut PdfView, cx: &mut gpui::Context<PdfView>| {
|
||||
view.edit_pages("rotating", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx)
|
||||
};
|
||||
// A running rotation would move the point: refused.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
rotate(view, cx);
|
||||
view.annotate("a note while turning", vec![note(300.0)], cx);
|
||||
}));
|
||||
assert_eq!(count(cx), 2, "refused while pages move");
|
||||
// A rotation waiting behind another edit: refused too.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.annotate("a third note", vec![note(250.0)], cx);
|
||||
rotate(view, cx);
|
||||
view.annotate("a note behind a turn", vec![note(200.0)], cx);
|
||||
}));
|
||||
assert_eq!(count(cx), 3, "only the note before the turn");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.annotate("a settled note", vec![note(150.0)], cx)));
|
||||
assert_eq!(count(cx), 4, "taken once pages are still");
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! Reads that fail are kept, not asked each frame (P-08).
|
||||
|
||||
use ely_pdf_engine::sample::{Extras, pdf_with};
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::{settle, shown, windowed};
|
||||
|
||||
/// Two pages; the first's square has an unreadable rectangle.
|
||||
fn bad_then_good() -> Vec<u8> {
|
||||
let nines = "9".repeat(50);
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [5 0 R] >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [6 0 R] >>".to_string(),
|
||||
format!("<< /Type /Annot /Subtype /Square /Rect [0 0 {nines} 100] >>"),
|
||||
"<< /Type /Annot /Subtype /Text /Rect [100 500 120 520] /Contents (fine) >>".to_string(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
|
||||
for offset in offsets {
|
||||
pdf.extend(format!("{offset:010} 00000 n \n").bytes());
|
||||
}
|
||||
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_page_spares_the_rest_of_its_batch() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("two.pdf");
|
||||
std::fs::write(&path, bad_then_good()).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
settle(cx, |app| !view.read(app).reading_notes);
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.notes.clear();
|
||||
view.unread.clear();
|
||||
view.ask_notes([0, 1], cx)
|
||||
}));
|
||||
settle(cx, |app| !view.read(app).reading_notes);
|
||||
let (good, bad) = cx.update(|_, app| {
|
||||
let view = view.read(app);
|
||||
(view.notes.contains_key(&1), view.unread.contains(&crate::view::Unread::Notes(0)))
|
||||
});
|
||||
assert!(bad, "the bad page is kept as failed");
|
||||
assert!(good, "the good page is read all the same");
|
||||
assert!(!cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Notes(1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_read_from_before_a_save_counts_for_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("one.pdf");
|
||||
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
|
||||
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
let gone = dir.path().join("gone.pdf");
|
||||
// The read fails, but a save lands before its answer.
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.ask_signatures(gone.clone(), cx);
|
||||
let same = view.saved.clone();
|
||||
view.saved(&same, cx);
|
||||
}));
|
||||
settle(cx, |app| !view.read(app).reading_signatures);
|
||||
assert!(cx.update(|_, app| view.read(app).unread.is_empty()), "a stale failure blocks nothing");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(gone.clone(), cx)));
|
||||
assert!(cx.update(|_, app| view.read(app).reading_signatures), "asked again after the save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_read_is_not_asked_again() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("one.pdf");
|
||||
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
|
||||
std::fs::write(&path, pdf_with(&[page], &[], &Extras::default())).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
assert!(cx.update(|_, app| view.read(app).form_names.is_none()), "not read yet");
|
||||
// A dead worker fails every read.
|
||||
let pid = cx.update(|_, app| viewer.read(app).core.worker.lock().expect("the lock").get().expect("a worker").pid());
|
||||
std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().expect("kills");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
|
||||
settle(cx, |app| !view.read(app).reading_names);
|
||||
assert!(cx.update(|_, app| view.read(app).unread.contains(&crate::view::Unread::Names)), "the failure is kept");
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.ask_names(cx)));
|
||||
assert!(!cx.update(|_, app| view.read(app).reading_names), "not asked again");
|
||||
}
|
||||
@@ -234,33 +234,3 @@ fn a_cancelled_run_cannot_touch_the_next() {
|
||||
assert!(cx.update(|_, app| viewer.read(app).ocr.is_some()), "the second run goes on");
|
||||
settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_text_layer_reports_no_success() {
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = scanned(dir.path());
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
// The window's toasts live on its root.
|
||||
let root = cx.update(|window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
|
||||
root
|
||||
});
|
||||
let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
|
||||
let run = cx.update(|window, app| viewer.update(app, |viewer, cx| {
|
||||
viewer.ask_ocr(window, cx);
|
||||
viewer.start_ocr(cx);
|
||||
viewer.ocr_runs - 1
|
||||
}));
|
||||
// An edit still runs when the text is laid.
|
||||
cx.update(|_, app| view.update(app, |view, _| view.editing = true));
|
||||
let line = ely_pdf_engine::OcrLine { rect: [72.0, 700.0, 300.0, 720.0], text: "Hello".into() };
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ocr_read((tab, run, 0), Ok(Some(vec![line])), cx)));
|
||||
settle(cx, |app| viewer.read(app).ocr.is_none());
|
||||
let toasts = cx.update(|_, app| root.read(app).toasts(app));
|
||||
assert!(toasts.iter().any(|(_, severity)| *severity == Some(Severity::Warning)), "{toasts:?}");
|
||||
assert!(!toasts.iter().any(|(_, severity)| *severity == Some(Severity::Success)), "{toasts:?}");
|
||||
}
|
||||
@@ -23,17 +23,6 @@ pub struct Failed(pub String);
|
||||
/// The document changed what it shows.
|
||||
pub struct Reported;
|
||||
|
||||
/// A page or document read that failed once.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Unread {
|
||||
Notes(usize),
|
||||
Layer(usize),
|
||||
Names,
|
||||
Files,
|
||||
/// The signatures of the file at this path.
|
||||
Signatures(std::path::PathBuf),
|
||||
}
|
||||
|
||||
pub struct PdfView {
|
||||
worker: Arc<Mutex<Mupdf>>,
|
||||
pub doc: Doc,
|
||||
@@ -52,8 +41,6 @@ pub struct PdfView {
|
||||
pub painted: f64,
|
||||
/// Tiles the worker could not draw; not asked again.
|
||||
pub failed: HashSet<TileKey>,
|
||||
/// Reads that failed; not asked again until an edit.
|
||||
pub unread: HashSet<Unread>,
|
||||
/// Old tiles shown under their page until covered.
|
||||
pub backdrop: Vec<crate::backdrop::Old>,
|
||||
pub text: HashMap<usize, Arc<Vec<Char>>>,
|
||||
@@ -78,13 +65,9 @@ pub struct PdfView {
|
||||
/// Edits waiting their turn, and whether one runs.
|
||||
pub queue: VecDeque<crate::edits::Queued>,
|
||||
pub editing: bool,
|
||||
/// The running edit may move pages.
|
||||
pub moving: bool,
|
||||
/// History marks: now, and at the last load or save.
|
||||
pub mark: String,
|
||||
pub saved: String,
|
||||
/// Saves so far: a read from before one is stale.
|
||||
pub saves: u64,
|
||||
/// Page pictures for the side panel, by page and darkness.
|
||||
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
|
||||
pub thumbs_failed: HashSet<usize>,
|
||||
@@ -188,7 +171,6 @@ impl PdfView {
|
||||
retired: Default::default(),
|
||||
painted: 0.0,
|
||||
failed: HashSet::new(),
|
||||
unread: HashSet::new(),
|
||||
backdrop: Vec::new(),
|
||||
text: HashMap::new(),
|
||||
asking_text: HashSet::new(),
|
||||
@@ -204,9 +186,7 @@ impl PdfView {
|
||||
edited: 0,
|
||||
queue: VecDeque::new(),
|
||||
editing: false,
|
||||
moving: false,
|
||||
saved: loaded.mark.clone(),
|
||||
saves: 0,
|
||||
mark: loaded.mark,
|
||||
thumbs: HashMap::new(),
|
||||
thumbs_failed: HashSet::new(),
|
||||
|
||||
@@ -92,17 +92,15 @@ impl PdfView {
|
||||
rgb: 0,
|
||||
bx: [at[0], at[1], self.sizes[page].0 - at[0]],
|
||||
};
|
||||
let write = move |worker: &mut ely_pdf_engine::Worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||
self.settled_edit("placing a typed signature", cx, move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||
Wrote::Done(mark) => Ok(mark),
|
||||
Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))),
|
||||
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
|
||||
Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")),
|
||||
};
|
||||
self.edit_at("placing a typed signature", write, cx);
|
||||
});
|
||||
}
|
||||
Stamp::Picture { path, aspect } => {
|
||||
let at = placed(aspect, rect, click);
|
||||
self.edit_at("placing a signature picture", move |worker, doc| worker.add_image(doc, page, &path, at), cx);
|
||||
self.settled_edit("placing a signature picture", cx, move |worker, doc| worker.add_image(doc, page, &path, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,7 @@ impl Viewer {
|
||||
let (asked, viewer) = (writing.clone(), cx.weak_entity());
|
||||
view.update(cx, |view, cx| {
|
||||
let doc = view.doc;
|
||||
// A stale answer still reaches `previewed`, which says so.
|
||||
view.off(
|
||||
view.read(
|
||||
move |worker| worker.put_text(doc, page, &asked, PREVIEW),
|
||||
move |_, answer, cx| {
|
||||
// The viewer edits this view; wait until it is free.
|
||||
@@ -96,19 +95,7 @@ impl Viewer {
|
||||
replacing: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let same = self.tab(tab).is_some_and(|open| open.view.read(cx).version == version);
|
||||
if !same {
|
||||
let text = Self::text(cx, "pdf.object.changed", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
match answer {
|
||||
Ok(Wrote::Unwritable) => {
|
||||
let note = Self::text(cx, "pdf.text.unwritable", &[]);
|
||||
self.edit_panel.update(cx, |panel, cx| {
|
||||
panel.note = Some(note);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
Ok(Wrote::Missing(c)) => {
|
||||
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
|
||||
self.edit_panel.update(cx, |panel, cx| {
|
||||
@@ -141,7 +128,6 @@ impl Viewer {
|
||||
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
|
||||
Wrote::Done(mark) => Ok(mark),
|
||||
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
|
||||
Wrote::Unwritable => Err(io::Error::other("the document's font cannot write")),
|
||||
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
|
||||
},
|
||||
cx,
|
||||
@@ -182,27 +168,25 @@ impl Viewer {
|
||||
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
|
||||
viewer.on_same(&view, version, cx, |view, cx| {
|
||||
view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx)
|
||||
});
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether `step` ran: no edit came since `version`.
|
||||
/// Runs `step` on `view` if no edit came since `version`.
|
||||
pub(crate) fn on_same(
|
||||
&self,
|
||||
view: &gpui::Entity<crate::view::PdfView>,
|
||||
version: u64,
|
||||
cx: &mut Context<Self>,
|
||||
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
|
||||
) -> bool {
|
||||
) {
|
||||
// Landed or queued, an edit since may move the target.
|
||||
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
|
||||
if !open || view.read(cx).version != version || !view.read(cx).settled() {
|
||||
let text = Self::text(cx, "pdf.object.changed", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
return false;
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
view.update(cx, step);
|
||||
true
|
||||
}
|
||||
|
||||
/// Asks for a picture; it lands mid-page.
|
||||
|
||||
@@ -186,8 +186,8 @@ impl Deck {
|
||||
return log::info!("show: a chart dialog for tab {tab} came late");
|
||||
}
|
||||
let editing = Editing::new(tab, chart, window, cx);
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = deck.open_dialog(Dialog::Chart(Box::new(editing)), cx);
|
||||
deck.dialog = Some(Dialog::Chart(Box::new(editing)));
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
|
||||
+10
-20
@@ -108,21 +108,6 @@ pub fn findings(check: &Check, slides: &[ShowSlide], missing_fonts: &[String]) -
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Shows a finding's slide; master view numbers masters instead.
|
||||
pub(crate) fn go_to_finding(&mut self, part: usize, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
return;
|
||||
};
|
||||
if tab.extra.masters {
|
||||
let text = Deck::text(cx, "show.masters.busy", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
let view = tab.view.clone();
|
||||
view.update(cx, |view, _| view.show_part(part));
|
||||
self.dialog = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reads every slide and master, then lists what to fix.
|
||||
pub fn check_show(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
@@ -143,8 +128,8 @@ impl Deck {
|
||||
Ok((check, slides)) if deck.dialog_due(asked, id) => {
|
||||
let found = findings(&check, &slides, &missing);
|
||||
log::info!("show: the check found {} things", found.len());
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = deck.open_dialog(Dialog::Check(found), cx);
|
||||
deck.dialog = Some(Dialog::Check(found));
|
||||
cx.notify();
|
||||
}
|
||||
Ok(_) => log::info!("show: the check is no longer due"),
|
||||
Err(error) => deck.engine_failed(&format!("the check: {error}"), cx),
|
||||
@@ -198,9 +183,14 @@ impl Deck {
|
||||
let firsts: Vec<Option<usize>> = found.iter().map(|f| f.slides.first().copied()).collect();
|
||||
let list = list.on_change(move |keys, _, cx| {
|
||||
let first = keys.first().and_then(|key| key.parse::<usize>().ok()).and_then(|at| firsts[at]);
|
||||
if let Some(part) = first {
|
||||
this.update(cx, |deck, cx| deck.go_to_finding(part, cx));
|
||||
}
|
||||
this.update(cx, |deck, cx| {
|
||||
if let (Some(part), Some(tab)) = (first, deck.active_tab()) {
|
||||
let view = tab.view.clone();
|
||||
view.update(cx, |view, _| view.show_part(part));
|
||||
deck.dialog = None;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
card.child(list).into_any_element()
|
||||
}
|
||||
|
||||
@@ -220,7 +220,6 @@ impl Deck {
|
||||
}
|
||||
(tab.extra.reading, tab.extra.read) = (true, Some(seen));
|
||||
let landed = tab.extra.transition_landed(view.read(cx));
|
||||
let sized = tab.extra.size_landed(view.read(cx));
|
||||
let doc = tab.doc.clone();
|
||||
ely_lok::read_off(
|
||||
&self.core.worker,
|
||||
@@ -297,6 +296,12 @@ impl Deck {
|
||||
{
|
||||
tab.extra.stale = tab.extra.thumbs.keys().cloned().collect();
|
||||
}
|
||||
let read = crate::design::mm100(size);
|
||||
if tab.extra.size_asked.is_some_and(|(w, h)| {
|
||||
(w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
|
||||
}) {
|
||||
tab.extra.size_asked = None;
|
||||
}
|
||||
(tab.extra.slides, tab.extra.design) = (slides, Some(design));
|
||||
tab.extra.objects = Some(objects);
|
||||
(tab.extra.notes, tab.extra.remarks) = (Some(notes), remarks);
|
||||
@@ -304,7 +309,6 @@ impl Deck {
|
||||
tab.extra.settle_transition(landed);
|
||||
tab.extra.reads += 1;
|
||||
tab.extra.size = size;
|
||||
tab.extra.settle_size(sized);
|
||||
tab.extra.failed.clear();
|
||||
deck.paint_next(id, cx);
|
||||
deck.read_fonts(id, cx);
|
||||
|
||||
@@ -59,18 +59,9 @@ impl Deck {
|
||||
log::info!("show: the slides already measure {w} x {h}");
|
||||
return;
|
||||
}
|
||||
let (id, view) = (tab.id, tab.view.clone());
|
||||
let before = view.read(cx).sent();
|
||||
tab.extra.size_asked = Some(((w, h), before + 1));
|
||||
let (sw, sh) = (w.to_string(), h.to_string());
|
||||
self.run_in(Some(id), "slide.size", &[&sw, &sh, "true"], cx);
|
||||
// Refused before sending: run_in said so already.
|
||||
if view.read(cx).sent() == before
|
||||
&& let Some(tab) = self.tab_mut(id)
|
||||
{
|
||||
tab.extra.size_asked = None;
|
||||
}
|
||||
self.force_reread(id, cx);
|
||||
tab.extra.size_asked = Some((w, h));
|
||||
let (w, h) = (w.to_string(), h.to_string());
|
||||
self.design_command("slide.size", &[&w, &h, "true"], cx);
|
||||
}
|
||||
|
||||
/// Enters or leaves the master view.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! ElyShow's one open dialog.
|
||||
|
||||
use ely_gpui_component::{forms::TextInput, primitives::Severity};
|
||||
use ely_office::prelude::*;
|
||||
use ely_gpui_component::forms::TextInput;
|
||||
use gpui::{App, Context, Entity, IntoElement as _};
|
||||
|
||||
use crate::deck::Deck;
|
||||
@@ -42,18 +41,6 @@ pub(crate) fn edit(
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
/// Opens `dialog`, or hands it back while another is open.
|
||||
pub(crate) fn open_dialog(&mut self, dialog: Dialog, cx: &mut Context<Self>) -> Result<(), Dialog> {
|
||||
if self.dialog.is_some() {
|
||||
let text = Deck::text(cx, "show.dialog.busy", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
return Err(dialog);
|
||||
}
|
||||
self.dialog = Some(dialog);
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A read that will open a dialog; earlier ones lapse.
|
||||
pub fn ask_dialog(&mut self) -> u64 {
|
||||
self.dialog_asked += 1;
|
||||
@@ -69,9 +56,9 @@ impl Deck {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether read `asked` still stands; `open_dialog` checks the rest.
|
||||
/// Whether read `asked` may open its dialog now.
|
||||
pub fn dialog_due(&self, asked: u64, id: u64) -> bool {
|
||||
self.dialog_asked == asked && self.core.active == Some(id)
|
||||
self.dialog_asked == asked && self.dialog.is_none() && self.core.active == Some(id)
|
||||
}
|
||||
|
||||
/// The open dialog, drawn.
|
||||
|
||||
@@ -37,8 +37,8 @@ pub struct Extra {
|
||||
pub size: (i64, i64),
|
||||
/// Undo and redo lists at the last read.
|
||||
pub history: Option<(Vec<Step>, Vec<Step>)>,
|
||||
/// A size sent (1/100 mm) and its input, until read.
|
||||
pub size_asked: Option<((i64, i64), u64)>,
|
||||
/// A size sent, in 1/100 mm, until read back.
|
||||
pub size_asked: Option<(i64, i64)>,
|
||||
/// A transition sent for a slide, until read back.
|
||||
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
|
||||
/// Pictures by slide name; stale ones show until repainted.
|
||||
@@ -60,22 +60,6 @@ impl Extra {
|
||||
/// The size to show, in 1/100 mm: asked, else read.
|
||||
pub fn shown_size(&self) -> (i64, i64) {
|
||||
self.size_asked
|
||||
.map_or_else(|| crate::design::mm100(self.size), |(size, _)| size)
|
||||
}
|
||||
|
||||
/// The size input that landed before this read began.
|
||||
pub fn size_landed(&self, view: &ely_lok::LokView) -> Option<u64> {
|
||||
let (_, seq) = self.size_asked?;
|
||||
view.reached(seq).then_some(seq)
|
||||
}
|
||||
|
||||
/// Forgets the asked size once read back, taken or refused.
|
||||
pub fn settle_size(&mut self, landed: Option<u64>) {
|
||||
let read = crate::design::mm100(self.size);
|
||||
if self.size_asked.is_some_and(|((w, h), seq)| {
|
||||
landed == Some(seq) || (w - read.0).abs() <= 50 && (h - read.1).abs() <= 50
|
||||
}) {
|
||||
self.size_asked = None;
|
||||
}
|
||||
.unwrap_or_else(|| crate::design::mm100(self.size))
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ impl Deck {
|
||||
match ely_lok::templates("presnt") {
|
||||
Ok(templates) => {
|
||||
log::info!("show: {} bundled templates", templates.len());
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = self.open_dialog(Dialog::New(Choosing { templates }), cx);
|
||||
self.dialog = Some(Dialog::New(Choosing { templates }));
|
||||
cx.notify();
|
||||
}
|
||||
Err(error) => self.engine_failed(&error.to_string(), cx),
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub struct NotesField {
|
||||
field: Option<Entity<TextInput>>,
|
||||
shown: Option<(u64, usize, String)>,
|
||||
/// The state the shown slide number was read in.
|
||||
pub(crate) at: Option<ely_lok::Doc>,
|
||||
at: Option<ely_lok::Doc>,
|
||||
_events: Option<gpui::Subscription>,
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ impl Deck {
|
||||
return log::warn!("show: notes for slide {part} have no read state");
|
||||
};
|
||||
log::info!("show: notes for slide {part} changed");
|
||||
// `shown` stays the kernel's until a read confirms the send.
|
||||
self.notes.shown = Some((tab, part, text.clone()));
|
||||
self.run_on(tab, &at, "notes.set", &[&text, &part.to_string()], cx);
|
||||
self.force_reread(tab, cx);
|
||||
}
|
||||
@@ -90,9 +90,6 @@ impl Deck {
|
||||
if !typing && self.notes.shown.as_ref() != Some(&read) {
|
||||
field.update(cx, |field, cx| field.set_text(notes.text, cx));
|
||||
self.notes.shown = Some(read);
|
||||
} else if let Some(shown) = self.notes.shown.as_mut().filter(|shown| (shown.0, shown.1) == (id, part)) {
|
||||
// A read while typing moves the base, not the draft.
|
||||
shown.2 = notes.text;
|
||||
}
|
||||
Some(field)
|
||||
}
|
||||
|
||||
@@ -148,8 +148,7 @@ impl Deck {
|
||||
|worker, doc| ely_lok::show::slide_show(&worker.values(doc, ely_lok::show::SLIDE_SHOW)?),
|
||||
move |deck, read, _, cx| match read {
|
||||
Ok(slides) if deck.dialog_due(asked, id) => {
|
||||
// Refused while another dialog is open; it says so.
|
||||
let output = Output {
|
||||
deck.dialog = Some(Dialog::Output(Output {
|
||||
tab: id,
|
||||
print,
|
||||
kind: Kind::Slides,
|
||||
@@ -157,8 +156,8 @@ impl Deck {
|
||||
per: 3,
|
||||
current,
|
||||
hidden: slides.iter().map(|slide| slide.hidden).collect(),
|
||||
};
|
||||
let _ = deck.open_dialog(Dialog::Output(output), cx);
|
||||
}));
|
||||
cx.notify();
|
||||
}
|
||||
Ok(_) => log::info!("show: the output dialog is no longer due"),
|
||||
Err(error) => deck.engine_failed(&format!("the slides: {error}"), cx),
|
||||
@@ -186,7 +185,7 @@ impl Deck {
|
||||
let path = work.join("print.pdf");
|
||||
let printing = path.clone();
|
||||
return self.write_pdf(&output, path, cx, move |_, cx| {
|
||||
ely_office::print_later(printing, cx)
|
||||
ely_office::print_later(printing, cx, |deck, error, cx| deck.output_failed(&error, cx))
|
||||
});
|
||||
}
|
||||
let Some(dir) = ely_office::folder(source) else {
|
||||
|
||||
@@ -13,11 +13,6 @@ use super::{Kind, Output, Range};
|
||||
use crate::deck::Deck;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
/// A typed slide number on the deck; 0 means all.
|
||||
fn slide_number(value: f64, last: u32) -> u32 {
|
||||
value.round().clamp(1.0, f64::from(last.max(1))) as u32
|
||||
}
|
||||
|
||||
/// What goes out, which slides, how many a page.
|
||||
pub fn output_dialog(
|
||||
this: gpui::Entity<Deck>,
|
||||
@@ -84,7 +79,7 @@ pub fn output_dialog(
|
||||
let bound = |id: &'static str, value: u32, first: bool| {
|
||||
// A typed number counts before blur commits it.
|
||||
let set = move |this: &gpui::Entity<Deck>, value: f64, cx: &mut gpui::App| {
|
||||
let value = slide_number(value, last);
|
||||
let value = value.round() as u32;
|
||||
change(this, cx, &|output| {
|
||||
if let Range::Slides(from, to) = &mut output.range {
|
||||
*(if first { from } else { to }) = value;
|
||||
@@ -140,16 +135,3 @@ pub fn output_dialog(
|
||||
.on_click(move |_, _, cx| this.update(cx, |deck, cx| deck.output(cx)))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::slide_number;
|
||||
|
||||
#[test]
|
||||
fn a_typed_number_stays_on_the_deck() {
|
||||
assert_eq!(slide_number(0.0, 5), 1, "0 would export every slide");
|
||||
assert_eq!(slide_number(-3.0, 5), 1);
|
||||
assert_eq!(slide_number(2.6, 5), 3);
|
||||
assert_eq!(slide_number(9.0, 5), 5);
|
||||
}
|
||||
}
|
||||
@@ -19,21 +19,20 @@ use gpui::{
|
||||
use crate::deck::Deck;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
/// Threads quoted with their slides; unreadable comments apart.
|
||||
fn threads(remarks: &[Remark], cx: &App) -> (Vec<Thread>, Vec<(String, String)>) {
|
||||
/// Each comment as a thread quoted with its slide.
|
||||
fn threads(remarks: &[Remark], cx: &App) -> Result<Vec<Thread>, String> {
|
||||
let authors: Vec<&str> = remarks
|
||||
.iter()
|
||||
.map(|remark| remark.author.as_str())
|
||||
.collect();
|
||||
let (good, bad): (Vec<_>, Vec<_>) = remarks
|
||||
remarks
|
||||
.iter()
|
||||
.map(|remark| {
|
||||
let at = remark
|
||||
.at
|
||||
.parse::<jiff::civil::DateTime>()
|
||||
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
|
||||
.map_err(|error| (remark.at.clone(), error.to_string()))?
|
||||
.timestamp();
|
||||
.map_err(|error| format!("a bad comment time {}: {error}", remark.at))?;
|
||||
let hue = authors
|
||||
.iter()
|
||||
.position(|name| *name == remark.author)
|
||||
@@ -44,16 +43,14 @@ fn threads(remarks: &[Remark], cx: &App) -> (Vec<Thread>, Vec<(String, String)>)
|
||||
quote: Some(Deck::text(cx, "show.remark.slide", &[("n", &slide)])),
|
||||
comments: vec![Comment {
|
||||
author: Peer::new(remark.author.clone(), remark.author.clone(), hue),
|
||||
at,
|
||||
at: at.timestamp(),
|
||||
body: remark.text.clone().into(),
|
||||
reactions: Vec::new(),
|
||||
}],
|
||||
resolved: false,
|
||||
})
|
||||
})
|
||||
.partition(Result::is_ok);
|
||||
let bad = bad.into_iter().filter_map(Result::err).collect();
|
||||
(good.into_iter().filter_map(Result::ok).collect(), bad)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Deck {
|
||||
@@ -80,8 +77,8 @@ impl Deck {
|
||||
input.set_text(text, cx);
|
||||
input
|
||||
});
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = self.open_dialog(Dialog::Remark { tab, edit, field }, cx);
|
||||
self.dialog = Some(Dialog::Remark { tab, edit, field });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sends the dialog's text: a new comment or an edit.
|
||||
@@ -169,15 +166,14 @@ impl Deck {
|
||||
.active_tab()
|
||||
.map(|tab| tab.extra.remarks.clone())
|
||||
.unwrap_or_default();
|
||||
let (threads, bad) = threads(&remarks, cx);
|
||||
for (at, error) in &bad {
|
||||
log::error!("show: a bad comment time {at}: {error}");
|
||||
}
|
||||
// One line however many: the comments below stay in view.
|
||||
let bad = bad.first().map(|(at, _)| {
|
||||
let n = bad.len().to_string();
|
||||
InlineMessage::new(Severity::Danger, Deck::text(cx, "show.remark.bad", &[("n", &n), ("at", at)]))
|
||||
});
|
||||
let threads = match threads(&remarks, cx) {
|
||||
Ok(threads) => threads,
|
||||
Err(error) => {
|
||||
log::error!("show: {error}");
|
||||
let message = InlineMessage::new(Severity::Danger, error);
|
||||
return div().p_2().child(message).into_any_element();
|
||||
}
|
||||
};
|
||||
let active = self.shown_remark();
|
||||
let act = |id: &'static str,
|
||||
key: &str,
|
||||
@@ -231,7 +227,6 @@ impl Deck {
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.child(head)
|
||||
.children(bad)
|
||||
.child(div().flex_1().min_h_0().child(sidebar))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -294,32 +289,3 @@ impl Deck {
|
||||
(!pins.is_empty()).then(|| div().absolute().inset_0().children(pins).into_any_element())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ely_lok::show::Remark;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::threads;
|
||||
|
||||
#[test]
|
||||
fn one_unreadable_time_spares_the_other_comments() {
|
||||
let cx = TestAppContext::single();
|
||||
cx.update(|cx| ely_ui::i18n::install_as("en-US", cx));
|
||||
let remark = |id: &str, at: &str| Remark {
|
||||
id: id.into(),
|
||||
author: "A".into(),
|
||||
text: "t".into(),
|
||||
at: at.into(),
|
||||
part: 0,
|
||||
anchor: (0, 0, 0, 0),
|
||||
};
|
||||
// The kernel writes an empty date as 0-00-00.
|
||||
let remarks = [remark("1", "2025-03-01"), remark("2", "0-00-00"), remark("3", "2025-03-01T10:15:00")];
|
||||
let (good, bad) = cx.update(|cx| threads(&remarks, cx));
|
||||
let keys: Vec<_> = good.iter().map(|thread| thread.key.to_string()).collect();
|
||||
assert_eq!(keys, ["1", "3"]);
|
||||
assert_eq!(bad.len(), 1);
|
||||
assert_eq!(bad[0].0, "0-00-00");
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,7 @@ impl Deck {
|
||||
/// Plays a copy from the first or shown slide.
|
||||
pub fn start_show(&mut self, here: bool, cx: &mut Context<Self>) {
|
||||
if self.show.is_some() || self.show_starting {
|
||||
log::info!("show: already playing or starting");
|
||||
let text = Deck::text(cx, "show.play.busy", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
return log::info!("show: already playing or starting");
|
||||
}
|
||||
let Some(id) = self.core.active else {
|
||||
return;
|
||||
@@ -182,9 +180,7 @@ impl Deck {
|
||||
let Some(first) = first.filter(|_| deck.core.active == Some(id)) else {
|
||||
discard(worker, doc, path, cx);
|
||||
if deck.core.active != Some(id) {
|
||||
log::info!("show: tab {id} is no longer shown");
|
||||
let text = Deck::text(cx, "show.play.gone", &[]);
|
||||
return deck.toast(text, Severity::Warning, cx);
|
||||
return log::info!("show: tab {id} is no longer shown");
|
||||
}
|
||||
let text = Deck::text(cx, "show.play.none", &[]);
|
||||
return deck.toast(text, Severity::Warning, cx);
|
||||
@@ -200,11 +196,11 @@ impl Deck {
|
||||
};
|
||||
match cx.displays().len() {
|
||||
1 => deck.open_show(starting, cx),
|
||||
_ => match deck.open_dialog(Dialog::Show(starting), cx) {
|
||||
Ok(()) => deck.show_starting = true,
|
||||
Err(Dialog::Show(refused)) => discard(worker, refused.doc, refused.copy, cx),
|
||||
Err(_) => unreachable!("the show dialog comes back"),
|
||||
},
|
||||
_ => {
|
||||
deck.show_starting = true;
|
||||
deck.dialog = Some(Dialog::Show(starting));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
cx,
|
||||
@@ -218,8 +214,6 @@ impl Deck {
|
||||
let (Some(tab), Some(audience)) = (self.tab(starting.tab), displays.get(starting.audience))
|
||||
else {
|
||||
log::warn!("show: the tab or screen {} is gone", starting.audience);
|
||||
let text = Deck::text(cx, "show.play.gone", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
return discard(worker, starting.doc, starting.copy, cx);
|
||||
};
|
||||
let presenter = (displays.iter())
|
||||
@@ -353,45 +347,4 @@ mod tests {
|
||||
};
|
||||
assert_eq!(super::bounds(area), want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_whose_screen_is_gone_says_so_and_drops_its_copy() {
|
||||
use ely_office::prelude::*;
|
||||
use gpui::AppContext as _;
|
||||
|
||||
use crate::tests::{settle, update};
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let Some(deck) = crate::tests::deck(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
|
||||
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| !tab.extra.slides.list.is_empty()));
|
||||
// The window's toasts live on its root.
|
||||
let handle = cx.update(|app| deck.read(app).core.window).expect("a window");
|
||||
let root = cx
|
||||
.update_window(handle, |_, window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Show, deck.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
deck.update(app, |deck, _| deck.core.root = root.downgrade());
|
||||
root
|
||||
})
|
||||
.expect("the window lives");
|
||||
let path = dir.path().join("copy.odp");
|
||||
let starting = cx.update(|app| {
|
||||
let deck = deck.read(app);
|
||||
let tab = deck.active_tab().expect("a tab");
|
||||
let mut worker = deck.core.worker.lock().expect("the worker lock");
|
||||
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy");
|
||||
super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false }
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.show_starting = true;
|
||||
deck.open_show(starting, cx);
|
||||
});
|
||||
settle(&cx, |_| !path.exists());
|
||||
let (toasts, gone) = cx.update(|app| (root.read(app).toasts(app), crate::deck::Deck::text(app, "show.play.gone", &[])));
|
||||
assert!(toasts.iter().any(|(text, _)| *text == gone), "{toasts:?}");
|
||||
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,12 @@ use crate::dialog::{Dialog, edit};
|
||||
impl Deck {
|
||||
pub fn ask_table(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(tab) = self.core.active {
|
||||
// Refused while another dialog is open; it says so.
|
||||
let _ = self.open_dialog(Dialog::Table { tab, columns: 3, rows: 3 }, cx);
|
||||
self.dialog = Some(Dialog::Table {
|
||||
tab,
|
||||
columns: 3,
|
||||
rows: 3,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ use gpui::{AppContext as _, Entity, TestAppContext};
|
||||
use crate::deck::Deck;
|
||||
|
||||
mod motion;
|
||||
mod notes;
|
||||
mod objects;
|
||||
mod output;
|
||||
mod play;
|
||||
@@ -45,7 +44,7 @@ pub(super) fn deck(cx: &mut TestAppContext, dir: &Path) -> Option<Entity<Deck>>
|
||||
|
||||
/// Steps the scheduler until `done` or ten seconds pass.
|
||||
#[track_caller]
|
||||
pub(super) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
||||
fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !cx.executor().tick() {
|
||||
@@ -59,7 +58,7 @@ pub(super) fn settle(cx: &TestAppContext, done: impl Fn(&gpui::App) -> bool) {
|
||||
panic!("the state never settled");
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
fn update(
|
||||
cx: &mut TestAppContext,
|
||||
deck: &Entity<Deck>,
|
||||
f: impl FnOnce(&mut Deck, &mut gpui::Context<Deck>),
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
//! The notes field under the slide (SHOW-08).
|
||||
|
||||
use ely_office::prelude::*;
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::objects::blank;
|
||||
use super::slides::{in_window, type_in};
|
||||
use super::{settle, update};
|
||||
|
||||
#[test]
|
||||
fn typed_notes_reach_the_slide() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let notes = |app: &gpui::App| {
|
||||
deck.read(app)
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.extra.notes.clone())
|
||||
};
|
||||
settle(&cx, |app| notes(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
deck.notes_input(window, cx)
|
||||
});
|
||||
let field = field.expect("the field");
|
||||
type_in(&mut cx, &field, "Start with the totals");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| {
|
||||
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_refused_as_stale_stay_typed_and_go_next_time() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let notes = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone());
|
||||
let text = |app: &gpui::App| notes(app).map(|notes| notes.text);
|
||||
settle(&cx, |app| notes(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
|
||||
let stale = cx.update(|app| deck.read(app).notes.at.clone());
|
||||
type_in(&mut cx, &field, "first");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| text(app).as_deref() == Some("first"));
|
||||
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
|
||||
// Typing began before the edit above was read back.
|
||||
type_in(&mut cx, &field, "second");
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.notes.at = stale;
|
||||
deck.commit_notes(cx);
|
||||
});
|
||||
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.view.read(app).settled()));
|
||||
cx.run_until_parked();
|
||||
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
|
||||
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the typing stays");
|
||||
assert_eq!(cx.update(|app| text(app)).as_deref(), Some("first"), "the stale send was refused");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| text(app).as_deref() == Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_while_typing_keeps_the_new_draft() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let text = |app: &gpui::App| deck.read(app).active_tab().and_then(|tab| tab.extra.notes.clone()).map(|notes| notes.text);
|
||||
settle(&cx, |app| text(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx)).expect("the field");
|
||||
type_in(&mut cx, &field, "first");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
// Back in the field before the send is read back.
|
||||
in_window(&mut cx, &deck, |_, window, cx| window.focus(&gpui::Focusable::focus_handle(field.read(cx), cx), cx));
|
||||
type_in(&mut cx, &field, "second");
|
||||
settle(&cx, |app| text(app).as_deref() == Some("first"));
|
||||
in_window(&mut cx, &deck, |deck, window, cx| deck.notes_input(window, cx));
|
||||
// The blur redraws before its commit runs.
|
||||
in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
window.blur(cx);
|
||||
deck.notes_input(window, cx)
|
||||
});
|
||||
assert_eq!(cx.update(|app| field.read(app).text().to_string()), "second", "the draft stays");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| text(app).as_deref() == Some("second"));
|
||||
}
|
||||
@@ -145,56 +145,3 @@ fn a_click_on_media_finds_it_and_elsewhere_does_not() {
|
||||
assert_eq!(cx.update(|app| show.read(app).media_at(centre)), Some(0));
|
||||
assert_eq!(cx.update(|app| show.read(app).media_at((0.01, 0.01))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_start_says_the_show_is_busy() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
// The window's toasts live on its root.
|
||||
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
let this = cx.entity();
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
|
||||
deck.core.root = root.downgrade();
|
||||
root
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.show_starting = true;
|
||||
deck.start_show(false, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let (toasts, busy) = cx.update(|app| (root.read(app).toasts(app), Deck::text(app, "show.play.busy", &[])));
|
||||
assert!(toasts.iter().any(|(text, _)| *text == busy), "{toasts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_left_for_another_tab_says_so() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let first = cx.update(|app| deck.read(app).core.active.expect("a tab"));
|
||||
update(&mut cx, &deck, |deck, cx| deck.new_blank(cx));
|
||||
settle(&cx, |app| deck.read(app).core.tabs.len() == 2 && deck.read(app).core.active != Some(first));
|
||||
let second = cx.update(|app| deck.read(app).core.active.expect("a tab"));
|
||||
let root = super::slides::in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
let this = cx.entity();
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Show, this.into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
|
||||
deck.core.root = root.downgrade();
|
||||
root
|
||||
});
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.core.active = Some(first);
|
||||
deck.start_show(false, cx);
|
||||
// Another tab comes forward while the copy is read.
|
||||
deck.core.active = Some(second);
|
||||
});
|
||||
let gone = cx.update(|app| Deck::text(app, "show.play.gone", &[]));
|
||||
settle(&cx, |app| root.read(app).toasts(app).iter().any(|(text, _)| *text == gone));
|
||||
assert!(cx.update(|app| !deck.read(app).show_starting && deck.read(app).show.is_none()));
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use crate::deck::Deck;
|
||||
use crate::dialog::Dialog;
|
||||
|
||||
/// Runs `f` on the deck inside its window.
|
||||
pub(super) fn in_window<R>(
|
||||
fn in_window<R>(
|
||||
cx: &mut TestAppContext,
|
||||
deck: &Entity<Deck>,
|
||||
f: impl FnOnce(&mut Deck, &mut Window, &mut gpui::Context<Deck>) -> R,
|
||||
@@ -25,7 +25,7 @@ pub(super) fn in_window<R>(
|
||||
.expect("the window lives")
|
||||
}
|
||||
|
||||
pub(super) fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
|
||||
fn type_in(cx: &mut TestAppContext, field: &Entity<TextInput>, text: &str) {
|
||||
let text = text.to_string();
|
||||
cx.update(|app| field.update(app, |field, cx| field.set_text(text, cx)));
|
||||
}
|
||||
@@ -105,20 +105,28 @@ fn the_table_dialog_inserts_a_table() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_dialog_is_not_pushed_aside() {
|
||||
fn typed_notes_reach_the_slide() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
update(&mut cx, &deck, |deck, cx| deck.ask_table(cx));
|
||||
update(&mut cx, &deck, |deck, cx| deck.ask_new(cx));
|
||||
// The chart dialog opens after a background read.
|
||||
update(&mut cx, &deck, |deck, cx| deck.ask_chart(cx));
|
||||
cx.run_until_parked();
|
||||
let kept = cx.update(|app| matches!(deck.read(app).dialog, Some(crate::dialog::Dialog::Table { .. })));
|
||||
assert!(kept, "the table dialog stays");
|
||||
let notes = |app: &gpui::App| {
|
||||
deck.read(app)
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.extra.notes.clone())
|
||||
};
|
||||
settle(&cx, |app| notes(app).is_some());
|
||||
let field = in_window(&mut cx, &deck, |deck, window, cx| {
|
||||
deck.notes_input(window, cx)
|
||||
});
|
||||
let field = field.expect("the field");
|
||||
type_in(&mut cx, &field, "Start with the totals");
|
||||
update(&mut cx, &deck, |deck, cx| deck.commit_notes(cx));
|
||||
settle(&cx, |app| {
|
||||
notes(app).is_some_and(|notes| notes.text == "Start with the totals")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -400,64 +408,3 @@ fn a_zoom_stays_through_later_reports() {
|
||||
assert_eq!(view.read(app).zoom(), zoom, "the zoom stays");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_row_does_not_jump_among_masters() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
update(&mut cx, &deck, |deck, cx| deck.masters(true, cx));
|
||||
settle(&cx, |app| deck.read(app).active_tab().is_some_and(|tab| tab.extra.masters));
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.dialog = Some(crate::dialog::Dialog::Check(Vec::new()));
|
||||
deck.go_to_finding(0, cx);
|
||||
});
|
||||
assert!(cx.update(|app| deck.read(app).dialog.is_some()), "the check stays open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_size_does_not_stay_shown() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let before = cx.update(|app| deck.read(app).active_tab().expect("a tab").extra.size);
|
||||
update(&mut cx, &deck, |deck, cx| deck.resize((-500, -500), cx));
|
||||
settle(&cx, |app| {
|
||||
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
|
||||
});
|
||||
let extra = cx.update(|app| {
|
||||
let tab = deck.read(app).active_tab().expect("a tab");
|
||||
(tab.extra.size, tab.extra.shown_size())
|
||||
});
|
||||
assert_eq!(extra.0, before, "the kernel kept its size");
|
||||
assert_eq!(extra.1, crate::design::mm100(before), "the fields show it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_the_kernel_does_not_take_clears_after_its_read() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
deck.resize((30_000, 19_050), cx);
|
||||
// As if the kernel refused: it keeps another size.
|
||||
let id = deck.core.active.expect("a tab");
|
||||
let tab = deck.tab_mut(id).expect("a tab");
|
||||
let (_, seq) = tab.extra.size_asked.expect("asked");
|
||||
tab.extra.size_asked = Some(((40_000, 19_050), seq));
|
||||
});
|
||||
settle(&cx, |app| {
|
||||
deck.read(app).active_tab().is_some_and(|tab| tab.extra.size_asked.is_none())
|
||||
});
|
||||
let read = cx.update(|app| crate::design::mm100(deck.read(app).active_tab().expect("a tab").extra.size));
|
||||
assert!((read.0 - 30_000).abs() <= 50, "the kernel's own size shows: {read:?}");
|
||||
}
|
||||
@@ -81,7 +81,7 @@ impl Book {
|
||||
}
|
||||
(Asked::Refresh, Ok(_)) => book.warn_key("table.pivot.none", cx),
|
||||
(_, Ok(Found::Pivot(Some(pivot)))) => {
|
||||
book.open_dialog(Dialog::Pivot {
|
||||
book.dialog = Some(Dialog::Pivot {
|
||||
tab: id,
|
||||
source: pivot.source,
|
||||
fields: pivot.fields,
|
||||
@@ -120,7 +120,7 @@ impl Book {
|
||||
if picked.top.iter().any(String::is_empty) {
|
||||
return self.warn_key("table.pivot.headers", cx);
|
||||
}
|
||||
self.open_dialog(Dialog::Pivot {
|
||||
self.dialog = Some(Dialog::Pivot {
|
||||
tab: id,
|
||||
source: picked.data,
|
||||
fields: picked.top,
|
||||
@@ -146,7 +146,7 @@ impl Book {
|
||||
range: ely_office::chart::field(range, "table.chart.range", window, cx),
|
||||
look: ely_office::chart::LookFields::new(&look, window, cx),
|
||||
};
|
||||
self.open_dialog(Dialog::Chart {
|
||||
self.dialog = Some(Dialog::Chart {
|
||||
tab: id,
|
||||
chart: chart.map(Box::new),
|
||||
look: Box::new(look),
|
||||
|
||||
@@ -101,17 +101,6 @@ impl Host for Book {
|
||||
self.sync_bars(view, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed tab's dialog and list go with it.
|
||||
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
|
||||
if self.dialog.as_ref().and_then(crate::dialog::Dialog::tab) == Some(tab.id) {
|
||||
self.dialog = None;
|
||||
}
|
||||
if self.choices.as_ref().is_some_and(|choices| choices.tab == tab.id) {
|
||||
self.choices = None;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// The window root with a blank workbook.
|
||||
|
||||
@@ -121,7 +121,7 @@ impl Book {
|
||||
|worker, doc| doc.selection_text(worker),
|
||||
move |book, text, _, cx| match text {
|
||||
Ok(text) if filled(&text) > 1 => {
|
||||
book.open_dialog(Dialog::Merge { tab });
|
||||
book.dialog = Some(Dialog::Merge { tab });
|
||||
cx.notify();
|
||||
}
|
||||
Ok(_) => book.run_in(id, "cells.merge", &["false"], cx),
|
||||
@@ -150,7 +150,7 @@ impl Book {
|
||||
return self.toast(text, ely_gpui_component::primitives::Severity::Warning, cx);
|
||||
};
|
||||
let field = cx.new(|cx| TextInput::new(window, cx));
|
||||
self.open_dialog(Dialog::Size { tab, column, field });
|
||||
self.dialog = Some(Dialog::Size { tab, column, field });
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Book {
|
||||
fn data_dialog(&mut self, id: u64, asked: Asked, picked: Selection, state: ReadState, cx: &mut Context<Self>) {
|
||||
log::info!("table: {asked:?} on {}", picked.range);
|
||||
let header = picked.header;
|
||||
self.open_dialog(match asked {
|
||||
self.dialog = Some(match asked {
|
||||
Asked::Sort => {
|
||||
let (first, ..) = picked.area.expect("a data area");
|
||||
Dialog::Sort {
|
||||
@@ -314,7 +314,7 @@ impl Book {
|
||||
return book.warn_key("table.columns.same", cx);
|
||||
}
|
||||
let other = cx.new(|cx| TextInput::new(window, cx));
|
||||
book.open_dialog(Dialog::Columns {
|
||||
book.dialog = Some(Dialog::Columns {
|
||||
tab: id,
|
||||
separators: vec!["9".into()],
|
||||
other,
|
||||
@@ -394,10 +394,9 @@ impl Book {
|
||||
.filter(|entry| entry.selected)
|
||||
.map(|entry| SharedString::from(entry.text.clone()))
|
||||
.collect();
|
||||
book.open_dialog(Dialog::Column {
|
||||
book.dialog = Some(Dialog::Column {
|
||||
tab: id,
|
||||
column,
|
||||
row,
|
||||
entries: filter.entries,
|
||||
picked,
|
||||
});
|
||||
@@ -414,15 +413,12 @@ impl Book {
|
||||
let Some(Dialog::Column {
|
||||
tab,
|
||||
column,
|
||||
row,
|
||||
entries,
|
||||
picked,
|
||||
}) = self.dialog.take()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
// The kernel filters the cursor's range: go to the button.
|
||||
self.run_in(Some(tab), "cell.goto", &[&sheet::address(column, row)], cx);
|
||||
match sort {
|
||||
Some(rising) => {
|
||||
let key = SortKey {
|
||||
|
||||
@@ -94,8 +94,6 @@ pub enum Dialog {
|
||||
Column {
|
||||
tab: u64,
|
||||
column: u32,
|
||||
/// The button's row: its cell names the filtered range.
|
||||
row: u32,
|
||||
entries: Vec<Entry>,
|
||||
picked: Vec<SharedString>,
|
||||
},
|
||||
@@ -152,35 +150,6 @@ pub enum Dialog {
|
||||
},
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
/// The workbook it asks about; imports have none yet.
|
||||
pub fn tab(&self) -> Option<u64> {
|
||||
match self {
|
||||
Dialog::Rename { tab, .. }
|
||||
| Dialog::Show { tab, .. }
|
||||
| Dialog::Delete { tab, .. }
|
||||
| Dialog::Size { tab, .. }
|
||||
| Dialog::Merge { tab, .. }
|
||||
| Dialog::Define { tab, .. }
|
||||
| Dialog::Names { tab, .. }
|
||||
| Dialog::Sort { tab, .. }
|
||||
| Dialog::Filter { tab, .. }
|
||||
| Dialog::Dedupe { tab, .. }
|
||||
| Dialog::Columns { tab, .. }
|
||||
| Dialog::Column { tab, .. }
|
||||
| Dialog::Validation { tab, .. }
|
||||
| Dialog::Conditional { tab, .. }
|
||||
| Dialog::Formats { tab, .. }
|
||||
| Dialog::Pivot { tab, .. }
|
||||
| Dialog::Chart { tab, .. }
|
||||
| Dialog::Export { tab, .. }
|
||||
| Dialog::Page { tab, .. }
|
||||
| Dialog::Protect { tab, .. } => Some(*tab),
|
||||
Dialog::Import(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Book {
|
||||
/// The define dialog's sheets: its own workbook's.
|
||||
pub(crate) fn define_scopes(&self) -> Option<Vec<String>> {
|
||||
|
||||
@@ -116,7 +116,7 @@ impl Book {
|
||||
let name = cx.new(|cx| TextInput::new(window, cx));
|
||||
let range = cx.new(|cx| TextInput::new(window, cx));
|
||||
range.update(cx, |range, cx| range.set_text(refers, cx));
|
||||
book.open_dialog(Dialog::Define {
|
||||
book.dialog = Some(Dialog::Define {
|
||||
tab: id,
|
||||
name,
|
||||
range,
|
||||
@@ -206,7 +206,7 @@ impl Book {
|
||||
let range = cx.new(|cx| TextInput::new(window, cx));
|
||||
let formula = first.formula.clone();
|
||||
range.update(cx, |range, cx| range.set_text(formula, cx));
|
||||
book.open_dialog(Dialog::Names {
|
||||
book.dialog = Some(Dialog::Names {
|
||||
tab: id,
|
||||
names,
|
||||
chosen: 0,
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Book {
|
||||
let Some(tab) = self.core.active else {
|
||||
return self.warn_key("command.blocked.no_document", cx);
|
||||
};
|
||||
self.open_dialog(Dialog::Export {
|
||||
self.dialog = Some(Dialog::Export {
|
||||
tab,
|
||||
format: Format::Pdf,
|
||||
});
|
||||
@@ -144,8 +144,10 @@ impl Book {
|
||||
tab,
|
||||
move |worker, doc| doc.export_pdf(worker, &url, &WHOLE),
|
||||
move |book, written, _, cx| match written {
|
||||
Ok(()) => ely_office::print_later(path, cx),
|
||||
Err(error) => book.print_failed(&error.to_string(), cx),
|
||||
Ok(()) => ely_office::print_later(path, cx, |book, error, cx| {
|
||||
book.output_failed(&error, cx)
|
||||
}),
|
||||
Err(error) => book.output_failed(&error.to_string(), cx),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
|
||||
@@ -82,15 +82,6 @@ fn cell_ok(cell: &str) -> bool {
|
||||
&& row_number(digits).is_some()
|
||||
}
|
||||
|
||||
/// Whole columns or rows, like `$A:$D`; one `$` each.
|
||||
fn whole(from: &str, to: &str, number: fn(&str) -> Option<u32>) -> bool {
|
||||
let end = |end: &str| {
|
||||
let end = end.strip_prefix('$').unwrap_or(end);
|
||||
end.bytes().all(|byte| byte.is_ascii_alphanumeric()).then(|| number(&end.to_ascii_uppercase()))?
|
||||
};
|
||||
matches!((end(from), end(to)), (Some(first), Some(last)) if first <= last)
|
||||
}
|
||||
|
||||
/// Areas as the kernel takes them, or None.
|
||||
fn areas(text: &str) -> Option<String> {
|
||||
let tokens: Vec<&str> = text
|
||||
@@ -99,9 +90,7 @@ fn areas(text: &str) -> Option<String> {
|
||||
.filter(|token| !token.is_empty())
|
||||
.collect();
|
||||
let ok = |token: &&str| match token.split_once(':') {
|
||||
Some((from, to)) => {
|
||||
cell_ok(from) && cell_ok(to) || whole(from, to, column_number) || whole(from, to, row_number)
|
||||
}
|
||||
Some((from, to)) => cell_ok(from) && cell_ok(to),
|
||||
None => cell_ok(token),
|
||||
};
|
||||
tokens.iter().all(ok).then(|| tokens.join(","))
|
||||
@@ -154,7 +143,7 @@ impl Book {
|
||||
rows: field(sheet::rows_ref(layout.rows), "table.page.rows_hint", cx),
|
||||
columns: field(sheet::columns_ref(layout.columns), "table.page.columns_hint", cx),
|
||||
};
|
||||
self.open_dialog(Dialog::Page {
|
||||
self.dialog = Some(Dialog::Page {
|
||||
tab: id,
|
||||
landscape: layout.landscape,
|
||||
scale: layout.scale,
|
||||
@@ -239,11 +228,6 @@ mod tests {
|
||||
assert_eq!(areas("A1:B"), None);
|
||||
assert_eq!(areas("XFE1"), None);
|
||||
assert_eq!(areas("S!A1"), None);
|
||||
assert_eq!(areas("$A:$D, 1:3").as_deref(), Some("$A:$D,1:3"));
|
||||
assert_eq!(areas("A:3"), None);
|
||||
for bad in ["$$A:$$D", "$$1:$$3", "$A:$$D", "A:D,$$E:$$F", "+1:2", "D:A"] {
|
||||
assert_eq!(areas(bad), None, "{bad}");
|
||||
}
|
||||
assert_eq!(span("$1:$3", row_number), Some(Some((1, 3))));
|
||||
assert_eq!(span("2", row_number), Some(Some((2, 2))));
|
||||
assert_eq!(span("3:1", row_number), None);
|
||||
|
||||
@@ -58,7 +58,7 @@ impl Book {
|
||||
confirm: masked("table.protect.confirm", cx),
|
||||
now,
|
||||
};
|
||||
book.open_dialog(Dialog::Protect {
|
||||
book.dialog = Some(Dialog::Protect {
|
||||
tab: id,
|
||||
protecting: Box::new(protecting),
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ impl Book {
|
||||
} else {
|
||||
rule
|
||||
};
|
||||
self.open_dialog(Dialog::Validation {
|
||||
self.dialog = Some(Dialog::Validation {
|
||||
tab: id,
|
||||
rule: Box::new(rule),
|
||||
fields: Box::new(fields),
|
||||
@@ -275,7 +275,7 @@ impl Book {
|
||||
};
|
||||
let value1 = cx.new(|cx| TextInput::new(window, cx));
|
||||
let value2 = cx.new(|cx| TextInput::new(window, cx));
|
||||
self.open_dialog(Dialog::Conditional {
|
||||
self.dialog = Some(Dialog::Conditional {
|
||||
tab: id,
|
||||
kind: "cell",
|
||||
operator: "greater",
|
||||
@@ -338,7 +338,7 @@ impl Book {
|
||||
book.toast(text, Severity::Info, cx);
|
||||
}
|
||||
Ok(formats) => {
|
||||
book.open_dialog(Dialog::Formats {
|
||||
book.dialog = Some(Dialog::Formats {
|
||||
tab: id,
|
||||
formats,
|
||||
chosen: 0,
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::book::Book;
|
||||
|
||||
/// The open choices menu: tab, values, request.
|
||||
pub struct Choices {
|
||||
pub(crate) tab: u64,
|
||||
tab: u64,
|
||||
pub(crate) values: Vec<String>,
|
||||
request: (u64, Point<Pixels>),
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ impl Book {
|
||||
return self.warn("table.show.none", cx);
|
||||
};
|
||||
let tab = tab.id;
|
||||
self.open_dialog(Dialog::Show {
|
||||
self.dialog = Some(Dialog::Show {
|
||||
tab,
|
||||
hidden,
|
||||
chosen,
|
||||
@@ -246,7 +246,7 @@ impl Book {
|
||||
};
|
||||
let field = cx.new(|cx| TextInput::new(window, cx));
|
||||
field.update(cx, |field, cx| field.set_text(name, cx));
|
||||
self.open_dialog(Dialog::Rename { tab, at, index, field });
|
||||
self.dialog = Some(Dialog::Rename { tab, at, index, field });
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ impl Book {
|
||||
},
|
||||
move |book, read, _, cx| match read {
|
||||
Ok((references, at, name)) => {
|
||||
book.open_dialog(Dialog::Delete {
|
||||
book.dialog = Some(Dialog::Delete {
|
||||
tab: id,
|
||||
at,
|
||||
index,
|
||||
|
||||
@@ -300,41 +300,3 @@ fn the_panel_reads_number_formats_and_find_counts_cells() {
|
||||
.is_some_and(|finder| finder.all.len() == 3)
|
||||
});
|
||||
}
|
||||
|
||||
/// Two blocks: keys in A, other numbers in C.
|
||||
const BLOCKS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
|
||||
<office:body><office:spreadsheet><table:table table:name="S">
|
||||
<table:table-row><table:table-cell><text:p>K</text:p></table:table-cell><table:table-cell/><table:table-cell><text:p>L</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell office:value-type="float" office:value="3"><text:p>3</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="9"><text:p>9</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="7"><text:p>7</text:p></table:table-cell></table:table-row>
|
||||
<table:table-row><table:table-cell office:value-type="float" office:value="2"><text:p>2</text:p></table:table-cell><table:table-cell/><table:table-cell office:value-type="float" office:value="8"><text:p>8</text:p></table:table-cell></table:table-row>
|
||||
</table:table></office:spreadsheet></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
#[test]
|
||||
fn a_column_button_sorts_its_own_block_not_the_cursors() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.new_from_text("blocks.fods", BLOCKS, cx)
|
||||
});
|
||||
settle(&cx, |app| sheet_names(app, &book) == ["S"]);
|
||||
run(&mut cx, &book, "cell.goto", &["A1"]);
|
||||
run(&mut cx, &book, "data.autofilter", &[]);
|
||||
run(&mut cx, &book, "cell.goto", &["C3"]);
|
||||
let id = cx.update(|app| book.read(app).core.active.expect("a tab"));
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.ask_column(id, &Request::Filter { column: 0, row: 0 }, cx)
|
||||
});
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Column { .. }));
|
||||
update(&mut cx, &book, |book, cx| book.column_asked(Some(true), cx));
|
||||
assert_eq!(cells(&mut cx, &book, "A2:A4"), "1\n2\n3");
|
||||
assert_eq!(cells(&mut cx, &book, "C2:C4"), "9\n7\n8");
|
||||
}
|
||||
@@ -44,27 +44,3 @@ fn a_failed_command_still_moves_the_state_on() {
|
||||
update(&mut cx, &book, |book, cx| book.run_active("sheet.rename", &["Plan", "1"], cx));
|
||||
settle(&cx, |app| sheet_names(app, &book) == ["Plan"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_print_says_printing_failed() {
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
// The window's toasts live on its root.
|
||||
let root = window
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Table, book.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, cx);
|
||||
book.update(cx, |book, _| book.core.root = root.downgrade());
|
||||
root
|
||||
})
|
||||
.expect("the window");
|
||||
let gone = dir.path().join("gone.pdf");
|
||||
update(&mut cx, &book, |_, cx| ely_office::print_later(gone, cx));
|
||||
cx.run_until_parked();
|
||||
let (toasts, prefix) = cx.update(|app| (root.read(app).toasts(app), ely_office::text(app, "office.print.failed", &[("error", "")])));
|
||||
assert!(toasts.iter().any(|(text, severity)| *severity == Some(Severity::Danger) && text.starts_with(prefix.as_ref())), "{toasts:?}");
|
||||
}
|
||||
@@ -377,124 +377,3 @@ fn text_files_wait_their_turn_for_the_import_dialog() {
|
||||
names.sort();
|
||||
assert_eq!(names, ["a.csv", "b.csv"], "both files open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_waits_behind_a_dialog_that_displaces_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
blank(&mut cx, &book);
|
||||
let path = dir.path().join("wait.csv");
|
||||
std::fs::write(&path, "Id,Ignored\n1E3,hidden\n").expect("writes");
|
||||
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if !pending.reading));
|
||||
update(&mut cx, &book, |book, _| {
|
||||
if let Some(Dialog::Import(pending)) = &mut book.dialog {
|
||||
pending.how.columns = vec![Column::Text, Column::Skip];
|
||||
}
|
||||
});
|
||||
window
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
book.update(cx, |book, cx| book.on_export(&ely_ui::commands::Export, window, cx))
|
||||
})
|
||||
.expect("the window");
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Export { .. }));
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.dialog = None;
|
||||
cx.notify();
|
||||
});
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(pending) if pending.path.ends_with("wait.csv")));
|
||||
cx.run_until_parked();
|
||||
update(&mut cx, &book, |book, cx| book.import_asked(cx));
|
||||
settle(&cx, |app| book.read(app).active_tab().is_some_and(|tab| tab.name == "wait.csv"));
|
||||
// Text keeps `1E3`; the skipped column stays empty.
|
||||
assert_eq!(cells(&mut cx, &book, "A2:B2").trim_end(), "1E3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_tab_takes_its_dialog_and_frees_the_import() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
blank(&mut cx, &book);
|
||||
window
|
||||
.update(&mut cx, |_, window, cx| {
|
||||
book.update(cx, |book, cx| book.on_export(&ely_ui::commands::Export, window, cx))
|
||||
})
|
||||
.expect("the window");
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Export { .. }));
|
||||
let id = cx.update(|app| book.read(app).core.tabs[0].id);
|
||||
update(&mut cx, &book, |book, cx| book.close_tab(id, cx));
|
||||
let path = dir.path().join("after.csv");
|
||||
std::fs::write(&path, "Name\nx\n").expect("writes");
|
||||
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_column_print_area_still_applies() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
blank(&mut cx, &book);
|
||||
update(&mut cx, &book, |book, cx| book.ask_page(cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
|
||||
type_in(&mut cx, &book, area, "A:D");
|
||||
update(&mut cx, &book, |book, cx| book.page_asked(cx));
|
||||
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
|
||||
assert_eq!(layout.areas.len(), 1, "set: {:?}", layout.areas);
|
||||
// The kernel's own text comes back into the dialog.
|
||||
update(&mut cx, &book, |book, cx| book.ask_page(cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Page { .. }));
|
||||
let shown = cx.update(|app| match book.read(app).dialog.as_ref() {
|
||||
Some(dialog) => area(dialog).read(app).text().to_string(),
|
||||
None => unreachable!("a page dialog"),
|
||||
});
|
||||
assert_eq!(shown, layout.areas[0]);
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
if let Some(Dialog::Page { landscape, .. }) = &mut book.dialog {
|
||||
*landscape = true;
|
||||
}
|
||||
book.page_asked(cx)
|
||||
});
|
||||
let layout = read(&mut cx, &book, sheet::print_layout, sheet::LAYOUT);
|
||||
assert!(layout.landscape, "applied with {shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_encoding_follows_the_apps_language() {
|
||||
use ely_gpui_component::i18n::I18n;
|
||||
use ely_lok::sheet::csv::Encoding;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some((_window, book)) = book(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let path = dir.path().join("gb.csv");
|
||||
// 你好 in GB18030: not UTF-8.
|
||||
std::fs::write(&path, [0xC4, 0xE3, 0xBA, 0xC3, b'\n']).expect("writes");
|
||||
for (tag, expected) in [("zh-CN", Encoding::Gb18030), ("en-US", Encoding::Windows1252)] {
|
||||
cx.update(|app| I18n::set_locale(tag, app));
|
||||
update(&mut cx, &book, |book, cx| book.ask_import(path.clone(), cx));
|
||||
asked(&cx, &book, |dialog| matches!(dialog, Dialog::Import(_)));
|
||||
let found = cx.update(|app| match book.read(app).dialog.as_ref() {
|
||||
Some(Dialog::Import(pending)) => pending.how.encoding,
|
||||
_ => unreachable!("an import dialog"),
|
||||
});
|
||||
assert_eq!(found, expected, "{tag}");
|
||||
update(&mut cx, &book, |book, cx| {
|
||||
book.dialog = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
+16
-30
@@ -154,20 +154,22 @@ fn decode(bytes: &[u8], encoding: Encoding) -> Option<String> {
|
||||
.map(|text| text.into_owned())
|
||||
}
|
||||
|
||||
/// Calc's text-import rows (impex.cxx), one at a time.
|
||||
fn split<'a>(text: &'a str, how: &'a Import) -> impl Iterator<Item = Vec<String>> + 'a {
|
||||
/// Rows as Calc's text import reads them (impex.cxx).
|
||||
fn split(text: &str, how: &Import) -> Vec<Vec<String>> {
|
||||
let mut chars = text.chars().peekable();
|
||||
std::iter::from_fn(move || {
|
||||
chars.peek()?;
|
||||
let mut rows = Vec::new();
|
||||
while chars.peek().is_some() {
|
||||
let mut row = Vec::new();
|
||||
loop {
|
||||
let (value, more) = field(&mut chars, how);
|
||||
row.push(value);
|
||||
if !more {
|
||||
return Some(row);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
rows.push(row);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// One field, and whether the record goes on.
|
||||
@@ -231,19 +233,15 @@ impl Book {
|
||||
|
||||
pub(crate) fn ask_import(&mut self, path: PathBuf, cx: &mut Context<Self>) {
|
||||
let reading = path.clone();
|
||||
// The language the app shows, not the system's.
|
||||
let chinese = cx.global::<ely_gpui_component::i18n::I18n>().locale().tag == "zh-CN";
|
||||
let chinese = ely_ui::i18n::resolve(&ely_platform::locale::system()) == "zh-CN";
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Guessing checks the whole file: off the window thread.
|
||||
let pending = cx
|
||||
.background_spawn(async move {
|
||||
std::fs::read(&reading).map(|bytes| Pending::new(reading, bytes, chinese))
|
||||
})
|
||||
let bytes = cx
|
||||
.background_spawn(async move { std::fs::read(&reading) })
|
||||
.await;
|
||||
in_window(&this, cx, |book, _, cx| match pending {
|
||||
Ok(pending) => {
|
||||
log::info!("table: import asks for {} bytes", pending.bytes.len());
|
||||
book.imports.push_back(pending);
|
||||
in_window(&this, cx, |book, _, cx| match bytes {
|
||||
Ok(bytes) => {
|
||||
log::info!("table: import asks for {} bytes", bytes.len());
|
||||
book.imports.push_back(Pending::new(path, bytes, chinese));
|
||||
book.next_import(cx);
|
||||
}
|
||||
Err(error) => book.failed_open(&path.display().to_string(), &error, cx),
|
||||
@@ -252,26 +250,14 @@ impl Book {
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// Opens `dialog`; a displaced import waits its turn again.
|
||||
pub(crate) fn open_dialog(&mut self, dialog: Dialog) {
|
||||
if let Some(Dialog::Import(pending)) = self.dialog.replace(dialog) {
|
||||
log::info!("table: import waits behind another dialog");
|
||||
self.imports.push_front(*pending);
|
||||
}
|
||||
}
|
||||
|
||||
/// The next waiting import asks, once no dialog is open.
|
||||
pub(crate) fn next_import(&mut self, cx: &mut Context<Self>) {
|
||||
if self.dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(pending) = self.imports.pop_front() {
|
||||
// A finished scan keeps the reader's column choices.
|
||||
let unread = pending.reading;
|
||||
self.dialog = Some(Dialog::Import(Box::new(pending)));
|
||||
if unread {
|
||||
self.scan_import(cx);
|
||||
}
|
||||
self.scan_import(cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,15 +98,6 @@ impl Doc {
|
||||
Ok((unpack.i64()?, unpack.i64()?))
|
||||
}
|
||||
|
||||
/// The size and the client zoom it was measured at.
|
||||
pub fn size_at(&self, worker: &mut Worker) -> io::Result<((i64, i64), (i32, i32))> {
|
||||
let payload = worker.request(tag::DOC_SIZE, &Pack::new().u64(worker.doc_id(self)?).bytes())?;
|
||||
let mut unpack = Unpack::new(&payload);
|
||||
let size = (unpack.i64()?, unpack.i64()?);
|
||||
let number = |value: i64| i32::try_from(value).map_err(io::Error::other);
|
||||
Ok((size, (number(unpack.i64()?)?, number(unpack.i64()?)?)))
|
||||
}
|
||||
|
||||
/// Page rectangles in twips; empty without pages.
|
||||
pub fn pages(&self, worker: &mut Worker) -> io::Result<Vec<Rect>> {
|
||||
let payload = worker.request(tag::PAGES, &Pack::new().u64(worker.doc_id(self)?).bytes())?;
|
||||
|
||||
@@ -256,21 +256,3 @@ fn commands_from_one_read_run_together_and_hovering_changes_nothing() {
|
||||
assert!(crate::is_stale(&stale), "{stale}");
|
||||
assert_eq!(only(&mut worker), ["One", "Two", "U"], "nothing else was renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_act_after_a_click_is_not_stale() {
|
||||
if install().is_none() {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
}
|
||||
let (_dir, mut worker, doc) = open_book(SHEETS);
|
||||
let read = worker.now(&doc).expect("the state");
|
||||
// A click moves the revision; rows and columns stay.
|
||||
for kind in [0, 1] {
|
||||
let click = crate::Mouse { kind, x: 500, y: 500, count: 1, buttons: 1, modifiers: 0 };
|
||||
worker.post_mouse(&read, click).expect("clicks");
|
||||
}
|
||||
assert_ne!(worker.now(&doc).expect("the state"), read, "the click counts as a change");
|
||||
worker.run(&read, "select.column", &["2", "0"]).expect("the column header still selects");
|
||||
worker.run(&read, "columns.width", &["800", "3"]).expect("the column's width still sets");
|
||||
}
|
||||
@@ -61,7 +61,7 @@ pub struct Command {
|
||||
|
||||
/// Arguments that count things as a read saw them.
|
||||
const NUMBERED: &[&str] = &[
|
||||
"Index", "nTabNumber", "TabNo", "Part", "StartColumn", "StartRow", "EndColumn", "EndRow",
|
||||
"Index", "nTabNumber", "TabNo", "Part", "Row", "Col", "Column", "StartColumn", "StartRow", "EndColumn", "EndRow",
|
||||
];
|
||||
|
||||
impl Command {
|
||||
@@ -306,9 +306,7 @@ mod tests {
|
||||
assert!(find("sheet.delete").expect("the row").addressed(&["1"]));
|
||||
assert!(!find("cell.enter").expect("the row").addressed(&["1"]));
|
||||
assert!(find("notes.set").expect("the row").addressed(&["text", "1"]), "a slide by number");
|
||||
// Clicks renumber no row or column: never stale.
|
||||
assert!(!find("select.row").expect("the row").addressed(&["3", "0"]), "a header row");
|
||||
assert!(!find("columns.width").expect("the row").addressed(&["500", "2"]), "a column's width");
|
||||
assert!(find("select.row").expect("the row").addressed(&["3", "0"]), "a row by number");
|
||||
assert!(!find("rows.height_selected").expect("the row").addressed(&["500"]), "a size names nothing");
|
||||
}
|
||||
|
||||
|
||||
@@ -113,8 +113,6 @@ struct Slot {
|
||||
result: Option<(String, bool)>,
|
||||
/// The seq last pushed to the client.
|
||||
pushed: u64,
|
||||
/// The client zoom last set: tile pixels, tile twips.
|
||||
zoom: (i32, i32),
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
@@ -449,23 +447,6 @@ pub fn wait_cropping(id: u64, timeout: Duration) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The client zoom last set on `id`, in tile units.
|
||||
pub fn zoom(id: u64) -> (i32, i32) {
|
||||
slots().lock().expect("the events lock").get(&id).map_or((0, 0), |slot| slot.zoom)
|
||||
}
|
||||
|
||||
/// A new client zoom resizes the document unannounced (Calc).
|
||||
pub fn rescaled(id: u64, zoom: (i32, i32)) {
|
||||
if let Some(slot) = slots().lock().expect("the events lock").get_mut(&id)
|
||||
&& slot.zoom != zoom
|
||||
{
|
||||
slot.zoom = zoom;
|
||||
slot.batch.resized += 1;
|
||||
slot.batch.seq += 1;
|
||||
}
|
||||
signal().notify_all();
|
||||
}
|
||||
|
||||
/// Seeds the a11y text from the kernel's snapshot (§7 tail).
|
||||
pub fn seed_a11y(id: u64, paragraph: &str) {
|
||||
if let Some(slot) = slots().lock().expect("the events lock").get_mut(&id) {
|
||||
|
||||
@@ -18,16 +18,11 @@ pub use client::{
|
||||
};
|
||||
#[cfg(feature = "view")]
|
||||
pub use view::{
|
||||
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS,
|
||||
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, TILE_TWIPS, ZOOM_STEPS,
|
||||
Zoomed, init, layout, paper, read_off, scheme,
|
||||
};
|
||||
#[cfg(feature = "view")]
|
||||
mod view;
|
||||
|
||||
/// One tile edge in twips.
|
||||
pub const TILE_TWIPS: i32 = 3840;
|
||||
/// Twips per pixel at zoom 1.0 and DPI 1.0.
|
||||
pub(crate) const TWIPS_PER_PIXEL: f32 = 15.0;
|
||||
pub use ipc::STALE;
|
||||
pub use ipc::file_url_of as file_url;
|
||||
pub use ipc::path_of_file_url as file_path;
|
||||
|
||||
@@ -33,7 +33,10 @@ pub use input::{CONTEXT, Copy, Cut, Paste, init};
|
||||
pub use off::read_off;
|
||||
pub use render::scheme;
|
||||
|
||||
pub(crate) use crate::{TILE_TWIPS, TWIPS_PER_PIXEL};
|
||||
/// One tile edge in twips.
|
||||
pub const TILE_TWIPS: i32 = 3840;
|
||||
/// Twips per pixel at zoom 1.0 and DPI 1.0.
|
||||
pub(crate) const TWIPS_PER_PIXEL: f32 = 15.0;
|
||||
|
||||
/// Document/view conversion under one zoom and DPI (LOK-04).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -144,7 +147,7 @@ pub struct LokView {
|
||||
/// A document's size and page frames, in twips.
|
||||
pub type Layout = ((i64, i64), Vec<Rect>);
|
||||
|
||||
use sheet::{Measured, measure};
|
||||
type Measured = io::Result<Layout>;
|
||||
|
||||
/// A page's colour; the kernel's Ely schemes share it.
|
||||
pub fn paper(cx: &gpui::App) -> gpui::Hsla {
|
||||
@@ -162,6 +165,10 @@ pub fn layout(worker: &mut Worker, doc: &Doc) -> io::Result<Layout> {
|
||||
Ok((doc.size(worker)?, doc.pages(worker)?))
|
||||
}
|
||||
|
||||
/// Reads the layout under the worker lock.
|
||||
fn measure(worker: &Mutex<Worker>, doc: &Doc) -> Measured {
|
||||
layout(&mut worker.lock().expect("the worker lock"), doc)
|
||||
}
|
||||
|
||||
impl EventEmitter<LokError> for LokView {}
|
||||
|
||||
@@ -355,8 +362,8 @@ impl LokView {
|
||||
// What the view shows is the document at this revision.
|
||||
self.seen_revision(batch.revision);
|
||||
match measured {
|
||||
Some(Ok((((width, height), pages), zoom))) => {
|
||||
self.doc_size = self.at_scale((width as i32, height as i32), zoom);
|
||||
Some(Ok(((width, height), pages))) => {
|
||||
self.doc_size = (width as i32, height as i32);
|
||||
if pages.len() != self.pages.len() {
|
||||
log::info!("lok: {} pages", pages.len());
|
||||
}
|
||||
|
||||
@@ -111,20 +111,10 @@ impl LokView {
|
||||
if self.fit {
|
||||
return self.doc_size;
|
||||
}
|
||||
// The kernel sizes a sheet in tile twips already.
|
||||
let reach = self.tile_of(self.reach);
|
||||
let (w, h) = (self.doc_size.0.max(reach.0), self.doc_size.1.max(reach.1));
|
||||
let Some(sheet) = &self.sheet else {
|
||||
return (w, h);
|
||||
};
|
||||
// Sheets scroll ahead of the kernel, to their end.
|
||||
let end = self.tile_of((sheet.columns.start(sheet.columns.count()), sheet.rows.start(sheet.rows.count())));
|
||||
let (fx, fy) = self.frozen_tiles();
|
||||
let ahead = (
|
||||
fx + self.scroll.0 + 2 * self.geom.twips_of(self.viewport.0),
|
||||
fy + self.scroll.1 + 2 * self.geom.twips_of(self.viewport.1),
|
||||
);
|
||||
(w.max(ahead.0.min(end.0)), h.max(ahead.1.min(end.1)))
|
||||
self.tile_of((
|
||||
self.doc_size.0.max(self.reach.0),
|
||||
self.doc_size.1.max(self.reach.1),
|
||||
))
|
||||
}
|
||||
|
||||
/// The print-twips document point under a view position (LOK-04).
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
//! Calc reports print twips; tiles and clicks take tile twips.
|
||||
|
||||
use std::{io, sync::Mutex};
|
||||
|
||||
use gpui::Context;
|
||||
|
||||
use super::{Layout, LokView, TILE_TWIPS};
|
||||
use crate::{Doc, Rect, Worker, sheet::Geometry};
|
||||
|
||||
/// A layout and the client zoom it was measured at.
|
||||
pub(super) type Measured = io::Result<(Layout, (i32, i32))>;
|
||||
|
||||
/// Reads the layout under the worker lock.
|
||||
pub(super) fn measure(worker: &Mutex<Worker>, doc: &Doc) -> Measured {
|
||||
let mut worker = worker.lock().expect("the worker lock");
|
||||
let (size, zoom) = doc.size_at(&mut worker)?;
|
||||
Ok(((size, doc.pages(&mut worker)?), zoom))
|
||||
}
|
||||
use super::{LokView, TILE_TWIPS};
|
||||
use crate::{Rect, sheet::Geometry};
|
||||
|
||||
impl LokView {
|
||||
/// Lays the sheet out the kernel's way.
|
||||
@@ -63,29 +51,11 @@ impl LokView {
|
||||
/// Rescales, keeping the scrolling pane's start in place.
|
||||
pub(super) fn keep_scroll(&mut self, change: impl FnOnce(&mut Self)) {
|
||||
let start = self.scrolled_start();
|
||||
self.carry_size(change);
|
||||
change(self);
|
||||
let ((x, y), (fx, fy)) = (self.tile_of(start), self.frozen_tiles());
|
||||
self.scroll = (x - fx, y - fy);
|
||||
}
|
||||
|
||||
/// Rescales; the measured size moves to the new scale too.
|
||||
pub(super) fn carry_size(&mut self, change: impl FnOnce(&mut Self)) {
|
||||
// Until measured again, scrolls clamp against this.
|
||||
let print = self.print_of(self.doc_size);
|
||||
change(self);
|
||||
self.doc_size = self.tile_of(print);
|
||||
}
|
||||
|
||||
/// A sheet size the kernel measured at `zoom`, at ours.
|
||||
pub(super) fn at_scale(&self, size: (i32, i32), (pixels, twips): (i32, i32)) -> (i32, i32) {
|
||||
let Some(sheet) = self.sheet.as_ref().filter(|_| pixels > 0 && twips > 0) else {
|
||||
return size;
|
||||
};
|
||||
let theirs = f64::from(pixels) / f64::from(twips);
|
||||
let print = (sheet.columns.print(size.0, theirs), sheet.rows.print(size.1, theirs));
|
||||
self.tile_of(print)
|
||||
}
|
||||
|
||||
/// Where the scrolling pane starts, in print twips.
|
||||
pub fn scrolled_start(&self) -> (i32, i32) {
|
||||
let (fx, fy) = self.frozen_tiles();
|
||||
|
||||
@@ -82,22 +82,14 @@ fn far_down(cx: &mut gpui::TestAppContext, install: &std::path::Path) -> (Entity
|
||||
});
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let row = cell(&geometry, 1, 4997);
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) };
|
||||
cx.update(|app| {
|
||||
view.update(app, |view, cx| {
|
||||
view.geom.dpi = 2.0;
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(bounds);
|
||||
view.prepare(Bounds { origin: point(px(0.0), px(0.0)), size: size(px(800.0), px(600.0)) });
|
||||
view.show(row, cx);
|
||||
})
|
||||
});
|
||||
// The first scale's report and size land before the move.
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.show(row, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
(view, geometry)
|
||||
}
|
||||
|
||||
@@ -200,11 +192,6 @@ fn a_sheet_brings_far_cut_and_frozen_cells_into_view() {
|
||||
view.set_geometry(crate::sheet::geometry(&json).expect("parsed"), cx);
|
||||
view.prepare(wide);
|
||||
assert_eq!(view.spare, (0.0, 0.0), "and stays left after it");
|
||||
let size = view.doc_size;
|
||||
view.doc_size = view.tile_of((view.print_of(size).0 + 20 * 1280, size.1));
|
||||
let extent = view.extent();
|
||||
assert!(extent.0 >= view.doc_size.0 && extent.1 >= view.doc_size.1, "the kernel's size is uncut");
|
||||
view.doc_size = size;
|
||||
view.viewport = (800.0, 200.0);
|
||||
let (width, height) = (view.geom.twips_of(view.viewport.0), view.geom.twips_of(view.viewport.1));
|
||||
let shows = |view: &LokView, rect: crate::Rect| {
|
||||
@@ -267,204 +254,3 @@ fn a_newly_selected_object_comes_into_view_a_moved_one_stays() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Data out to the last cell sets the size.
|
||||
const FULL_SHEET: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
|
||||
<office:automatic-styles><style:style style:name="co1" style:family="table-column"><style:table-column-properties style:column-width="0.4cm"/></style:style><style:style style:name="ro1" style:family="table-row"><style:table-row-properties style:row-height="0.5cm" style:use-optimal-row-height="false"/></style:style></office:automatic-styles>
|
||||
<office:body><office:spreadsheet><table:table table:name="S">
|
||||
<table:table-column table:style-name="co1" table:number-columns-repeated="2048"/>
|
||||
<table:table-row table:style-name="ro1" table:number-rows-repeated="5999"><table:table-cell table:number-columns-repeated="2048"/></table:table-row>
|
||||
<table:table-row table:style-name="ro1"><table:table-cell table:number-columns-repeated="2047"/><table:table-cell office:value-type="float" office:value="1"><text:p>1</text:p></table:table-cell></table:table-row>
|
||||
</table:table></office:spreadsheet></office:body>
|
||||
</office:document>"#;
|
||||
|
||||
#[test]
|
||||
fn a_zoom_measures_the_sheet_again() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, _) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, _| view.prepare(bounds)));
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
let before = cx.update(|app| view.read(app).doc_size);
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_zoom(1.25, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
// The kernel's size at the new zoom, read fresh.
|
||||
let kernel = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
let (w, h) = read.doc.size(&mut read.worker.lock().expect("the worker lock")).expect("a size");
|
||||
(w as i32, h as i32)
|
||||
})
|
||||
};
|
||||
// The kernel takes the zoom, then the view its size.
|
||||
let (mut seen, mut now) = (before, before);
|
||||
for _ in 0..2000 {
|
||||
cx.executor().tick();
|
||||
(seen, now) = (cx.update(|app| view.read(app).doc_size), kernel(&mut cx));
|
||||
if now != before && seen == now {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_ne!(now, before, "the zoom changes the kernel's size here");
|
||||
assert_eq!(seen, now, "the view takes the size the kernel has at the new zoom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_at_the_end_of_a_sheet_keeps_the_row_in_the_middle() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
// Scrolled to the end, as by the wheel.
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.scroll_by(0.0, -1.0e7, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
for _ in 0..100 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
let middle = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
let at = read.origin() + point(px(200.0), px(100.0));
|
||||
geometry.rows.at(read.doc_point(at).1)
|
||||
})
|
||||
};
|
||||
let before = middle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_zoom(1.25, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
let right_after = middle(&mut cx);
|
||||
for _ in 0..400 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
let measured = middle(&mut cx);
|
||||
assert!(before.abs_diff(right_after) <= 1, "row {before} became {right_after} at the zoom");
|
||||
assert!(before.abs_diff(measured) <= 1, "row {before} became {measured} once measured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_size_from_the_last_zoom_keeps_the_row() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
let settle = |cx: &mut gpui::TestAppContext| {
|
||||
for _ in 0..200 {
|
||||
cx.executor().tick();
|
||||
}
|
||||
};
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.set_zoom(1.25, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
settle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.scroll_by(0.0, -1.0e7, cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
settle(&mut cx);
|
||||
// Measured at 125%, landing after the zoom to 150%.
|
||||
let late = cx.update(|app| super::super::measure(&view.read(app).worker, &view.read(app).doc));
|
||||
let middle = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
geometry.rows.at(read.doc_point(read.origin() + point(px(200.0), px(100.0))).1)
|
||||
})
|
||||
};
|
||||
cx.update(|app| view.update(app, |view, cx| view.set_zoom(1.5, cx)));
|
||||
let before = middle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| view.apply_batch(crate::Events::default(), Some(late), cx)));
|
||||
let after = middle(&mut cx);
|
||||
assert!(before.abs_diff(after) <= 1, "row {before} became {after} when the old size landed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_size_has_a_scale_and_keeps_the_row() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "full.fods", FULL_SHEET));
|
||||
// Read before any visible area: the load set a scale.
|
||||
let first = cx.update(|app| super::super::measure(&view.read(app).worker, &view.read(app).doc));
|
||||
assert_eq!(first.as_ref().map(|(_, zoom)| *zoom).ok(), Some((256, 3840)), "a known scale");
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(bounds);
|
||||
view.scroll_by(0.0, -1.0e7, cx);
|
||||
}));
|
||||
let middle = |cx: &mut gpui::TestAppContext| {
|
||||
cx.update(|app| {
|
||||
let read = view.read(app);
|
||||
geometry.rows.at(read.doc_point(read.origin() + point(px(200.0), px(100.0))).1)
|
||||
})
|
||||
};
|
||||
cx.update(|app| view.update(app, |view, cx| view.set_zoom(1.25, cx)));
|
||||
let before = middle(&mut cx);
|
||||
cx.update(|app| view.update(app, |view, cx| view.apply_batch(crate::Events::default(), Some(first), cx)));
|
||||
let after = middle(&mut cx);
|
||||
assert!(before.abs_diff(after) <= 1, "row {before} became {after} when the first size landed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_sheet_scrolls_on_without_waiting() {
|
||||
let Some(install) = crate::client::tests::install() else {
|
||||
eprintln!("SKIP: no pinned LOK build");
|
||||
return;
|
||||
};
|
||||
let mut cx = gpui::TestAppContext::single();
|
||||
let (view, worker) = cx.update(|app| live_view_of(app, &install, "short.fods", SHORT_ROWS));
|
||||
let json = cx.update(|app| worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry"));
|
||||
let geometry = crate::sheet::geometry(&json).expect("parsed");
|
||||
let bounds = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(400.0), px(200.0)) };
|
||||
cx.update(|app| view.update(app, |view, cx| {
|
||||
view.set_geometry(geometry.clone(), cx);
|
||||
view.prepare(bounds);
|
||||
}));
|
||||
// Each step a viewport down, before the kernel answers.
|
||||
for step in 1..=20 {
|
||||
let moved = cx.update(|app| view.update(app, |view, cx| {
|
||||
let from = view.scroll.1;
|
||||
view.scroll_by(0.0, -200.0, cx);
|
||||
view.scroll.1 - from
|
||||
}));
|
||||
let full = cx.update(|app| view.read(app).geom.twips_of(200.0));
|
||||
assert_eq!(moved, full, "step {step} went the whole way");
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl LokView {
|
||||
let under = self.print_of((self.scroll.0 + self.geom.twips_of(sx), self.scroll.1 + self.geom.twips_of(sy)));
|
||||
self.fit = false;
|
||||
if (zoom - self.geom.zoom).abs() > 0.0001 {
|
||||
self.carry_size(|view| view.geom.zoom = zoom);
|
||||
self.geom.zoom = zoom;
|
||||
self.retire_tiles();
|
||||
self.failed.clear();
|
||||
}
|
||||
|
||||
@@ -192,12 +192,8 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
|
||||
tag::EXPORT_TEXT => text::export(unpack).map(|()| Vec::new()),
|
||||
tag::DOC_SIZE => unpack
|
||||
.u64()
|
||||
.and_then(|id| Ok((with_doc(id, |doc| doc.size())?, crate::events::zoom(id))))
|
||||
.map(|((width, height), (pixels, twips))| {
|
||||
// The scale the kernel measured at; 0 if never set.
|
||||
let pack = Pack::new().i64(width).i64(height);
|
||||
pack.i64(i64::from(pixels)).i64(i64::from(twips)).bytes()
|
||||
}),
|
||||
.and_then(|id| with_doc(id, |doc| doc.size()))
|
||||
.map(|(width, height)| Pack::new().i64(width).i64(height).bytes()),
|
||||
tag::SAVE_AS => save(unpack).map(|()| Vec::new()),
|
||||
tag::EXPORT_PDF => export_pdf(unpack).map(|()| Vec::new()),
|
||||
tag::CLOSE_DOC => unpack.u64().map(|id| {
|
||||
@@ -259,7 +255,6 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
|
||||
doc.set_client_zoom(pixels, twips);
|
||||
doc.set_visible_area(x, y, w, h)
|
||||
})
|
||||
.inspect(|_| crate::events::rescaled(id, (pixels, twips)))
|
||||
})
|
||||
.map(|()| Vec::new()),
|
||||
tag::SELECTION_TEXT => unpack
|
||||
@@ -336,12 +331,6 @@ fn load(lok: &Lok, url: &str, password: String, filter: &str) -> io::Result<u64>
|
||||
crate::events::seed_a11y(id, &doc.a11y_paragraph()?);
|
||||
// Settle the initial layout now; later paints see it (LOK-03).
|
||||
doc.post_uno(".uno:Repaginate", None)?;
|
||||
// A sheet's size has a known scale from the start.
|
||||
if doc.kind() == 1 {
|
||||
let zoom = (crate::TILE_TWIPS / crate::TWIPS_PER_PIXEL as i32, crate::TILE_TWIPS);
|
||||
doc.set_client_zoom(zoom.0, zoom.1);
|
||||
crate::events::rescaled(id, zoom);
|
||||
}
|
||||
let mut docs = docs().lock().expect("the docs lock");
|
||||
docs.insert(id, doc);
|
||||
log::info!("lok-worker: {} documents open", docs.len());
|
||||
|
||||
@@ -242,7 +242,11 @@ pub fn in_window<H: Host, R>(
|
||||
}
|
||||
|
||||
/// Opens the print panel after GPUI releases the app.
|
||||
pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
|
||||
pub fn print_later<H: Host>(
|
||||
pdf: std::path::PathBuf,
|
||||
cx: &mut Context<H>,
|
||||
failed: impl FnOnce(&mut H, String, &mut Context<H>) + 'static,
|
||||
) {
|
||||
// The panel's modal loop calls back into GPUI.
|
||||
cx.spawn(async move |this, cx| {
|
||||
let printed = ely_platform::print::print_pdf(&pdf);
|
||||
@@ -254,7 +258,7 @@ pub fn print_later<H: Tabs>(pdf: std::path::PathBuf, cx: &mut Context<H>) {
|
||||
Ok(printed) => log::info!("office: print done, printed {printed}"),
|
||||
Err(error) => {
|
||||
if this
|
||||
.update(cx, |host, cx| host.print_failed(&error.to_string(), cx))
|
||||
.update(cx, |host, cx| failed(host, error.to_string(), cx))
|
||||
.is_err()
|
||||
{
|
||||
log::error!("office: printing failed after its window closed: {error}");
|
||||
@@ -289,11 +293,6 @@ pub trait Tabs: Host {
|
||||
self.toast_unlogged(text, severity, cx);
|
||||
}
|
||||
|
||||
fn print_failed(&self, error: &str, cx: &mut App) {
|
||||
let text = text(cx, "office.print.failed", &[("error", error)]);
|
||||
self.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// A success toast with a button that opens `path`.
|
||||
fn toast_output(&self, text: SharedString, path: std::path::PathBuf, cx: &mut App) {
|
||||
log::info!("{}: {text}", Self::KIND.app.slug());
|
||||
|
||||
@@ -13,10 +13,10 @@ typedef struct
|
||||
int page;
|
||||
} inner_link;
|
||||
|
||||
/* Takes page references off; links noted, `*n` counted as kept. */
|
||||
static void unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room, int *n)
|
||||
/* Takes page references off the source's annotations, links noted. */
|
||||
static int unhook(fz_context *ctx, pdf_document *src, inner_link *links, int room)
|
||||
{
|
||||
int i, j, count = pdf_count_pages(ctx, src);
|
||||
int i, j, n = 0, count = pdf_count_pages(ctx, src);
|
||||
for (i = 0; i < count; i++)
|
||||
{
|
||||
pdf_obj *annots = pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots));
|
||||
@@ -26,7 +26,7 @@ static void unhook(fz_context *ctx, pdf_document *src, inner_link *links, int ro
|
||||
pdf_obj *page = ely_target(ctx, src, annot);
|
||||
int to = page ? pdf_lookup_page_number(ctx, src, page) : -1;
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(P));
|
||||
if (to < 0 || *n == room)
|
||||
if (to < 0 || n == room)
|
||||
continue;
|
||||
/* The view after the page: /XYZ, /Fit and so on. */
|
||||
pdf_obj *dest = pdf_dict_get(ctx, annot, PDF_NAME(Dest));
|
||||
@@ -36,17 +36,16 @@ static void unhook(fz_context *ctx, pdf_document *src, inner_link *links, int ro
|
||||
dest = pdf_lookup_dest(ctx, src, dest);
|
||||
if (pdf_is_dict(ctx, dest))
|
||||
dest = pdf_dict_get(ctx, dest, PDF_NAME(D));
|
||||
/* Counted as soon as kept: a throw still frees it. */
|
||||
links[*n].annot = pdf_keep_obj(ctx, annot);
|
||||
links[*n].rest = NULL;
|
||||
links[*n].page = to;
|
||||
(*n)++;
|
||||
links[*n - 1].rest = pdf_copy_array(ctx, dest);
|
||||
pdf_array_delete(ctx, links[*n - 1].rest, 0);
|
||||
links[n].annot = pdf_keep_obj(ctx, annot);
|
||||
links[n].rest = pdf_copy_array(ctx, dest);
|
||||
pdf_array_delete(ctx, links[n].rest, 0);
|
||||
links[n].page = to;
|
||||
n++;
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(Dest));
|
||||
pdf_dict_del(ctx, annot, PDF_NAME(A));
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The top of the field tree `widget` belongs to. */
|
||||
@@ -339,7 +338,7 @@ int ely_insert_file(fz_context *ctx, fz_document *doc, int at, const char *path,
|
||||
for (i = 0; i < *count; i++)
|
||||
room += pdf_array_len(ctx, pdf_dict_get(ctx, pdf_lookup_page_obj(ctx, src, i), PDF_NAME(Annots)));
|
||||
links = fz_calloc(ctx, room + 1, sizeof *links);
|
||||
unhook(ctx, src, links, room, &n);
|
||||
n = unhook(ctx, src, links, room);
|
||||
map = pdf_new_graft_map(ctx, pdf);
|
||||
pdf_begin_operation(ctx, pdf, "Insert pages");
|
||||
begun = 1;
|
||||
|
||||
@@ -29,13 +29,13 @@ static int unicode_of(pdf_font_desc *desc, int cid)
|
||||
return n == 1 && ucs[0] > 0 && ucs[0] != FZ_REPLACEMENT_CHARACTER ? ucs[0] : -1;
|
||||
}
|
||||
|
||||
/* `desc` Unicode-to-code pairs; -1 when codes are not CIDs. */
|
||||
static int own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
|
||||
/* Unicode and code pairs of `desc`; its codes are CIDs. */
|
||||
static void own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
|
||||
{
|
||||
pdf_cmap *enc = desc->encoding;
|
||||
int cid, end;
|
||||
if (!enc || strcmp(enc->cmap_name, "Identity-H") || enc->codespace_len != 1 || enc->codespace[0].n > 2)
|
||||
return -1;
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font uses codes Ely cannot write");
|
||||
p->bytes = enc->codespace[0].n;
|
||||
end = 1 << (8 * p->bytes);
|
||||
p->map = fz_malloc_array(ctx, 2 * end, int);
|
||||
@@ -45,11 +45,10 @@ static int own_codes(fz_context *ctx, pdf_font_desc *desc, ely_pen *p)
|
||||
if (u > 0 && pdf_font_cid_to_gid(ctx, desc, cid) > 0)
|
||||
p->map[2 * p->mapped] = u, p->map[2 * p->mapped + 1] = cid, p->mapped++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The page font drawn as `own`; -1: cannot write. */
|
||||
static int own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
|
||||
/* The page font drawn as `own`, with its codes. */
|
||||
static void own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
|
||||
{
|
||||
pdf_obj *res = pdf_page_resources(ctx, page);
|
||||
pdf_resource_stack stack = { NULL, res };
|
||||
@@ -64,15 +63,16 @@ static int own_font(fz_context *ctx, pdf_page *page, fz_font *own, ely_pen *p)
|
||||
continue;
|
||||
}
|
||||
p->desc = desc;
|
||||
/* A direct font cannot be named again after the edit. */
|
||||
p->num = pdf_to_num(ctx, pdf_dict_get_val(ctx, fonts, i));
|
||||
return p->num ? own_codes(ctx, desc, p) : -1;
|
||||
if (!p->num)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the document's font is not an object of its own");
|
||||
own_codes(ctx, desc, p);
|
||||
}
|
||||
/* Drawn from elsewhere, as in a form: not writable here. */
|
||||
return -1;
|
||||
if (!p->desc)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block's font is not a font of its page");
|
||||
}
|
||||
|
||||
int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p)
|
||||
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p)
|
||||
{
|
||||
static const struct { const char *choice, *name; } latin[] = {
|
||||
{ "helvetica", "Helvetica" }, { "times", "Times-Roman" }, { "courier", "Courier" } };
|
||||
@@ -85,21 +85,22 @@ int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no script font was given");
|
||||
p->kind = PEN_CID;
|
||||
p->font = fz_new_font_from_memory(ctx, "GreatVibes", script_data, (int)script_len, 0, 0);
|
||||
return 0;
|
||||
return;
|
||||
}
|
||||
if (!strcmp(choice, "original"))
|
||||
{
|
||||
if (!own)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "the block has no font of its own");
|
||||
p->kind = PEN_OWN;
|
||||
return own_font(ctx, page, own, p);
|
||||
own_font(ctx, page, own, p);
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < nelem(latin); i++)
|
||||
if (!strcmp(choice, latin[i].choice))
|
||||
{
|
||||
p->kind = PEN_SIMPLE;
|
||||
p->font = fz_new_base14_font(ctx, latin[i].name);
|
||||
return 0;
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < nelem(cjk); i++)
|
||||
if (!strcmp(choice, cjk[i].choice))
|
||||
@@ -107,7 +108,7 @@ int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *
|
||||
p->kind = PEN_CJK;
|
||||
p->ordering = cjk[i].ordering;
|
||||
p->font = fz_new_cjk_font(ctx, cjk[i].ordering);
|
||||
return 0;
|
||||
return;
|
||||
}
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "no font %s", choice);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ typedef struct
|
||||
int *map, mapped;
|
||||
} ely_pen;
|
||||
|
||||
int ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p);
|
||||
void ely_choose_pen(fz_context *ctx, const char *choice, fz_font *own, pdf_page *page, ely_pen *p);
|
||||
const char *ely_pen_resource(fz_context *ctx, pdf_document *pdf, pdf_page *page, ely_pen *p, char *name, size_t room);
|
||||
int ely_pen_has(fz_context *ctx, ely_pen *p, int u);
|
||||
float ely_pen_advance(fz_context *ctx, ely_pen *p, int u, float size);
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "ely_shim.h"
|
||||
#include "mupdf/ucdn.h"
|
||||
|
||||
/* The page's text, read the same way every time. */
|
||||
static fz_stext_page *read_text(fz_context *ctx, fz_document *doc, int number)
|
||||
@@ -59,55 +58,14 @@ static void block_targets(fz_context *ctx, fz_stext_block *block, fz_matrix to_u
|
||||
}
|
||||
}
|
||||
|
||||
/* `c` as stext spells it (stext-device.c); ligatures split. */
|
||||
static int spell(int c, int *out)
|
||||
{
|
||||
static const char *named[] = { "ff", "fi", "fl", "ffi", "ffl", "st", "st" };
|
||||
int i, n = 0;
|
||||
uint32_t lig[18];
|
||||
if (c >= 0xFB00 && c <= 0xFB06)
|
||||
{
|
||||
for (i = 0; named[c - 0xFB00][i]; i++)
|
||||
out[n++] = named[c - 0xFB00][i];
|
||||
return n;
|
||||
}
|
||||
if ((c >= 0xfb00 && c <= 0xfdff) || (c >= 0xfe70 && c <= 0xfefc))
|
||||
n = ucdn_compat_decompose((uint32_t)c, lig);
|
||||
for (i = 0; i < n; i++)
|
||||
out[i] = (int)lig[i];
|
||||
if (n > 0)
|
||||
return n;
|
||||
out[0] = c;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Targets from `i` one glyph spells: a ligature spells several. */
|
||||
static int spelled(targets *t, int i, const int *ucs, int ucslen)
|
||||
{
|
||||
int j = 1;
|
||||
if (ucslen < 2 || ucs[0] != t->c[i])
|
||||
return 1;
|
||||
while (j < ucslen && i + j < t->n && t->c[i + j] == ucs[j] && (!t->owner || t->owner[i + j] == t->owner[i]))
|
||||
j++;
|
||||
return j;
|
||||
}
|
||||
|
||||
/* Removes a glyph drawn at one of the targets. */
|
||||
static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_matrix trm, fz_matrix ctm,
|
||||
fz_rect bbox, int tr, float ca, float CA)
|
||||
{
|
||||
targets *t = opaque;
|
||||
fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm);
|
||||
/* MuPDF gives at most PDF_MRANGE_CAP; each spells to 18. */
|
||||
int i, k, found = 0, spelt[18 * PDF_MRANGE_CAP], n = 0;
|
||||
int i, k, found = 0;
|
||||
t->glyph++;
|
||||
if (ucslen > PDF_MRANGE_CAP)
|
||||
fz_throw(ctx, FZ_ERROR_ARGUMENT, "a glyph with %d characters", ucslen);
|
||||
/* Match what stext reads: ligature code points spelled out. */
|
||||
for (k = 0; k < ucslen; k++)
|
||||
n += spell(ucs[k], spelt + n);
|
||||
ucs = spelt;
|
||||
ucslen = n;
|
||||
for (i = 0; i < t->n; i++)
|
||||
{
|
||||
int match = 0;
|
||||
@@ -119,14 +77,14 @@ static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_ma
|
||||
continue;
|
||||
if (!t->owner)
|
||||
{
|
||||
t->hit += spelled(t, i, ucs, ucslen);
|
||||
t->hit++;
|
||||
return 1;
|
||||
}
|
||||
/* Every block this glyph matches counts it once. */
|
||||
if (t->seen[t->owner[i]] != t->glyph)
|
||||
{
|
||||
t->seen[t->owner[i]] = t->glyph;
|
||||
t->hits[t->owner[i]] += spelled(t, i, ucs, ucslen);
|
||||
t->hits[t->owner[i]]++;
|
||||
}
|
||||
found = 1;
|
||||
}
|
||||
|
||||
@@ -156,12 +156,11 @@ static void drop_laid(fz_context *ctx, laid *l)
|
||||
fz_free(ctx, l->to);
|
||||
}
|
||||
|
||||
/* Lays `a` out; a missing glyph, or -1: unwritable font. */
|
||||
/* Lays `a` out from `first`, or names a missing glyph. */
|
||||
static int lay_text(fz_context *ctx, text_args *a, fz_font *own, pdf_page *page, fz_point first, float gap, laid *l)
|
||||
{
|
||||
int i;
|
||||
if (ely_choose_pen(ctx, a->font, own, page, &l->p) < 0)
|
||||
return -1;
|
||||
ely_choose_pen(ctx, a->font, own, page, &l->p);
|
||||
l->n = runes(ctx, a->text, &l->u);
|
||||
for (i = 0; i < l->n; i++)
|
||||
if (l->u[i] != '\n' && !ely_pen_has(ctx, &l->p, l->u[i]))
|
||||
@@ -260,7 +259,7 @@ static void preview(fz_context *ctx, pdf_document *pdf, int number, int block, c
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* Writes or previews text: 0, 2 missing, 3 unwritable, -1. */
|
||||
/* Edits, adds or previews text: 0, 2 missing, -1 failed. */
|
||||
int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const char *expected, const char *text,
|
||||
const char *font, float size, int rgb, const float box[3], const char *password, float scale, int *missing,
|
||||
int *grew, unsigned char **pixels, int *w, int *h)
|
||||
@@ -326,5 +325,5 @@ int ely_put_text(fz_context *ctx, fz_document *doc, int number, int block, const
|
||||
*pixels = NULL;
|
||||
return ely_edit_failed(ctx, pdf, begun);
|
||||
}
|
||||
return *missing < 0 ? 3 : *missing ? 2 : 0;
|
||||
return *missing ? 2 : 0;
|
||||
}
|
||||
@@ -59,8 +59,6 @@ pub enum Wrote {
|
||||
Done,
|
||||
/// A character the font cannot draw; nothing changed.
|
||||
Missing(char),
|
||||
/// The document's own font cannot write; nothing changed.
|
||||
Unwritable,
|
||||
/// The would-be result: BGRA, size, and whether it grew.
|
||||
Preview(Vec<u8>, (u32, u32), bool),
|
||||
}
|
||||
@@ -108,9 +106,6 @@ impl Mupdf {
|
||||
&mut height,
|
||||
)
|
||||
})?;
|
||||
if status == 3 {
|
||||
return Ok(Wrote::Unwritable);
|
||||
}
|
||||
if status == 2 {
|
||||
let missing = u32::try_from(missing).ok().and_then(char::from_u32);
|
||||
return Ok(Wrote::Missing(missing.ok_or_else(|| io::Error::other("a missing glyph out of range"))?));
|
||||
|
||||
@@ -68,11 +68,7 @@ impl Doc {
|
||||
return Err(io::Error::other(format!("nothing to {}", if redo { "redo" } else { "undo" })));
|
||||
}
|
||||
loop {
|
||||
if let Err(error) = mupdf.undo(&self.open, redo) {
|
||||
// MuPDF may have moved part way; follow it.
|
||||
self.at = mupdf.state(&self.open)?.0;
|
||||
return Err(error);
|
||||
}
|
||||
mupdf.undo(&self.open, redo)?;
|
||||
let passed = match redo {
|
||||
true => {
|
||||
self.at += 1;
|
||||
@@ -151,7 +147,6 @@ impl Worker {
|
||||
Ok(match wrote? {
|
||||
content::Wrote::Done => Pack::new().u64(0).str(&doc.mark()).bytes(),
|
||||
content::Wrote::Missing(c) => Pack::new().u64(2).u64(u64::from(c)).bytes(),
|
||||
content::Wrote::Unwritable => Pack::new().u64(3).bytes(),
|
||||
content::Wrote::Preview(pixels, (w, h), grew) => {
|
||||
Pack::new().u64(1).u64(u64::from(w)).u64(u64::from(h)).u64(u64::from(grew)).blob(&pixels).bytes()
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ pub enum Wrote {
|
||||
Done(String),
|
||||
/// The font lacks this character; nothing changed.
|
||||
Missing(char),
|
||||
/// The document's own font cannot write; nothing changed.
|
||||
Unwritable,
|
||||
/// BGRA of the area, its size, whether text grew.
|
||||
Preview(Vec<u8>, (u32, u32), bool),
|
||||
}
|
||||
@@ -115,13 +113,11 @@ impl Worker {
|
||||
let code = u32::try_from(reply.u64()?).map_err(io::Error::other)?;
|
||||
Ok(Wrote::Missing(char::from_u32(code).ok_or_else(|| io::Error::other("a bad missing glyph"))?))
|
||||
}
|
||||
1 => {
|
||||
_ => {
|
||||
let size = (reply.u64()? as u32, reply.u64()? as u32);
|
||||
let grew = reply.u64()? != 0;
|
||||
Ok(Wrote::Preview(reply.blob()?, size, grew))
|
||||
}
|
||||
3 => Ok(Wrote::Unwritable),
|
||||
other => Err(io::Error::other(format!("a text reply of kind {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Fonts the editor meets in the wild (PDF-03).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{Doc, Worker, Writing, Wrote};
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
fn page_text(worker: &mut Worker, doc: Doc) -> String {
|
||||
worker.text(doc, 0).expect("reads").iter().map(|c| c.c).collect()
|
||||
}
|
||||
|
||||
/// A rewrite of block `block` as `text` in `font`.
|
||||
fn rewrite(worker: &mut Worker, doc: Doc, block: usize, text: &str, font: &str) -> Writing {
|
||||
let read = worker.text_blocks(doc, 0).expect("blocks")[block].clone();
|
||||
Writing {
|
||||
block: Some(block),
|
||||
expected: read.text,
|
||||
text: text.into(),
|
||||
font: font.into(),
|
||||
size: read.size,
|
||||
rgb: read.rgb,
|
||||
bx: [read.rect[0], read.rect[1], read.rect[2] - read.rect[0]],
|
||||
}
|
||||
}
|
||||
|
||||
/// A page whose text uses a CMap-encoded CID font.
|
||||
fn cmap_font_pdf() -> Vec<u8> {
|
||||
let content = "BT /F1 24 Tf 72 700 Td <00480069> Tj ET";
|
||||
one_font_page(content, &[
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>".to_string(),
|
||||
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor 7 0 R >>".to_string(),
|
||||
"<< /Type /FontDescriptor /FontName /STSong-Light /Flags 4 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 700 /StemV 80 >>".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
/// A page drawing `content`; font objects start at 5.
|
||||
fn one_font_page(content: &str, font: &[String]) -> Vec<u8> {
|
||||
page_with("<< /F1 5 0 R >>", content, font)
|
||||
}
|
||||
|
||||
/// A page with font resources `fonts`; objects from 5.
|
||||
fn page_with(fonts: &str, content: &str, font: &[String]) -> Vec<u8> {
|
||||
let mut objects = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font {fonts} >> /Contents 4 0 R >>"),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
objects.extend_from_slice(font);
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
|
||||
for offset in offsets {
|
||||
pdf.extend(format!("{offset:010} 00000 n \n").bytes());
|
||||
}
|
||||
pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_whose_codes_ely_cannot_write_says_so() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("cmap.pdf");
|
||||
std::fs::write(&path, cmap_font_pdf()).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
assert_eq!(page_text(&mut worker, doc), "Hi");
|
||||
let writing = rewrite(&mut worker, doc, 0, "Yo", "original");
|
||||
assert_eq!(worker.put_text(doc, 0, &writing, 2.0).expect("answers"), Wrote::Unwritable);
|
||||
assert_eq!(page_text(&mut worker, doc), "Hi", "nothing changed");
|
||||
let helvetica = rewrite(&mut worker, doc, 0, "Yo", "helvetica");
|
||||
assert!(matches!(worker.put_text(doc, 0, &helvetica, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ligature_block_edits_like_any_other() {
|
||||
// Code 1 is one ligature glyph, in either Unicode form.
|
||||
let mut forms = vec![("00660069".to_string(), "find".to_string()), ("FB01".into(), "find".into()), ("FB03".into(), "ffind".into())];
|
||||
// Long mappings, to MuPDF's cap of 256: none cut short.
|
||||
forms.push(("0066".repeat(48), format!("{}nd", "f".repeat(48))));
|
||||
forms.push(("FB03".repeat(256), format!("{}nd", "ffi".repeat(256))));
|
||||
for (unicode, text) in forms {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("lig.pdf");
|
||||
let cmap = format!("/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Lig def 1 begincodespacerange <00> <FF> endcodespacerange 3 beginbfchar <01> <{unicode}> <6E> <006E> <64> <0064> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end");
|
||||
let content = "BT /F1 24 Tf 72 700 Td <016E64> Tj ET";
|
||||
let pdf = one_font_page(content, &[
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 /fi] >> /ToUnicode 6 0 R >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
|
||||
]);
|
||||
std::fs::write(&path, pdf).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
let block = worker.text_blocks(doc, 0).expect("blocks").into_iter().next().expect("a block");
|
||||
assert_eq!(block.text, text);
|
||||
assert!(block.editable, "{unicode}: {block:?}");
|
||||
let writing = rewrite(&mut worker, doc, 0, "Yo", "helvetica");
|
||||
let Wrote::Done(mark) = worker.put_text(doc, 0, &writing, 0.0).expect("writes") else {
|
||||
panic!("{unicode}: the ligature block was not rewritten");
|
||||
};
|
||||
assert_eq!(page_text(&mut worker, doc.at(&mark).expect("a mark")), "Yo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_written_into_the_page_says_it_cannot_write() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let path = dir.path().join("direct.pdf");
|
||||
// The font dictionary sits in the page itself.
|
||||
let fonts = "<< /F1 << /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /Identity-H /DescendantFonts [5 0 R] >> >>";
|
||||
let pdf = page_with(fonts, "BT /F1 24 Tf 72 700 Td <00480069> Tj ET", &[
|
||||
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor 6 0 R >>".to_string(),
|
||||
"<< /Type /FontDescriptor /FontName /STSong-Light /Flags 4 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 700 /StemV 80 >>".to_string(),
|
||||
]);
|
||||
std::fs::write(&path, pdf).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
let writing = rewrite(&mut worker, doc, 0, "Yo", "original");
|
||||
assert_eq!(worker.put_text(doc, 0, &writing, 2.0).expect("answers"), Wrote::Unwritable);
|
||||
let helvetica = rewrite(&mut worker, doc, 0, "Yo", "helvetica");
|
||||
assert!(matches!(worker.put_text(doc, 0, &helvetica, 0.0).expect("writes"), Wrote::Done(_)));
|
||||
}
|
||||
@@ -16,7 +16,6 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"A conversion writes a new file; pick another name",
|
||||
),
|
||||
("office.error.changed", "The document changed meanwhile; try again"),
|
||||
("office.print.failed", "Printing failed: {error}"),
|
||||
("office.recovery.failed", "A recovery copy was not written: {error}"),
|
||||
("office.recovery.off", "Recovery copies are off for this run: {error}"),
|
||||
("office.recovery.lost", "{name} could not be recovered: {error}"),
|
||||
@@ -102,7 +101,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("office.error.conflict", "{name} 已在磁盘上改动,保存已停止"),
|
||||
("office.error.convert", "格式转换要写成新文件,请换个名字"),
|
||||
("office.error.changed", "文档在此间已改动,请重试"),
|
||||
("office.print.failed", "打印失败:{error}"),
|
||||
("office.recovery.failed", "恢复副本未能写入:{error}"),
|
||||
("office.recovery.off", "本次运行不写恢复副本:{error}"),
|
||||
("office.recovery.lost", "{name} 未能恢复:{error}"),
|
||||
|
||||
@@ -67,14 +67,12 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"pdf.text.missing",
|
||||
"This font cannot draw “{char}”. Choose another font.",
|
||||
),
|
||||
("pdf.text.unwritable", "The document's own font cannot write new text. Choose another font."),
|
||||
("pdf.object.replace", "Replace Image…"),
|
||||
("pdf.object.delete", "Delete"),
|
||||
(
|
||||
"pdf.object.changed",
|
||||
"The document changed meanwhile; nothing was done.",
|
||||
),
|
||||
("pdf.pages.changed", "Pages are moving; nothing was done. Try again."),
|
||||
("pdf.tool.edit", "Edit Text and Images"),
|
||||
("pdf.tool.text", "Add Text"),
|
||||
("pdf.tool.image", "Add Image…"),
|
||||
@@ -289,11 +287,9 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("pdf.text.apply", "应用"),
|
||||
("pdf.text.erase", "删除文字"),
|
||||
("pdf.text.missing", "这种字体画不出“{char}”。请换一种字体。"),
|
||||
("pdf.text.unwritable", "文档自带的字体写不了新字。请换一种字体。"),
|
||||
("pdf.object.replace", "替换图片…"),
|
||||
("pdf.object.delete", "删除"),
|
||||
("pdf.object.changed", "文档已改动,未执行。"),
|
||||
("pdf.pages.changed", "页面正在变动,未执行,请再试。"),
|
||||
("pdf.tool.edit", "编辑文字和图片"),
|
||||
("pdf.tool.text", "添加文字"),
|
||||
("pdf.tool.image", "添加图片…"),
|
||||
|
||||
@@ -10,8 +10,6 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("show.play.screen", "Screen {number} ({width} × {height})"),
|
||||
("show.play.start", "Start"),
|
||||
("show.play.none", "Every slide is hidden"),
|
||||
("show.play.busy", "The show is already starting or playing"),
|
||||
("show.play.gone", "The show did not start: its deck or screen changed"),
|
||||
("show.play.back", "Previous"),
|
||||
("show.play.next", "Next"),
|
||||
("show.play.place", "Slide {number} of {count}"),
|
||||
@@ -72,8 +70,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("show.play.screen", "屏幕 {number}({width} × {height})"),
|
||||
("show.play.start", "开始放映"),
|
||||
("show.play.none", "所有幻灯片都已隐藏"),
|
||||
("show.play.busy", "放映已在开始或进行中"),
|
||||
("show.play.gone", "放映未开始:文稿或屏幕已变"),
|
||||
("show.play.back", "上一步"),
|
||||
("show.play.next", "下一步"),
|
||||
("show.play.place", "第 {number} 张,共 {count} 张"),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
pub(super) const EN: &[(&str, &str)] = &[
|
||||
("menu.slide", "Slide"),
|
||||
("show.dialog.busy", "Finish or close the open dialog first"),
|
||||
("show.slide.new", "New Slide"),
|
||||
("show.slide.duplicate", "Duplicate Slide"),
|
||||
("show.slide.delete", "Delete Slide"),
|
||||
@@ -174,7 +173,6 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
"These notes are formatted; Ely shows them read-only to keep it.",
|
||||
),
|
||||
("show.remark.slide", "Slide {n}"),
|
||||
("show.remark.bad", "Comments with unreadable times: {n}, first {at}"),
|
||||
("show.remark.text", "Comment"),
|
||||
("show.remark.new_title", "New Comment"),
|
||||
("show.remark.edit_title", "Edit Comment"),
|
||||
@@ -191,7 +189,6 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
|
||||
pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("menu.slide", "幻灯片"),
|
||||
("show.dialog.busy", "请先完成或关闭已打开的对话框"),
|
||||
("show.slide.new", "新建幻灯片"),
|
||||
("show.slide.duplicate", "复制幻灯片"),
|
||||
("show.slide.delete", "删除幻灯片"),
|
||||
@@ -354,7 +351,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("show.notes.none", "这张幻灯片没有备注框。"),
|
||||
("show.notes.rich", "备注带格式,为保留格式在此只读。"),
|
||||
("show.remark.slide", "第 {n} 张幻灯片"),
|
||||
("show.remark.bad", "时间读不出的批注:{n} 条,第一条为 {at}"),
|
||||
("show.remark.text", "批注"),
|
||||
("show.remark.new_title", "新建批注"),
|
||||
("show.remark.edit_title", "编辑批注"),
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
## 自有代码
|
||||
|
||||
Ely Office 的自有代码以 AGPL-3.0-or-later 发布,全文在仓库根的 `LICENSE`,并随每个应用包放在 `Resources/Licenses/LICENSE`。组件库 Ely-GPUI-Components 保留其 MIT/Apache-2.0。
|
||||
Ely Office 的自有代码以 MIT 发布,全文在仓库根的 `LICENSE`,并随每个应用包放在 `Resources/Licenses/LICENSE`。随包的引擎不随之改许可:ElyPDF 链接的 MuPDF 是 AGPL-3.0,ElyPDF 的发行包整体须按 AGPL 履行(对应源码见上游源码包);ElyDoc、ElyTable、ElyShow 的 LibreOffice 仍为 MPL-2.0 等;ElyTree 带的 Git 仍为 GPL-2.0。组件库 Ely-GPUI-Components 保留其 MIT/Apache-2.0。
|
||||
|
||||
## 每个发行版附带
|
||||
|
||||
@@ -12,7 +12,7 @@ Ely Office 的自有代码以 AGPL-3.0-or-later 发布,全文在仓库根的 `
|
||||
| --- | --- | --- |
|
||||
| `ElyOffice-<版本>-source.tar.gz` | 自有源码、`Cargo.lock`、`rust-toolchain.toml`、ElyMark 网页的 `package-lock.json`、上游补丁(`patches/libreoffice`、`patches/mupdf`)、构建与取上游的脚本(各上游钉在提交,脚本里写明地址与散列)、测试、文档 | `packaging/macos/release.sh` |
|
||||
| `ElyOffice-<版本>-upstream-source.tar.gz` | 实际编译的 MuPDF 源码(钉住的提交加我们的补丁,AGPL)与随包 Git 的源码(v2.56.0,GPLv2) | 同上;`tests/scripts/release-macos.sh` 核对其中是打过补丁的 MuPDF |
|
||||
| 各应用包 `Resources/Licenses/` | Ely 的 AGPL 全文;`rust-crates.txt`(链进该应用各二进制的全部 crate 与其许可文件,无许可文件的取标准文本并写明作者);所带引擎的许可与通知(见下) | `packaging/licenses.sh`,缺任何一份即打包失败 |
|
||||
| 各应用包 `Resources/Licenses/` | Ely 的 MIT 全文;`rust-crates.txt`(链进该应用各二进制的全部 crate 与其许可文件,无许可文件的取标准文本并写明作者);所带引擎的许可与通知(见下) | `packaging/licenses.sh`,缺任何一份即打包失败 |
|
||||
|
||||
## 第三方,按应用
|
||||
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ const en = {
|
||||
ElyTree: "Stage lines, commit, read the graph. Git without a terminal.",
|
||||
ElyMark: "Markdown as you read it. Math and diagrams in place.",
|
||||
offline: "Works with the network off.",
|
||||
open: "Open source, under the AGPL.",
|
||||
open: "Open source, under the MIT licence.",
|
||||
local: "Your files stay in your folders.",
|
||||
download: "Download",
|
||||
platforms: "Out now for macOS. Windows and Linux are coming soon.",
|
||||
@@ -46,7 +46,7 @@ const zh: typeof en = {
|
||||
ElyTree: "按行暂存、提交、看提交图。不用终端的 Git。",
|
||||
ElyMark: "写的就是看到的 Markdown。公式与图表就地显示。",
|
||||
offline: "断网照常工作。",
|
||||
open: "开源,AGPL 许可。",
|
||||
open: "开源,MIT 许可。",
|
||||
local: "文件留在你自己的文件夹里。",
|
||||
download: "下载",
|
||||
platforms: "macOS 版已发布。Windows 与 Linux 版即将推出。",
|
||||
|
||||
+29
-30
@@ -6,45 +6,45 @@
|
||||
|
||||
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。K-01 不进 0.1.2:只影响 Linux 与 Windows 的 worker,本机不能运行验证,0.1.2 只发 macOS;留在下面的清单里,未勾。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
|
||||
|
||||
- [x] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 — 启动检查默认关、菜单“检查更新…”、退出后再装、失败记录先于重开、经 Gitea API 找清单;R1–R4 授权、提交点、保存询问、版本绑定、横幅位置、取消退出、记录先后;R5 两处(启动检查失败记日志、以替身 open 断言先后)已修并以变异验证,五轮上限后由 UPD-2 复核通过
|
||||
- [x] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 — 启动检查默认关、菜单“检查更新…”、退出后再装、失败记录先于重开、经 Gitea API 找清单;R1–R4 授权、提交点、保存询问、版本绑定、横幅位置、取消退出、记录先后;R5 两处(启动检查失败记日志、以替身 open 断言先后)已修并以变异验证,五轮上限,未再审
|
||||
|
||||
- [x] T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位 — R2.16 已修(图块 twips 与打印 twips 互换)
|
||||
- [x] T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动 — R1 拖选中仍滚、超宽格跳回起点;R2 PASS
|
||||
- [x] T-03 视口比工作表宽时网格先居中再左移一次 — 载入回复带文档种类,表格不居中;R1 测试漏几何到达前的首帧;R2 PASS
|
||||
- [x] T-04 从不调用 `setClientZoom`,内核命中与几何按默认缩放算 — R2.16 已修(worker 每次报可见区前 setClientZoom)
|
||||
- [x] T-05 文档尺寸按内核像素取整,表尾被裁 — 内核尺寸按其测量时的比例换到当前比例(加载即设比例、缩放即改比例并重测、迟到的测量也换算);R1–R4 缩放后未重测、表尾锚点被截、连续缩放迟到、首次测量无比例与文件超行;R5 PASS
|
||||
- [x] T-06 滚轮向下被内核平铺区卡住 — 表格的可滚范围先行两屏、止于表尾;R1 PASS
|
||||
- [x] T-07 表头点选、行高列宽拖动是“按编号的命令”,而每次点击与拖选都推进修订,紧接着的表头操作被当作过期拒绝 — 行列号不再算作按编号的命令;R1 PASS
|
||||
- [x] T-08 自动筛选的列菜单对光标所在的表而不是按钮所在的表筛选、排序 — 先把光标移到按钮所在格再排序或筛选;R1 PASS
|
||||
- [x] T-09 导入文本文件时整文件的 UTF-8 校验在界面线程跑 — 编码与分隔符的猜测随读文件在后台;R1 PASS
|
||||
- [x] T-10 CSV 导入对话框开着时打开任何别的对话框,待导入的文件静默消失 — 被挤开的导入回到队头,已读完的保留列类型;R2 PASS
|
||||
- [x] T-11 对话框比其标签活得久:导出、定义名称、重命名不可见却“开着”,挡住导入;名称表的“转到”激活已关闭的标签 — 关标签时一并关掉其对话框与选项列表;R1 PASS
|
||||
- [x] T-12 打印区域是整列或整行时页面设置无法应用 — 打印区域可为整列、整行(每端至多一个 $);R2 PASS
|
||||
- [x] T-13 打印失败报成“导出失败”(非 macOS 必现) — print_later 自己以“打印失败”报错,五个应用同;R1 PASS
|
||||
- [x] T-14 导入扫描把整个文件的每个字段都建成字符串 — split 逐行给出,扫描只留预览行与列标记;R1 PASS
|
||||
- [x] T-15 非 UTF-8 文本的默认编码跟系统区域而不是界面语言 — 默认编码按应用所显示的语言;R1 PASS
|
||||
- [x] S-01 切换幻灯片后主画布不重画 — 已由 be31260(R2.4)修好并有单测,此处补勾
|
||||
- [ ] T-05 文档尺寸按内核像素取整,表尾被裁
|
||||
- [ ] T-06 滚轮向下被内核平铺区卡住
|
||||
- [ ] T-07 表头点选、行高列宽拖动是“按编号的命令”,而每次点击与拖选都推进修订,紧接着的表头操作被当作过期拒绝
|
||||
- [ ] T-08 自动筛选的列菜单对光标所在的表而不是按钮所在的表筛选、排序
|
||||
- [ ] T-09 导入文本文件时整文件的 UTF-8 校验在界面线程跑
|
||||
- [ ] T-10 CSV 导入对话框开着时打开任何别的对话框,待导入的文件静默消失
|
||||
- [ ] T-11 对话框比其标签活得久:导出、定义名称、重命名不可见却“开着”,挡住导入;名称表的“转到”激活已关闭的标签
|
||||
- [ ] T-12 打印区域是整列或整行时页面设置无法应用
|
||||
- [ ] T-13 打印失败报成“导出失败”(非 macOS 必现)
|
||||
- [ ] T-14 导入扫描把整个文件的每个字段都建成字符串
|
||||
- [ ] T-15 非 UTF-8 文本的默认编码跟系统区域而不是界面语言
|
||||
- [ ] S-01 切换幻灯片后主画布不重画
|
||||
- [x] S-02 `deck.rs` 超过 500 行 — 随 S-03 把 Extra 移到 extra.rs,deck.rs 463 行
|
||||
- [x] S-03 ElyShow 的缩放一两帧后弹回“适合幻灯片”,缩放功能不可用 — 只在标签首次观察时适合一次;R1 PASS
|
||||
- [x] S-04 选屏对话框被别的对话框顶掉后,`show_starting` 卡住、放映副本泄漏 — 有对话框开着时不再顶掉它,提示先完成;选屏对话框被拒即丢副本;R2 PASS
|
||||
- [x] S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示 — 读不出时间的批注只占一行汇总,其余照常显示(jiff 本可读只有日期的时间);R2 PASS
|
||||
- [x] S-06 导出或打印的“从–到”填 0 时导出全部幻灯片 — 键入的页码夹到 1..=末页;R1 PASS
|
||||
- [x] S-07 母版视图下演示检查的行按幻灯片序号跳转 — 母版视图下点行提示先关母版,不跳;R1 PASS
|
||||
- [x] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢 — 发出的备注待读回确认,键入中读回只移基线不动草稿;R2 PASS
|
||||
- [x] S-09 改尺寸失败后 `size_asked` 一直留着 — 所要尺寸带输入序号,发送前先记、未入队即清,命令落地后的读回即清;R2 PASS
|
||||
- [x] S-10 开始放映的两处拒绝无提示(规则 2) — 三处拒绝都提示(另含读回时已换标签);R2 PASS
|
||||
- [ ] S-04 选屏对话框被别的对话框顶掉后,`show_starting` 卡住、放映副本泄漏
|
||||
- [ ] S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示
|
||||
- [ ] S-06 导出或打印的“从–到”填 0 时导出全部幻灯片
|
||||
- [ ] S-07 母版视图下演示检查的行按幻灯片序号跳转
|
||||
- [ ] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢
|
||||
- [ ] S-09 改尺寸失败后 `size_asked` 一直留着
|
||||
- [ ] S-10 开始放映的两处拒绝无提示(规则 2)
|
||||
- [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS
|
||||
- [x] P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失 — 图块随 R2.6;缩略图在编辑后留到重画;R1、R2 测试未证实际绘制;R3 PASS
|
||||
- [x] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图 — 包住文字块的对象让位于文字,移动须过 3 个窗口像素;R1 漏掉同点的小图;R2 PASS
|
||||
- [x] P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应 — 整页一遍干跑判定全部文字块(1000 块 1.5 s→0.1 s);R1 转向文字后错位、共用原点只计一块;R2 PASS
|
||||
- [x] P-05 连续快画或快点被拒:“文档在此间已改动,未执行” — 按坐标的编辑排队、跑在出队时的状态上,只在页面变动时拒绝;R2 PASS
|
||||
- [x] P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话 — 原字体写不了新字(非 CID 编码、页面内直接字体、不在本页)时面板提示换字体;R2 PASS
|
||||
- [x] P-07 含连字的文字块永远不可编辑 — 字形按 stext 的写法展开(多字字形、连字码点),整页、单块与删除同一判法;R3 PASS
|
||||
- [x] P-08 页面读取失败后每帧重试、每帧弹提示 — 失败的读取按页或种类记下,编辑后重读;签名读取以保存计数判过期
|
||||
- [x] P-09 识别文字的写入被拒后仍弹“成功” — on_same 回报是否执行;被拒时只有警告
|
||||
- [x] P-10 预览回复被丢弃时“应用”静默无事 — 预览回复照常交回,版本已变即提示,不显示过期预览
|
||||
- [x] P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步 — 撤销失败后重读历史标记
|
||||
- [x] P-12 `unhook` 中途抛出时泄漏保留的对象 — 保留的链接随保留随计数
|
||||
- [ ] P-05 连续快画或快点被拒:“文档在此间已改动,未执行”
|
||||
- [ ] P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话
|
||||
- [ ] P-07 含连字的文字块永远不可编辑
|
||||
- [ ] P-08 页面读取失败后每帧重试、每帧弹提示
|
||||
- [ ] P-09 识别文字的写入被拒后仍弹“成功”
|
||||
- [ ] P-10 预览回复被丢弃时“应用”静默无事
|
||||
- [ ] P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步
|
||||
- [ ] P-12 `unhook` 中途抛出时泄漏保留的对象
|
||||
- [ ] P-13 优化导出的“之前”大小取磁盘文件而非当前文档
|
||||
- [ ] P-14 文字块含控制字符时 JSON 回复不可解析
|
||||
- [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS
|
||||
@@ -104,6 +104,5 @@
|
||||
|
||||
## 0.1.2 发布
|
||||
|
||||
- [x] UPD-2 复核 UPD-1 第五轮的两处修复:启动检查(含 Gitea 查询)失败写日志;失败记录先于重开,以替身 open 断言 — R1 PASS
|
||||
- [x] REL-1 dmg 里有指向“应用程序”的快捷方式,可直接拖入安装(用户 2026-10-09 报) — release.sh 在 dmg 里放“应用程序”替身,随 UPD-1 审查
|
||||
- [x] REL-2 修完上列各条后打包、发布 0.1.2,官网指向新发布页 — v0.1.2 发在 git.zacharyzhang.com(六个 dmg、两个源码包、六份更新清单;release-macos.sh 通过;经 API 取清单与 dmg 散列核对一致),官网显示 0.1.2
|
||||
- [ ] REL-2 修完上列各条后打包、发布 0.1.2,官网指向新发布页
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-pdf/src/tests/notes.rs:346 — 页面变动期间拒绝批注的断言存在假阳性:测试手工设置 moving=true,此时 editing=false;若 edit_at 接受批注,next_edit 会立即将它出队执行,queue.is_empty() 仍为真。隔离副本中将 edits.rs:53 的整个拒绝条件替换为 if false 后,quick_strokes_queue_behind_each_other 仍通过(1/1);使用真实 edit_pages 启动旋转的对照探针则确认批注被接受并使拒绝断言失败。该测试也绕过了 next_edit 设置 moving 的路径,缺少等待中页面编辑的覆盖。修复方向:分别通过真实运行中、等待中的页面编辑触发拒绝,检查失败提示,并等待队列落定后确认批注数量保持原值;再验证页面编辑完成后恢复接受批注。原始 worker 构建成功,ElyPDF 全部 129 项测试通过;变异与对照日志保存在 /tmp/p05-r1-mutation.log、/tmp/p05-r1-mutation-moving.log、/tmp/p05-r1-evidence.log,隔离源码与独立构建目录已删除。
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-pdf-engine/shim/ely_pen.c:69 — 原字体不可写仍有路径暴露原始引擎错误,违反 PRD §11.3 的换字体提示要求。复现:沿用 `tests/fonts.rs` 的 `UniGB-UCS2-H` 样本,将页面 `/Resources /Font` 下的 `/F1 5 0 R` 改为直接字体字典,保留其 `/DescendantFonts [6 0 R]`。页面读出 `Hi`,文字块 `editable=true`;编辑面板填入 `Yo` 并点击应用时,`own_font` 在 `own_codes` 前因 `p->num == 0` 抛出 `the document's font is not an object of its own`。独立引擎探针收到 `Err`;实际应用代码的 GPUI 窗口探针记录 `panel.note=None`,危险提示为 `previewing text失败:the document's font is not an object of its own`。切换 Helvetica 后可预览并成功写入 `Yo`,确认换字体可恢复。修复方向:将这种原字体无法用于写入的条件接入 `-1 → status 3 → Wrote::Unwritable`,由编辑面板显示 `pdf.text.unwritable`,补充直接字体资源的预览、提交及面板提示回归。指定字体测试、19 项 content/independent 回归、18 项应用编辑回归均通过;新增窗口探针的间接字体对照通过,直接字体提示断言失败。小型证据日志:`/tmp/ely-p06-direct.log`、`/tmp/ely-p06-ui-probe.log`。探针源码及构建目录已删除,P-06 范围内源码散列保持一致。
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-pdf-engine/shim/ely_text.c:85 — Unicode 连字码点仍无法编辑,违反 PRD PDF-03。复现:将新增测试 a_ligature_block_edits_like_any_other 的 ToUnicode 映射 `<01> <00660069>` 改为 `<01> <FB01>`,其余内容保持原样;实际读取的文字块仍为 `find`,但 `editable` 为 false,调用 put_text 返回 `this text is not in the page's own content`。U+FB02(fl)与 U+FB03(ffi)同样复现,已独立运行并重复验证。stext 会展开这些码点,而 take_glyph 用原始 Unicode 值匹配目标字符;匹配失败后跳过 spelled,且 spelled 的 ucslen < 2 分支也无法计入展开后的字符。修复方向:在匹配与命中计数前按 stext 的规则展开 Unicode 连字,让整页判定、单块检查与删除使用一致的字符序列,并补充单码点连字的编辑回归测试。
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
crates/ely-pdf-engine/shim/ely_text.c:104 — 新增的展开循环静默截断较长的 ToUnicode 一对多映射,违反 PRD PDF-03。`spelt[64]` 配合每次预留 18 个位置的条件,使普通字符最多处理 47 个。具体复现:沿用 `a_ligature_block_edits_like_any_other` 的 PDF,将 `<01>` 的 ToUnicode 目标改为 48 次 `0066`;文字块完整读取为 48 个 `f` 加 `nd`,但 `editable=false`,`put_text` 报 `this text is not in the page's own content`;47 次映射可以编辑。目标改为 17 次 `FB03` 时同样失败,16 次可以编辑。独立副本中移除新增展开逻辑后,48、64 个普通字符的映射均能替换并保存重开;仅将展开缓冲区扩大为 `18 * PDF_MRANGE_CAP` 后,全部 27 个扩展用例通过替换与保存重开验证;恢复当前代码后再次复现截断失败。原有 fonts、content、redact、independent 共 41 项测试通过,FB01 回归测试在移除展开逻辑后按预期失败。修复方向:按 MuPDF 支持的完整映射长度及最大展开长度容纳所有字符,确保整页判定、单块检查与删除均处理完整序列,并加入容量边界回归测试。
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1,5 +0,0 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-pdf/src/annotate.rs:109 — 批次中一个页面读取失败,就把整批页面写入 Unread::Notes;collect 的短路还会丢掉此前成功读取的页面,并封住尚未读取的健康页面,破坏 PDF-05、PDF-06、PDF-07。复现:两页 PDF,第一页含 /Rect [0 0 <50 个连续的 9> 100] 的 Square 批注,第二页含正常 Text 批注;真实 worker 的 marks(0) 失败、marks(1) 成功。视图执行 ask_notes([0, 1]) 后再执行 ask_notes([1]),第二页仍无数据且被标记失败;仅移除第二页的失败标记后即可正常读出。重建后的探针记录 healthy page cached=false, marked failed=true(/tmp/ely-p08-r1-batch-confirmed.log)。修复方向:逐页保留读取结果,缓存成功页,仅记录实际失败页,并继续处理其他页;补充混合成功与失败页面的回归测试。
|
||||
|
||||
apps/ely-pdf/src/sign.rs:71 — 签名失败回调缺少保存代次校验,会在 saved() 清理失败记录后重新插入旧请求的 Unread::Signatures;第 50 行的全局 guard 随即封住新文件的签名读取,破坏 PDF-12。复现:打开 old.pdf 后把原文件改名,发起另存为 saved.pdf,并在保存进行中请求旧路径的签名。另存为成功、标签路径已指向 saved.pdf 后,旧路径失败回复写回标记;再次请求 saved.pdf 的签名,reading_signatures 保持 false、signatures 保持 None。真实另存为探针在明确等待异步操作结束后重复复现 saved path blocked=true, failure marker=true(/tmp/ely-p08-r1-save-race-confirmed.log、/tmp/ely-p08-r1-save-race-repeat.log、/tmp/ely-p08-r1-save-race-rebuilt.log);清除标记后同一 worker 可读取有效文件。修复方向:将签名读取绑定到磁盘文件的保存代次,保存完成使旧请求失效,旧回调清理进行中状态并丢弃过期结果及失败记录;补充保存与失败读取交错的回归测试。
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-pdf/src/sign.rs:69 — 保存失效校验使用 `saved` 历史字符串,同一历史状态再次保存时,旧签名失败回复仍会写回当前文件的 `Unread::Signatures(path)`,封住有效文件的验证状态读取,破坏 PDF-12。复现:打开一份 PDF,保持内容原样,在外部移走原文件;执行冲突处理的“保留我的”,并在保存进行中读取原路径的签名。保存重新写出原路径、清理失败集合后,旧请求的失败回调插回标记;再次请求签名被 guard 拦住,签名列表持续空白。真实 worker 和真实保存流程连续五次得到 `same mark=true, readable=true, stale marker=true, blocked=true`(`/tmp/ely-p08-r2-repeat.log`);还原并重建后再次五次复现(`/tmp/ely-p08-r2-restored-rebuilt.log`)。修复方向:每次成功保存递增独立保存代次,签名读取按该代次丢弃过期结果;补充同历史标记保存与旧回复交错的回归测试。当前测试直接把 `saved` 改为不同字符串,覆盖范围遗漏此场景;探针使用独立保存代次后五次均通过(`/tmp/ely-p08-r2-generation.log`)。
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-pdf/src/tests/editing.rs:414 — 回归测试覆盖不到新增的 `previewed` 版本守卫。这里保持原字体并缩短文字,回复进入 `commit_text`,已有的 `on_same` 也会发出相同警告。复现:在独立副本中保留 `view.off`,仅删除 `writes.rs:99-103` 的版本检查,18 个文字编辑测试仍全部通过;同一竞态中将字体设为 `times`,过期回复会展示布局预览且缺少警告,补充探针以 `stale layout preview was presented` 失败。恢复守卫后两个竞态探针均通过,原实现的全部 131 个 ElyPDF 测试通过。修复方向:让本用例通过字体变更或文字增长进入布局预览分支,同时断言 `pdf.object.changed` 警告、`preview.is_none()` 和写入计数。证据:`/tmp/ely-p10-r1-evidence.log`;可重建探针:`/tmp/ely-p10-r1-probe-source.log`。探针副本及独立构建目录已删除。
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-show/src/chart.rs:189 — `open_chart` 的异步回调仍直接覆盖 `deck.dialog`;其守卫只核对标签与选中图表,绕过了新增的 `open_dialog`。单屏隔离探针先调用 `ask_table`,再调用 `ask_chart` 并推进调度,原表格对话框被图表对话框替换,保留原对话框的断言失败(`/tmp/ely-s04-chart-probe.log`)。两屏下,选屏对话框打开后执行“插入图表”会走同一覆盖路径,丢弃 `Starting`,保留 `show_starting = true`,且跳过 `discard`,因此 S-04 的放映卡住与副本泄漏路径仍存在。修复方向:让图表回调中的首次打开经过 `open_dialog`,保留当前对话框并显示忙碌提示;补充图表入口的覆盖回归测试。临时副本仅把这处首次打开改为 `open_dialog` 后,同一探针通过(`/tmp/ely-s04-chart-control.log`)。独立验证:LOK worker 与 ElyShow 构建成功,ElyShow 80 项测试、ely-ui 34 项测试及注释检查通过;移除拒绝逻辑后,现有 `an_open_dialog_is_not_pushed_aside` 测试失败。原生双屏选屏流程受单屏环境限制;隔离源码副本、独立构建目录与增量缓存均已清理。
|
||||
@@ -1 +0,0 @@
|
||||
PASS
|
||||
@@ -1,3 +0,0 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-show/src/remarks.rs:229 — Unscrollable error rows can still hide every readable comment (SHOW-08). The danger messages occupy the outer column above the sidebar, while scrolling belongs exclusively to the sidebar below them. Reproduction: render the actual remarks pane at 280×600 with one comment dated `2025-03-01` and twelve comments dated `0-00-00`. Native macOS rendering shows only errors; the readable comment and later errors lie below the viewport. Three downward scroll gestures at different heights leave the screenshot pixel-identical, preventing selection, editing and navigation to the readable comment. A layout probe also fails with eight bad dates, placing the readable row at y=740 in a 600-high pane. The supplied unit test passes because it checks only `threads()`. Put the errors into a scrollable layout that preserves access to readable comments, and cover multiple invalid timestamps with a rendered-pane scrolling regression test. Evidence: `/tmp/ely-s05-r1-layout.log` and `/tmp/ely-s05-r1-native.log`.
|
||||
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user