Compare commits
79 commits
1546d7feb4
...
a8d34b8118
| Author | SHA1 | Date | |
|---|---|---|---|
| a8d34b8118 | |||
| 496614f790 | |||
|
|
fc6e8c8056 | ||
|
|
ee51827262 | ||
|
|
3be3203fe4 | ||
|
|
3292cd4cf5 | ||
|
|
6bd5426b51 | ||
|
|
7c7cf41183 | ||
|
|
44ed10ba42 | ||
|
|
ea4acf9fdd | ||
|
|
a8344f77a1 | ||
|
|
9624eec699 | ||
|
|
6748a25e19 | ||
|
|
6002b37406 | ||
|
|
5126b24cb2 | ||
|
|
d05c459e92 | ||
|
|
22bf19ddd3 | ||
|
|
32468c2758 | ||
|
|
531cfef715 | ||
|
|
be956f701b | ||
|
|
f60907b51c | ||
|
|
a9284bc22b | ||
|
|
49bff3dd89 | ||
|
|
5d729c7495 | ||
|
|
387afdf4f2 | ||
|
|
35ff783f25 | ||
|
|
34d0045e0d | ||
|
|
6a4629bb47 | ||
|
|
fc73e4b76b | ||
|
|
f8087a3c6a | ||
|
|
beab03ccf7 | ||
|
|
252ccdd95b | ||
|
|
c8545627fa | ||
|
|
5c830d5504 | ||
|
|
8ff43ea5d7 | ||
|
|
3cf99ad19d | ||
|
|
9399a008c4 | ||
|
|
0e31b146a3 | ||
|
|
1182b7b55d | ||
|
|
220a886acc | ||
|
|
ca7661fa3e | ||
|
|
a4bba8d4f1 | ||
|
|
31fd194d44 | ||
|
|
4cfb56de61 | ||
|
|
3726ea16de | ||
|
|
47f2c7020c | ||
|
|
6115e25fed | ||
|
|
3ca9315e7f | ||
|
|
b7d024bde2 | ||
|
|
7df18617bd | ||
|
|
ffcca4dbfe | ||
|
|
00425fcf4b | ||
|
|
c132265d31 | ||
|
|
4593986fc6 | ||
|
|
68b21215f4 | ||
|
|
69f22bafcd | ||
|
|
2905a3f6f1 | ||
|
|
4afc049f60 | ||
|
|
e71a97a2cb | ||
|
|
afdee6b430 | ||
|
|
7b36ff143c | ||
|
|
b1b0999ebe | ||
|
|
aa83e9ab1d | ||
|
|
97988a7be6 | ||
|
|
7414caf185 | ||
|
|
823dfe9fa2 | ||
|
|
6623a12632 | ||
|
|
b67b91e25f | ||
|
|
96b4e40b08 | ||
|
|
ed414c9923 | ||
|
|
d8e5ff559d | ||
|
|
21974724c3 | ||
|
|
d8e7b2e72f | ||
|
|
49a17fb342 | ||
|
|
9aa6fe2d37 | ||
|
|
7c21e5cf9b | ||
|
|
4c10a80b67 | ||
|
|
4de63d8549 | ||
|
|
ab93f649bd |
78 changed files with 18393 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.cargo/
|
||||
*.pdb
|
||||
**/*.rs.bk
|
||||
tmp/
|
||||
debug/
|
||||
target/
|
||||
vendor/
|
||||
vendor.tar
|
||||
debian/*
|
||||
!debian/changelog
|
||||
!debian/control
|
||||
!debian/copyright
|
||||
!debian/install
|
||||
!debian/rules
|
||||
!debian/source
|
||||
7880
Cargo.lock
generated
Normal file
7880
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
87
Cargo.toml
Normal file
87
Cargo.toml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Cargo.toml
|
||||
#
|
||||
# Project metadata and dependencies for the Noctua document viewer.
|
||||
|
||||
[package]
|
||||
name = "noctua"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "A wise document and image viewer for the COSMIC™ desktop"
|
||||
repository = "https://codeberg.org/wfx/noctua"
|
||||
authors = ["Wolfgang Morawetz <wfx@mailbox.org>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
keywords = ["document", "image", "viewer", "pdf", "cosmic"]
|
||||
categories = ["gui", "multimedia::graphics", "multimedia::images"]
|
||||
|
||||
[features]
|
||||
default = ["image", "vector", "portable"]
|
||||
image = ["dep:image", "dep:kamadak-exif"]
|
||||
vector = ["dep:resvg"]
|
||||
portable = ["dep:poppler", "dep:cairo-rs"]
|
||||
full = ["image", "vector", "portable"]
|
||||
|
||||
[dependencies]
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
# Feature-gated dependencies
|
||||
kamadak-exif = { version = "0.5.5", optional = true }
|
||||
image = { version = "0.25.9", optional = true }
|
||||
poppler = { version = "0.4", features = ["render"], optional = true }
|
||||
cairo-rs = { version = "0.18", features = ["png"], optional = true }
|
||||
resvg = { version = "0.45", optional = true }
|
||||
|
||||
# Async / concurrency
|
||||
futures-util = "0.3.31"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
|
||||
# Logging
|
||||
log = "0.4.20"
|
||||
simple_logger = "4.2.0"
|
||||
|
||||
# i18n / localization
|
||||
i18n-embed = { version = "0.16", features = [
|
||||
"fluent-system",
|
||||
"desktop-requester",
|
||||
] }
|
||||
i18n-embed-fl = "0.10"
|
||||
|
||||
# Misc utilities
|
||||
open = "5.3.2"
|
||||
rust-embed = "8.8.0"
|
||||
dirs = "5.0"
|
||||
sha2 = "0.10"
|
||||
clap = { version = "4.5.54", features = ["derive"] }
|
||||
env_logger = "0.11.8"
|
||||
wallpaper = "3.2"
|
||||
|
||||
[dependencies.libcosmic]
|
||||
git = "https://github.com/pop-os/libcosmic.git"
|
||||
# See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features.
|
||||
features = [
|
||||
# Accessibility support
|
||||
"a11y",
|
||||
# About widget for the app
|
||||
"about",
|
||||
# Uses cosmic-settings-daemon to watch for config file changes
|
||||
"dbus-config",
|
||||
# Support creating additional application windows.
|
||||
"multi-window",
|
||||
# On app startup, focuses an existing instance if the app is already open
|
||||
"single-instance",
|
||||
# Uses tokio as the executor for the runtime
|
||||
"tokio",
|
||||
# Windowing support for X11, Windows, Mac, & Redox
|
||||
"winit",
|
||||
# Add Wayland support to winit
|
||||
"wayland",
|
||||
# GPU-accelerated rendering
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
# Uncomment to test a locally-cloned libcosmic
|
||||
[patch.'https://github.com/pop-os/libcosmic']
|
||||
libcosmic-yoda = { path = "../libcosmic" }
|
||||
cosmic-config = { path = "../libcosmic/cosmic-config" }
|
||||
cosmic-theme = { path = "../libcosmic/cosmic-theme" }
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 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 General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
202
docs/features.md
Normal file
202
docs/features.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# Noctua Features
|
||||
|
||||
This document describes the implemented and planned features of Noctua, a modern image viewer for the COSMIC desktop environment.
|
||||
|
||||
## Current Features
|
||||
|
||||
### Document Support
|
||||
|
||||
#### Raster Images (Implemented)
|
||||
- **Formats**: PNG, JPEG, GIF, BMP, TIFF, WebP, and all formats supported by `image-rs`
|
||||
- **Capabilities**:
|
||||
- Full pixel-perfect rendering at 100% zoom
|
||||
- Lossless transformations (rotate, flip)
|
||||
- Real-time transformation preview
|
||||
- EXIF metadata extraction
|
||||
|
||||
#### Vector Graphics (Implemented)
|
||||
- **Formats**: SVG
|
||||
- **Rendering**: High-quality rendering via `resvg` library
|
||||
- **Capabilities**:
|
||||
- Scalable display at any zoom level without quality loss
|
||||
- Transformations (rotate, flip)
|
||||
|
||||
#### Portable Documents (Implemented)
|
||||
- **Formats**: PDF
|
||||
- **Rendering**: Full PDF rendering via poppler library
|
||||
- **Multi-page navigation**: Browse through all pages of a document
|
||||
- **Page thumbnails**: Left sidebar shows page previews (generated on demand)
|
||||
- **Transformations**: Rotate and flip on rendered pages
|
||||
|
||||
### Navigation
|
||||
|
||||
#### Folder Navigation (Implemented)
|
||||
- **Automatic folder scanning**: When opening an image, all supported images in the same folder are indexed
|
||||
- **Quick navigation**:
|
||||
- Arrow keys (Left/Right) to navigate between images
|
||||
- Footer displays current position (e.g., "3 / 42")
|
||||
- Seamless transitions between images
|
||||
|
||||
#### File Opening (Implemented)
|
||||
- **Command-line arguments**: Open images directly from terminal
|
||||
- **Default directory**: Configurable starting location (defaults to XDG Pictures)
|
||||
- File dialog not yet implemented
|
||||
|
||||
### View Controls
|
||||
|
||||
#### Zoom (Implemented)
|
||||
- **Mouse wheel**: Zoom in/out centered on cursor position
|
||||
- **Keyboard shortcuts**:
|
||||
- `+` or `=` - Zoom in
|
||||
- `-` - Zoom out
|
||||
- `1` - Reset to 100% (Actual Size)
|
||||
- `f` - Fit to window
|
||||
- **View modes**:
|
||||
- **Fit**: Automatically scales image to fit window while preserving aspect ratio
|
||||
- **Actual Size**: Displays image at 100% (1:1 pixel mapping)
|
||||
- **Custom**: Any zoom level from 10% to 2000%
|
||||
- **Footer display**: Real-time zoom percentage or "Fit" indicator
|
||||
|
||||
#### Pan (Implemented)
|
||||
- **Mouse drag**: Click and drag to pan around zoomed images
|
||||
- **Keyboard shortcuts**: `Ctrl + Arrow Keys` for precise panning
|
||||
- **Smart boundaries**: Pan is automatically limited to image boundaries
|
||||
- **Auto-center**: Images smaller than viewport are automatically centered
|
||||
|
||||
#### Bidirectional State Sync (Implemented)
|
||||
- Mouse interactions update keyboard/button controls
|
||||
- Keyboard/button controls update mouse interaction state
|
||||
- No conflicts between input methods
|
||||
|
||||
### Transformations
|
||||
|
||||
#### Image Manipulation (Implemented)
|
||||
- **Rotate**:
|
||||
- `r` - Rotate 90° clockwise
|
||||
- `Shift + r` - Rotate 90° counter-clockwise
|
||||
- Toolbar buttons available
|
||||
- **Flip**:
|
||||
- `h` - Flip horizontally (mirror)
|
||||
- `v` - Flip vertically
|
||||
- Toolbar buttons available
|
||||
- **Lossless operations**: All transformations preserve original image quality
|
||||
- **Real-time preview**: Changes are immediately visible
|
||||
|
||||
### User Interface
|
||||
|
||||
#### COSMIC Integration (Implemented)
|
||||
- **Native COSMIC design**: Follows COSMIC desktop design language
|
||||
- **Theme support**: Automatically adapts to system light/dark theme
|
||||
- **Header toolbar**:
|
||||
- Left: Navigation controls (Previous/Next) and panel toggle
|
||||
- Center: Transformation buttons (Rotate, Flip) - horizontally centered
|
||||
- Right: Information panel toggle
|
||||
- **Footer bar**:
|
||||
- Zoom controls with buttons
|
||||
- Current zoom level display
|
||||
- Image dimensions
|
||||
- Navigation position counter
|
||||
|
||||
#### Panels (Implemented)
|
||||
- **Properties panel**:
|
||||
- Image metadata display
|
||||
- File information
|
||||
- Action buttons:
|
||||
- Set as Wallpaper (works with COSMIC, GNOME, KDE, XFCE, and tiling WMs)
|
||||
- Open With… (planned)
|
||||
- Show in Folder (planned)
|
||||
- Toggle with `i` key or toolbar button
|
||||
- **Navigation panel** (Left sidebar):
|
||||
- Toggle with `n` key or toolbar button
|
||||
- For multi-page documents (PDF): Shows page thumbnails
|
||||
- Click to navigate to specific page
|
||||
|
||||
#### Keyboard Shortcuts (Implemented)
|
||||
Full keyboard-driven workflow:
|
||||
- Navigation: `←` `→`
|
||||
- Zoom: `+` `-` `1` `f`
|
||||
- Pan: `Ctrl + ←` `Ctrl + →` `Ctrl + ↑` `Ctrl + ↓`
|
||||
- Transform: `r` `Shift+r` `h` `v`
|
||||
- Panels: `i` `n`
|
||||
- Actions: `w` (Set as Wallpaper)
|
||||
|
||||
### Desktop Integration
|
||||
|
||||
#### Wallpaper Support (Implemented)
|
||||
- **Set as Wallpaper**: One-click wallpaper setting with cross-desktop compatibility
|
||||
- **Supported desktop environments**:
|
||||
- COSMIC Desktop (direct config file integration)
|
||||
- GNOME (via gsettings)
|
||||
- KDE Plasma (via wallpaper crate)
|
||||
- XFCE (via wallpaper crate)
|
||||
- Tiling window managers (via feh)
|
||||
- **Multiple access methods**:
|
||||
- Keyboard shortcut: `w`
|
||||
- Icon button in Properties panel
|
||||
- Tooltip support for discoverability
|
||||
- **Automatic fallback**: Tries multiple methods until one succeeds
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Persistent Settings (Implemented)
|
||||
- **Panel states**: Remembers which panels were open
|
||||
- **Default directory**: Customizable starting location
|
||||
- **Settings location**: `~/.config/noctua/config.toml`
|
||||
|
||||
### Technical Features
|
||||
|
||||
#### Architecture (Implemented)
|
||||
- **Clean separation**: View layer agnostic to document format
|
||||
- **Polymorphic documents**: Single `DocumentContent` interface for all formats
|
||||
- **Efficient rendering**: Leverages COSMIC's iced renderer
|
||||
- **Type-safe transformations**: Compile-time guarantees for image operations
|
||||
|
||||
#### Performance (Implemented)
|
||||
- **Lazy loading**: Images loaded on-demand
|
||||
- **Efficient folder scanning**: Fast directory traversal
|
||||
- **Minimal memory footprint**: Only active document kept in memory
|
||||
- **Smooth zooming**: Hardware-accelerated rendering
|
||||
|
||||
## Planned Features
|
||||
|
||||
### High Priority
|
||||
|
||||
#### File Operations
|
||||
- File dialog integration (OpenPath message prepared)
|
||||
- Save transformed images
|
||||
- Copy/Move/Delete operations
|
||||
- Drag-and-drop support
|
||||
|
||||
### Medium Priority
|
||||
|
||||
#### Multi-format TIFF Support
|
||||
- Multi-page TIFF navigation (infrastructure ready)
|
||||
- Page thumbnails for TIFF (same as PDF)
|
||||
|
||||
#### Metadata Editing
|
||||
- EXIF data modification
|
||||
- Comment annotations
|
||||
- Tag management
|
||||
|
||||
### Low Priority
|
||||
|
||||
#### Advanced Editing
|
||||
- Crop tool (message prepared)
|
||||
- Scale/Resize tool (message prepared)
|
||||
- Basic color adjustments (brightness, contrast)
|
||||
|
||||
#### Enhanced Navigation
|
||||
- Grid view for folder contents
|
||||
- Quick jump to file
|
||||
|
||||
#### Slideshow Mode
|
||||
- Auto-advance timer
|
||||
- Configurable intervals
|
||||
- Fullscreen support
|
||||
|
||||
## Feature Status Legend
|
||||
|
||||
- **Implemented**: Fully functional and tested
|
||||
- **Planned**: Design complete, implementation pending
|
||||
- **Partial**: Basic functionality exists, enhancements needed
|
||||
- **In Progress**: Currently being developed
|
||||
BIN
docs/images/screenshot.png
Normal file
BIN
docs/images/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
193
docs/usage.md
Normal file
193
docs/usage.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Usage Guide
|
||||
|
||||
Noctua is a modern image viewer for the COSMIC desktop environment with full keyboard support.
|
||||
|
||||
## Opening Images
|
||||
|
||||
### Command Line
|
||||
Open an image directly from the terminal:
|
||||
```bash
|
||||
noctua /path/to/image.png
|
||||
```
|
||||
|
||||
When you open an image, Noctua automatically scans the folder and indexes all supported images for quick navigation.
|
||||
|
||||
### Supported Formats
|
||||
- **Raster Images**: PNG, JPEG, GIF, BMP, TIFF, WebP, and all formats supported by `image-rs`
|
||||
- **Vector Graphics**: SVG (with scalable rendering)
|
||||
- **Portable Documents**: PDF (with multi-page navigation and thumbnails)
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
All keyboard shortcuts are case-insensitive unless otherwise noted.
|
||||
|
||||
### Navigation
|
||||
|
||||
| Key | Action | Description |
|
||||
|:------|:------------------|:-----------------------------------------------|
|
||||
| `←` | Previous image | Navigate to the previous image in the folder |
|
||||
| `→` | Next image | Navigate to the next image in the folder |
|
||||
|
||||
The footer shows your current position (e.g., "3 / 42").
|
||||
|
||||
For multi-page documents (PDF):
|
||||
- Click thumbnails in the left sidebar to jump to a specific page
|
||||
- Use `←` `→` to navigate between pages
|
||||
|
||||
### Zoom and View
|
||||
|
||||
| Key | Action | Description |
|
||||
|:----------|:---------------------------|:------------------------------------------------------|
|
||||
| `+` / `=` | Zoom in | Increase zoom by 10% |
|
||||
| `-` | Zoom out | Decrease zoom by ~9% |
|
||||
| `1` | Actual size (100%) | Display image at pixel-perfect 1:1 scale |
|
||||
| `f` | Fit to window | Scale image to fit the window while preserving ratio |
|
||||
|
||||
You can also zoom with the **mouse wheel** - the zoom centers on your cursor position.
|
||||
|
||||
The current zoom level is displayed in the footer (e.g., "150%" or "Fit").
|
||||
|
||||
### Pan
|
||||
|
||||
Pan controls allow you to move around zoomed images:
|
||||
|
||||
| Key | Action | Description |
|
||||
|:-----------|:-------------------|:-----------------------------------------|
|
||||
| `Ctrl + ←` | Pan left | Move view to the left |
|
||||
| `Ctrl + →` | Pan right | Move view to the right |
|
||||
| `Ctrl + ↑` | Pan up | Move view upward |
|
||||
| `Ctrl + ↓` | Pan down | Move view downward |
|
||||
| `0` | Reset pan | Center the image |
|
||||
|
||||
You can also **click and drag** with the mouse to pan around zoomed images.
|
||||
|
||||
### Transformations
|
||||
|
||||
| Key | Action | Description |
|
||||
|:------------|:-------------------------------|:------------------------------------------|
|
||||
| `h` | Flip horizontal | Mirror the image horizontally |
|
||||
| `v` | Flip vertical | Flip the image upside down |
|
||||
| `r` | Rotate clockwise | Rotate 90° clockwise |
|
||||
| `Shift + r` | Rotate counter-clockwise | Rotate 90° counter-clockwise |
|
||||
|
||||
All transformations are lossless and show in real-time.
|
||||
|
||||
### Panels and UI
|
||||
|
||||
| Key | Action | Description |
|
||||
|:----|:-----------------------|:-----------------------------------------|
|
||||
| `i` | Toggle properties | Show/hide the properties panel (metadata)|
|
||||
| `n` | Toggle navigation | Show/hide the navigation sidebar |
|
||||
|
||||
For multi-page documents (PDF), the navigation sidebar displays page thumbnails.
|
||||
Click on a thumbnail to jump to that page.
|
||||
|
||||
### Actions
|
||||
|
||||
| Key | Action | Description |
|
||||
|:----|:-----------------------|:-----------------------------------------|
|
||||
| `w` | Set as wallpaper | Set the current image as desktop wallpaper|
|
||||
|
||||
## Mouse Controls
|
||||
|
||||
### Zoom
|
||||
- **Mouse wheel up/down**: Zoom in/out centered on cursor
|
||||
- **Footer buttons**: Click zoom in/out buttons for step-by-step control
|
||||
|
||||
### Pan
|
||||
- **Click and drag**: Pan around zoomed images
|
||||
- Hold and drag anywhere on the image to move the view
|
||||
|
||||
### Navigation
|
||||
- **Footer navigation**: Use Previous/Next buttons to browse images
|
||||
|
||||
## Toolbar
|
||||
|
||||
The header toolbar provides quick access to common operations:
|
||||
|
||||
### Left Side
|
||||
- **Navigation toggle**: Show/hide the sidebar
|
||||
- **Previous/Next buttons**: Navigate between images in the folder
|
||||
|
||||
### Center (Horizontally Centered)
|
||||
- **Rotate buttons**: Rotate clockwise or counter-clockwise
|
||||
- **Flip buttons**: Flip horizontally or vertically
|
||||
|
||||
### Right Side
|
||||
- **Properties toggle**: Show/hide the metadata panel
|
||||
|
||||
## Properties Panel
|
||||
|
||||
The properties panel (toggle with `i`) displays image metadata and provides quick actions:
|
||||
|
||||
### Action Buttons
|
||||
Located at the top-right of the properties panel:
|
||||
|
||||
- **Set as Wallpaper** (`w` key): Set the current image as your desktop wallpaper
|
||||
- Works with COSMIC, GNOME, KDE, XFCE, and tiling window managers
|
||||
- Automatically detects your desktop environment
|
||||
- Falls back to alternative methods if the primary method fails
|
||||
|
||||
- **Open With** (planned): Open the image with another application
|
||||
|
||||
- **Show in Folder** (planned): Open the containing folder in your file manager
|
||||
|
||||
### Metadata Display
|
||||
- **File Information**: Name, format, dimensions, file size, color type
|
||||
- **Camera Information** (if available): Camera model, date taken, exposure settings, GPS location
|
||||
|
||||
## Footer Information
|
||||
|
||||
The footer displays useful information:
|
||||
- **Zoom controls**: Zoom out, current zoom level, zoom in, fit buttons
|
||||
- **Image dimensions**: Width × Height in pixels
|
||||
- **Navigation position**: Current image / Total images in folder
|
||||
|
||||
For PDFs, it also shows the current page number.
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Keyboard-Driven Workflow
|
||||
Noctua is designed for efficient keyboard use:
|
||||
1. Open an image from terminal
|
||||
2. Use `←` `→` to browse through the folder
|
||||
3. Press `r` to rotate, `h` or `v` to flip
|
||||
4. Use `+` `-` to zoom, `Ctrl + arrows` to pan
|
||||
5. Press `i` to check metadata
|
||||
|
||||
### Zoom and Pan Together
|
||||
- Zoom with mouse wheel while hovering over the area you want to examine
|
||||
- The zoom centers on your cursor, making it easy to focus on details
|
||||
- Once zoomed, drag with mouse or use `Ctrl + arrows` to navigate
|
||||
|
||||
### Bidirectional Controls
|
||||
Mouse and keyboard work together seamlessly:
|
||||
- Zoom with keyboard, pan with mouse
|
||||
- Zoom with mouse wheel, the footer updates automatically
|
||||
- Pan with keyboard, continue with mouse drag
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are stored in `~/.config/cosmic/org.codeberg.wfx.Noctua/v1/`.
|
||||
|
||||
### Configurable Options
|
||||
- **Default directory**: Set your preferred starting location
|
||||
- **Panel states**: Your panel preferences are remembered between sessions
|
||||
|
||||
## Planned Features
|
||||
|
||||
The following features are prepared in code but not yet implemented:
|
||||
|
||||
### File Operations
|
||||
- File open dialog
|
||||
- Save transformed images
|
||||
- (Copy/Move/)Delete operations
|
||||
|
||||
### Document Support
|
||||
- SVG rendering (implemented)
|
||||
|
||||
### Advanced Editing
|
||||
- Crop mode (`c` key prepared)
|
||||
- Scale/Resize mode (`s` key prepared)
|
||||
|
||||
See [features.md](features.md) for a complete list of planned features.
|
||||
4
i18n.toml
Normal file
4
i18n.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fallback_language = "en"
|
||||
|
||||
[fluent]
|
||||
assets_dir = "i18n"
|
||||
121
i18n/cs/noctua.ftl
Normal file
121
i18n/cs/noctua.ftl
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# i18n/en/noctua.ftl
|
||||
#
|
||||
# Localization strings for Noctua (English).
|
||||
# Usage: fl!("message-id", arg1, arg2, ...)
|
||||
#
|
||||
# Positional arguments ($1, $2, ...) are used for variable content.
|
||||
|
||||
|
||||
## Application
|
||||
noctua-app-name = Noctua
|
||||
noctua-app-description = Prohlížeč dokumentů a obrázků pro prostředí COSMIC
|
||||
|
||||
|
||||
## Main window
|
||||
window-title = { $filename ->
|
||||
[none] Noctua
|
||||
*[some] { $filename } — Noctua
|
||||
}
|
||||
|
||||
|
||||
## Menu entries
|
||||
menu-file-open = Otevřít…
|
||||
menu-file-quit = Ukončit
|
||||
menu-view-zoom-in = Přiblížit
|
||||
menu-view-zoom-out = Oddálit
|
||||
menu-view-zoom-reset = Obnovit přiblížení
|
||||
menu-view-zoom-fit = Přizpůsobit oknu
|
||||
menu-view-flip-horizontal = Překlopit vodorovně
|
||||
menu-view-flip-vertical = Překlopit svisle
|
||||
menu-view-rotate-cw = Otočit po směru hodinových ručiček
|
||||
menu-view-rotate-ccw = Otočit proti směru hodinových ručiček
|
||||
|
||||
|
||||
## Tooltips (for buttons and icons)
|
||||
tooltip-nav-previous = Předchozí dokument
|
||||
tooltip-nav-next = Další dokument
|
||||
tooltip-nav-toggle = Přepnout navigační panel
|
||||
tooltip-zoom-in = Přiblížit
|
||||
tooltip-zoom-out = Oddálit
|
||||
tooltip-zoom-fit = Přizpůsobit oknu
|
||||
tooltip-rotate-ccw = Otočit proti směru hodinových ručiček
|
||||
tooltip-rotate-cw = Otočit po směru hodinových ručiček
|
||||
tooltip-flip-horizontal = Překlopit vodorovně
|
||||
tooltip-flip-vertical = Překlopit svisle
|
||||
tooltip-info-panel = Přepnout informační panel
|
||||
|
||||
|
||||
## Footer / Status bar
|
||||
status-zoom-fit = Přizpůsobit
|
||||
status-zoom-percent = { $percent }%
|
||||
status-doc-dimensions = { $width } × { $height }
|
||||
status-nav-position = { $current } / { $total }
|
||||
status-separator = |
|
||||
|
||||
|
||||
## Placeholders / Empty states
|
||||
no-document = Není načten žádný dokument
|
||||
|
||||
|
||||
## Labels
|
||||
label-zoom = Přiblížení
|
||||
label-tools = Nástroje
|
||||
label-crop = Oříznout
|
||||
label-scale = Měřítko
|
||||
label-page = Stránka
|
||||
label-pages = Stránky
|
||||
|
||||
|
||||
## Loading states
|
||||
loading-metadata = Načítání metadat…
|
||||
loading-thumbnails = Načítání { $current } / { $total }…
|
||||
|
||||
|
||||
## Error messages
|
||||
error-failed-to-open = Nepodařilo se otevřít „{ $path }“
|
||||
error-unsupported-format = Nepodporovaný formát souboru
|
||||
error-no-image-loaded = Není načten žádný obrázek
|
||||
|
||||
|
||||
## Properties panel
|
||||
panel-properties = Vlastnosti
|
||||
panel-actions = Akce
|
||||
|
||||
meta-section-file = Informace o souboru
|
||||
meta-section-exif = Informace o kameře
|
||||
meta-section-image = Informace o obrázku
|
||||
|
||||
## File metadata
|
||||
meta-filename = Název
|
||||
meta-format = Formát
|
||||
meta-dimensions = Rozměry
|
||||
meta-filesize = Velikost
|
||||
meta-colortype = Typ barev
|
||||
meta-path = Cesta
|
||||
meta-pages = Stránky
|
||||
meta-current-page = Aktuální stránka
|
||||
|
||||
## Image metadata
|
||||
meta-width = Šířka
|
||||
meta-height = Výška
|
||||
meta-depth = Bitová hloubka
|
||||
|
||||
## EXIF metadata
|
||||
meta-camera = Kamera
|
||||
meta-datetime = Datum pořízení
|
||||
meta-exposure = Expozice
|
||||
meta-aperture = Clona
|
||||
meta-iso = ISO { $iso }
|
||||
meta-focal = Ohnisková vzdálenost
|
||||
meta-gps = Poloha GPS
|
||||
|
||||
## Action buttons
|
||||
action-set-wallpaper = Nastavit jako tapetu
|
||||
action-open-with = Otevřít pomocí…
|
||||
action-show-in-folder = Zobrazit ve složce
|
||||
|
||||
|
||||
## Navigation panel (thumbnails)
|
||||
nav-panel-title = Stránky
|
||||
nav-panel-loading = Načítání { $current } / { $total }…
|
||||
128
i18n/en/noctua.ftl
Normal file
128
i18n/en/noctua.ftl
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# i18n/en/noctua.ftl
|
||||
#
|
||||
# Localization strings for Noctua (English).
|
||||
# Usage: fl!("message-id", arg1, arg2, ...)
|
||||
#
|
||||
# Positional arguments ($1, $2, ...) are used for variable content.
|
||||
|
||||
|
||||
## Application
|
||||
noctua-app-name = Noctua
|
||||
noctua-app-description = A document and image viewer for the COSMIC desktop
|
||||
|
||||
|
||||
## Main window
|
||||
window-title = { $filename ->
|
||||
[none] Noctua
|
||||
*[some] { $filename } — Noctua
|
||||
}
|
||||
|
||||
|
||||
## Menu entries
|
||||
menu-main = Menu
|
||||
menu-file-open = Open…
|
||||
menu-file-quit = Quit
|
||||
menu-view-zoom-in = Zoom In
|
||||
menu-view-zoom-out = Zoom Out
|
||||
menu-view-zoom-reset = Reset Zoom
|
||||
menu-view-zoom-fit = Fit to Window
|
||||
menu-view-flip-horizontal = Flip Horizontally
|
||||
menu-view-flip-vertical = Flip Vertically
|
||||
menu-view-rotate-cw = Rotate Clockwise
|
||||
menu-view-rotate-ccw = Rotate Counter-Clockwise
|
||||
|
||||
|
||||
## Tooltips (for buttons and icons)
|
||||
tooltip-nav-previous = Previous document
|
||||
tooltip-nav-next = Next document
|
||||
tooltip-nav-toggle = Toggle navigation panel
|
||||
tooltip-zoom-in = Zoom in
|
||||
tooltip-zoom-out = Zoom out
|
||||
tooltip-zoom-fit = Fit to window
|
||||
tooltip-rotate-ccw = Rotate counter-clockwise
|
||||
tooltip-rotate-cw = Rotate clockwise
|
||||
tooltip-flip-horizontal = Flip horizontally
|
||||
tooltip-flip-vertical = Flip vertically
|
||||
tooltip-info-panel = Toggle info panel
|
||||
|
||||
|
||||
## Footer / Status bar
|
||||
status-zoom-fit = Fit
|
||||
status-zoom-percent = { $percent }%
|
||||
status-doc-dimensions = { $width } × { $height }
|
||||
status-nav-position = { $current } / { $total }
|
||||
status-separator = |
|
||||
|
||||
|
||||
## Placeholders / Empty states
|
||||
no-document = No document loaded
|
||||
|
||||
|
||||
## Labels
|
||||
label-zoom = Zoom
|
||||
label-tools = Tools
|
||||
label-crop = Crop
|
||||
label-scale = Scale
|
||||
label-page = Page
|
||||
label-pages = Pages
|
||||
|
||||
|
||||
## Loading states
|
||||
loading-metadata = Loading metadata…
|
||||
loading-thumbnails = Loading { $current } / { $total }…
|
||||
|
||||
|
||||
## Error messages
|
||||
error-failed-to-open = Failed to open "{ $path }"
|
||||
error-unsupported-format = Unsupported file format
|
||||
error-no-image-loaded = No image loaded
|
||||
|
||||
|
||||
## Properties panel
|
||||
panel-properties = Properties
|
||||
panel-actions = Actions
|
||||
|
||||
meta-section-file = File Information
|
||||
meta-section-exif = Camera Information
|
||||
meta-section-image = Image Information
|
||||
|
||||
## File metadata
|
||||
meta-filename = Name
|
||||
meta-format = Format
|
||||
meta-dimensions = Dimensions
|
||||
meta-filesize = Size
|
||||
meta-colortype = Color Type
|
||||
meta-path = Path
|
||||
meta-pages = Pages
|
||||
meta-current-page = Current Page
|
||||
|
||||
## Image metadata
|
||||
meta-width = Width
|
||||
meta-height = Height
|
||||
meta-depth = Bit Depth
|
||||
|
||||
## EXIF metadata
|
||||
meta-camera = Camera
|
||||
meta-datetime = Date Taken
|
||||
meta-exposure = Exposure
|
||||
meta-aperture = Aperture
|
||||
meta-iso = ISO { $iso }
|
||||
meta-focal = Focal Length
|
||||
meta-gps = GPS Location
|
||||
|
||||
## Action buttons
|
||||
action-set-wallpaper = Set as Wallpaper
|
||||
action-open-with = Open With…
|
||||
action-show-in-folder = Show in Folder
|
||||
|
||||
|
||||
## Navigation panel (thumbnails)
|
||||
nav-panel-title = Pages
|
||||
nav-panel-loading = Loading { $current } / { $total }…
|
||||
|
||||
|
||||
## Format panel
|
||||
format-section-title = Paper Format
|
||||
format-section-subtitle = Select paper size for export
|
||||
orientation-section-title = Orientation
|
||||
127
i18n/sv/noctua.ftl
Normal file
127
i18n/sv/noctua.ftl
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# i18n/sv/noctua.ftl
|
||||
#
|
||||
# Localization strings for Noctua (Swedish).
|
||||
# Usage: fl!("message-id", arg1, arg2, ...)
|
||||
#
|
||||
# Positional arguments ($1, $2, ...) are used for variable content.
|
||||
|
||||
|
||||
## Program
|
||||
noctua-app-name = Noctua
|
||||
noctua-app-description = En dokument och bildvisare för COSMIC skrivbordet
|
||||
|
||||
## Main window
|
||||
window-title = { $filename ->
|
||||
[none] Noctua
|
||||
*[some] { $filename } — Noctua
|
||||
}
|
||||
|
||||
|
||||
## Menyposter
|
||||
menu-main = Meny
|
||||
menu-file-open = Öppna…
|
||||
menu-file-quit = Avsluta
|
||||
menu-view-zoom-in = Zooma in
|
||||
menu-view-zoom-out = Zooma ut
|
||||
menu-view-zoom-reset = Återställ zoom
|
||||
menu-view-zoom-fit = Anpassa till fönster
|
||||
menu-view-flip-horizontal = Vänd horisontellt
|
||||
menu-view-flip-vertical = Vänd vertikalt
|
||||
menu-view-rotate-cw = Rotera medurs
|
||||
menu-view-rotate-ccw = Rotera moturs
|
||||
|
||||
|
||||
## Verktygstips (för knappar och ikoner)
|
||||
tooltip-nav-previous = Föregående dokument
|
||||
tooltip-nav-next = Nästa dokument
|
||||
tooltip-nav-toggle = Växla navigeringspanel
|
||||
tooltip-zoom-in = Zooma in
|
||||
tooltip-zoom-out = Zooma ut
|
||||
tooltip-zoom-fit = Anpassa till fönster
|
||||
tooltip-rotate-ccw = Rotera moturs
|
||||
tooltip-rotate-cw = Rotera medurs
|
||||
tooltip-flip-horizontal = Vänd horisontellt
|
||||
tooltip-flip-vertical = Vänd vertikalt
|
||||
tooltip-info-panel = Växla informationspanel
|
||||
|
||||
|
||||
## Sidfot / Statusfält
|
||||
status-zoom-fit = Passa
|
||||
status-zoom-percent = { $percent }%
|
||||
status-doc-dimensions = { $width } × { $height }
|
||||
status-nav-position = { $current } / { $total }
|
||||
status-separator = |
|
||||
|
||||
|
||||
## Platshållare / Tomma tillstånd
|
||||
no-document = Inget dokument laddat
|
||||
|
||||
|
||||
## Etiketter
|
||||
label-zoom = Zoom
|
||||
label-tools = Verktyg
|
||||
label-crop = Beskära
|
||||
label-scale = Skala
|
||||
label-page = Sida
|
||||
label-pages = Sidor
|
||||
|
||||
|
||||
## Laddningstillstånd
|
||||
loading-metadata = Laddar metadata…
|
||||
loading-thumbnails = Laddar { $current } / { $total }…
|
||||
|
||||
|
||||
## Felmeddelanden
|
||||
error-failed-to-open = Misslyckades att öppna "{ $path }"
|
||||
error-unsupported-format = Filformat som inte stöds
|
||||
error-no-image-loaded = Ingen bild laddad
|
||||
|
||||
|
||||
## Egenskapspanel
|
||||
panel-properties = Egenskaper
|
||||
panel-actions = Åtgärder
|
||||
|
||||
meta-section-file = Fil information
|
||||
meta-section-exif = Kamera information
|
||||
meta-section-image = Bild information
|
||||
|
||||
## Fil metadata
|
||||
meta-filename = Namn
|
||||
meta-format = Format
|
||||
meta-dimensions = Dimensioner
|
||||
meta-filesize = Storlek
|
||||
meta-colortype = Färg-typ
|
||||
meta-path = Sökväg
|
||||
meta-pages = Sidor
|
||||
meta-current-page = Nuvarande sida
|
||||
|
||||
## Bildmetadata
|
||||
meta-width = Bredd
|
||||
meta-height = Höjd
|
||||
meta-depth = Bitdjup
|
||||
|
||||
## EXIF metadata
|
||||
meta-camera = Kamera
|
||||
meta-datetime = Datum taget
|
||||
meta-exposure = Exponering
|
||||
meta-aperture = Bländare
|
||||
meta-iso = ISO { $iso }
|
||||
meta-focal = Brännvidd
|
||||
meta-gps = GPS plats
|
||||
|
||||
## Åtgärdsknappar
|
||||
action-set-wallpaper = Använd som bakgrundsbild
|
||||
action-open-with = Öppna med…
|
||||
action-show-in-folder = Visa i mapp
|
||||
|
||||
|
||||
## Navigeringspanel (tumnaglar)
|
||||
nav-panel-title = Sidor
|
||||
nav-panel-loading = Laddar { $current } / { $total }…
|
||||
|
||||
|
||||
## Formatpanel
|
||||
format-section-title = Pappersformat
|
||||
format-section-subtitle = Välj pappersstorlek för export
|
||||
orientation-section-title = Orientering
|
||||
102
justfile
Normal file
102
justfile
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Name of the application's binary.
|
||||
name := 'noctua'
|
||||
# The unique ID of the application.
|
||||
appid := 'org.codeberg.wfx.Noctua'
|
||||
|
||||
# Path to root file system, which defaults to `/`.
|
||||
rootdir := ''
|
||||
# The prefix for the `/usr` directory.
|
||||
prefix := '/usr'
|
||||
# The location of the cargo target directory.
|
||||
cargo-target-dir := env('CARGO_TARGET_DIR', 'target')
|
||||
|
||||
# Application's appstream metadata
|
||||
appdata := appid + '.metainfo.xml'
|
||||
# Application's desktop entry
|
||||
desktop := appid + '.desktop'
|
||||
# Application's icon.
|
||||
icon-svg := appid + '.svg'
|
||||
|
||||
# Install destinations
|
||||
base-dir := absolute_path(clean(rootdir / prefix))
|
||||
appdata-dst := base-dir / 'share' / 'metainfo' / appdata
|
||||
bin-dst := base-dir / 'bin' / name
|
||||
desktop-dst := base-dir / 'share' / 'applications' / desktop
|
||||
icons-dst := base-dir / 'share' / 'icons' / 'hicolor'
|
||||
icon-svg-dst := icons-dst / 'scalable' / 'apps' / icon-svg
|
||||
|
||||
# Default recipe which runs `just build-release`
|
||||
default: build-release
|
||||
|
||||
# Runs `cargo clean`
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Removes vendored dependencies
|
||||
clean-vendor:
|
||||
rm -rf .cargo vendor vendor.tar
|
||||
|
||||
# `cargo clean` and removes vendored dependencies
|
||||
clean-dist: clean clean-vendor
|
||||
|
||||
# Compiles with debug profile
|
||||
build-debug *args:
|
||||
cargo build {{args}}
|
||||
|
||||
# Compiles with release profile
|
||||
build-release *args: (build-debug '--release' args)
|
||||
|
||||
# Compiles release profile with vendored dependencies
|
||||
build-vendored *args: vendor-extract (build-release '--frozen --offline' args)
|
||||
|
||||
# Runs a clippy check
|
||||
check *args:
|
||||
cargo clippy --all-features {{args}} -- -W clippy::pedantic
|
||||
|
||||
# Runs a clippy check with JSON message format
|
||||
check-json: (check '--message-format=json')
|
||||
|
||||
# Run the application for testing purposes
|
||||
run *args:
|
||||
env RUST_BACKTRACE=full cargo run --release {{args}}
|
||||
|
||||
# Installs files
|
||||
install:
|
||||
install -Dm0755 {{ cargo-target-dir / 'release' / name }} {{bin-dst}}
|
||||
install -Dm0644 {{ 'resources' / desktop }} {{desktop-dst}}
|
||||
install -Dm0644 {{ 'resources' / appdata }} {{appdata-dst}}
|
||||
install -Dm0644 {{ 'resources' / 'icons' / 'hicolor' / 'scalable' / 'apps' / icon-svg }} {{icon-svg-dst}}
|
||||
|
||||
# Update icon cache and desktop database after install
|
||||
post-install:
|
||||
-gtk-update-icon-cache -f -t {{icons-dst}}
|
||||
-update-desktop-database {{base-dir / 'share' / 'applications'}}
|
||||
|
||||
# Uninstalls installed files
|
||||
uninstall:
|
||||
rm -f {{bin-dst}} {{desktop-dst}} {{appdata-dst}} {{icon-svg-dst}}
|
||||
-gtk-update-icon-cache -f -t {{icons-dst}}
|
||||
-update-desktop-database {{base-dir / 'share' / 'applications'}}
|
||||
|
||||
# Vendor dependencies locally
|
||||
vendor:
|
||||
mkdir -p .cargo
|
||||
cargo vendor | head -n -1 > .cargo/config.toml
|
||||
echo 'directory = "vendor"' >> .cargo/config.toml
|
||||
tar pcf vendor.tar vendor
|
||||
rm -rf vendor
|
||||
|
||||
# Extracts vendored dependencies
|
||||
vendor-extract:
|
||||
rm -rf vendor
|
||||
tar pxf vendor.tar
|
||||
|
||||
# Bump cargo version, create git commit, and create tag
|
||||
tag version:
|
||||
find -type f -name Cargo.toml -exec sed -i '0,/^version/s/^version.*/version = "{{version}}"/' '{}' \; -exec git add '{}' \;
|
||||
cargo check
|
||||
cargo clean
|
||||
git add Cargo.lock
|
||||
git commit -m 'release: {{version}}'
|
||||
git commit --amend
|
||||
git tag -a {{version}} -m ''
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="512"
|
||||
height="512"
|
||||
viewBox="0 0 512 512.00002"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
sodipodi:docname="noctua.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="px"
|
||||
inkscape:zoom="0.70710678"
|
||||
inkscape:cx="371.93817"
|
||||
inkscape:cy="454.66966"
|
||||
inkscape:window-width="2151"
|
||||
inkscape:window-height="1268"
|
||||
inkscape:window-x="26"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" /><defs
|
||||
id="defs1"><filter
|
||||
inkscape:collect="always"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
id="filter51"
|
||||
x="-0.038666843"
|
||||
y="-0.043024329"
|
||||
width="1.0773337"
|
||||
height="1.0860487"><feGaussianBlur
|
||||
inkscape:collect="always"
|
||||
stdDeviation="8.526584"
|
||||
id="feGaussianBlur51" /></filter></defs><g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"><g
|
||||
id="g52"
|
||||
transform="translate(-3.0243593,1.4705614)"><g
|
||||
id="g51"
|
||||
transform="matrix(0.78387977,0,0,0.79126391,717.47018,-314.39252)"
|
||||
style="mix-blend-mode:normal;filter:url(#filter51)"><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:12;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -419.65433,901.91918 53.49446,-39.90981 35.37546,-128.04173 -57.3272,-10.62458 54.53538,-22.93687 -4.79183,-67.55245 c 0,0 -101.90345,-151.66632 -248.17903,-151.66632 -146.27558,0 -251.17903,156.66632 -251.17903,156.66632 l -1.79181,61.55245 52.53536,23.93687 -52.3272,25.62458 39.10583,114.03065 54.49448,43.90981 z"
|
||||
id="path27"
|
||||
sodipodi:nodetypes="cccccccccccccc" /><ellipse
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.9304;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="ellipse27"
|
||||
cx="762.00842"
|
||||
cy="388.49088"
|
||||
rx="118.31967"
|
||||
ry="122.18041"
|
||||
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:9.2322;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle27"
|
||||
cx="763.00842"
|
||||
cy="369.59668"
|
||||
r="101.55416"
|
||||
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><g
|
||||
id="g28"
|
||||
transform="matrix(0.83587403,0.4825921,-0.4825921,0.83587403,33.580597,933.65116)"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none"><ellipse
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:11.3247;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="ellipse28"
|
||||
cx="-774.25"
|
||||
cy="190.75"
|
||||
rx="122.58766"
|
||||
ry="126.58766" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:9.56522;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle28"
|
||||
cx="-774.25"
|
||||
cy="169.37973"
|
||||
r="105.21739" /></g><path
|
||||
id="path28"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.88785;stroke-linecap:square"
|
||||
d="m -582.02574,955.5487 46.94932,-39.28617 c 2.02674,-1.69594 2.02674,-4.42659 0,-6.12253 l 49.69748,-61.45357 -35.55253,-19.95356 c -17.02674,-12.69595 -27.86062,-14.80883 -69.02121,-11.80767 -22.35449,0.81222 -38.69236,3.51123 -38.69236,3.51123 l -89.6468,33.45357 c -2.02674,1.69594 79.97326,60.67659 82,62.37253 l 46.94932,39.28617 c 2.02674,1.69594 5.29004,1.69594 7.31678,0 z"
|
||||
sodipodi:nodetypes="ccccccccccc" /><g
|
||||
id="g32"
|
||||
transform="matrix(0.60714286,0,0,0.60714286,-264.23676,600.72825)"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none"><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle29"
|
||||
cx="-663.80597"
|
||||
cy="141.60011"
|
||||
r="56" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle30"
|
||||
cx="-664.05597"
|
||||
cy="141.35011"
|
||||
r="45.75" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle31"
|
||||
cx="-660.80597"
|
||||
cy="136.60011"
|
||||
r="30.5" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle32"
|
||||
cx="-657.55597"
|
||||
cy="129.85011"
|
||||
r="12.25" /></g><path
|
||||
id="path32"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:12;stroke-linecap:square;stroke-opacity:1"
|
||||
d="m -844.86876,512.94355 c 0,0 93.66733,39.64166 114.08139,47.72091 14.6246,5.21818 41.759,33.69181 47.39416,35.68323 6.10156,1.99164 13.64238,-2.0756 19.79311,-0.24154 4.6994,1.25378 68.17055,46.53654 68.17055,46.53654 2.80835,1.93942 7.28846,5.25496 7.49799,8.66147 l 3.21421,52.25444 c 0.20958,3.40649 -1.29711,7.92141 -4.31602,6.32935 l -230.4111,-121.51038 c -3.01886,-1.59204 -6.86858,-2.2072 -7.97268,-5.43662 l -21.76765,-63.66804 c -1.1041,-3.22941 1.28667,-7.90134 4.31604,-6.32936 z"
|
||||
sodipodi:nodetypes="scccssssssss" /><g
|
||||
id="g36"
|
||||
transform="matrix(1.3535534,0,0,1.3535534,152.94895,518.45065)"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none"><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle33"
|
||||
cx="456.60492"
|
||||
cy="141.60011"
|
||||
r="56"
|
||||
transform="scale(-1,1)" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle34"
|
||||
cx="456.35492"
|
||||
cy="141.35011"
|
||||
r="45.75"
|
||||
transform="scale(-1,1)" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle35"
|
||||
cx="459.60492"
|
||||
cy="136.60011"
|
||||
r="30.5"
|
||||
transform="scale(-1,1)" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle36"
|
||||
cx="462.85492"
|
||||
cy="129.85011"
|
||||
r="12.25"
|
||||
transform="scale(-1,1)" /></g><path
|
||||
id="path36"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:12;stroke-linecap:square;stroke-opacity:1"
|
||||
d="m -324.90554,504.63387 c 0,0 -92.87343,41.46753 -113.12544,49.94482 -14.51967,5.50344 -41.09151,34.50275 -46.68661,36.60409 -6.0614,2.11069 -13.68038,-1.80817 -19.79405,0.14594 -4.67395,1.34553 -67.24657,47.86201 -67.24657,47.86201 -2.76985,1.99401 -7.18419,5.39662 -7.32701,8.80657 l -2.19077,52.30734 c -0.14285,3.40995 1.45197,7.8945 4.4391,6.24367 L -348.8484,580.5511 c 2.98712,-1.65083 6.82405,-2.34123 7.86474,-5.59164 l 20.51722,-64.08192 c 1.04068,-3.25041 -1.44109,-7.87464 -4.4391,-6.24367 z"
|
||||
sodipodi:nodetypes="scccssssssss" /><g
|
||||
id="g39"
|
||||
transform="matrix(1.3358771,-0.25249858,0.25249858,1.3358771,-891.08015,619.11458)"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none"><path
|
||||
id="path37"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
|
||||
id="path38"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
|
||||
sodipodi:nodetypes="sssssssss" /><path
|
||||
id="path39"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:8.29698;stroke-linecap:square"
|
||||
d="m 128.63706,190.89948 8.47588,7.21311 c 3.31861,2.82419 6.73967,6.8681 6.73967,10.05107 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 10.67106,-4.26403 13.98967,-1.43984 z"
|
||||
sodipodi:nodetypes="sssssssss" /></g><g
|
||||
id="g42"
|
||||
transform="matrix(-1.3358771,-0.25249858,-0.25249858,1.3358771,-283.3154,618.49326)"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none"><path
|
||||
id="path40"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
|
||||
id="path41"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
|
||||
sodipodi:nodetypes="sssssssss" /><path
|
||||
id="path42"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:8.29698;stroke-linecap:square"
|
||||
d="m 131.76184,189.76981 7.01824,4.42333 c 3.68654,2.32349 5.07253,10.78755 5.07253,13.97052 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 13.42791,-4.89299 17.11445,-2.56951 z"
|
||||
sodipodi:nodetypes="sssssssss" /></g><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -663.45584,835.09964 76,93 74,-94"
|
||||
id="path43"
|
||||
sodipodi:nodetypes="ccc" /><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:7;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -636.63681,683.04797 33.45534,95.55906"
|
||||
id="path44"
|
||||
sodipodi:nodetypes="cc" /><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:8;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -529.62166,679.37557 -34.31316,72.98585"
|
||||
id="path45"
|
||||
sodipodi:nodetypes="cc" /><path
|
||||
id="path46"
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:9.85198;stroke-linecap:square"
|
||||
d="m -581.93031,719.70018 48.17402,62.55806 c 2.07961,2.57848 2.07961,6.73009 0,9.30853 l -50.38597,88.80066 c -2.07962,2.57849 -5.42804,2.57849 -7.50764,0 l -45.96207,-88.80066 c -2.07961,-2.57844 -2.07961,-6.73005 0,-9.30853 l 48.17402,-62.55806 c 2.07962,-2.57849 5.42802,-2.57849 7.50764,0 z"
|
||||
sodipodi:nodetypes="ccccccccc" /><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -586.54709,481.18742 7.46187,166.80988"
|
||||
id="path47" /><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -626.4966,619.42554 v -55.15433 l -24.04163,-24.04163 h -25.45585"
|
||||
id="path48" /><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M -650.53823,540.22958 V 491.06237"
|
||||
id="path49" /><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -533.88643,607.06158 v -42.79037 l 24.04163,-24.04163 h 25.45585"
|
||||
id="path50"
|
||||
sodipodi:nodetypes="cccc" /><path
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M -509.8448,540.22958 V 497.56237"
|
||||
id="path51"
|
||||
sodipodi:nodetypes="cc" /></g><path
|
||||
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:8.96455;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 382.60794,392.14364 39.96282,-29.81446 26.4271,-95.65306 -42.82606,-7.93705 40.74044,-17.1349 -3.57972,-50.46479 c 0,0 -76.12656,-113.301713 -185.40115,-113.301713 -109.27459,0 -187.642299,117.036943 -187.642299,117.036943 l -1.338565,45.98251 39.246334,17.88195 -39.090829,19.14274 29.213862,85.18614 40.709877,32.80265 z"
|
||||
id="path19"
|
||||
sodipodi:nodetypes="cccccccccccccc" /><ellipse
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:8.16551;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="ellipse9"
|
||||
cx="-174.40848"
|
||||
cy="394.37506"
|
||||
rx="88.390236"
|
||||
ry="91.274391"
|
||||
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><circle
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:6.89688;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle9"
|
||||
cx="-173.66142"
|
||||
cy="380.26022"
|
||||
r="75.865631"
|
||||
transform="matrix(-0.8660254,0.5,0.5,0.8660254,0,0)" /><g
|
||||
id="g4"
|
||||
transform="matrix(0.62443636,0.3605185,-0.3605185,0.62443636,721.19529,415.84889)"
|
||||
style="fill:#f1dfc0;fill-opacity:1"><ellipse
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:11.3247;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle4"
|
||||
cx="-774.25"
|
||||
cy="190.75"
|
||||
rx="122.58766"
|
||||
ry="126.58766" /><circle
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:9.56522;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path3"
|
||||
cx="-774.25"
|
||||
cy="169.37973"
|
||||
r="105.21739" /></g><path
|
||||
id="path16"
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:5.89259;stroke-linecap:square"
|
||||
d="m 261.30902,432.20736 35.07331,-29.34857 c 1.51407,-1.26695 1.51407,-3.30687 0,-4.57381 l 37.1263,-45.90865 -26.55937,-14.90623 c -12.71976,-9.48446 -20.81317,-11.06287 -51.56202,-8.82087 -16.69984,0.60677 -28.90498,2.62305 -28.90498,2.62305 l -66.97028,24.99136 c -1.51407,1.26694 59.7437,45.3282 61.25777,46.59515 l 35.0733,29.34857 c 1.51407,1.26695 3.95191,1.26695 5.46597,0 z"
|
||||
sodipodi:nodetypes="ccccccccccc" /><g
|
||||
id="g11"
|
||||
transform="matrix(0.45356365,0,0,0.45356365,498.71202,167.14017)"><circle
|
||||
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1"
|
||||
cx="-663.80597"
|
||||
cy="141.60011"
|
||||
r="56" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle1"
|
||||
cx="-664.05597"
|
||||
cy="141.35011"
|
||||
r="45.75" /><circle
|
||||
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle2"
|
||||
cx="-660.80597"
|
||||
cy="136.60011"
|
||||
r="30.5" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle3"
|
||||
cx="-657.55597"
|
||||
cy="129.85011"
|
||||
r="12.25" /></g><path
|
||||
id="rect1"
|
||||
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:8.96455;stroke-linecap:square;stroke-opacity:1"
|
||||
d="m 64.95319,101.56096 c 0,0 69.97381,29.61414 85.22405,35.64971 10.92525,3.89822 31.1959,25.16934 35.40562,26.65701 4.55815,1.48785 10.19149,-1.55056 14.78636,-0.18043 3.51067,0.93663 50.92654,34.76493 50.92654,34.76493 2.09797,1.44883 5.44482,3.9257 5.60135,6.47051 l 2.40116,39.03647 c 0.15656,2.54481 -0.969,5.91766 -3.22427,4.72832 L 83.946304,157.91363 c -2.255227,-1.18932 -5.131145,-1.64888 -5.955959,-4.06139 L 61.728909,106.28928 c -0.824813,-2.41252 0.961202,-5.90266 3.224281,-4.72832 z"
|
||||
sodipodi:nodetypes="scccssssssss" /><g
|
||||
id="g22"
|
||||
transform="matrix(1.0111667,0,0,1.0111667,810.36892,105.67502)"><circle
|
||||
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:13.0486;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle5"
|
||||
cx="456.60492"
|
||||
cy="141.60011"
|
||||
r="56"
|
||||
transform="scale(-1,1)" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:10.6602;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle6"
|
||||
cx="456.35492"
|
||||
cy="141.35011"
|
||||
r="45.75"
|
||||
transform="scale(-1,1)" /><circle
|
||||
style="fill:#35414a;fill-opacity:1;stroke:none;stroke-width:7.1068;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle7"
|
||||
cx="459.60492"
|
||||
cy="136.60011"
|
||||
r="30.5"
|
||||
transform="scale(-1,1)" /><circle
|
||||
style="fill:#40f6f8;fill-opacity:1;stroke:none;stroke-width:2.85437;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle8"
|
||||
cx="462.85492"
|
||||
cy="129.85011"
|
||||
r="12.25"
|
||||
transform="scale(-1,1)" /></g><path
|
||||
id="path9"
|
||||
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:8.96455;stroke-linecap:square;stroke-opacity:1"
|
||||
d="m 453.38965,95.353247 c 0,0 -69.38073,30.978153 -84.50991,37.311073 -10.84687,4.11133 -30.69725,25.77515 -34.87705,27.34494 -4.52814,1.57679 -10.21987,-1.35078 -14.78707,0.10903 -3.49165,1.00517 -50.23628,35.75511 -50.23628,35.75511 -2.0692,1.48963 -5.36692,4.03153 -5.47361,6.57892 l -1.63661,39.07599 c -0.10671,2.54739 1.08469,5.89755 3.31621,4.6643 l 170.3179,-94.12571 c 2.23151,-1.23324 5.09788,-1.749 5.87532,-4.1772 l 15.32731,-47.87215 c 0.77743,-2.4282 -1.07656,-5.882717 -3.31621,-4.664303 z"
|
||||
sodipodi:nodetypes="scccssssssss" /><g
|
||||
id="g12"
|
||||
transform="matrix(0.99796166,-0.18862806,0.18862806,0.99796166,30.431155,180.8756)"
|
||||
style="fill:#f1dfc0;fill-opacity:1"><path
|
||||
id="rect11"
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
|
||||
id="path11"
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
|
||||
sodipodi:nodetypes="sssssssss" /><path
|
||||
id="path12"
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:8.29698;stroke-linecap:square"
|
||||
d="m 128.63706,190.89948 8.47588,7.21311 c 3.31861,2.82419 6.73967,6.8681 6.73967,10.05107 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 10.67106,-4.26403 13.98967,-1.43984 z"
|
||||
sodipodi:nodetypes="sssssssss" /></g><g
|
||||
id="g15"
|
||||
transform="matrix(-0.99796166,-0.18862806,-0.18862806,0.99796166,484.45939,180.41145)"
|
||||
style="fill:#f1dfc0;fill-opacity:1"><path
|
||||
id="path13"
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 65.751448,168.93619 13.747104,8.20178 c 3.263955,1.94733 5.891615,5.73597 5.891615,8.49469 v 48.96654 c 0,2.75873 -2.62766,3.41194 -5.891615,1.46461 l -13.747104,-8.20178 c -3.263955,-1.94733 -5.891615,-5.73597 -5.891615,-8.49469 V 170.4008 c 0,-2.75873 2.62766,-3.41194 5.891615,-1.46461 z" /><path
|
||||
id="path14"
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:7.22196;stroke-linecap:square"
|
||||
d="m 92.001448,185.00099 14.747102,3.82218 c 3.60717,0.93491 5.92875,5.4664 5.89162,8.22487 l -0.75,55.71654 c -0.0371,2.75848 -2.62766,3.53228 -5.89162,1.73443 l -13.747102,-7.57218 c -3.263955,-1.79785 -5.878344,-5.46618 -5.891615,-8.22487 l -0.25,-51.96654 c -0.01327,-2.7587 2.284454,-2.66934 5.891615,-1.73443 z"
|
||||
sodipodi:nodetypes="sssssssss" /><path
|
||||
id="path15"
|
||||
style="fill:#f1dfc0;fill-opacity:1;stroke:#35414a;stroke-width:8.29698;stroke-linecap:square"
|
||||
d="m 131.76184,189.76981 7.01824,4.42333 c 3.68654,2.32349 5.07253,10.78755 5.07253,13.97052 v 62.74702 c 0,3.18299 -3.32448,4.39645 -6.73967,1.68984 l -15.72588,-12.46311 c -3.41519,-2.70661 -6.73967,-6.6181 -6.73967,-9.80107 v -57.99702 c 0,-3.18299 13.42791,-4.89299 17.11445,-2.56951 z"
|
||||
sodipodi:nodetypes="sssssssss" /></g><path
|
||||
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 200.47699,342.22637 56.7755,69.47528 55.28141,-70.22232"
|
||||
id="path17"
|
||||
sodipodi:nodetypes="ccc" /><path
|
||||
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:5.22932;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 220.51204,228.63678 24.99268,71.38701"
|
||||
id="path20"
|
||||
sodipodi:nodetypes="cc" /><path
|
||||
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:5.97637;stroke-linecap:square;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 300.45728,225.89333 -25.63351,54.52379"
|
||||
id="path21"
|
||||
sodipodi:nodetypes="cc" /><path
|
||||
id="rect4"
|
||||
style="fill:#9e6d45;fill-opacity:1;stroke:#35414a;stroke-width:7.35988;stroke-linecap:square"
|
||||
d="m 261.38032,256.01767 35.98821,46.73375 c 1.55356,1.92624 1.55356,5.02768 0,6.9539 l -37.64064,66.33818 c -1.55357,1.92625 -4.055,1.92625 -5.60856,0 l -34.33578,-66.33818 c -1.55356,-1.92622 -1.55356,-5.02766 0,-6.9539 l 35.98821,-46.73375 c 1.55357,-1.92625 4.05498,-1.92625 5.60856,0 z"
|
||||
sodipodi:nodetypes="ccccccccc" /><path
|
||||
style="fill:#35414a;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 257.93137,77.837667 5.57436,124.614653"
|
||||
id="path22" /><path
|
||||
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 228.08725,181.1079 v -41.20282 l -17.96021,-17.9602 h -19.01669"
|
||||
id="path23" /><path
|
||||
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 210.12704,121.94488 V 85.21471"
|
||||
id="path24" /><path
|
||||
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 297.2713,171.87146 v -31.96638 l 17.96021,-17.9602 h 19.01669"
|
||||
id="path25"
|
||||
sodipodi:nodetypes="cccc" /><path
|
||||
style="fill:none;fill-opacity:1;stroke:#35414a;stroke-width:7.47046;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 315.23151,121.94488 V 90.070509"
|
||||
id="path26"
|
||||
sodipodi:nodetypes="cc" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 25 KiB |
14
resources/org.codeberg.wfx.Noctua.desktop
Normal file
14
resources/org.codeberg.wfx.Noctua.desktop
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[Desktop Entry]
|
||||
Name=Noctua
|
||||
Comment=A wise document and image viewer for the COSMIC™ desktop
|
||||
Comment[cs]=Chytrý prohlížeč dokumentů a obrázku pro prostředí COSMIC™
|
||||
Type=Application
|
||||
Icon=org.codeberg.wfx.Noctua
|
||||
Exec=noctua %F
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
Categories=Graphics;Viewer;Utility;
|
||||
StartupWMClass=org.codeberg.wfx.Noctua
|
||||
Keywords=image;document;pdf;viewer;cosmic;
|
||||
Keywords[cs]=obrázek;dokument;pdf;prohlížeč;fotka;
|
||||
MimeType=image/png;image/jpeg;image/gif;image/webp;image/bmp;image/tiff;image/svg+xml;application/pdf;
|
||||
67
resources/org.codeberg.wfx.Noctua.metainfo.xml
Normal file
67
resources/org.codeberg.wfx.Noctua.metainfo.xml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!-- resources/app.metainfo.xml -->
|
||||
<!-- AppStream metadata for software centers (e.g., COSMIC Store, GNOME Software, KDE Discover). -->
|
||||
<component type="desktop-application">
|
||||
<id>org.codeberg.wfx.Noctua</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
<name>Noctua</name>
|
||||
<summary>A wise document and image viewer for the COSMIC™ desktop</summary>
|
||||
<summary xml:lang="cs">Chytrý prohlížeč dokumentů a obrázků pro prostředí COSMIC™</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Noctua is a lightweight, keyboard-driven viewer for images, PDFs, and vector graphics.
|
||||
Built with Rust and libcosmic, it integrates seamlessly with COSMIC™ and GTK-based desktops.
|
||||
</p>
|
||||
<p xml:lang="cs">
|
||||
Noctua je nenáročný prohlížeč obrázků, PDF a vektorové grafiky, snadno ovladatelný pomocí klávesnice.
|
||||
Je napsaný v jazyce Rust a postavený na libcosmic, díky čemuž se plynule integruje s prostředím COSMIC™ a prostředími založenými na GTK.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">org.codeberg.wfx.Noctua.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://codeberg.org/wfx/noctua</url>
|
||||
<url type="bugtracker">https://codeberg.org/wfx/noctua/issues</url>
|
||||
<url type="vcs-browser">https://codeberg.org/wfx/noctua</url>
|
||||
|
||||
<provides>
|
||||
<binary>noctua</binary>
|
||||
<mediatype>image/png</mediatype>
|
||||
<mediatype>image/jpeg</mediatype>
|
||||
<mediatype>image/gif</mediatype>
|
||||
<mediatype>image/webp</mediatype>
|
||||
<mediatype>image/bmp</mediatype>
|
||||
<mediatype>image/tiff</mediatype>
|
||||
<mediatype>image/svg+xml</mediatype>
|
||||
<mediatype>application/pdf</mediatype>
|
||||
</provides>
|
||||
|
||||
<requires>
|
||||
<display_length compare="ge">360</display_length>
|
||||
</requires>
|
||||
|
||||
<supports>
|
||||
<control>keyboard</control>
|
||||
<control>pointing</control>
|
||||
<control>touch</control>
|
||||
</supports>
|
||||
|
||||
<categories>
|
||||
<category>Graphics</category>
|
||||
<category>Viewer</category>
|
||||
<category>Utility</category>
|
||||
</categories>
|
||||
|
||||
<keywords>
|
||||
<keyword>image</keyword>
|
||||
<keyword>document</keyword>
|
||||
<keyword>pdf</keyword>
|
||||
<keyword>viewer</keyword>
|
||||
<keyword>cosmic</keyword>
|
||||
</keywords>
|
||||
|
||||
<content_rating type="oars-1.1"/>
|
||||
</component>
|
||||
5
rustfmt.toml
Normal file
5
rustfmt.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# rustfmt.toml
|
||||
reorder_imports = false
|
||||
reorder_modules = false
|
||||
imports_granularity = "Preserve"
|
||||
group_imports = "Preserve"
|
||||
228
src/application/commands/crop_document.rs
Normal file
228
src/application/commands/crop_document.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/commands/crop_document.rs
|
||||
//
|
||||
// Crop document command: crop the current document to a specified region.
|
||||
|
||||
use cosmic::iced::{ContentFit, Size, Vector};
|
||||
|
||||
use crate::application::DocumentManager;
|
||||
use crate::domain::document::core::content::DocumentKind;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::operations::CropRegion;
|
||||
|
||||
/// Crop document command.
|
||||
///
|
||||
/// Crops the current document to the specified rectangular region.
|
||||
/// The coordinates are in image pixels (not canvas/screen coordinates).
|
||||
pub struct CropDocumentCommand {
|
||||
/// X coordinate of the crop region (top-left corner).
|
||||
pub x: u32,
|
||||
/// Y coordinate of the crop region (top-left corner).
|
||||
pub y: u32,
|
||||
/// Width of the crop region in pixels.
|
||||
pub width: u32,
|
||||
/// Height of the crop region in pixels.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl CropDocumentCommand {
|
||||
/// Create a new crop document command.
|
||||
#[must_use]
|
||||
pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a crop command from canvas coordinates.
|
||||
///
|
||||
/// Converts canvas-space coordinates to image-space pixels based on
|
||||
/// the current view state (scale, pan, content fit).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the crop region is invalid or outside image bounds.
|
||||
pub fn from_canvas_selection(
|
||||
crop_region: &CropRegion,
|
||||
canvas_size: Size,
|
||||
image_size: Size,
|
||||
scale: f32,
|
||||
pan_offset: Vector,
|
||||
) -> Result<Self, String> {
|
||||
let (x, y, w, h) = crop_region.as_tuple();
|
||||
let canvas_rect = (x as f32, y as f32, w as f32, h as f32);
|
||||
|
||||
// Convert canvas coordinates to image pixel coordinates
|
||||
let image_rect = Self::canvas_rect_to_image_rect(
|
||||
canvas_rect,
|
||||
canvas_size,
|
||||
image_size,
|
||||
scale,
|
||||
pan_offset,
|
||||
ContentFit::Contain,
|
||||
)
|
||||
.ok_or_else(|| "Invalid crop region".to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
x: image_rect.0,
|
||||
y: image_rect.1,
|
||||
width: image_rect.2,
|
||||
height: image_rect.3,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert canvas rectangle to image pixel rectangle.
|
||||
///
|
||||
/// This is the core coordinate transformation logic that maps from
|
||||
/// canvas/screen coordinates to actual image pixel coordinates.
|
||||
fn canvas_rect_to_image_rect(
|
||||
canvas_rect: (f32, f32, f32, f32),
|
||||
canvas_size: Size,
|
||||
image_size: Size,
|
||||
scale: f32,
|
||||
offset: Vector,
|
||||
content_fit: ContentFit,
|
||||
) -> Option<(u32, u32, u32, u32)> {
|
||||
let (cx, cy, cw, ch) = canvas_rect;
|
||||
|
||||
if cw <= 1.0 || ch <= 1.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Transform top-left and bottom-right corners
|
||||
let (x1, y1) = Self::canvas_to_image_coords(
|
||||
cx,
|
||||
cy,
|
||||
canvas_size,
|
||||
image_size,
|
||||
scale,
|
||||
offset,
|
||||
content_fit,
|
||||
);
|
||||
let (x2, y2) = Self::canvas_to_image_coords(
|
||||
cx + cw,
|
||||
cy + ch,
|
||||
canvas_size,
|
||||
image_size,
|
||||
scale,
|
||||
offset,
|
||||
content_fit,
|
||||
);
|
||||
|
||||
// Clamp to image boundaries
|
||||
let img_x = x1.max(0.0).min(image_size.width);
|
||||
let img_y = y1.max(0.0).min(image_size.height);
|
||||
let img_w = (x2 - x1).max(1.0).min(image_size.width - img_x);
|
||||
let img_h = (y2 - y1).max(1.0).min(image_size.height - img_y);
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
Some((
|
||||
img_x.round() as u32,
|
||||
img_y.round() as u32,
|
||||
img_w.round() as u32,
|
||||
img_h.round() as u32,
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert a single point from canvas coordinates to image coordinates.
|
||||
fn canvas_to_image_coords(
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
canvas_size: Size,
|
||||
image_size: Size,
|
||||
scale: f32,
|
||||
offset: Vector,
|
||||
content_fit: ContentFit,
|
||||
) -> (f32, f32) {
|
||||
// Calculate displayed image dimensions based on ContentFit
|
||||
let (display_w, display_h) = match content_fit {
|
||||
ContentFit::Contain => {
|
||||
let aspect = image_size.width / image_size.height;
|
||||
let canvas_aspect = canvas_size.width / canvas_size.height;
|
||||
|
||||
if aspect > canvas_aspect {
|
||||
// Limited by width
|
||||
(canvas_size.width, canvas_size.width / aspect)
|
||||
} else {
|
||||
// Limited by height
|
||||
(canvas_size.height * aspect, canvas_size.height)
|
||||
}
|
||||
}
|
||||
_ => (image_size.width, image_size.height),
|
||||
};
|
||||
|
||||
// Apply scale
|
||||
let scaled_w = display_w * scale;
|
||||
let scaled_h = display_h * scale;
|
||||
|
||||
// Center in canvas
|
||||
let center_x = (canvas_size.width - scaled_w) / 2.0;
|
||||
let center_y = (canvas_size.height - scaled_h) / 2.0;
|
||||
|
||||
// Convert canvas coords to scaled image coords
|
||||
let img_x = (cx - center_x - offset.x) / scale;
|
||||
let img_y = (cy - center_y - offset.y) / scale;
|
||||
|
||||
// Scale from display space to actual image pixel space
|
||||
let pixel_x = (img_x / display_w) * image_size.width;
|
||||
let pixel_y = (img_y / display_h) * image_size.height;
|
||||
|
||||
(pixel_x, pixel_y)
|
||||
}
|
||||
|
||||
/// Execute the crop command on the document manager.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - No document is currently open
|
||||
/// - The document type doesn't support cropping
|
||||
/// - The crop region is invalid
|
||||
/// - The crop operation fails
|
||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
||||
let doc = manager
|
||||
.current_document_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("No document open"))?;
|
||||
|
||||
// Only raster images support cropping
|
||||
if doc.kind() != DocumentKind::Raster {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Crop operation is only supported for raster images"
|
||||
));
|
||||
}
|
||||
|
||||
// Get the raster document and apply crop
|
||||
if let crate::domain::document::core::content::DocumentContent::Raster(raster) = doc {
|
||||
raster
|
||||
.crop(self.x, self.y, self.width, self.height)
|
||||
.map_err(|e| anyhow::anyhow!("Crop failed: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if the command can be executed.
|
||||
#[must_use]
|
||||
pub fn can_execute(&self, manager: &DocumentManager) -> bool {
|
||||
manager
|
||||
.current_document()
|
||||
.map_or(false, |doc| doc.kind() == DocumentKind::Raster)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_command_creation() {
|
||||
let cmd = CropDocumentCommand::new(10, 20, 100, 150);
|
||||
assert_eq!(cmd.x, 10);
|
||||
assert_eq!(cmd.y, 20);
|
||||
assert_eq!(cmd.width, 100);
|
||||
assert_eq!(cmd.height, 150);
|
||||
}
|
||||
}
|
||||
10
src/application/commands/mod.rs
Normal file
10
src/application/commands/mod.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/commands/mod.rs
|
||||
//
|
||||
// Application commands: document operations and navigation.
|
||||
|
||||
pub mod crop_document;
|
||||
pub mod navigate;
|
||||
pub mod open_document;
|
||||
pub mod save_document;
|
||||
pub mod transform_document;
|
||||
67
src/application/commands/navigate.rs
Normal file
67
src/application/commands/navigate.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/commands/navigate.rs
|
||||
//
|
||||
// Navigation command: next/previous document.
|
||||
// Reserved for future CQRS pattern - currently using direct DocumentManager methods.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::application::document_manager::DocumentManager;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
|
||||
/// Navigation direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NavigationDirection {
|
||||
/// Navigate to next document.
|
||||
Next,
|
||||
/// Navigate to previous document.
|
||||
Previous,
|
||||
}
|
||||
|
||||
/// Navigate command.
|
||||
pub struct NavigateCommand {
|
||||
direction: NavigationDirection,
|
||||
}
|
||||
|
||||
impl NavigateCommand {
|
||||
/// Create a new navigate command.
|
||||
#[must_use]
|
||||
pub fn new(direction: NavigationDirection) -> Self {
|
||||
Self { direction }
|
||||
}
|
||||
|
||||
/// Execute the navigate command.
|
||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<Option<PathBuf>> {
|
||||
let path = match self.direction {
|
||||
NavigationDirection::Next => manager.next_document(),
|
||||
NavigationDirection::Previous => manager.previous_document(),
|
||||
};
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Check if navigation is possible.
|
||||
#[must_use]
|
||||
pub fn can_execute(&self, manager: &DocumentManager) -> bool {
|
||||
match self.direction {
|
||||
NavigationDirection::Next => manager.has_next(),
|
||||
NavigationDirection::Previous => manager.has_previous(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_navigate_command_creation() {
|
||||
let cmd = NavigateCommand::new(NavigationDirection::Next);
|
||||
assert_eq!(cmd.direction, NavigationDirection::Next);
|
||||
|
||||
let cmd = NavigateCommand::new(NavigationDirection::Previous);
|
||||
assert_eq!(cmd.direction, NavigationDirection::Previous);
|
||||
}
|
||||
}
|
||||
34
src/application/commands/open_document.rs
Normal file
34
src/application/commands/open_document.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/commands/open_document.rs
|
||||
//
|
||||
// Open document command: load a document from a file path.
|
||||
// Reserved for future CQRS pattern - currently using direct DocumentManager methods.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::application::document_manager::DocumentManager;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
|
||||
/// Open document command.
|
||||
pub struct OpenDocumentCommand;
|
||||
|
||||
impl OpenDocumentCommand {
|
||||
/// Create a new open document command.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Execute the open document command.
|
||||
pub fn execute(&self, manager: &mut DocumentManager, path: &Path) -> DocResult<()> {
|
||||
manager.open_document(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpenDocumentCommand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
64
src/application/commands/save_document.rs
Normal file
64
src/application/commands/save_document.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/commands/save_document.rs
|
||||
//
|
||||
// Save document command: export document to a file.
|
||||
// Reserved for future implementation - not yet used.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::application::document_manager::DocumentManager;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::operations::export::ExportFormat;
|
||||
|
||||
/// Save document command.
|
||||
pub struct SaveDocumentCommand {
|
||||
/// Target format for export.
|
||||
format: Option<ExportFormat>,
|
||||
}
|
||||
|
||||
impl SaveDocumentCommand {
|
||||
/// Create a new save document command with automatic format detection.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self { format: None }
|
||||
}
|
||||
|
||||
/// Create a save document command with a specific format.
|
||||
#[must_use]
|
||||
pub fn with_format(format: ExportFormat) -> Self {
|
||||
Self {
|
||||
format: Some(format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the save document command.
|
||||
pub fn execute(&self, manager: &DocumentManager, path: &Path) -> DocResult<()> {
|
||||
let _document = manager
|
||||
.current_document()
|
||||
.ok_or_else(|| anyhow::anyhow!("No document loaded"))?;
|
||||
|
||||
// Detect format from path or use specified format
|
||||
let format = self
|
||||
.format
|
||||
.or_else(|| ExportFormat::from_path(path))
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine export format"))?;
|
||||
|
||||
// TODO: Implement actual save logic
|
||||
// This would involve:
|
||||
// 1. Getting the rendered image from the document
|
||||
// 2. Applying any necessary transformations
|
||||
// 3. Exporting to the target format
|
||||
|
||||
log::info!("Save to {} as {:?}", path.display(), format);
|
||||
|
||||
Err(anyhow::anyhow!("Save operation not yet implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SaveDocumentCommand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
81
src/application/commands/transform_document.rs
Normal file
81
src/application/commands/transform_document.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/commands/transform_document.rs
|
||||
//
|
||||
// Transform document command: rotate, flip, and other transformations.
|
||||
|
||||
use crate::application::document_manager::DocumentManager;
|
||||
use crate::domain::document::core::document::{DocResult, Rotation};
|
||||
use crate::domain::document::operations::transform;
|
||||
|
||||
/// Transformation operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum TransformOperation {
|
||||
/// Rotate clockwise by 90 degrees.
|
||||
RotateCw,
|
||||
/// Rotate counter-clockwise by 90 degrees.
|
||||
RotateCcw,
|
||||
/// Flip horizontally.
|
||||
FlipHorizontal,
|
||||
/// Flip vertically.
|
||||
FlipVertical,
|
||||
/// Rotate to a specific angle.
|
||||
RotateTo(Rotation),
|
||||
}
|
||||
|
||||
/// Transform document command.
|
||||
pub struct TransformDocumentCommand {
|
||||
operation: TransformOperation,
|
||||
}
|
||||
|
||||
impl TransformDocumentCommand {
|
||||
/// Create a new transform document command.
|
||||
#[must_use]
|
||||
pub fn new(operation: TransformOperation) -> Self {
|
||||
Self { operation }
|
||||
}
|
||||
|
||||
/// Execute the transform command.
|
||||
///
|
||||
/// Uses high-level transform operations that work across all document types
|
||||
/// (Raster, Vector, Portable).
|
||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
||||
let document = manager
|
||||
.current_document_mut()
|
||||
.ok_or_else(|| anyhow::anyhow!("No document loaded"))?;
|
||||
|
||||
match self.operation {
|
||||
TransformOperation::RotateCw => {
|
||||
transform::rotate_document_cw(document)?;
|
||||
}
|
||||
TransformOperation::RotateCcw => {
|
||||
transform::rotate_document_ccw(document)?;
|
||||
}
|
||||
TransformOperation::FlipHorizontal => {
|
||||
transform::flip_document_horizontal(document)?;
|
||||
}
|
||||
TransformOperation::FlipVertical => {
|
||||
transform::flip_document_vertical(document)?;
|
||||
}
|
||||
TransformOperation::RotateTo(rotation) => {
|
||||
transform::rotate_document_to(document, rotation)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transform_command_creation() {
|
||||
let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw);
|
||||
assert_eq!(cmd.operation, TransformOperation::RotateCw);
|
||||
|
||||
let cmd = TransformDocumentCommand::new(TransformOperation::FlipHorizontal);
|
||||
assert_eq!(cmd.operation, TransformOperation::FlipHorizontal);
|
||||
}
|
||||
}
|
||||
216
src/application/document_manager.rs
Normal file
216
src/application/document_manager.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/document_manager.rs
|
||||
//
|
||||
// Document manager: orchestrates document lifecycle and navigation.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::domain::document::collection::DocumentCollection;
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::core::metadata::DocumentMeta;
|
||||
use crate::infrastructure::filesystem::file_ops;
|
||||
use crate::infrastructure::loaders::DocumentLoaderFactory;
|
||||
|
||||
/// Central document manager.
|
||||
///
|
||||
/// Orchestrates document loading, metadata extraction, and folder navigation.
|
||||
/// Uses DocumentCollection (Domain Layer) for navigation logic.
|
||||
pub struct DocumentManager {
|
||||
/// Document collection for navigation (Domain Layer abstraction).
|
||||
collection: DocumentCollection,
|
||||
/// Current document metadata.
|
||||
current_metadata: Option<DocumentMeta>,
|
||||
/// Document loader factory.
|
||||
loader: DocumentLoaderFactory,
|
||||
}
|
||||
|
||||
impl DocumentManager {
|
||||
/// Create a new document manager.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collection: DocumentCollection::new(),
|
||||
current_metadata: None,
|
||||
loader: DocumentLoaderFactory::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a document from a file path or directory.
|
||||
///
|
||||
/// If a directory is provided, opens the first supported file found.
|
||||
/// Also scans the parent folder for navigation.
|
||||
pub fn open_document(&mut self, path: &Path) -> DocResult<()> {
|
||||
// Determine the actual file to open
|
||||
let file_path = if path.is_dir() {
|
||||
// Scan directory and find first supported file
|
||||
let paths = file_ops::collect_supported_files(path);
|
||||
self.collection = DocumentCollection::from_paths(paths);
|
||||
|
||||
self.collection
|
||||
.current_path()
|
||||
.ok_or_else(|| anyhow::anyhow!("No supported files found in directory"))?
|
||||
.clone()
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
// Load the document
|
||||
let document = self.loader.load(&file_path)?;
|
||||
|
||||
// Extract metadata
|
||||
let metadata = self.extract_metadata(&file_path, &document);
|
||||
|
||||
// Scan folder for navigation if not already done
|
||||
if !path.is_dir() {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
let paths = file_ops::collect_supported_files(parent);
|
||||
self.collection = DocumentCollection::from_paths(paths);
|
||||
// Find and set current document index
|
||||
if let Some(idx) = self.collection.paths().iter().position(|p| p == &file_path) {
|
||||
self.collection.goto(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnails for multi-page documents (PDF)
|
||||
let mut document = document;
|
||||
if document.is_multi_page() {
|
||||
log::info!("Generating thumbnails for multi-page document...");
|
||||
if let Err(e) = document.generate_thumbnails() {
|
||||
log::warn!("Failed to generate thumbnails: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Store document in collection
|
||||
self.collection.set_current_document(document);
|
||||
self.current_metadata = Some(metadata);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current document.
|
||||
#[must_use]
|
||||
pub fn current_document(&self) -> Option<&DocumentContent> {
|
||||
self.collection.current_document()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the current document.
|
||||
#[must_use]
|
||||
pub fn current_document_mut(&mut self) -> Option<&mut DocumentContent> {
|
||||
self.collection.current_document_mut()
|
||||
}
|
||||
|
||||
/// Get thumbnail handle for a specific page (read-only access).
|
||||
/// Returns None if the thumbnail hasn't been generated yet.
|
||||
#[must_use]
|
||||
pub fn get_thumbnail_handle(&self, page: usize) -> Option<cosmic::widget::image::Handle> {
|
||||
self.collection
|
||||
.current_document()?
|
||||
.get_thumbnail_handle(page)
|
||||
}
|
||||
|
||||
/// Get the current document path.
|
||||
#[must_use]
|
||||
pub fn current_path(&self) -> Option<&Path> {
|
||||
self.collection.current_path().map(|p| p.as_path())
|
||||
}
|
||||
|
||||
/// Get the current document metadata.
|
||||
#[must_use]
|
||||
pub fn current_metadata(&self) -> Option<&DocumentMeta> {
|
||||
self.current_metadata.as_ref()
|
||||
}
|
||||
|
||||
/// Get all folder entries for navigation.
|
||||
#[must_use]
|
||||
pub fn folder_entries(&self) -> &[PathBuf] {
|
||||
self.collection.paths()
|
||||
}
|
||||
|
||||
/// Get current index in folder.
|
||||
#[must_use]
|
||||
pub fn current_index(&self) -> Option<usize> {
|
||||
self.collection.current_index()
|
||||
}
|
||||
|
||||
/// Navigate to the next document in the folder.
|
||||
///
|
||||
/// Wraps around to the first document when at the end.
|
||||
pub fn next_document(&mut self) -> Option<PathBuf> {
|
||||
// Use DocumentCollection navigation
|
||||
if self.collection.has_next() {
|
||||
self.collection.next();
|
||||
} else if !self.collection.is_empty() {
|
||||
// Wrap around to first
|
||||
self.collection.goto(0);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
|
||||
let next_path = self.collection.current_path()?.clone();
|
||||
if self.open_document(&next_path).is_ok() {
|
||||
Some(next_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the previous document in the folder.
|
||||
///
|
||||
/// Wraps around to the last document when at the beginning.
|
||||
pub fn previous_document(&mut self) -> Option<PathBuf> {
|
||||
// Use DocumentCollection navigation
|
||||
if self.collection.has_previous() {
|
||||
self.collection.previous();
|
||||
} else if !self.collection.is_empty() {
|
||||
// Wrap around to last
|
||||
let last_idx = self.collection.len() - 1;
|
||||
self.collection.goto(last_idx);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prev_path = self.collection.current_path()?.clone();
|
||||
if self.open_document(&prev_path).is_ok() {
|
||||
Some(prev_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the current document.
|
||||
#[allow(dead_code)]
|
||||
pub fn close_document(&mut self) {
|
||||
self.collection.clear_current_document();
|
||||
self.current_metadata = None;
|
||||
}
|
||||
|
||||
/// Extract metadata from a document.
|
||||
fn extract_metadata(&self, path: &Path, document: &DocumentContent) -> DocumentMeta {
|
||||
// Use the document's own extract_meta() method
|
||||
// This properly delegates to the type-specific implementation
|
||||
// (RasterDocument, VectorDocument, or PortableDocument)
|
||||
document.extract_meta(path)
|
||||
}
|
||||
|
||||
/// Check if there is a next document available.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn has_next(&self) -> bool {
|
||||
self.collection.has_next()
|
||||
}
|
||||
|
||||
/// Check if there is a previous document available.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn has_previous(&self) -> bool {
|
||||
self.collection.has_previous()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DocumentManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
11
src/application/mod.rs
Normal file
11
src/application/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/mod.rs
|
||||
//
|
||||
// Application layer: use cases, commands, queries, and services.
|
||||
|
||||
pub mod commands;
|
||||
pub mod document_manager;
|
||||
pub mod services;
|
||||
|
||||
// Re-export document manager
|
||||
pub use document_manager::DocumentManager;
|
||||
81
src/application/services/cache_service.rs
Normal file
81
src/application/services/cache_service.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/services/cache_service.rs
|
||||
//
|
||||
// Cache service: manages document and thumbnail caching.
|
||||
// Reserved for future caching layer implementation.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
use image::DynamicImage;
|
||||
|
||||
use crate::infrastructure::cache::ThumbnailCache;
|
||||
|
||||
/// Cache service for managing document caches.
|
||||
///
|
||||
/// Provides high-level caching operations for the application layer.
|
||||
pub struct CacheService;
|
||||
|
||||
impl CacheService {
|
||||
/// Create a new cache service.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Load a thumbnail from cache.
|
||||
///
|
||||
/// Returns None if the thumbnail is not cached or the cache is invalid.
|
||||
#[must_use]
|
||||
pub fn get_thumbnail(&self, path: &Path, page: usize) -> Option<ImageHandle> {
|
||||
ThumbnailCache::load(path, page)
|
||||
}
|
||||
|
||||
/// Save a thumbnail to cache.
|
||||
///
|
||||
/// Returns true if the thumbnail was successfully cached.
|
||||
pub fn put_thumbnail(&self, path: &Path, page: usize, image: &DynamicImage) -> bool {
|
||||
ThumbnailCache::save(path, page, image).is_some()
|
||||
}
|
||||
|
||||
/// Clear all cached thumbnails.
|
||||
///
|
||||
/// This operation is not yet implemented.
|
||||
pub fn clear_cache(&self) -> Result<(), String> {
|
||||
ThumbnailCache::clear_cache().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get the size of the cache directory.
|
||||
///
|
||||
/// Returns the total size in bytes, or None if it cannot be determined.
|
||||
#[must_use]
|
||||
pub fn cache_size(&self) -> Option<u64> {
|
||||
// TODO: Implement cache size calculation
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CacheService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cache_service_creation() {
|
||||
let service = CacheService::new();
|
||||
assert!(std::ptr::eq(&service, &service)); // Dummy test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_service_default() {
|
||||
let service = CacheService::default();
|
||||
assert!(std::ptr::eq(&service, &service)); // Dummy test
|
||||
}
|
||||
}
|
||||
7
src/application/services/mod.rs
Normal file
7
src/application/services/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/services/mod.rs
|
||||
//
|
||||
// Application services: cache management and preview generation.
|
||||
|
||||
pub mod cache_service;
|
||||
pub mod preview_service;
|
||||
119
src/application/services/preview_service.rs
Normal file
119
src/application/services/preview_service.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/application/services/preview_service.rs
|
||||
//
|
||||
// Preview service: generates thumbnails and previews for documents.
|
||||
// Reserved for future async thumbnail generation implementation.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
|
||||
/// Preview service for generating document thumbnails and previews.
|
||||
///
|
||||
/// Provides high-level preview generation operations for the application layer.
|
||||
pub struct PreviewService {
|
||||
/// Target thumbnail size (width in pixels).
|
||||
thumbnail_size: u32,
|
||||
}
|
||||
|
||||
impl PreviewService {
|
||||
/// Create a new preview service with default thumbnail size.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
thumbnail_size: 256,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a preview service with a specific thumbnail size.
|
||||
#[must_use]
|
||||
pub fn with_thumbnail_size(size: u32) -> Self {
|
||||
Self {
|
||||
thumbnail_size: size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the thumbnail size.
|
||||
pub fn set_thumbnail_size(&mut self, size: u32) {
|
||||
self.thumbnail_size = size;
|
||||
}
|
||||
|
||||
/// Get the current thumbnail size.
|
||||
#[must_use]
|
||||
pub fn thumbnail_size(&self) -> u32 {
|
||||
self.thumbnail_size
|
||||
}
|
||||
|
||||
/// Generate a thumbnail for a document page.
|
||||
///
|
||||
/// For single-page documents, the page parameter is ignored.
|
||||
pub fn generate_thumbnail(
|
||||
&self,
|
||||
document: &mut DocumentContent,
|
||||
page: usize,
|
||||
) -> DocResult<Option<ImageHandle>> {
|
||||
if document.is_multi_page() {
|
||||
document.get_thumbnail(page)
|
||||
} else {
|
||||
// For single-page documents, return the current handle
|
||||
Ok(document.handle())
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails for a multi-page document.
|
||||
///
|
||||
/// Returns the number of thumbnails generated.
|
||||
pub fn generate_all_thumbnails(&self, document: &mut DocumentContent) -> DocResult<usize> {
|
||||
if !document.is_multi_page() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
document.generate_thumbnails()?;
|
||||
Ok(document.thumbnails_loaded())
|
||||
}
|
||||
|
||||
/// Check if all thumbnails are ready for a document.
|
||||
#[must_use]
|
||||
pub fn thumbnails_ready(&self, document: &DocumentContent) -> bool {
|
||||
document.thumbnails_ready()
|
||||
}
|
||||
|
||||
/// Get the number of thumbnails loaded for a document.
|
||||
#[must_use]
|
||||
pub fn thumbnails_loaded(&self, document: &DocumentContent) -> usize {
|
||||
document.thumbnails_loaded()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PreviewService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_preview_service_creation() {
|
||||
let service = PreviewService::new();
|
||||
assert_eq!(service.thumbnail_size(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preview_service_with_size() {
|
||||
let service = PreviewService::with_thumbnail_size(512);
|
||||
assert_eq!(service.thumbnail_size(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_thumbnail_size() {
|
||||
let mut service = PreviewService::new();
|
||||
service.set_thumbnail_size(128);
|
||||
assert_eq!(service.thumbnail_size(), 128);
|
||||
}
|
||||
}
|
||||
44
src/config.rs
Normal file
44
src/config.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/config.rs
|
||||
//
|
||||
// Global configuration for the application with cosmic-config support.
|
||||
|
||||
use cosmic::cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Global configuration for the application.
|
||||
#[derive(Debug, Clone, CosmicConfigEntry, PartialEq)]
|
||||
#[version = 1]
|
||||
pub struct AppConfig {
|
||||
/// Default directory to open when browsing for documents.
|
||||
pub default_image_dir: Option<PathBuf>,
|
||||
/// Show page navigation panel (left sidebar for multi-page documents).
|
||||
pub nav_bar_visible: bool,
|
||||
/// Show properties panel (right sidebar with metadata).
|
||||
pub context_drawer_visible: bool,
|
||||
/// Zoom step multiplier for keyboard shortcuts (1.1 = 10% increase per step).
|
||||
pub scale_step: f32,
|
||||
/// Pan distance in pixels per arrow key press.
|
||||
pub pan_step: f32,
|
||||
/// Minimum zoom level (0.1 = 10% of original size).
|
||||
pub min_scale: f32,
|
||||
/// Maximum zoom level (8.0 = 800% of original size).
|
||||
pub max_scale: f32,
|
||||
/// Show 3x3 grid during crop selection.
|
||||
pub crop_show_grid: bool,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_image_dir: dirs::picture_dir().or_else(dirs::home_dir),
|
||||
nav_bar_visible: false,
|
||||
context_drawer_visible: false,
|
||||
scale_step: 1.1,
|
||||
pan_step: 50.0,
|
||||
min_scale: 0.1,
|
||||
max_scale: 8.0,
|
||||
crop_show_grid: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
277
src/domain/document/collection.rs
Normal file
277
src/domain/document/collection.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/collection.rs
|
||||
//
|
||||
// Document collection for managing multiple documents.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
|
||||
/// A collection of documents with navigation support.
|
||||
///
|
||||
/// This abstraction is useful for:
|
||||
/// - Browsing through folders of images
|
||||
/// - Batch operations on multiple documents
|
||||
/// - Comparison views (showing multiple documents side-by-side)
|
||||
#[derive(Debug)]
|
||||
pub struct DocumentCollection {
|
||||
/// List of document paths in the collection.
|
||||
paths: Vec<PathBuf>,
|
||||
/// Currently active document index.
|
||||
current_index: Option<usize>,
|
||||
/// Currently loaded document (lazy-loaded).
|
||||
current_document: Option<DocumentContent>,
|
||||
}
|
||||
|
||||
impl DocumentCollection {
|
||||
/// Create an empty collection.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
paths: Vec::new(),
|
||||
current_index: None,
|
||||
current_document: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a collection from a list of paths.
|
||||
#[must_use]
|
||||
pub fn from_paths(paths: Vec<PathBuf>) -> Self {
|
||||
let current_index = if paths.is_empty() { None } else { Some(0) };
|
||||
|
||||
Self {
|
||||
paths,
|
||||
current_index,
|
||||
current_document: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of documents in the collection.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.paths.len()
|
||||
}
|
||||
|
||||
/// Check if the collection is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.paths.is_empty()
|
||||
}
|
||||
|
||||
/// Get the current document index (0-based).
|
||||
#[must_use]
|
||||
pub fn current_index(&self) -> Option<usize> {
|
||||
self.current_index
|
||||
}
|
||||
|
||||
/// Get the current document path.
|
||||
#[must_use]
|
||||
pub fn current_path(&self) -> Option<&PathBuf> {
|
||||
self.current_index.and_then(|idx| self.paths.get(idx))
|
||||
}
|
||||
|
||||
/// Get all paths in the collection.
|
||||
#[must_use]
|
||||
pub fn paths(&self) -> &[PathBuf] {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
/// Get a reference to the currently loaded document.
|
||||
#[must_use]
|
||||
pub fn current_document(&self) -> Option<&DocumentContent> {
|
||||
self.current_document.as_ref()
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the currently loaded document.
|
||||
#[must_use]
|
||||
pub fn current_document_mut(&mut self) -> Option<&mut DocumentContent> {
|
||||
self.current_document.as_mut()
|
||||
}
|
||||
|
||||
/// Set the currently loaded document.
|
||||
pub fn set_current_document(&mut self, document: DocumentContent) {
|
||||
self.current_document = Some(document);
|
||||
}
|
||||
|
||||
/// Clear the currently loaded document.
|
||||
pub fn clear_current_document(&mut self) {
|
||||
self.current_document = None;
|
||||
}
|
||||
|
||||
/// Navigate to the next document in the collection.
|
||||
///
|
||||
/// Returns the new index if successful, None if already at the end.
|
||||
pub fn next(&mut self) -> Option<usize> {
|
||||
if let Some(current) = self.current_index
|
||||
&& current + 1 < self.paths.len()
|
||||
{
|
||||
self.current_index = Some(current + 1);
|
||||
self.current_document = None; // Clear document (needs reload)
|
||||
return self.current_index;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Navigate to the previous document in the collection.
|
||||
///
|
||||
/// Returns the new index if successful, None if already at the start.
|
||||
pub fn previous(&mut self) -> Option<usize> {
|
||||
if let Some(current) = self.current_index
|
||||
&& current > 0
|
||||
{
|
||||
self.current_index = Some(current - 1);
|
||||
self.current_document = None; // Clear document (needs reload)
|
||||
return self.current_index;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Navigate to a specific index.
|
||||
///
|
||||
/// Returns true if the index is valid and navigation succeeded.
|
||||
pub fn goto(&mut self, index: usize) -> bool {
|
||||
if index < self.paths.len() {
|
||||
self.current_index = Some(index);
|
||||
self.current_document = None; // Clear document (needs reload)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a document path to the collection.
|
||||
pub fn add_path(&mut self, path: PathBuf) {
|
||||
self.paths.push(path);
|
||||
if self.current_index.is_none() {
|
||||
self.current_index = Some(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a document path at the given index.
|
||||
///
|
||||
/// Returns the removed path if successful.
|
||||
pub fn remove_at(&mut self, index: usize) -> Option<PathBuf> {
|
||||
if index < self.paths.len() {
|
||||
let removed = self.paths.remove(index);
|
||||
|
||||
// Update current index if needed
|
||||
if let Some(current) = self.current_index {
|
||||
if current == index {
|
||||
// Removed current document
|
||||
self.current_document = None;
|
||||
if self.paths.is_empty() {
|
||||
self.current_index = None;
|
||||
} else if current >= self.paths.len() {
|
||||
self.current_index = Some(self.paths.len() - 1);
|
||||
}
|
||||
} else if current > index {
|
||||
// Adjust index after removal
|
||||
self.current_index = Some(current - 1);
|
||||
}
|
||||
}
|
||||
|
||||
Some(removed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the entire collection.
|
||||
pub fn clear(&mut self) {
|
||||
self.paths.clear();
|
||||
self.current_index = None;
|
||||
self.current_document = None;
|
||||
}
|
||||
|
||||
/// Check if there is a next document available.
|
||||
#[must_use]
|
||||
pub fn has_next(&self) -> bool {
|
||||
if let Some(current) = self.current_index {
|
||||
current + 1 < self.paths.len()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if there is a previous document available.
|
||||
#[must_use]
|
||||
pub fn has_previous(&self) -> bool {
|
||||
if let Some(current) = self.current_index {
|
||||
current > 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the path at a specific index.
|
||||
#[must_use]
|
||||
pub fn path_at(&self, index: usize) -> Option<&PathBuf> {
|
||||
self.paths.get(index)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DocumentCollection {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_empty_collection() {
|
||||
let collection = DocumentCollection::new();
|
||||
assert!(collection.is_empty());
|
||||
assert_eq!(collection.len(), 0);
|
||||
assert_eq!(collection.current_index(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigation() {
|
||||
let paths = vec![
|
||||
PathBuf::from("a.png"),
|
||||
PathBuf::from("b.png"),
|
||||
PathBuf::from("c.png"),
|
||||
];
|
||||
let mut collection = DocumentCollection::from_paths(paths);
|
||||
|
||||
assert_eq!(collection.current_index(), Some(0));
|
||||
assert_eq!(collection.next(), Some(1));
|
||||
assert_eq!(collection.next(), Some(2));
|
||||
assert_eq!(collection.next(), None); // At end
|
||||
assert_eq!(collection.previous(), Some(1));
|
||||
assert_eq!(collection.previous(), Some(0));
|
||||
assert_eq!(collection.previous(), None); // At start
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_goto() {
|
||||
let paths = vec![
|
||||
PathBuf::from("a.png"),
|
||||
PathBuf::from("b.png"),
|
||||
PathBuf::from("c.png"),
|
||||
];
|
||||
let mut collection = DocumentCollection::from_paths(paths);
|
||||
|
||||
assert!(collection.goto(2));
|
||||
assert_eq!(collection.current_index(), Some(2));
|
||||
assert!(!collection.goto(10)); // Invalid index
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove() {
|
||||
let paths = vec![
|
||||
PathBuf::from("a.png"),
|
||||
PathBuf::from("b.png"),
|
||||
PathBuf::from("c.png"),
|
||||
];
|
||||
let mut collection = DocumentCollection::from_paths(paths);
|
||||
|
||||
collection.goto(1);
|
||||
assert_eq!(collection.remove_at(1), Some(PathBuf::from("b.png")));
|
||||
assert_eq!(collection.len(), 2);
|
||||
assert_eq!(collection.current_index(), Some(1)); // Now points to c.png
|
||||
}
|
||||
}
|
||||
403
src/domain/document/core/content.rs
Normal file
403
src/domain/document/core/content.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/core/content.rs
|
||||
//
|
||||
// Type-erased document content enum.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use super::document::{
|
||||
DocResult, DocumentInfo, FlipDirection, InterpolationQuality, MultiPage, MultiPageThumbnails,
|
||||
RenderOutput, Renderable, Rotation, RotationMode, Transformable, TransformState,
|
||||
};
|
||||
|
||||
use crate::domain::document::types::raster::RasterDocument;
|
||||
#[cfg(feature = "vector")]
|
||||
use crate::domain::document::types::vector::VectorDocument;
|
||||
#[cfg(feature = "portable")]
|
||||
use crate::domain::document::types::portable::PortableDocument;
|
||||
|
||||
// ============================================================================
|
||||
// Document Kind
|
||||
// ============================================================================
|
||||
|
||||
/// Supported document kinds (for format detection).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DocumentKind {
|
||||
Raster,
|
||||
Vector,
|
||||
Portable,
|
||||
}
|
||||
|
||||
impl DocumentKind {
|
||||
/// Detect document kind from file path.
|
||||
#[must_use]
|
||||
pub fn from_path(path: &Path) -> Option<Self> {
|
||||
let ext = path.extension()?.to_str()?.to_lowercase();
|
||||
|
||||
// SVG
|
||||
#[cfg(feature = "vector")]
|
||||
if ext == "svg" || ext == "svgz" {
|
||||
return Some(Self::Vector);
|
||||
}
|
||||
|
||||
// PDF
|
||||
#[cfg(feature = "portable")]
|
||||
if ext == "pdf" {
|
||||
return Some(Self::Portable);
|
||||
}
|
||||
|
||||
// Raster: Check via cosmic/image-rs
|
||||
if CosmicImageFormat::from_path(path).is_ok() {
|
||||
return Some(Self::Raster);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DocumentKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Raster => write!(f, "Raster"),
|
||||
Self::Vector => write!(f, "Vector"),
|
||||
Self::Portable => write!(f, "Portable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document Content Enum
|
||||
// ============================================================================
|
||||
|
||||
/// Type-erased document content.
|
||||
///
|
||||
/// The application only holds one document at a time, so the size difference
|
||||
/// between variants is acceptable. Boxing would add unnecessary indirection.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum DocumentContent {
|
||||
Raster(RasterDocument),
|
||||
#[cfg(feature = "vector")]
|
||||
Vector(VectorDocument),
|
||||
#[cfg(feature = "portable")]
|
||||
Portable(PortableDocument),
|
||||
}
|
||||
|
||||
impl fmt::Debug for DocumentContent {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Raster(_) => write!(f, "DocumentContent::Raster(...)"),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(_) => write!(f, "DocumentContent::Vector(...)"),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(_) => write!(f, "DocumentContent::Portable(...)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for DocumentContent {
|
||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput> {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.render(scale),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.render(scale),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.render(scale),
|
||||
}
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.info(),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.info(),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.info(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for DocumentContent {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.rotate(rotation),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.rotate(rotation),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.rotate(rotation),
|
||||
}
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.flip(direction),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.flip(direction),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.flip(direction),
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.transform_state(),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.transform_state(),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.transform_state(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rotate_fine(&mut self, angle_degrees: f32) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.rotate_fine(angle_degrees),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.rotate_fine(angle_degrees),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.rotate_fine(angle_degrees),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_fine_rotation(&mut self) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.reset_fine_rotation(),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.reset_fine_rotation(),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.reset_fine_rotation(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_interpolation_quality(&mut self, quality: InterpolationQuality) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.set_interpolation_quality(quality),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.set_interpolation_quality(quality),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.set_interpolation_quality(quality),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Methods
|
||||
// ============================================================================
|
||||
|
||||
impl DocumentContent {
|
||||
/// Rotate document 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
let new_rotation_mode = self.transform_state().rotation.rotate_cw();
|
||||
match new_rotation_mode {
|
||||
RotationMode::Standard(rot) => self.rotate(rot),
|
||||
RotationMode::Fine(deg) => {
|
||||
let normalized = ((deg / 90.0).round() as i16 * 90) % 360;
|
||||
let rot = match normalized {
|
||||
0 => Rotation::None,
|
||||
90 => Rotation::Cw90,
|
||||
180 => Rotation::Cw180,
|
||||
270 => Rotation::Cw270,
|
||||
_ => Rotation::None,
|
||||
};
|
||||
self.rotate(rot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate document 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
let new_rotation_mode = self.transform_state().rotation.rotate_ccw();
|
||||
match new_rotation_mode {
|
||||
RotationMode::Standard(rot) => self.rotate(rot),
|
||||
RotationMode::Fine(deg) => {
|
||||
let normalized = ((deg / 90.0).round() as i16 * 90 + 360) % 360;
|
||||
let rot = match normalized {
|
||||
0 => Rotation::None,
|
||||
90 => Rotation::Cw90,
|
||||
180 => Rotation::Cw180,
|
||||
270 => Rotation::Cw270,
|
||||
_ => Rotation::None,
|
||||
};
|
||||
self.rotate(rot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip document horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.flip(FlipDirection::Horizontal);
|
||||
}
|
||||
|
||||
/// Flip document vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.flip(FlipDirection::Vertical);
|
||||
}
|
||||
|
||||
/// Get the document kind.
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> DocumentKind {
|
||||
match self {
|
||||
Self::Raster(_) => DocumentKind::Raster,
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(_) => DocumentKind::Vector,
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(_) => DocumentKind::Portable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if document supports multiple pages.
|
||||
#[must_use]
|
||||
pub fn is_multi_page(&self) -> bool {
|
||||
matches!(self, Self::Portable(_))
|
||||
}
|
||||
|
||||
/// Get total page count (returns 1 for single-page documents).
|
||||
#[must_use]
|
||||
pub fn page_count(&self) -> usize {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.page_count(),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current page index (0 for single-page documents).
|
||||
#[must_use]
|
||||
pub fn current_page(&self) -> usize {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.current_page(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to a specific page (no-op for single-page documents).
|
||||
pub fn go_to_page(&mut self, page: usize) -> DocResult<()> {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.go_to_page(page),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get thumbnail for a specific page (mutable access for trait compatibility).
|
||||
pub fn get_thumbnail(&mut self, page: usize) -> DocResult<Option<ImageHandle>> {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.get_thumbnail(page),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get thumbnail handle for a specific page (read-only access).
|
||||
/// Returns None if the thumbnail hasn't been generated yet.
|
||||
#[must_use]
|
||||
pub fn get_thumbnail_handle(&self, page: usize) -> Option<ImageHandle> {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.get_thumbnail_handle(page),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if thumbnails are ready to be generated.
|
||||
#[must_use]
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.thumbnails_ready(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get count of thumbnails currently loaded.
|
||||
#[must_use]
|
||||
pub fn thumbnails_loaded(&self) -> usize {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => PortableDocument::thumbnails_loaded(doc),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if all thumbnails have been loaded (trait-compliant).
|
||||
#[must_use]
|
||||
pub fn all_thumbnails_loaded(&self) -> bool {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => MultiPageThumbnails::thumbnails_loaded(doc),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate thumbnail for a specific page.
|
||||
pub fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()> {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => MultiPageThumbnails::generate_thumbnail_page(doc, page),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails.
|
||||
pub fn generate_thumbnails(&mut self) -> DocResult<()> {
|
||||
match self {
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => MultiPageThumbnails::generate_all_thumbnails(doc),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current rendered image handle.
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> Option<ImageHandle> {
|
||||
match self {
|
||||
Self::Raster(doc) => Some(doc.handle()),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => Some(doc.handle()),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => Some(doc.handle()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current dimensions after transformations.
|
||||
#[must_use]
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.dimensions(),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.dimensions(),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.dimensions(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop the document (supported for all types - works on rendered output).
|
||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.crop(x, y, width, height).map_err(|e| anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract document metadata (basic info and EXIF if available).
|
||||
#[must_use]
|
||||
pub fn extract_meta(&self, path: &Path) -> crate::domain::document::core::metadata::DocumentMeta {
|
||||
match self {
|
||||
Self::Raster(doc) => doc.extract_meta(path),
|
||||
#[cfg(feature = "vector")]
|
||||
Self::Vector(doc) => doc.extract_meta(path),
|
||||
#[cfg(feature = "portable")]
|
||||
Self::Portable(doc) => doc.extract_meta(path),
|
||||
}
|
||||
}
|
||||
}
|
||||
250
src/domain/document/core/document.rs
Normal file
250
src/domain/document/core/document.rs
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/core/document.rs
|
||||
//
|
||||
// Core document traits and abstractions.
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// Result type alias for document operations.
|
||||
pub type DocResult<T> = anyhow::Result<T>;
|
||||
|
||||
/// Rotation state for documents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Rotation {
|
||||
/// No rotation (0 degrees).
|
||||
#[default]
|
||||
None,
|
||||
/// 90 degrees clockwise.
|
||||
Cw90,
|
||||
/// 180 degrees.
|
||||
Cw180,
|
||||
/// 270 degrees clockwise (90 counter-clockwise).
|
||||
Cw270,
|
||||
}
|
||||
|
||||
impl Rotation {
|
||||
/// Rotate clockwise by 90 degrees.
|
||||
#[must_use]
|
||||
pub fn rotate_cw(self) -> Self {
|
||||
match self {
|
||||
Self::None => Self::Cw90,
|
||||
Self::Cw90 => Self::Cw180,
|
||||
Self::Cw180 => Self::Cw270,
|
||||
Self::Cw270 => Self::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate counter-clockwise by 90 degrees.
|
||||
#[must_use]
|
||||
pub fn rotate_ccw(self) -> Self {
|
||||
match self {
|
||||
Self::None => Self::Cw270,
|
||||
Self::Cw270 => Self::Cw180,
|
||||
Self::Cw180 => Self::Cw90,
|
||||
Self::Cw90 => Self::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to degrees (0, 90, 180, 270).
|
||||
#[must_use]
|
||||
pub fn to_degrees(self) -> i16 {
|
||||
match self {
|
||||
Self::None => 0,
|
||||
Self::Cw90 => 90,
|
||||
Self::Cw180 => 180,
|
||||
Self::Cw270 => 270,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation mode: standard 90° steps or fine-grained rotation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum RotationMode {
|
||||
/// Standard 90° rotation (lossless for most formats).
|
||||
Standard(Rotation),
|
||||
/// Fine-grained rotation in degrees (0.0 - 360.0) with interpolation.
|
||||
Fine(f32),
|
||||
}
|
||||
|
||||
impl Default for RotationMode {
|
||||
fn default() -> Self {
|
||||
Self::Standard(Rotation::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl RotationMode {
|
||||
/// Convert rotation to degrees (0.0 - 360.0).
|
||||
#[must_use]
|
||||
pub fn to_degrees(self) -> f32 {
|
||||
match self {
|
||||
Self::Standard(r) => f32::from(r.to_degrees()),
|
||||
Self::Fine(deg) => deg,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if rotation is a multiple of 90 degrees.
|
||||
#[must_use]
|
||||
pub fn is_multiple_of_90(self) -> bool {
|
||||
match self {
|
||||
Self::Standard(_) => true,
|
||||
Self::Fine(deg) => (deg % 90.0).abs() < 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if no rotation is applied.
|
||||
#[must_use]
|
||||
pub fn is_none(self) -> bool {
|
||||
match self {
|
||||
Self::Standard(Rotation::None) => true,
|
||||
Self::Standard(_) => false,
|
||||
Self::Fine(deg) => deg.abs() < 0.01,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate clockwise by 90 degrees.
|
||||
#[must_use]
|
||||
pub fn rotate_cw(self) -> Self {
|
||||
match self {
|
||||
Self::Standard(r) => Self::Standard(r.rotate_cw()),
|
||||
Self::Fine(deg) => Self::Fine((deg + 90.0) % 360.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate counter-clockwise by 90 degrees.
|
||||
#[must_use]
|
||||
pub fn rotate_ccw(self) -> Self {
|
||||
match self {
|
||||
Self::Standard(r) => Self::Standard(r.rotate_ccw()),
|
||||
Self::Fine(deg) => Self::Fine((deg - 90.0 + 360.0) % 360.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpolation quality for fine rotation and resizing operations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub enum InterpolationQuality {
|
||||
/// Fast, nearest neighbor interpolation.
|
||||
Fast,
|
||||
/// Balanced bilinear interpolation (default).
|
||||
#[default]
|
||||
Balanced,
|
||||
/// Best quality, bicubic interpolation.
|
||||
Best,
|
||||
}
|
||||
|
||||
/// Flip direction for documents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FlipDirection {
|
||||
/// Flip along the vertical axis (mirror left-right).
|
||||
Horizontal,
|
||||
/// Flip along the horizontal axis (mirror top-bottom).
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// Current transformation state of a document.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct TransformState {
|
||||
/// Current rotation mode (standard 90° or fine rotation).
|
||||
pub rotation: RotationMode,
|
||||
/// Whether flipped horizontally.
|
||||
pub flip_h: bool,
|
||||
/// Whether flipped vertically.
|
||||
pub flip_v: bool,
|
||||
}
|
||||
|
||||
/// Output of a render operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderOutput {
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Rendered width in pixels.
|
||||
pub width: u32,
|
||||
/// Rendered height in pixels.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Document metadata/information.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DocumentInfo {
|
||||
/// Native width in pixels (before transforms).
|
||||
pub width: u32,
|
||||
/// Native height in pixels (before transforms).
|
||||
pub height: u32,
|
||||
/// Document format description.
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Traits
|
||||
// ============================================================================
|
||||
|
||||
/// Trait for documents that can be rendered to an image.
|
||||
pub trait Renderable {
|
||||
/// Render the document at the given scale factor.
|
||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput>;
|
||||
|
||||
/// Get document information (dimensions, format).
|
||||
fn info(&self) -> DocumentInfo;
|
||||
}
|
||||
|
||||
/// Trait for documents that support geometric transformations.
|
||||
pub trait Transformable {
|
||||
/// Apply a standard 90° rotation.
|
||||
fn rotate(&mut self, rotation: Rotation);
|
||||
|
||||
/// Flip in the given direction.
|
||||
fn flip(&mut self, direction: FlipDirection);
|
||||
|
||||
/// Get the current transformation state.
|
||||
fn transform_state(&self) -> TransformState;
|
||||
|
||||
/// Apply fine-grained rotation in degrees (0.0 - 360.0).
|
||||
fn rotate_fine(&mut self, _angle_degrees: f32) {
|
||||
// Default: no-op (not all formats support fine rotation)
|
||||
}
|
||||
|
||||
/// Reset any accumulated fine rotation.
|
||||
fn reset_fine_rotation(&mut self) {
|
||||
// Default: no-op
|
||||
}
|
||||
|
||||
/// Set interpolation quality for transformations.
|
||||
fn set_interpolation_quality(&mut self, _quality: InterpolationQuality) {
|
||||
// Default: no-op
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for documents with multiple pages.
|
||||
pub trait MultiPage {
|
||||
/// Get total number of pages.
|
||||
fn page_count(&self) -> usize;
|
||||
|
||||
/// Get current page index (0-based).
|
||||
fn current_page(&self) -> usize;
|
||||
|
||||
/// Navigate to a specific page.
|
||||
fn go_to_page(&mut self, page: usize) -> DocResult<()>;
|
||||
}
|
||||
|
||||
/// Trait for multi-page documents that support thumbnail generation.
|
||||
pub trait MultiPageThumbnails: MultiPage {
|
||||
/// Get thumbnail for a specific page.
|
||||
fn get_thumbnail(&mut self, page: usize) -> DocResult<Option<ImageHandle>>;
|
||||
|
||||
/// Check if thumbnails are ready to be generated.
|
||||
fn thumbnails_ready(&self) -> bool;
|
||||
|
||||
/// Check if all thumbnails have been loaded.
|
||||
fn thumbnails_loaded(&self) -> bool;
|
||||
|
||||
/// Generate thumbnail for a specific page.
|
||||
fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()>;
|
||||
|
||||
/// Generate all thumbnails.
|
||||
fn generate_all_thumbnails(&mut self) -> DocResult<()>;
|
||||
}
|
||||
197
src/domain/document/core/metadata.rs
Normal file
197
src/domain/document/core/metadata.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/core/metadata.rs
|
||||
//
|
||||
// Document metadata structures and EXIF parsing.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Minutes per degree for GPS coordinate conversion (DMS to decimal degrees).
|
||||
const MINUTES_PER_DEGREE: f64 = 60.0;
|
||||
|
||||
/// Seconds per degree for GPS coordinate conversion (DMS to decimal degrees).
|
||||
const SECONDS_PER_DEGREE: f64 = 3600.0;
|
||||
|
||||
/// Basic document metadata (always available).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BasicMeta {
|
||||
/// File name (without path).
|
||||
pub file_name: String,
|
||||
/// Full file path.
|
||||
pub file_path: String,
|
||||
/// Image format as string (e.g., "PNG", "JPEG", "PDF").
|
||||
pub format: String,
|
||||
/// Width in pixels.
|
||||
pub width: u32,
|
||||
/// Height in pixels.
|
||||
pub height: u32,
|
||||
/// File size in bytes.
|
||||
pub file_size: u64,
|
||||
/// Color type description (e.g., "RGBA8", "RGB8", "Grayscale").
|
||||
pub color_type: String,
|
||||
}
|
||||
|
||||
impl BasicMeta {
|
||||
/// Format file size as human-readable string.
|
||||
pub fn file_size_display(&self) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = KB * 1024;
|
||||
const GB: u64 = MB * 1024;
|
||||
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
if self.file_size >= GB {
|
||||
let size_gb = self.file_size as f64 / GB as f64;
|
||||
format!("{size_gb:.2} GB")
|
||||
} else if self.file_size >= MB {
|
||||
let size_mb = self.file_size as f64 / MB as f64;
|
||||
format!("{size_mb:.2} MB")
|
||||
} else if self.file_size >= KB {
|
||||
let size_kb = self.file_size as f64 / KB as f64;
|
||||
format!("{size_kb:.1} KB")
|
||||
} else {
|
||||
let size = self.file_size;
|
||||
format!("{size} B")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format resolution as "W × H".
|
||||
pub fn resolution_display(&self) -> String {
|
||||
format!("{} × {}", self.width, self.height)
|
||||
}
|
||||
}
|
||||
|
||||
/// EXIF metadata (optional, mainly for JPEG/TIFF).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExifMeta {
|
||||
pub camera_make: Option<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub date_time: Option<String>,
|
||||
pub exposure_time: Option<String>,
|
||||
pub f_number: Option<String>,
|
||||
pub iso: Option<u32>,
|
||||
pub focal_length: Option<String>,
|
||||
pub gps_latitude: Option<f64>,
|
||||
pub gps_longitude: Option<f64>,
|
||||
}
|
||||
|
||||
impl ExifMeta {
|
||||
/// Parse EXIF data from raw image bytes.
|
||||
///
|
||||
/// Extracts camera information, exposure settings, and GPS coordinates
|
||||
/// from JPEG/TIFF EXIF metadata using the kamadak-exif crate.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
|
||||
use exif::{In, Reader, Tag};
|
||||
|
||||
let cursor = Cursor::new(bytes);
|
||||
let exif_reader = Reader::new();
|
||||
let exif = exif_reader.read_from_container(&mut cursor.clone()).ok()?;
|
||||
|
||||
let mut meta = Self::default();
|
||||
|
||||
// Camera make and model
|
||||
if let Some(field) = exif.get_field(Tag::Make, In::PRIMARY) {
|
||||
meta.camera_make = Some(field.display_value().to_string().trim().to_string());
|
||||
}
|
||||
if let Some(field) = exif.get_field(Tag::Model, In::PRIMARY) {
|
||||
meta.camera_model = Some(field.display_value().to_string().trim().to_string());
|
||||
}
|
||||
|
||||
// Date and time
|
||||
if let Some(field) = exif.get_field(Tag::DateTime, In::PRIMARY) {
|
||||
meta.date_time = Some(field.display_value().to_string());
|
||||
}
|
||||
|
||||
// Exposure time
|
||||
if let Some(field) = exif.get_field(Tag::ExposureTime, In::PRIMARY) {
|
||||
meta.exposure_time = Some(field.display_value().to_string());
|
||||
}
|
||||
|
||||
// F-number (aperture)
|
||||
if let Some(field) = exif.get_field(Tag::FNumber, In::PRIMARY) {
|
||||
meta.f_number = Some(field.display_value().to_string());
|
||||
}
|
||||
|
||||
// ISO speed
|
||||
if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY) {
|
||||
if let exif::Value::Short(ref vec) = field.value {
|
||||
if let Some(&iso) = vec.first() {
|
||||
meta.iso = Some(u32::from(iso));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Focal length
|
||||
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
|
||||
meta.focal_length = Some(field.display_value().to_string());
|
||||
}
|
||||
|
||||
// GPS coordinates
|
||||
meta.gps_latitude = Self::parse_gps_coord(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef);
|
||||
meta.gps_longitude = Self::parse_gps_coord(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef);
|
||||
|
||||
Some(meta)
|
||||
}
|
||||
|
||||
/// Parse GPS coordinate from EXIF data (converts DMS to decimal degrees).
|
||||
fn parse_gps_coord(exif: &exif::Exif, coord_tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
|
||||
use exif::{In, Value};
|
||||
|
||||
let coord_field = exif.get_field(coord_tag, In::PRIMARY)?;
|
||||
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
|
||||
|
||||
// Get reference (N/S for latitude, E/W for longitude)
|
||||
let reference = ref_field.display_value().to_string();
|
||||
|
||||
// Parse DMS (Degrees, Minutes, Seconds) values
|
||||
if let Value::Rational(ref rationals) = coord_field.value {
|
||||
if rationals.len() >= 3 {
|
||||
let degrees = rationals[0].to_f64();
|
||||
let minutes = rationals[1].to_f64();
|
||||
let seconds = rationals[2].to_f64();
|
||||
|
||||
// Convert to decimal degrees
|
||||
let mut decimal =
|
||||
degrees + (minutes / MINUTES_PER_DEGREE) + (seconds / SECONDS_PER_DEGREE);
|
||||
|
||||
// Apply sign based on hemisphere
|
||||
if reference == "S" || reference == "W" {
|
||||
decimal = -decimal;
|
||||
}
|
||||
|
||||
return Some(decimal);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Combined camera make and model for display.
|
||||
pub fn camera_display(&self) -> Option<String> {
|
||||
match (&self.camera_make, &self.camera_model) {
|
||||
(Some(make), Some(model)) => {
|
||||
if model.starts_with(make) {
|
||||
Some(model.clone())
|
||||
} else {
|
||||
Some(format!("{make} {model}"))
|
||||
}
|
||||
}
|
||||
(Some(make), None) => Some(make.clone()),
|
||||
(None, Some(model)) => Some(model.clone()),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format GPS coordinates for display.
|
||||
pub fn gps_display(&self) -> Option<String> {
|
||||
match (self.gps_latitude, self.gps_longitude) {
|
||||
(Some(lat), Some(lon)) => Some(format!("{lat:.5}, {lon:.5}")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete document metadata container.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DocumentMeta {
|
||||
pub basic: BasicMeta,
|
||||
pub exif: Option<ExifMeta>,
|
||||
}
|
||||
13
src/domain/document/core/mod.rs
Normal file
13
src/domain/document/core/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/core/mod.rs
|
||||
//
|
||||
// Core document abstractions: traits, types, and metadata.
|
||||
|
||||
pub mod content;
|
||||
pub mod document;
|
||||
pub mod metadata;
|
||||
pub mod page;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use content::DocumentContent;
|
||||
pub use metadata::DocumentMeta;
|
||||
74
src/domain/document/core/page.rs
Normal file
74
src/domain/document/core/page.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/core/page.rs
|
||||
//
|
||||
// Page abstraction for multi-page documents.
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
/// Represents a single page in a multi-page document.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct Page {
|
||||
/// Page index (0-based).
|
||||
pub index: usize,
|
||||
/// Page width in pixels.
|
||||
pub width: u32,
|
||||
/// Page height in pixels.
|
||||
pub height: u32,
|
||||
/// Optional thumbnail handle.
|
||||
pub thumbnail: Option<ImageHandle>,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
/// Create a new page.
|
||||
#[must_use]
|
||||
pub fn new(index: usize, width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
index,
|
||||
width,
|
||||
height,
|
||||
thumbnail: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a page with a thumbnail.
|
||||
#[must_use]
|
||||
pub fn with_thumbnail(index: usize, width: u32, height: u32, thumbnail: ImageHandle) -> Self {
|
||||
Self {
|
||||
index,
|
||||
width,
|
||||
height,
|
||||
thumbnail: Some(thumbnail),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the thumbnail for this page.
|
||||
pub fn set_thumbnail(&mut self, thumbnail: ImageHandle) {
|
||||
self.thumbnail = Some(thumbnail);
|
||||
}
|
||||
|
||||
/// Check if this page has a thumbnail.
|
||||
#[must_use]
|
||||
pub fn has_thumbnail(&self) -> bool {
|
||||
self.thumbnail.is_some()
|
||||
}
|
||||
|
||||
/// Get the aspect ratio of the page.
|
||||
#[must_use]
|
||||
pub fn aspect_ratio(&self) -> f32 {
|
||||
if self.height == 0 {
|
||||
1.0
|
||||
} else {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
{
|
||||
self.width as f32 / self.height as f32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get page dimensions as a tuple.
|
||||
#[must_use]
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
}
|
||||
17
src/domain/document/mod.rs
Normal file
17
src/domain/document/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/mod.rs
|
||||
//
|
||||
// Document domain: core abstractions, types, and operations.
|
||||
|
||||
pub mod collection;
|
||||
pub mod core;
|
||||
pub mod operations;
|
||||
pub mod types;
|
||||
|
||||
// Re-export core abstractions (only used ones)
|
||||
#[allow(unused_imports)]
|
||||
pub use core::{DocumentContent, DocumentMeta};
|
||||
|
||||
// Note: Low-level pixel operations (apply_rotation, apply_flip, crop_image)
|
||||
// are internal helpers used only by document type implementations.
|
||||
// Use high-level operations above for all application and UI code.
|
||||
281
src/domain/document/operations/README.md
Normal file
281
src/domain/document/operations/README.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# Document Operations
|
||||
|
||||
This module provides transformation, rendering, and export operations for documents.
|
||||
|
||||
## Architecture: Two-Level Operations
|
||||
|
||||
The operations module is designed with **two distinct levels** of abstraction:
|
||||
|
||||
### 1. Low-Level Operations (Internal/Private)
|
||||
|
||||
**Purpose:** Direct manipulation of pixel data for raster images.
|
||||
|
||||
**Visibility:** `pub(crate)` - Internal to the crate only.
|
||||
|
||||
**Location:** `transform.rs` (internal helpers)
|
||||
|
||||
**Functions:**
|
||||
- `apply_rotation(img, rotation)` - Rotate raster pixels
|
||||
- `apply_flip(img, direction)` - Flip raster pixels
|
||||
- `crop_to_image(img, x, y, w, h)` - Crop raster to image
|
||||
|
||||
**When to use:**
|
||||
- ONLY in document type implementations (RasterDocument, VectorDocument, PortableDocument)
|
||||
- NOT accessible outside the crate
|
||||
- NOT for application or UI code
|
||||
|
||||
**Example:**
|
||||
```rust
|
||||
// INTERNAL USE ONLY - in document type implementations
|
||||
impl Transformable for RasterDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
// Low-level operation used internally
|
||||
self.image = apply_rotation(self.image, rotation);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. High-Level Operations (Type-Agnostic)
|
||||
|
||||
**Purpose:** Document transformations that work across **all** document types (Raster, Vector, Portable).
|
||||
|
||||
**Location:** `transform.rs` (high-level section)
|
||||
|
||||
**Functions:**
|
||||
- `rotate_document_cw(document)` - Rotate any document 90° CW
|
||||
- `rotate_document_ccw(document)` - Rotate any document 90° CCW
|
||||
- `flip_document_horizontal(document)` - Flip any document horizontally
|
||||
- `flip_document_vertical(document)` - Flip any document vertically
|
||||
- `rotate_document_to(document, rotation)` - Rotate to specific angle
|
||||
- `reset_document_transforms(document)` - Reset all transformations
|
||||
|
||||
**When to use:**
|
||||
- In application commands (`TransformDocumentCommand`)
|
||||
- In UI message handlers
|
||||
- Anywhere you work with `DocumentContent` (type-erased document)
|
||||
|
||||
**Example:**
|
||||
```rust
|
||||
use crate::domain::document::operations::transform;
|
||||
|
||||
// RECOMMENDED: Use high-level operations
|
||||
let mut document = DocumentContent::Raster(raster_doc);
|
||||
transform::rotate_document_cw(&mut document)?;
|
||||
transform::flip_document_horizontal(&mut document)?;
|
||||
|
||||
// Works with Vector and Portable too!
|
||||
let mut svg = DocumentContent::Vector(vector_doc);
|
||||
transform::rotate_document_cw(&mut svg)?; // Lossless viewport transform
|
||||
|
||||
// Works with PDF!
|
||||
let mut pdf = DocumentContent::Portable(portable_doc);
|
||||
transform::rotate_document_cw(&mut pdf)?; // Backend handles rendering
|
||||
```
|
||||
|
||||
## Why This Separation?
|
||||
|
||||
### Why Low-Level Operations Are Internal
|
||||
|
||||
**Problem:** Exposing low-level operations creates confusion:
|
||||
- Developers don't know whether to use `apply_rotation()` or `rotate_document_cw()`
|
||||
- Low-level operations only work on `DynamicImage`, not `DocumentContent`
|
||||
- Creates two ways to do the same thing (violates DRY)
|
||||
|
||||
**Solution:** Make them `pub(crate)`:
|
||||
```rust
|
||||
// NOT POSSIBLE - apply_rotation is internal
|
||||
transform::apply_rotation(img, Rotation::Cw90); // Compile error!
|
||||
|
||||
// USE THIS - high-level operation
|
||||
transform::rotate_document_cw(&mut document)?; // Works!
|
||||
```
|
||||
|
||||
### Why High-Level Operations Exist
|
||||
|
||||
**Problem without them:**
|
||||
```rust
|
||||
// Coupled to implementation details
|
||||
match document {
|
||||
DocumentContent::Raster(ref mut doc) => doc.rotate(Rotation::Cw90),
|
||||
DocumentContent::Vector(ref mut doc) => doc.rotate(Rotation::Cw90),
|
||||
DocumentContent::Portable(ref mut doc) => doc.rotate(Rotation::Cw90),
|
||||
}
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```rust
|
||||
// Single API for all types
|
||||
transform::rotate_document_cw(&mut document)?;
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Single Source of Truth**
|
||||
- Rotation logic (handling RotationMode::Fine, etc.) is in ONE place
|
||||
- No duplication across UI handlers, commands, and tests
|
||||
|
||||
2. **Type Safety**
|
||||
- Works through `DocumentContent` abstraction
|
||||
- Compiler ensures all document types implement required traits
|
||||
|
||||
3. **Future-Proof**
|
||||
- Adding new document types (DJVU, EPUB) doesn't require updating call sites
|
||||
- Operations automatically work with new types
|
||||
|
||||
4. **Testable**
|
||||
- High-level operations can be tested independently
|
||||
- No UI dependencies
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### How It Works
|
||||
|
||||
High-level operations use the `Transformable` trait:
|
||||
|
||||
```rust
|
||||
pub fn rotate_document_cw(document: &mut DocumentContent) -> DocResult<()> {
|
||||
let new_rotation_mode = document.transform_state().rotation.rotate_cw();
|
||||
|
||||
match new_rotation_mode {
|
||||
RotationMode::Standard(rot) => document.rotate(rot),
|
||||
RotationMode::Fine(deg) => {
|
||||
// Convert fine rotation to nearest 90° standard rotation
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
This delegates to the document type's implementation:
|
||||
|
||||
- **Raster:** Actual pixel rotation via `imageops::rotate90()`
|
||||
- **Vector:** Viewport matrix transformation (lossless!)
|
||||
- **Portable:** View rotation, rendered by backend (Poppler)
|
||||
|
||||
### Each Type Transforms Differently
|
||||
|
||||
```rust
|
||||
// Raster: Pixel manipulation (lossy for fine rotations)
|
||||
impl Transformable for RasterDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
self.image = apply_rotation(self.image, rotation);
|
||||
}
|
||||
}
|
||||
|
||||
// Vector: Viewport transform (always lossless!)
|
||||
impl Transformable for VectorDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
self.transform_matrix = self.transform_matrix.rotate(rotation.to_degrees());
|
||||
// No rasterization needed
|
||||
}
|
||||
}
|
||||
|
||||
// Portable: View rotation (backend handles rendering)
|
||||
impl Transformable for PortableDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
self.view_rotation = (self.view_rotation + rotation.to_degrees()) % 360;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
### Prefer High-Level Operations
|
||||
|
||||
```rust
|
||||
// In application commands
|
||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
||||
let document = manager.current_document_mut()?;
|
||||
transform::rotate_document_cw(document)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// In UI message handlers
|
||||
AppMessage::RotateCW => {
|
||||
if let Some(doc) = &mut self.model.document {
|
||||
transform::rotate_document_cw(doc)?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Don't Use Low-Level Operations in Application/UI Code
|
||||
|
||||
```rust
|
||||
// COMPILE ERROR - Low-level operations are pub(crate)
|
||||
let pixels = transform::apply_rotation(img, Rotation::Cw90); // Won't compile!
|
||||
|
||||
// CORRECT - Use high-level operations
|
||||
transform::rotate_document_cw(&mut document)?;
|
||||
```
|
||||
|
||||
### ℹ️ Low-Level Operations in Document Implementations
|
||||
|
||||
Low-level operations are only accessible within document type implementations:
|
||||
|
||||
```rust
|
||||
// INTERNAL ONLY - in domain/document/types/raster.rs
|
||||
impl Transformable for RasterDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
// This works because we're inside the crate
|
||||
self.image = apply_rotation(self.image, rotation);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
operations/
|
||||
├── mod.rs # Public API exports
|
||||
├── transform.rs # Low-level + High-level transforms
|
||||
├── render.rs # Rendering utilities (scale, fit, etc.)
|
||||
├── export.rs # Export to various formats
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Adding New Operations
|
||||
|
||||
When adding a new operation:
|
||||
|
||||
1. **Add low-level function** (if pixel manipulation is needed) - mark as `pub(crate)`
|
||||
2. **Add high-level function** that works on `DocumentContent` - mark as `pub`
|
||||
3. **Export high-level function only** from `mod.rs`
|
||||
4. **Update domain exports** in `domain/document/mod.rs`
|
||||
5. **Create command** in `application/commands/`
|
||||
|
||||
Example:
|
||||
|
||||
```rust
|
||||
// 1. Low-level (internal only) - in transform.rs
|
||||
pub(crate) fn apply_grayscale(img: DynamicImage) -> DynamicImage { ... }
|
||||
|
||||
// 2. High-level (public API) - in transform.rs
|
||||
pub fn grayscale_document(document: &mut DocumentContent) -> DocResult<()> {
|
||||
// Delegates to Transformable trait or uses low-level helper
|
||||
...
|
||||
}
|
||||
|
||||
// 3. Export high-level only - in operations/mod.rs
|
||||
pub use transform::{grayscale_document}; // NOT apply_grayscale!
|
||||
|
||||
// 4. Export from domain - in document/mod.rs
|
||||
pub use operations::{grayscale_document};
|
||||
|
||||
// 5. Command - in application/commands/
|
||||
pub struct GrayscaleCommand;
|
||||
impl GrayscaleCommand {
|
||||
pub fn execute(&self, manager: &mut DocumentManager) -> DocResult<()> {
|
||||
let doc = manager.current_document_mut()?;
|
||||
transform::grayscale_document(doc) // High-level operation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related Concepts
|
||||
|
||||
- **Traits:** `Renderable`, `Transformable`, `MultiPage` (in `domain/document/core/document.rs`)
|
||||
- **Type Erasure:** `DocumentContent` enum (in `domain/document/core/content.rs`)
|
||||
- **Commands:** Application layer operations (in `application/commands/`)
|
||||
- **Domain Layer:** Pure business logic, no UI dependencies
|
||||
31
src/domain/document/operations/crop.rs
Normal file
31
src/domain/document/operations/crop.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/operations/crop.rs
|
||||
//
|
||||
// Crop operation domain model.
|
||||
|
||||
/// Crop region in pixel coordinates.
|
||||
///
|
||||
/// Pure domain model - represents a rectangular region to crop.
|
||||
/// No UI concerns, just data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CropRegion {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl CropRegion {
|
||||
pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
||||
Self { x, y, width, height }
|
||||
}
|
||||
|
||||
pub fn as_tuple(&self) -> (u32, u32, u32, u32) {
|
||||
(self.x, self.y, self.width, self.height)
|
||||
}
|
||||
|
||||
/// Check if region has valid dimensions.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.width > 0 && self.height > 0
|
||||
}
|
||||
}
|
||||
161
src/domain/document/operations/export.rs
Normal file
161
src/domain/document/operations/export.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/operations/export.rs
|
||||
//
|
||||
// Document export operations to various formats.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use image::DynamicImage;
|
||||
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
|
||||
/// Supported export formats.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExportFormat {
|
||||
/// PNG format (lossless).
|
||||
Png,
|
||||
/// JPEG format (lossy).
|
||||
Jpeg,
|
||||
/// WebP format.
|
||||
WebP,
|
||||
/// PDF format.
|
||||
Pdf,
|
||||
/// SVG format (for vector documents).
|
||||
Svg,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
/// Get file extension for this format.
|
||||
#[must_use]
|
||||
pub fn extension(&self) -> &str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Jpeg => "jpg",
|
||||
Self::WebP => "webp",
|
||||
Self::Pdf => "pdf",
|
||||
Self::Svg => "svg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get MIME type for this format.
|
||||
#[must_use]
|
||||
pub fn mime_type(&self) -> &str {
|
||||
match self {
|
||||
Self::Png => "image/png",
|
||||
Self::Jpeg => "image/jpeg",
|
||||
Self::WebP => "image/webp",
|
||||
Self::Pdf => "application/pdf",
|
||||
Self::Svg => "image/svg+xml",
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect format from file extension.
|
||||
#[must_use]
|
||||
pub fn from_path(path: &Path) -> Option<Self> {
|
||||
let ext = path.extension()?.to_str()?.to_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" => Some(Self::Png),
|
||||
"jpg" | "jpeg" => Some(Self::Jpeg),
|
||||
"webp" => Some(Self::WebP),
|
||||
"pdf" => Some(Self::Pdf),
|
||||
"svg" => Some(Self::Svg),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export options for image formats.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ImageExportOptions {
|
||||
/// Quality setting (0-100) for lossy formats.
|
||||
pub quality: u8,
|
||||
/// Whether to preserve metadata (EXIF, etc.).
|
||||
pub preserve_metadata: bool,
|
||||
}
|
||||
|
||||
impl Default for ImageExportOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quality: 90,
|
||||
preserve_metadata: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Export a raster image to a file.
|
||||
///
|
||||
/// This function handles format-specific encoding and options.
|
||||
pub fn export_image(
|
||||
img: &DynamicImage,
|
||||
path: &Path,
|
||||
format: ExportFormat,
|
||||
_options: &ImageExportOptions,
|
||||
) -> DocResult<()> {
|
||||
match format {
|
||||
ExportFormat::Png => {
|
||||
img.save_with_format(path, image::ImageFormat::Png)?;
|
||||
}
|
||||
ExportFormat::Jpeg => {
|
||||
// TODO: Apply quality settings
|
||||
img.save_with_format(path, image::ImageFormat::Jpeg)?;
|
||||
}
|
||||
ExportFormat::WebP => {
|
||||
img.save_with_format(path, image::ImageFormat::WebP)?;
|
||||
}
|
||||
ExportFormat::Pdf | ExportFormat::Svg => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Export to {} not yet implemented",
|
||||
format.extension()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export a document to a standard paper format (A4, Letter, etc.).
|
||||
///
|
||||
/// This function resizes the document to fit the target format while maintaining
|
||||
/// aspect ratio, then exports it.
|
||||
pub fn export_to_paper_format(
|
||||
img: &DynamicImage,
|
||||
path: &Path,
|
||||
target_width: u32,
|
||||
target_height: u32,
|
||||
format: ExportFormat,
|
||||
) -> DocResult<()> {
|
||||
use image::imageops::FilterType;
|
||||
|
||||
// Resize to fit target dimensions
|
||||
let resized = img.resize(target_width, target_height, FilterType::Lanczos3);
|
||||
|
||||
// Export with default options
|
||||
let options = ImageExportOptions::default();
|
||||
export_image(&resized, path, format, &options)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_extension() {
|
||||
assert_eq!(ExportFormat::Png.extension(), "png");
|
||||
assert_eq!(ExportFormat::Jpeg.extension(), "jpg");
|
||||
assert_eq!(ExportFormat::Pdf.extension(), "pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_from_path() {
|
||||
assert_eq!(
|
||||
ExportFormat::from_path(Path::new("test.png")),
|
||||
Some(ExportFormat::Png)
|
||||
);
|
||||
assert_eq!(
|
||||
ExportFormat::from_path(Path::new("test.JPG")),
|
||||
Some(ExportFormat::Jpeg)
|
||||
);
|
||||
assert_eq!(ExportFormat::from_path(Path::new("test.txt")), None);
|
||||
}
|
||||
}
|
||||
16
src/domain/document/operations/mod.rs
Normal file
16
src/domain/document/operations/mod.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/operations/mod.rs
|
||||
//
|
||||
// Document operations: transformations, rendering, and export.
|
||||
|
||||
pub mod crop;
|
||||
pub mod export;
|
||||
pub mod render;
|
||||
pub mod transform;
|
||||
|
||||
// Re-export CropRegion for convenience
|
||||
pub use crop::CropRegion;
|
||||
|
||||
// Note: Low-level pixel operations (apply_rotation, apply_flip, crop_image)
|
||||
// are internal helpers (pub(crate)) used only by document type implementations.
|
||||
// Use high-level operations above for application and UI code.
|
||||
108
src/domain/document/operations/render.rs
Normal file
108
src/domain/document/operations/render.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/operations/render.rs
|
||||
//
|
||||
// Rendering operations for documents.
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
use image::{DynamicImage, GenericImageView};
|
||||
|
||||
/// Create an image handle from RGBA pixel data.
|
||||
///
|
||||
/// This is the primary way to create image handles for display in the UI.
|
||||
#[must_use]
|
||||
pub fn create_image_handle(pixels: Vec<u8>, width: u32, height: u32) -> ImageHandle {
|
||||
ImageHandle::from_rgba(width, height, pixels)
|
||||
}
|
||||
|
||||
/// Create an image handle from a `DynamicImage`.
|
||||
///
|
||||
/// Converts the image to RGBA8 format and creates a handle.
|
||||
#[must_use]
|
||||
pub fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
||||
let (width, height) = img.dimensions();
|
||||
let pixels = img.to_rgba8().into_raw();
|
||||
create_image_handle(pixels, width, height)
|
||||
}
|
||||
|
||||
/// Refresh image handle from a `DynamicImage`.
|
||||
///
|
||||
/// Alias for `create_image_handle_from_image` for compatibility.
|
||||
#[must_use]
|
||||
pub fn refresh_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
||||
create_image_handle_from_image(img)
|
||||
}
|
||||
|
||||
/// Calculate scaled dimensions maintaining aspect ratio.
|
||||
///
|
||||
/// Returns (width, height) scaled by the given factor.
|
||||
#[must_use]
|
||||
pub fn scale_dimensions(width: u32, height: u32, scale: f64) -> (u32, u32) {
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let scaled_width = (f64::from(width) * scale).round() as u32;
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let scaled_height = (f64::from(height) * scale).round() as u32;
|
||||
|
||||
(scaled_width.max(1), scaled_height.max(1))
|
||||
}
|
||||
|
||||
/// Calculate scale factor to fit dimensions into a target size.
|
||||
///
|
||||
/// Returns a scale factor that will make the image fit within the target
|
||||
/// dimensions while maintaining aspect ratio.
|
||||
#[must_use]
|
||||
pub fn calculate_fit_scale(width: u32, height: u32, target_width: u32, target_height: u32) -> f64 {
|
||||
if width == 0 || height == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let width_scale = f64::from(target_width) / f64::from(width);
|
||||
let height_scale = f64::from(target_height) / f64::from(height);
|
||||
|
||||
width_scale.min(height_scale)
|
||||
}
|
||||
|
||||
/// Calculate scale factor to fill dimensions.
|
||||
///
|
||||
/// Returns a scale factor that will make the image fill the target dimensions
|
||||
/// while maintaining aspect ratio (may crop).
|
||||
#[must_use]
|
||||
pub fn calculate_fill_scale(width: u32, height: u32, target_width: u32, target_height: u32) -> f64 {
|
||||
if width == 0 || height == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let width_scale = f64::from(target_width) / f64::from(width);
|
||||
let height_scale = f64::from(target_height) / f64::from(height);
|
||||
|
||||
width_scale.max(height_scale)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scale_dimensions() {
|
||||
assert_eq!(scale_dimensions(100, 200, 2.0), (200, 400));
|
||||
assert_eq!(scale_dimensions(100, 200, 0.5), (50, 100));
|
||||
assert_eq!(scale_dimensions(100, 200, 0.0), (1, 1)); // Minimum 1x1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_fit_scale() {
|
||||
// Landscape image fitting into square
|
||||
assert_eq!(calculate_fit_scale(200, 100, 100, 100), 0.5);
|
||||
// Portrait image fitting into square
|
||||
assert_eq!(calculate_fit_scale(100, 200, 100, 100), 0.5);
|
||||
// Square into square
|
||||
assert_eq!(calculate_fit_scale(100, 100, 100, 100), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_fill_scale() {
|
||||
// Landscape image filling square
|
||||
assert_eq!(calculate_fill_scale(200, 100, 100, 100), 1.0);
|
||||
// Portrait image filling square
|
||||
assert_eq!(calculate_fill_scale(100, 200, 100, 100), 1.0);
|
||||
}
|
||||
}
|
||||
323
src/domain/document/operations/transform.rs
Normal file
323
src/domain/document/operations/transform.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/operations/transform.rs
|
||||
//
|
||||
// Document transformation operations.
|
||||
//
|
||||
// This module provides two levels of transformation operations:
|
||||
//
|
||||
// 1. **Low-level operations** (internal) for direct pixel manipulation on raster images:
|
||||
// - `apply_rotation()` - Rotate pixels by 90°, 180°, or 270° [pub(crate)]
|
||||
// - `apply_flip()` - Flip pixels horizontally or vertically [pub(crate)]
|
||||
// - `crop_image()` - Crop to a specific region [pub(crate)]
|
||||
// These are used internally by document type implementations only.
|
||||
//
|
||||
// 2. **High-level operations** that work on any document type (raster, vector, PDF):
|
||||
// - `rotate_document_cw()` - Rotate any document 90° clockwise
|
||||
// - `rotate_document_ccw()` - Rotate any document 90° counter-clockwise
|
||||
// - `flip_document_horizontal()` - Flip any document horizontally
|
||||
// - `flip_document_vertical()` - Flip any document vertically
|
||||
// - `rotate_document_to()` - Rotate to a specific angle
|
||||
// - `reset_document_transforms()` - Reset all transformations
|
||||
//
|
||||
// ## Usage Example
|
||||
//
|
||||
// ```rust
|
||||
// use crate::domain::document::operations::transform;
|
||||
//
|
||||
// // High-level: Works with any DocumentContent (RECOMMENDED)
|
||||
// let mut document = DocumentContent::Raster(raster_doc);
|
||||
// transform::rotate_document_cw(&mut document)?;
|
||||
// transform::flip_document_horizontal(&mut document)?;
|
||||
// ```
|
||||
//
|
||||
// Note: Low-level operations (apply_rotation, apply_flip, crop_image) are
|
||||
// internal helpers used by document type implementations and are not part
|
||||
// of the public API.
|
||||
//
|
||||
// The high-level operations use the `Transformable` trait and work across all
|
||||
// document types (Raster, Vector, Portable), while low-level operations work
|
||||
// directly on pixel data.
|
||||
|
||||
use image::{DynamicImage, GenericImageView};
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::{
|
||||
DocResult, FlipDirection, Rotation, RotationMode, Transformable,
|
||||
};
|
||||
|
||||
/// Apply a 90-degree rotation to a raster image.
|
||||
///
|
||||
/// This function performs the actual pixel manipulation for standard rotations.
|
||||
/// Used internally by `RasterDocument` implementation.
|
||||
#[must_use]
|
||||
pub(crate) fn apply_rotation(img: DynamicImage, rotation: Rotation) -> DynamicImage {
|
||||
use image::imageops::{rotate180, rotate270, rotate90};
|
||||
|
||||
match rotation {
|
||||
Rotation::None => img,
|
||||
Rotation::Cw90 => DynamicImage::ImageRgba8(rotate90(&img.to_rgba8())),
|
||||
Rotation::Cw180 => DynamicImage::ImageRgba8(rotate180(&img.to_rgba8())),
|
||||
Rotation::Cw270 => DynamicImage::ImageRgba8(rotate270(&img.to_rgba8())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a flip transformation to a raster image.
|
||||
///
|
||||
/// This function performs the actual pixel manipulation for flip operations.
|
||||
/// Used internally by `RasterDocument` and `PortableDocument` implementations.
|
||||
#[must_use]
|
||||
pub(crate) fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage {
|
||||
use image::imageops::{flip_horizontal, flip_vertical};
|
||||
|
||||
match direction {
|
||||
FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())),
|
||||
FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop a raster image to the specified region.
|
||||
///
|
||||
/// Coordinates are in pixels relative to the top-left corner.
|
||||
/// Returns None if the crop region is invalid.
|
||||
/// Used internally for crop operations.
|
||||
#[must_use]
|
||||
pub(crate) fn crop_image(
|
||||
img: &DynamicImage,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Option<DynamicImage> {
|
||||
let (img_width, img_height) = img.dimensions();
|
||||
|
||||
// Validate crop region
|
||||
if x >= img_width || y >= img_height {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Clamp dimensions to image bounds
|
||||
let crop_width = width.min(img_width - x);
|
||||
let crop_height = height.min(img_height - y);
|
||||
|
||||
if crop_width == 0 || crop_height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(img.crop_imm(x, y, crop_width, crop_height))
|
||||
}
|
||||
|
||||
/// Calculate dimensions after rotation.
|
||||
///
|
||||
/// For 90° and 270° rotations, width and height are swapped.
|
||||
#[must_use]
|
||||
pub fn dimensions_after_rotation(width: u32, height: u32, rotation: Rotation) -> (u32, u32) {
|
||||
match rotation {
|
||||
Rotation::None | Rotation::Cw180 => (width, height),
|
||||
Rotation::Cw90 | Rotation::Cw270 => (height, width),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// High-Level Document Operations (Type-agnostic)
|
||||
// ============================================================================
|
||||
//
|
||||
// These operations work on ANY document type (Raster, Vector, Portable) through
|
||||
// the DocumentContent abstraction. They should be preferred over direct trait
|
||||
// calls when implementing UI commands or application logic.
|
||||
//
|
||||
// Benefits:
|
||||
// - Single API for all document types
|
||||
// - Handles rotation mode conversions (Standard ↔ Fine)
|
||||
// - Returns Result for error handling
|
||||
// - Future-proof for new document types
|
||||
|
||||
/// Rotate a document 90 degrees clockwise.
|
||||
///
|
||||
/// This operation works on any document type (Raster, Vector, Portable) by
|
||||
/// delegating to the underlying document's `Transformable` implementation.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crate::domain::document::operations::transform::rotate_document_cw;
|
||||
///
|
||||
/// // Works with any document type
|
||||
/// rotate_document_cw(&mut document)?;
|
||||
/// ```
|
||||
///
|
||||
/// # Implementation Details
|
||||
///
|
||||
/// - Raster: Actual pixel rotation via image operations
|
||||
/// - Vector: Viewport matrix transformation (lossless)
|
||||
/// - Portable: View rotation, rendered by backend
|
||||
pub fn rotate_document_cw(document: &mut DocumentContent) -> DocResult<()> {
|
||||
let new_rotation_mode = document.transform_state().rotation.rotate_cw();
|
||||
|
||||
match new_rotation_mode {
|
||||
RotationMode::Standard(rot) => {
|
||||
document.rotate(rot);
|
||||
}
|
||||
RotationMode::Fine(deg) => {
|
||||
// Convert to nearest 90° rotation
|
||||
let normalized = ((deg / 90.0).round() as i16 * 90) % 360;
|
||||
let rot = match normalized {
|
||||
0 => Rotation::None,
|
||||
90 => Rotation::Cw90,
|
||||
180 => Rotation::Cw180,
|
||||
270 => Rotation::Cw270,
|
||||
_ => Rotation::None,
|
||||
};
|
||||
document.rotate(rot);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rotate a document 90 degrees counter-clockwise.
|
||||
///
|
||||
/// This operation works on any document type (Raster, Vector, Portable) by
|
||||
/// delegating to the underlying document's `Transformable` implementation.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crate::domain::document::operations::transform::rotate_document_ccw;
|
||||
///
|
||||
/// rotate_document_ccw(&mut document)?;
|
||||
/// ```
|
||||
pub fn rotate_document_ccw(document: &mut DocumentContent) -> DocResult<()> {
|
||||
let new_rotation_mode = document.transform_state().rotation.rotate_ccw();
|
||||
|
||||
match new_rotation_mode {
|
||||
RotationMode::Standard(rot) => {
|
||||
document.rotate(rot);
|
||||
}
|
||||
RotationMode::Fine(deg) => {
|
||||
// Convert to nearest 90° rotation
|
||||
let normalized = ((deg / 90.0).round() as i16 * 90 + 360) % 360;
|
||||
let rot = match normalized {
|
||||
0 => Rotation::None,
|
||||
90 => Rotation::Cw90,
|
||||
180 => Rotation::Cw180,
|
||||
270 => Rotation::Cw270,
|
||||
_ => Rotation::None,
|
||||
};
|
||||
document.rotate(rot);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flip a document horizontally (mirror left-right).
|
||||
///
|
||||
/// This operation works on any document type by delegating to the underlying
|
||||
/// document's `Transformable` implementation.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crate::domain::document::operations::transform::flip_document_horizontal;
|
||||
///
|
||||
/// flip_document_horizontal(&mut document)?;
|
||||
/// ```
|
||||
pub fn flip_document_horizontal(document: &mut DocumentContent) -> DocResult<()> {
|
||||
document.flip(FlipDirection::Horizontal);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flip a document vertically (mirror top-bottom).
|
||||
///
|
||||
/// This operation works on any document type by delegating to the underlying
|
||||
/// document's `Transformable` implementation.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crate::domain::document::operations::transform::flip_document_vertical;
|
||||
///
|
||||
/// flip_document_vertical(&mut document)?;
|
||||
/// ```
|
||||
pub fn flip_document_vertical(document: &mut DocumentContent) -> DocResult<()> {
|
||||
document.flip(FlipDirection::Vertical);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rotate a document to a specific angle (0°, 90°, 180°, or 270°).
|
||||
///
|
||||
/// This operation works on any document type by delegating to the underlying
|
||||
/// document's `Transformable` implementation.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `document` - The document to rotate
|
||||
/// * `rotation` - Target rotation angle
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crate::domain::document::core::document::Rotation;
|
||||
/// use crate::domain::document::operations::transform::rotate_document_to;
|
||||
///
|
||||
/// // Rotate to 180 degrees
|
||||
/// rotate_document_to(&mut document, Rotation::Cw180)?;
|
||||
/// ```
|
||||
pub fn rotate_document_to(document: &mut DocumentContent, rotation: Rotation) -> DocResult<()> {
|
||||
document.rotate(rotation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reset all transformations on a document.
|
||||
///
|
||||
/// This resets the document to its original state (no rotation, no flips).
|
||||
/// Useful for implementing "Reset View" functionality.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use crate::domain::document::operations::transform::reset_document_transforms;
|
||||
///
|
||||
/// // Undo all rotations and flips
|
||||
/// reset_document_transforms(&mut document)?;
|
||||
/// ```
|
||||
pub fn reset_document_transforms(document: &mut DocumentContent) -> DocResult<()> {
|
||||
// Reset to no rotation
|
||||
document.rotate(Rotation::None);
|
||||
|
||||
// Reset flips by checking current state and flipping back if needed
|
||||
let state = document.transform_state();
|
||||
if state.flip_h {
|
||||
document.flip(FlipDirection::Horizontal);
|
||||
}
|
||||
if state.flip_v {
|
||||
document.flip(FlipDirection::Vertical);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dimensions_after_rotation() {
|
||||
assert_eq!(
|
||||
dimensions_after_rotation(100, 200, Rotation::None),
|
||||
(100, 200)
|
||||
);
|
||||
assert_eq!(
|
||||
dimensions_after_rotation(100, 200, Rotation::Cw90),
|
||||
(200, 100)
|
||||
);
|
||||
assert_eq!(
|
||||
dimensions_after_rotation(100, 200, Rotation::Cw180),
|
||||
(100, 200)
|
||||
);
|
||||
assert_eq!(
|
||||
dimensions_after_rotation(100, 200, Rotation::Cw270),
|
||||
(200, 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
10
src/domain/document/types/mod.rs
Normal file
10
src/domain/document/types/mod.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/types/mod.rs
|
||||
//
|
||||
// Concrete document type implementations.
|
||||
|
||||
pub mod raster;
|
||||
#[cfg(feature = "vector")]
|
||||
pub mod vector;
|
||||
#[cfg(feature = "portable")]
|
||||
pub mod portable;
|
||||
473
src/domain/document/types/portable.rs
Normal file
473
src/domain/document/types/portable.rs
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/types/portable.rs
|
||||
//
|
||||
// Portable documents (PDF) with poppler backend.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// PDF page render quality multiplier (2.0 = double resolution for sharp display).
|
||||
const PDF_RENDER_QUALITY: f64 = 3.0;
|
||||
|
||||
/// PDF thumbnail size multiplier (0.25 = 25% for fast preview generation).
|
||||
const PDF_THUMBNAIL_SIZE: f64 = 0.25;
|
||||
|
||||
use cairo::{Context, Format, ImageSurface};
|
||||
use image::{DynamicImage, GenericImageView};
|
||||
use poppler::PopplerDocument;
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use crate::domain::document::core::document::{
|
||||
DocResult, DocumentInfo, FlipDirection, MultiPage, MultiPageThumbnails, Renderable,
|
||||
RenderOutput, Rotation, RotationMode, TransformState, Transformable,
|
||||
};
|
||||
|
||||
/// Represents a portable document (PDF).
|
||||
pub struct PortableDocument {
|
||||
/// The parsed PDF document.
|
||||
document: PopplerDocument,
|
||||
/// Path to the source file (for caching).
|
||||
source_path: PathBuf,
|
||||
/// Total number of pages.
|
||||
num_pages: usize,
|
||||
/// Current page index (0-based).
|
||||
page_index: usize,
|
||||
/// Current transformation state.
|
||||
transform: TransformState,
|
||||
/// Current rendered page as image.
|
||||
pub rendered: DynamicImage,
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Cached thumbnail handles for each page (None = not yet generated).
|
||||
thumbnail_cache: Option<Vec<ImageHandle>>,
|
||||
}
|
||||
|
||||
impl PortableDocument {
|
||||
/// Open a PDF document and render the first page.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let document = PopplerDocument::new_from_file(path, None)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {e}"))?;
|
||||
|
||||
let num_pages = document.get_n_pages();
|
||||
if num_pages == 0 {
|
||||
return Err(anyhow::anyhow!("PDF has no pages"));
|
||||
}
|
||||
|
||||
let rendered = Self::render_page(&document, 0, RotationMode::Standard(Rotation::None))?;
|
||||
let handle = Self::create_image_handle_from_image(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
document,
|
||||
source_path: path.to_path_buf(),
|
||||
num_pages,
|
||||
page_index: 0,
|
||||
transform: TransformState::default(),
|
||||
rendered,
|
||||
handle,
|
||||
thumbnail_cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the current pixel dimensions (width, height).
|
||||
#[must_use]
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.rendered.dimensions()
|
||||
}
|
||||
|
||||
/// Get the current image handle.
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
|
||||
/// Get native dimensions of current page.
|
||||
#[must_use]
|
||||
pub fn native_dimensions(&self) -> (u32, u32) {
|
||||
self.rendered.dimensions()
|
||||
}
|
||||
|
||||
/// Get the number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> usize {
|
||||
self.thumbnail_cache.as_ref().map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
/// Get thumbnail handle for a specific page (read-only access).
|
||||
/// Returns None if the thumbnail hasn't been generated yet.
|
||||
#[must_use]
|
||||
pub fn get_thumbnail_handle(&self, page: usize) -> Option<ImageHandle> {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.get(page).cloned())
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
/// Extract metadata for this portable document.
|
||||
pub fn extract_meta(&self, path: &Path) -> crate::domain::document::core::metadata::DocumentMeta {
|
||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta};
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let file_path = path.to_string_lossy().to_string();
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
let (width, height) = self.dimensions();
|
||||
let format = format!("PDF ({} pages)", self.num_pages);
|
||||
|
||||
let basic = BasicMeta {
|
||||
file_name,
|
||||
file_path,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
file_size,
|
||||
color_type: "Rendered".to_string(),
|
||||
};
|
||||
|
||||
DocumentMeta { basic, exif: None }
|
||||
}
|
||||
|
||||
/// Crop the current page to the specified rectangle.
|
||||
/// Works on rendered output (raster).
|
||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> {
|
||||
let (img_width, img_height) = self.rendered.dimensions();
|
||||
|
||||
// Validate crop region
|
||||
if x >= img_width || y >= img_height {
|
||||
return Err(format!(
|
||||
"Crop region ({}, {}) is outside rendered bounds ({}, {})",
|
||||
x, y, img_width, img_height
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp dimensions
|
||||
let crop_width = width.min(img_width - x);
|
||||
let crop_height = height.min(img_height - y);
|
||||
|
||||
if crop_width == 0 || crop_height == 0 {
|
||||
return Err("Crop region has zero width or height".to_string());
|
||||
}
|
||||
|
||||
// Crop rendered image
|
||||
self.rendered = self.rendered.crop_imm(x, y, crop_width, crop_height);
|
||||
|
||||
// Update handle
|
||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
||||
let (width, height) = img.dimensions();
|
||||
let pixels = img.to_rgba8().into_raw();
|
||||
ImageHandle::from_rgba(width, height, pixels)
|
||||
}
|
||||
|
||||
/// Initialize thumbnail cache (empty, ready for incremental loading).
|
||||
fn init_thumbnail_cache(&mut self) {
|
||||
if self.thumbnail_cache.is_none() {
|
||||
self.thumbnail_cache = Some(Vec::with_capacity(self.num_pages));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns the next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: usize) -> Option<usize> {
|
||||
// Initialize cache if needed.
|
||||
self.init_thumbnail_cache();
|
||||
|
||||
// Check if we should generate this page.
|
||||
let should_generate = {
|
||||
let cache = self.thumbnail_cache.as_ref()?;
|
||||
page >= cache.len() && page < self.num_pages
|
||||
};
|
||||
|
||||
if should_generate {
|
||||
let handle = self.load_or_generate_thumbnail(page);
|
||||
if let Some(cache) = self.thumbnail_cache.as_mut() {
|
||||
cache.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Return next page if not done.
|
||||
let next = page + 1;
|
||||
if next < self.num_pages {
|
||||
Some(next)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Load thumbnail from cache or generate and cache it.
|
||||
fn load_or_generate_thumbnail(&self, page: usize) -> ImageHandle {
|
||||
// TODO: Re-enable cache once infrastructure layer is set up
|
||||
// if let Some(handle) = cache::load_thumbnail(&self.source_path, page) {
|
||||
// return handle;
|
||||
// }
|
||||
|
||||
match Self::render_page_at_scale(
|
||||
&self.document,
|
||||
page,
|
||||
RotationMode::Standard(Rotation::None),
|
||||
PDF_THUMBNAIL_SIZE,
|
||||
) {
|
||||
Ok(img) => {
|
||||
// TODO: Re-enable cache once infrastructure layer is set up
|
||||
// let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
||||
Self::create_image_handle_from_image(&img)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate thumbnail for page {page}: {e}");
|
||||
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a specific page from the document to an image.
|
||||
fn render_page(
|
||||
document: &PopplerDocument,
|
||||
page_index: usize,
|
||||
rotation: RotationMode,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_QUALITY)
|
||||
}
|
||||
|
||||
/// Render a specific page at a given scale.
|
||||
fn render_page_at_scale(
|
||||
document: &PopplerDocument,
|
||||
page_index: usize,
|
||||
rotation: RotationMode,
|
||||
scale: f64,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
let page = document
|
||||
.get_page(page_index)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get page {page_index}"))?;
|
||||
|
||||
let (page_width, page_height) = page.get_size();
|
||||
let rotation_degrees = rotation.to_degrees() as i16;
|
||||
|
||||
let (width, height) = if rotation_degrees == 90 || rotation_degrees == 270 {
|
||||
(page_height, page_width)
|
||||
} else {
|
||||
(page_width, page_height)
|
||||
};
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let scaled_width = (width * scale) as i32;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let scaled_height = (height * scale) as i32;
|
||||
|
||||
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {e}"))?;
|
||||
|
||||
let context = Context::new(&surface)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {e}"))?;
|
||||
|
||||
// Fill with white background.
|
||||
context.set_source_rgb(1.0, 1.0, 1.0);
|
||||
let _ = context.paint();
|
||||
|
||||
context.scale(scale, scale);
|
||||
|
||||
if rotation != RotationMode::Standard(Rotation::None) {
|
||||
let center_x = width / 2.0;
|
||||
let center_y = height / 2.0;
|
||||
context.translate(center_x, center_y);
|
||||
context.rotate(f64::from(rotation_degrees) * std::f64::consts::PI / 180.0);
|
||||
context.translate(-page_width / 2.0, -page_height / 2.0);
|
||||
}
|
||||
|
||||
page.render(&context);
|
||||
|
||||
drop(context);
|
||||
surface.flush();
|
||||
|
||||
// Direct conversion from Cairo surface to DynamicImage without PNG round-trip.
|
||||
// This preserves exact pixel data and avoids ARgb32 → PNG → RGBA conversion artifacts.
|
||||
let width = surface.width() as u32;
|
||||
let height = surface.height() as u32;
|
||||
let stride = surface.stride();
|
||||
|
||||
let data = surface
|
||||
.take_data()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to take Cairo surface data: {e}"))?;
|
||||
|
||||
// ARgb32 in Cairo is 4 bytes per pixel (A, R, G, B) with stride padding.
|
||||
// Convert to standard RGBA by copying pixel-by-pixel, skipping stride padding.
|
||||
let mut rgba_bytes = Vec::with_capacity((width * 4) as usize);
|
||||
for y in 0..height {
|
||||
let row_offset = (y as i32 * stride) as usize;
|
||||
for x in 0..width {
|
||||
let col_offset = (x as i32 * 4) as usize;
|
||||
let idx = row_offset + col_offset;
|
||||
// Cairo ARgb32: bytes are [A, R, G, B]
|
||||
let a = data[idx];
|
||||
let r = data[idx + 1];
|
||||
let g = data[idx + 2];
|
||||
let b = data[idx + 3];
|
||||
// Output standard RGBA: [R, G, B, A]
|
||||
rgba_bytes.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DynamicImage::ImageRgba8(image::RgbaImage::from_raw(width, height, rgba_bytes)
|
||||
.expect("Failed to create RgbaImage from Cairo surface data")))
|
||||
}
|
||||
|
||||
/// Re-render the current page with current transform.
|
||||
fn rerender(&mut self) {
|
||||
match Self::render_page(&self.document, self.page_index, self.transform.rotation) {
|
||||
Ok(mut rendered) => {
|
||||
// Apply flip transformations to the rendered result
|
||||
if self.transform.flip_h {
|
||||
rendered = Self::apply_flip(rendered, FlipDirection::Horizontal);
|
||||
}
|
||||
if self.transform.flip_v {
|
||||
rendered = Self::apply_flip(rendered, FlipDirection::Vertical);
|
||||
}
|
||||
self.rendered = rendered;
|
||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to render PDF page: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage {
|
||||
use image::imageops::{flip_horizontal, flip_vertical};
|
||||
match direction {
|
||||
FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())),
|
||||
FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the next page.
|
||||
#[allow(dead_code)]
|
||||
pub fn next_page(&mut self) -> bool {
|
||||
if self.page_index + 1 < self.num_pages {
|
||||
self.page_index += 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the previous page.
|
||||
#[allow(dead_code)]
|
||||
pub fn prev_page(&mut self) -> bool {
|
||||
if self.page_index > 0 {
|
||||
self.page_index -= 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for PortableDocument {
|
||||
fn render(&mut self, _scale: f64) -> DocResult<RenderOutput> {
|
||||
// PDF rendering quality is fixed for now (PDF_RENDER_QUALITY)
|
||||
let (width, height) = self.dimensions();
|
||||
Ok(RenderOutput {
|
||||
handle: self.handle.clone(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
let (width, height) = self.dimensions();
|
||||
DocumentInfo {
|
||||
width,
|
||||
height,
|
||||
format: "PDF".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for PortableDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
self.transform.rotation = RotationMode::Standard(rotation);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
match direction {
|
||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
||||
}
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
self.transform
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiPage for PortableDocument {
|
||||
fn page_count(&self) -> usize {
|
||||
self.num_pages
|
||||
}
|
||||
|
||||
fn current_page(&self) -> usize {
|
||||
self.page_index
|
||||
}
|
||||
|
||||
fn go_to_page(&mut self, page: usize) -> DocResult<()> {
|
||||
if page >= self.num_pages {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Page {} out of range (0-{})",
|
||||
page,
|
||||
self.num_pages - 1
|
||||
));
|
||||
}
|
||||
self.page_index = page;
|
||||
self.rerender();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiPageThumbnails for PortableDocument {
|
||||
fn thumbnails_ready(&self) -> bool {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.len() >= self.num_pages)
|
||||
}
|
||||
|
||||
fn thumbnails_loaded(&self) -> bool {
|
||||
let loaded = PortableDocument::thumbnails_loaded(self);
|
||||
loaded >= self.num_pages
|
||||
}
|
||||
|
||||
fn generate_thumbnail_page(&mut self, page: usize) -> DocResult<()> {
|
||||
PortableDocument::generate_thumbnail_page(self, page);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_all_thumbnails(&mut self) -> DocResult<()> {
|
||||
if self.thumbnails_ready() {
|
||||
return Ok(());
|
||||
}
|
||||
self.init_thumbnail_cache();
|
||||
for page in 0..self.num_pages {
|
||||
PortableDocument::generate_thumbnail_page(self, page);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_thumbnail(&mut self, page: usize) -> DocResult<Option<ImageHandle>> {
|
||||
Ok(self
|
||||
.thumbnail_cache
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.get(page).cloned()))
|
||||
}
|
||||
}
|
||||
353
src/domain/document/types/raster.rs
Normal file
353
src/domain/document/types/raster.rs
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/types/raster.rs
|
||||
//
|
||||
// Raster image document support (PNG, JPEG, WebP, etc.).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use image::{DynamicImage, GenericImageView, ImageReader};
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use crate::domain::document::core::document::{
|
||||
DocResult, DocumentInfo, FlipDirection, InterpolationQuality, Renderable, RenderOutput,
|
||||
Rotation, RotationMode, TransformState, Transformable,
|
||||
};
|
||||
|
||||
/// Represents a raster image document (PNG, JPEG, WebP, ...).
|
||||
pub struct RasterDocument {
|
||||
/// The decoded image document.
|
||||
document: DynamicImage,
|
||||
/// Native width (original, before transforms).
|
||||
native_width: u32,
|
||||
/// Native height (original, before transforms).
|
||||
native_height: u32,
|
||||
/// Current transformation state.
|
||||
transform: TransformState,
|
||||
/// Cached handle for rendering.
|
||||
handle: ImageHandle,
|
||||
/// Accumulated fine rotation angle in degrees.
|
||||
fine_rotation_angle: f32,
|
||||
/// Interpolation quality for fine rotation and resize operations.
|
||||
interpolation_quality: InterpolationQuality,
|
||||
}
|
||||
|
||||
impl RasterDocument {
|
||||
/// Load a raster document from disk.
|
||||
pub fn open(path: &Path) -> image::ImageResult<Self> {
|
||||
let document = ImageReader::open(path)?.decode()?;
|
||||
let (native_width, native_height) = document.dimensions();
|
||||
let handle = Self::create_image_handle_from_image(&document);
|
||||
|
||||
Ok(Self {
|
||||
document,
|
||||
native_width,
|
||||
native_height,
|
||||
transform: TransformState::default(),
|
||||
handle,
|
||||
fine_rotation_angle: 0.0,
|
||||
interpolation_quality: InterpolationQuality::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the current pixel dimensions (width, height) after transforms.
|
||||
#[must_use]
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.document.dimensions()
|
||||
}
|
||||
|
||||
/// Get the current image handle.
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
|
||||
/// Save the current document to disk.
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self, path: &Path) -> image::ImageResult<()> {
|
||||
self.document.save(path)
|
||||
}
|
||||
|
||||
/// Get the underlying `DynamicImage`.
|
||||
#[must_use]
|
||||
pub fn image(&self) -> &DynamicImage {
|
||||
&self.document
|
||||
}
|
||||
|
||||
/// Get native dimensions (before transformations).
|
||||
#[must_use]
|
||||
pub fn native_dimensions(&self) -> (u32, u32) {
|
||||
(self.native_width, self.native_height)
|
||||
}
|
||||
|
||||
/// Get a reference to the rendered image (for cropping from screen coordinates).
|
||||
pub fn get_rendered_image(&self) -> &DynamicImage {
|
||||
&self.document
|
||||
}
|
||||
|
||||
/// Crop the document to a specified rectangular region (in-place).
|
||||
///
|
||||
/// Coordinates are in pixels relative to the current image dimensions.
|
||||
/// The crop region is clamped to image bounds if it extends beyond.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the crop region is completely outside the image bounds.
|
||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> {
|
||||
let (img_width, img_height) = self.document.dimensions();
|
||||
|
||||
// Validate crop region
|
||||
if x >= img_width || y >= img_height {
|
||||
return Err(format!(
|
||||
"Crop region ({}, {}) is outside image bounds ({}, {})",
|
||||
x, y, img_width, img_height
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp dimensions to image bounds
|
||||
let crop_width = width.min(img_width - x);
|
||||
let crop_height = height.min(img_height - y);
|
||||
|
||||
if crop_width == 0 || crop_height == 0 {
|
||||
return Err("Crop region has zero width or height".to_string());
|
||||
}
|
||||
|
||||
// Apply crop
|
||||
self.document = self.document.crop_imm(x, y, crop_width, crop_height);
|
||||
|
||||
// Update native dimensions to the cropped size
|
||||
self.native_width = crop_width;
|
||||
self.native_height = crop_height;
|
||||
|
||||
// Reset transformations since we have a new "native" image
|
||||
self.transform = TransformState::default();
|
||||
self.fine_rotation_angle = 0.0;
|
||||
|
||||
// Regenerate handle
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
/// Crop the image to the specified rectangle and return as DynamicImage.
|
||||
///
|
||||
/// This does NOT modify the document - it's used for exporting cropped images.
|
||||
pub fn crop_to_image(
|
||||
&self,
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<DynamicImage, String> {
|
||||
let (img_width, img_height) = self.document.dimensions();
|
||||
|
||||
// Validate crop region
|
||||
if x >= img_width || y >= img_height {
|
||||
return Err(format!(
|
||||
"Crop rectangle out of bounds: {}x{} at ({}, {}) exceeds image size {}x{}",
|
||||
width, height, x, y, img_width, img_height
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp dimensions to image bounds
|
||||
let crop_width = width.min(img_width - x);
|
||||
let crop_height = height.min(img_height - y);
|
||||
|
||||
if crop_width == 0 || crop_height == 0 {
|
||||
return Err("Crop region has zero width or height".to_string());
|
||||
}
|
||||
|
||||
let cropped = self.document.crop_imm(x, y, crop_width, crop_height);
|
||||
Ok(cropped)
|
||||
}
|
||||
|
||||
/// Extract metadata for this raster document.
|
||||
///
|
||||
/// Returns basic metadata (dimensions, format, file size) and EXIF data if available.
|
||||
pub fn extract_meta(
|
||||
&self,
|
||||
path: &Path,
|
||||
) -> crate::domain::document::core::metadata::DocumentMeta {
|
||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta, ExifMeta};
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let file_path = path.to_string_lossy().to_string();
|
||||
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
// Detect format from path
|
||||
let format = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_uppercase();
|
||||
|
||||
let color_type = format!("{:?}", self.document.color());
|
||||
|
||||
let basic = BasicMeta {
|
||||
file_name,
|
||||
file_path,
|
||||
format,
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
file_size,
|
||||
color_type,
|
||||
};
|
||||
|
||||
// Try to extract EXIF data
|
||||
let exif = std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|bytes| ExifMeta::from_bytes(&bytes));
|
||||
|
||||
DocumentMeta { basic, exif }
|
||||
}
|
||||
|
||||
/// Resize the document to specific dimensions (for format conversion).
|
||||
///
|
||||
/// This is useful for converting images to standard paper formats (A4, US Letter, etc.).
|
||||
pub fn resize_to_format(&mut self, target_width: u32, target_height: u32) {
|
||||
use image::imageops::FilterType;
|
||||
|
||||
let filter = match self.interpolation_quality {
|
||||
InterpolationQuality::Fast => FilterType::Nearest,
|
||||
InterpolationQuality::Balanced => FilterType::Triangle,
|
||||
InterpolationQuality::Best => FilterType::CatmullRom,
|
||||
};
|
||||
|
||||
self.document = self
|
||||
.document
|
||||
.resize_exact(target_width, target_height, filter);
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
fn create_image_handle_from_image(img: &DynamicImage) -> ImageHandle {
|
||||
let (width, height) = img.dimensions();
|
||||
let pixels = img.to_rgba8().into_raw();
|
||||
ImageHandle::from_rgba(width, height, pixels)
|
||||
}
|
||||
|
||||
fn apply_rotation(img: DynamicImage, rotation: Rotation) -> DynamicImage {
|
||||
use image::imageops::{rotate180, rotate270, rotate90};
|
||||
match rotation {
|
||||
Rotation::None => img,
|
||||
Rotation::Cw90 => DynamicImage::ImageRgba8(rotate90(&img.to_rgba8())),
|
||||
Rotation::Cw180 => DynamicImage::ImageRgba8(rotate180(&img.to_rgba8())),
|
||||
Rotation::Cw270 => DynamicImage::ImageRgba8(rotate270(&img.to_rgba8())),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_flip(img: DynamicImage, direction: FlipDirection) -> DynamicImage {
|
||||
use image::imageops::{flip_horizontal, flip_vertical};
|
||||
match direction {
|
||||
FlipDirection::Horizontal => DynamicImage::ImageRgba8(flip_horizontal(&img.to_rgba8())),
|
||||
FlipDirection::Vertical => DynamicImage::ImageRgba8(flip_vertical(&img.to_rgba8())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for RasterDocument {
|
||||
fn render(&mut self, _scale: f64) -> DocResult<RenderOutput> {
|
||||
// Raster images don't re-render at different scales (lossy),
|
||||
// we just return the current handle.
|
||||
let (width, height) = self.dimensions();
|
||||
Ok(RenderOutput {
|
||||
handle: self.handle.clone(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
DocumentInfo {
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
format: "Raster".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for RasterDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
// Extract current rotation in degrees
|
||||
let current_deg = match self.transform.rotation {
|
||||
RotationMode::Standard(r) => r.to_degrees(),
|
||||
RotationMode::Fine(_) => {
|
||||
// If we have fine rotation, reset it and apply standard rotation
|
||||
self.fine_rotation_angle = 0.0;
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let new_deg = rotation.to_degrees();
|
||||
let diff_deg = (new_deg - current_deg + 360) % 360;
|
||||
|
||||
if diff_deg != 0 {
|
||||
let rotation_to_apply = match diff_deg {
|
||||
90 => Rotation::Cw90,
|
||||
180 => Rotation::Cw180,
|
||||
270 => Rotation::Cw270,
|
||||
_ => unreachable!("Invalid rotation diff: {}", diff_deg),
|
||||
};
|
||||
self.document = Self::apply_rotation(
|
||||
std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)),
|
||||
rotation_to_apply,
|
||||
);
|
||||
}
|
||||
|
||||
// Set to standard rotation mode
|
||||
self.transform.rotation = RotationMode::Standard(rotation);
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
self.document = Self::apply_flip(
|
||||
std::mem::replace(&mut self.document, DynamicImage::new_rgb8(1, 1)),
|
||||
direction,
|
||||
);
|
||||
match direction {
|
||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
||||
}
|
||||
self.handle = Self::create_image_handle_from_image(&self.document);
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
self.transform
|
||||
}
|
||||
|
||||
fn rotate_fine(&mut self, angle_degrees: f32) {
|
||||
// TODO: Re-enable when imageproc dependency is added to Cargo.toml
|
||||
// For now, round to nearest 90-degree rotation
|
||||
log::warn!("Fine rotation not yet implemented, rounding to nearest 90 degrees");
|
||||
|
||||
let rounded = ((angle_degrees / 90.0).round() as i16 * 90) % 360;
|
||||
let rotation = match rounded {
|
||||
0 => Rotation::None,
|
||||
90 => Rotation::Cw90,
|
||||
180 => Rotation::Cw180,
|
||||
270 => Rotation::Cw270,
|
||||
_ => Rotation::None,
|
||||
};
|
||||
|
||||
self.rotate(rotation);
|
||||
self.transform.rotation = RotationMode::Standard(rotation);
|
||||
}
|
||||
|
||||
fn reset_fine_rotation(&mut self) {
|
||||
self.fine_rotation_angle = 0.0;
|
||||
self.transform.rotation = RotationMode::Standard(Rotation::None);
|
||||
}
|
||||
|
||||
fn set_interpolation_quality(&mut self, quality: InterpolationQuality) {
|
||||
self.interpolation_quality = quality;
|
||||
}
|
||||
}
|
||||
334
src/domain/document/types/vector.rs
Normal file
334
src/domain/document/types/vector.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/document/types/vector.rs
|
||||
//
|
||||
// Vector documents (SVG, etc.).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Minimum pixmap size for SVG rendering (prevents zero-size pixmaps).
|
||||
const MIN_PIXMAP_SIZE: u32 = 1;
|
||||
|
||||
use image::{DynamicImage, GenericImageView, RgbaImage};
|
||||
use resvg::tiny_skia::{self, Pixmap};
|
||||
use resvg::usvg::{Options, Tree};
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use crate::domain::document::core::document::{
|
||||
DocResult, DocumentInfo, FlipDirection, Renderable, RenderOutput, Rotation, RotationMode,
|
||||
TransformState, Transformable,
|
||||
};
|
||||
|
||||
/// Represents a vector document such as SVG.
|
||||
pub struct VectorDocument {
|
||||
/// Parsed SVG document for re-rendering at different scales.
|
||||
document: Tree,
|
||||
/// Native width of the SVG (from viewBox or width attribute).
|
||||
native_width: u32,
|
||||
/// Native height of the SVG (from viewBox or height attribute).
|
||||
native_height: u32,
|
||||
/// Current render scale (1.0 = native size).
|
||||
current_scale: f64,
|
||||
/// Accumulated transformations.
|
||||
transform: TransformState,
|
||||
/// Rasterized image at the current scale.
|
||||
pub rendered: DynamicImage,
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Current rendered width.
|
||||
pub width: u32,
|
||||
/// Current rendered height.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl VectorDocument {
|
||||
/// Load a vector document from disk.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let raw_data = std::fs::read_to_string(path)?;
|
||||
|
||||
// Parse SVG with default options.
|
||||
let options = Options::default();
|
||||
let document = Tree::from_str(&raw_data, &options)?;
|
||||
|
||||
// Get native size from the parsed document.
|
||||
let size = document.size();
|
||||
let native_width = size.width().ceil() as u32;
|
||||
let native_height = size.height().ceil() as u32;
|
||||
|
||||
let transform = TransformState::default();
|
||||
|
||||
// Render at native scale (1.0).
|
||||
let (rendered, width, height) =
|
||||
render_document(&document, native_width, native_height, 1.0, transform)?;
|
||||
let handle = Self::create_image_handle_from_image(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
document,
|
||||
native_width,
|
||||
native_height,
|
||||
current_scale: 1.0,
|
||||
transform,
|
||||
rendered,
|
||||
handle,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the dimensions of the rasterized representation.
|
||||
#[must_use]
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Get the current image handle.
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> ImageHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
|
||||
/// Get native dimensions (before transformations).
|
||||
#[must_use]
|
||||
pub fn native_dimensions(&self) -> (u32, u32) {
|
||||
(self.native_width, self.native_height)
|
||||
}
|
||||
|
||||
/// Extract metadata for this vector document.
|
||||
pub fn extract_meta(
|
||||
&self,
|
||||
path: &Path,
|
||||
) -> crate::domain::document::core::metadata::DocumentMeta {
|
||||
use crate::domain::document::core::metadata::{BasicMeta, DocumentMeta};
|
||||
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let file_path = path.to_string_lossy().to_string();
|
||||
let file_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
let basic = BasicMeta {
|
||||
file_name,
|
||||
file_path,
|
||||
format: "SVG".to_string(),
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
file_size,
|
||||
color_type: "Vector".to_string(),
|
||||
};
|
||||
|
||||
DocumentMeta { basic, exif: None }
|
||||
}
|
||||
|
||||
/// Crop the document to the specified rectangle.
|
||||
/// Works on rendered output (raster).
|
||||
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<(), String> {
|
||||
let (img_width, img_height) = self.rendered.dimensions();
|
||||
|
||||
// Validate crop region
|
||||
if x >= img_width || y >= img_height {
|
||||
return Err(format!(
|
||||
"Crop region ({}, {}) is outside rendered bounds ({}, {})",
|
||||
x, y, img_width, img_height
|
||||
));
|
||||
}
|
||||
|
||||
// Clamp dimensions
|
||||
let crop_width = width.min(img_width - x);
|
||||
let crop_height = height.min(img_height - y);
|
||||
|
||||
if crop_width == 0 || crop_height == 0 {
|
||||
return Err("Crop region has zero width or height".to_string());
|
||||
}
|
||||
|
||||
// Crop rendered image
|
||||
self.rendered = self.rendered.crop_imm(x, y, crop_width, crop_height);
|
||||
self.width = crop_width;
|
||||
self.height = crop_height;
|
||||
|
||||
// Update handle
|
||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-render the SVG at a new scale, preserving transformations.
|
||||
/// Returns true if re-rendering occurred.
|
||||
#[allow(dead_code)]
|
||||
pub fn render_at_scale(&mut self, scale: f64) -> bool {
|
||||
// Skip if scale hasn't changed
|
||||
if (self.current_scale - scale).abs() < f64::EPSILON {
|
||||
return false;
|
||||
}
|
||||
|
||||
match render_document(
|
||||
&self.document,
|
||||
self.native_width,
|
||||
self.native_height,
|
||||
scale,
|
||||
self.transform,
|
||||
) {
|
||||
Ok((rendered, width, height)) => {
|
||||
self.current_scale = scale;
|
||||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to re-render SVG at scale {scale}: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-render with current scale and transform.
|
||||
fn rerender(&mut self) {
|
||||
if let Ok((rendered, width, height)) = render_document(
|
||||
&self.document,
|
||||
self.native_width,
|
||||
self.native_height,
|
||||
self.current_scale,
|
||||
self.transform,
|
||||
) {
|
||||
self.rendered = rendered;
|
||||
self.width = width;
|
||||
self.height = height;
|
||||
self.handle = Self::create_image_handle_from_image(&self.rendered);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function
|
||||
fn create_image_handle_from_image(img: &image::DynamicImage) -> ImageHandle {
|
||||
let (width, height) = img.dimensions();
|
||||
let pixels = img.to_rgba8().into_raw();
|
||||
ImageHandle::from_rgba(width, height, pixels)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Trait Implementations
|
||||
// ============================================================================
|
||||
|
||||
impl Renderable for VectorDocument {
|
||||
fn render(&mut self, scale: f64) -> DocResult<RenderOutput> {
|
||||
self.render_at_scale(scale);
|
||||
Ok(RenderOutput {
|
||||
handle: self.handle.clone(),
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
})
|
||||
}
|
||||
|
||||
fn info(&self) -> DocumentInfo {
|
||||
DocumentInfo {
|
||||
width: self.native_width,
|
||||
height: self.native_height,
|
||||
format: "SVG".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transformable for VectorDocument {
|
||||
fn rotate(&mut self, rotation: Rotation) {
|
||||
self.transform.rotation = RotationMode::Standard(rotation);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn flip(&mut self, direction: FlipDirection) {
|
||||
match direction {
|
||||
FlipDirection::Horizontal => self.transform.flip_h = !self.transform.flip_h,
|
||||
FlipDirection::Vertical => self.transform.flip_v = !self.transform.flip_v,
|
||||
}
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
fn transform_state(&self) -> TransformState {
|
||||
self.transform
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the SVG document at a given scale with transformations.
|
||||
fn render_document(
|
||||
document: &Tree,
|
||||
native_width: u32,
|
||||
native_height: u32,
|
||||
scale: f64,
|
||||
transform: TransformState,
|
||||
) -> anyhow::Result<(DynamicImage, u32, u32)> {
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let width = ((f64::from(native_width) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let height = ((f64::from(native_height) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
|
||||
|
||||
let mut pixmap =
|
||||
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let scale_f32 = scale as f32;
|
||||
let ts = tiny_skia::Transform::from_scale(scale_f32, scale_f32);
|
||||
resvg::render(document, ts, &mut pixmap.as_mut());
|
||||
|
||||
let mut image = pixmap_to_dynamic_image(&pixmap);
|
||||
|
||||
// Apply flip transformations using shared utilities
|
||||
if transform.flip_h {
|
||||
image = crate::domain::document::operations::transform::apply_flip(
|
||||
image,
|
||||
FlipDirection::Horizontal,
|
||||
);
|
||||
}
|
||||
if transform.flip_v {
|
||||
image = crate::domain::document::operations::transform::apply_flip(
|
||||
image,
|
||||
FlipDirection::Vertical,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply rotation using shared utilities
|
||||
image = match transform.rotation {
|
||||
RotationMode::Standard(rotation) => {
|
||||
crate::domain::document::operations::transform::apply_rotation(image, rotation)
|
||||
}
|
||||
RotationMode::Fine(_) => {
|
||||
// For vector documents, fine rotation is handled differently
|
||||
// For now, we just render without rotation
|
||||
// TODO: Implement fine rotation support for vector documents
|
||||
image
|
||||
}
|
||||
};
|
||||
|
||||
let final_width = image.width();
|
||||
let final_height = image.height();
|
||||
|
||||
Ok((image, final_width, final_height))
|
||||
}
|
||||
|
||||
/// Convert a `tiny_skia` Pixmap to a `DynamicImage`.
|
||||
fn pixmap_to_dynamic_image(pixmap: &Pixmap) -> DynamicImage {
|
||||
let width = pixmap.width();
|
||||
let height = pixmap.height();
|
||||
|
||||
// tiny_skia uses premultiplied alpha, we need to unpremultiply for image crate
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
for pixel in pixmap.pixels() {
|
||||
let a = pixel.alpha();
|
||||
if a == 0 {
|
||||
pixels.extend_from_slice(&[0, 0, 0, 0]);
|
||||
} else {
|
||||
// Unpremultiply: color = premultiplied_color * 255 / alpha
|
||||
let r = (u16::from(pixel.red()) * 255 / u16::from(a)) as u8;
|
||||
let g = (u16::from(pixel.green()) * 255 / u16::from(a)) as u8;
|
||||
let b = (u16::from(pixel.blue()) * 255 / u16::from(a)) as u8;
|
||||
pixels.extend_from_slice(&[r, g, b, a]);
|
||||
}
|
||||
}
|
||||
|
||||
let rgba_image = RgbaImage::from_raw(width, height, pixels)
|
||||
.expect("Failed to create RgbaImage from pixmap data");
|
||||
|
||||
DynamicImage::ImageRgba8(rgba_image)
|
||||
}
|
||||
20
src/domain/mod.rs
Normal file
20
src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/domain/mod.rs
|
||||
//
|
||||
// Domain layer: business logic, document abstractions, and viewport management.
|
||||
|
||||
pub mod document;
|
||||
|
||||
// Re-export core document types
|
||||
#[allow(unused_imports)]
|
||||
pub use document::core::content::DocumentContent;
|
||||
#[allow(unused_imports)]
|
||||
pub use document::core::metadata::DocumentMeta;
|
||||
|
||||
// Note: Viewport and error handling were removed to reduce code bloat.
|
||||
// - Viewport: Was 865 lines of unused code (planned feature)
|
||||
// - Domain Errors: Not integrated, anyhow::Result is sufficient
|
||||
//
|
||||
// Low-level pixel operations (apply_rotation, apply_flip, crop_image)
|
||||
// are internal helpers used only by document type implementations.
|
||||
// Use high-level operations for all application and UI code.
|
||||
55
src/i18n.rs
Normal file
55
src/i18n.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/i18n.rs
|
||||
//
|
||||
// Internationalization (i18n) support.
|
||||
|
||||
use i18n_embed::{
|
||||
DefaultLocalizer, LanguageLoader, Localizer,
|
||||
fluent::{FluentLanguageLoader, fluent_language_loader},
|
||||
unic_langid::LanguageIdentifier,
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Applies the requested language(s) to requested translations from the `fl!()` macro.
|
||||
pub fn init(requested_languages: &[LanguageIdentifier]) {
|
||||
if let Err(why) = localizer().select(requested_languages) {
|
||||
println!("error while loading fluent localizations: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
// Get the `Localizer` to be used for localizing this library.
|
||||
#[must_use]
|
||||
pub fn localizer() -> Box<dyn Localizer> {
|
||||
Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations))
|
||||
}
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "i18n/"]
|
||||
struct Localizations;
|
||||
|
||||
pub static LANGUAGE_LOADER: LazyLock<FluentLanguageLoader> = LazyLock::new(|| {
|
||||
let loader: FluentLanguageLoader = fluent_language_loader!();
|
||||
|
||||
loader
|
||||
.load_fallback_language(&Localizations)
|
||||
.expect("Error while loading fallback language");
|
||||
|
||||
loader
|
||||
});
|
||||
|
||||
/// Request a localized string by ID from the i18n/ directory.
|
||||
#[macro_export]
|
||||
macro_rules! fl {
|
||||
($message_id:literal) => {{
|
||||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id)
|
||||
}};
|
||||
|
||||
($message_id:literal, $($name:ident: $value:expr),*) => {{
|
||||
let mut args = std::collections::HashMap::new();
|
||||
$(
|
||||
args.insert(stringify!($name), $value.to_string());
|
||||
)*
|
||||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, args)
|
||||
}};
|
||||
}
|
||||
9
src/infrastructure/cache/mod.rs
vendored
Normal file
9
src/infrastructure/cache/mod.rs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/cache/mod.rs
|
||||
//
|
||||
// Cache infrastructure: thumbnail and document caching.
|
||||
|
||||
pub mod thumbnail_cache;
|
||||
|
||||
// Re-export ThumbnailCache
|
||||
pub use thumbnail_cache::ThumbnailCache;
|
||||
149
src/infrastructure/cache/thumbnail_cache.rs
vendored
Normal file
149
src/infrastructure/cache/thumbnail_cache.rs
vendored
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/cache/thumbnail_cache.rs
|
||||
//
|
||||
// Disk cache for document thumbnails stored in ~/.cache/noctua/
|
||||
|
||||
use std::fs;
|
||||
use std::io::BufWriter;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use image::DynamicImage;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use cosmic::widget::image::Handle as ImageHandle;
|
||||
|
||||
use crate::domain::document::operations::render::create_image_handle_from_image;
|
||||
|
||||
/// Cache directory name under ~/.cache/ for thumbnail storage.
|
||||
const CACHE_DIR: &str = "noctua";
|
||||
|
||||
/// File extension for cached thumbnails.
|
||||
const THUMBNAIL_EXT: &str = "png";
|
||||
|
||||
/// Thumbnail cache manager for disk-based caching.
|
||||
pub struct ThumbnailCache;
|
||||
|
||||
impl ThumbnailCache {
|
||||
/// Load a thumbnail from disk cache.
|
||||
/// Returns None if not cached or cache is invalid.
|
||||
pub fn load(file_path: &Path, page: usize) -> Option<ImageHandle> {
|
||||
let cache_path = Self::thumbnail_path(file_path, page)?;
|
||||
|
||||
log::debug!("Cache lookup: file={}, page={}", file_path.display(), page);
|
||||
|
||||
if !cache_path.exists() {
|
||||
log::debug!(
|
||||
"Thumbnail not found in cache: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let img = image::open(&cache_path).ok()?;
|
||||
log::debug!(
|
||||
"Thumbnail loaded from cache: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
Some(create_image_handle_from_image(&img))
|
||||
}
|
||||
|
||||
/// Save a thumbnail to disk cache.
|
||||
pub fn save(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
|
||||
let dir = Self::ensure_cache_dir()?;
|
||||
let key = Self::cache_key(file_path, page)?;
|
||||
let cache_path = dir.join(format!("{key}.{THUMBNAIL_EXT}"));
|
||||
|
||||
log::debug!(
|
||||
"Saving thumbnail to cache: file={}, page={}, path={}",
|
||||
file_path.display(),
|
||||
page,
|
||||
cache_path.display()
|
||||
);
|
||||
|
||||
let file = fs::File::create(&cache_path).ok()?;
|
||||
let writer = BufWriter::new(file);
|
||||
|
||||
let res = image.write_to(
|
||||
&mut std::io::BufWriter::new(writer),
|
||||
image::ImageFormat::Png,
|
||||
);
|
||||
match res {
|
||||
Ok(()) => {
|
||||
log::debug!(
|
||||
"Thumbnail cached successfully: file={} page={}",
|
||||
file_path.display(),
|
||||
page
|
||||
);
|
||||
Some(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to cache thumbnail: file={} page={}: {}",
|
||||
file_path.display(),
|
||||
page,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached thumbnails.
|
||||
pub fn clear_cache() -> std::io::Result<()> {
|
||||
if let Some(dir) = Self::cache_dir()
|
||||
&& dir.exists()
|
||||
{
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a thumbnail exists in cache.
|
||||
#[allow(dead_code)]
|
||||
pub fn has(file_path: &Path, page: usize) -> bool {
|
||||
Self::thumbnail_path(file_path, page).is_some_and(|p| p.exists())
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
/// Get the cache directory path (~/.cache/noctua/).
|
||||
fn cache_dir() -> Option<PathBuf> {
|
||||
dirs::cache_dir().map(|p| p.join(CACHE_DIR))
|
||||
}
|
||||
|
||||
/// Ensure the cache directory exists.
|
||||
fn ensure_cache_dir() -> Option<PathBuf> {
|
||||
let dir = Self::cache_dir()?;
|
||||
fs::create_dir_all(&dir).ok()?;
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
/// Generate a cache key from file path, modification time, and page number.
|
||||
/// Format: sha256(path + mtime + page)
|
||||
fn cache_key(file_path: &Path, page: usize) -> Option<String> {
|
||||
let metadata = fs::metadata(file_path).ok()?;
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_secs();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(file_path.to_string_lossy().as_bytes());
|
||||
hasher.update(mtime.to_le_bytes());
|
||||
hasher.update(page.to_le_bytes());
|
||||
|
||||
let hash = hasher.finalize();
|
||||
Some(format!("{hash:x}"))
|
||||
}
|
||||
|
||||
/// Get the full path for a cached thumbnail.
|
||||
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
|
||||
let dir = Self::cache_dir()?;
|
||||
let key = Self::cache_key(file_path, page)?;
|
||||
Some(dir.join(format!("{key}.{THUMBNAIL_EXT}")))
|
||||
}
|
||||
}
|
||||
189
src/infrastructure/filesystem/file_ops.rs
Normal file
189
src/infrastructure/filesystem/file_ops.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/filesystem/file_ops.rs
|
||||
//
|
||||
// File system operations for document handling.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use crate::domain::document::core::content::{DocumentContent, DocumentKind};
|
||||
|
||||
use crate::domain::document::types::raster::RasterDocument;
|
||||
#[cfg(feature = "vector")]
|
||||
use crate::domain::document::types::vector::VectorDocument;
|
||||
#[cfg(feature = "portable")]
|
||||
use crate::domain::document::types::portable::PortableDocument;
|
||||
|
||||
/// Open a document from a file path and dispatch to the correct type.
|
||||
///
|
||||
/// Raster formats are delegated to the `image` crate, which decides
|
||||
/// based on enabled codecs (e.g. default-formats).
|
||||
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(path)
|
||||
.ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?;
|
||||
|
||||
let content = match kind {
|
||||
DocumentKind::Raster => {
|
||||
let raster = RasterDocument::open(path)?;
|
||||
DocumentContent::Raster(raster)
|
||||
}
|
||||
#[cfg(feature = "vector")]
|
||||
DocumentKind::Vector => {
|
||||
let vector = VectorDocument::open(path)?;
|
||||
DocumentContent::Vector(vector)
|
||||
}
|
||||
#[cfg(feature = "portable")]
|
||||
DocumentKind::Portable => {
|
||||
let portable = PortableDocument::open(path)?;
|
||||
DocumentContent::Portable(portable)
|
||||
}
|
||||
#[cfg(not(any(feature = "vector", feature = "portable")))]
|
||||
_ => return Err(anyhow!("No document features enabled")),
|
||||
};
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Collect all supported document files from a directory, sorted alphabetically.
|
||||
///
|
||||
/// This scans the directory and returns a list of files that are recognized as
|
||||
/// supported document types (images, PDFs, SVGs, etc.).
|
||||
pub fn collect_supported_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut entries: Vec<PathBuf> = Vec::new();
|
||||
|
||||
if let Ok(read_dir) = fs::read_dir(dir) {
|
||||
for entry in read_dir.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Only keep regular files that are recognized as supported documents.
|
||||
if path.is_file() && DocumentKind::from_path(&path).is_some() {
|
||||
entries.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort();
|
||||
entries
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File metadata helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Retrieve the file size in bytes. Returns 0 if the file cannot be accessed.
|
||||
pub fn file_size(path: &Path) -> u64 {
|
||||
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read raw bytes from a file for metadata extraction (e.g., EXIF).
|
||||
/// Returns None if the file cannot be read.
|
||||
pub fn read_file_bytes(path: &Path) -> Option<Vec<u8>> {
|
||||
fs::read(path).ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEPRECATED FUNCTIONS
|
||||
// ---------------------------------------------------------------------------
|
||||
// The following functions have been replaced by DocumentManager and are
|
||||
// commented out to avoid AppModel dependencies.
|
||||
//
|
||||
// Instead of using these functions directly, use:
|
||||
// - DocumentManager::open_document() for opening files
|
||||
// - DocumentManager::next_document() / previous_document() for navigation
|
||||
// - Application commands for operations like crop, save, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
/// Open the initial path passed on the command line.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
||||
pub fn open_initial_path(model: &mut AppModel, path: &PathBuf) {
|
||||
if path.is_dir() {
|
||||
open_from_directory(model, path);
|
||||
} else {
|
||||
open_single_file(model, path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the first supported document from the given directory.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
||||
pub fn open_from_directory(model: &mut AppModel, dir: &Path) {
|
||||
let entries = collect_supported_files(dir);
|
||||
|
||||
if entries.is_empty() {
|
||||
model.set_error(format!(
|
||||
"No supported documents found in directory: {}",
|
||||
dir.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let first = entries[0].clone();
|
||||
model.folder_entries = entries;
|
||||
model.current_index = Some(0);
|
||||
|
||||
load_document_into_model(model, &first);
|
||||
}
|
||||
|
||||
/// Open a single file.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::open_document() instead.
|
||||
pub fn open_single_file(model: &mut AppModel, path: &Path) {
|
||||
load_document_into_model(model, path);
|
||||
|
||||
if model.document.is_some()
|
||||
&& let Some(parent) = path.parent()
|
||||
{
|
||||
refresh_folder_entries(model, parent, path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a document into the model.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager methods instead.
|
||||
fn load_document_into_model(model: &mut AppModel, path: &Path) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Refresh folder entries.
|
||||
///
|
||||
/// DEPRECATED: DocumentManager handles this automatically.
|
||||
pub fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Navigate to the next document.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::next_document() instead.
|
||||
pub fn navigate_next(model: &mut AppModel) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Navigate to the previous document.
|
||||
///
|
||||
/// DEPRECATED: Use DocumentManager::previous_document() instead.
|
||||
pub fn navigate_prev(model: &mut AppModel) {
|
||||
// Implementation omitted - use DocumentManager instead
|
||||
}
|
||||
|
||||
/// Apply crop operation.
|
||||
///
|
||||
/// DEPRECATED: Use CropDocumentCommand instead.
|
||||
pub fn apply_crop(
|
||||
crop_selection: &CropSelection,
|
||||
doc: &DocumentContent,
|
||||
current_path: &Path,
|
||||
canvas_size: cosmic::iced::Size,
|
||||
image_size: cosmic::iced::Size,
|
||||
scale: f32,
|
||||
pan_x: f32,
|
||||
pan_y: f32,
|
||||
view_mode: &ViewMode,
|
||||
) -> Result<PathBuf, String> {
|
||||
// Implementation omitted - use CropDocumentCommand instead
|
||||
Err("Deprecated function - use CropDocumentCommand".to_string())
|
||||
}
|
||||
*/
|
||||
9
src/infrastructure/filesystem/mod.rs
Normal file
9
src/infrastructure/filesystem/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/filesystem/mod.rs
|
||||
//
|
||||
// Filesystem operations: file I/O, folder scanning, and file watching.
|
||||
|
||||
pub mod file_ops;
|
||||
|
||||
// TODO: Re-implement these helpers without UI dependencies
|
||||
// pub use file_ops::{file_size, read_file_bytes};
|
||||
148
src/infrastructure/loaders/document_loader.rs
Normal file
148
src/infrastructure/loaders/document_loader.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/document_loader.rs
|
||||
//
|
||||
// Document loader trait and factory for loading documents from files.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::{DocumentContent, DocumentKind};
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
|
||||
use super::raster_loader::RasterLoader;
|
||||
#[cfg(feature = "vector")]
|
||||
use super::svg_loader::SvgLoader;
|
||||
#[cfg(feature = "portable")]
|
||||
use super::pdf_loader::PdfLoader;
|
||||
|
||||
/// Trait for loading documents from files.
|
||||
///
|
||||
/// Implementations handle specific document formats (raster, vector, portable).
|
||||
pub trait DocumentLoader {
|
||||
/// Load a document from a file path.
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent>;
|
||||
|
||||
/// Check if this loader supports the given file.
|
||||
fn supports(&self, path: &Path) -> bool;
|
||||
}
|
||||
|
||||
/// Document loader factory.
|
||||
///
|
||||
/// Detects the document format and delegates to the appropriate loader.
|
||||
pub struct DocumentLoaderFactory;
|
||||
|
||||
impl DocumentLoaderFactory {
|
||||
/// Create a new document loader factory.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Load a document from a file, automatically detecting the format.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The file format is not supported
|
||||
/// - The file cannot be read
|
||||
/// - The document is malformed
|
||||
pub fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let kind = DocumentKind::from_path(path).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Unsupported file format: {}",
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("unknown")
|
||||
)
|
||||
})?;
|
||||
|
||||
match kind {
|
||||
DocumentKind::Raster => {
|
||||
let loader = RasterLoader;
|
||||
loader.load(path)
|
||||
}
|
||||
#[cfg(feature = "vector")]
|
||||
DocumentKind::Vector => {
|
||||
let loader = SvgLoader;
|
||||
loader.load(path)
|
||||
}
|
||||
#[cfg(feature = "portable")]
|
||||
DocumentKind::Portable => {
|
||||
let loader = PdfLoader;
|
||||
loader.load(path)
|
||||
}
|
||||
#[cfg(not(any(feature = "vector", feature = "portable")))]
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"No document loaders available (check feature flags)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the document kind from a file path.
|
||||
#[must_use]
|
||||
pub fn detect_kind(&self, path: &Path) -> Option<DocumentKind> {
|
||||
DocumentKind::from_path(path)
|
||||
}
|
||||
|
||||
/// Check if a file is supported by any loader.
|
||||
#[must_use]
|
||||
pub fn is_supported(&self, path: &Path) -> bool {
|
||||
DocumentKind::from_path(path).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DocumentLoaderFactory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_factory_creation() {
|
||||
let factory = DocumentLoaderFactory::new();
|
||||
assert!(std::ptr::eq(&factory, &factory)); // Just a dummy test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_kind() {
|
||||
let factory = DocumentLoaderFactory::new();
|
||||
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.png")),
|
||||
Some(DocumentKind::Raster)
|
||||
);
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.jpg")),
|
||||
Some(DocumentKind::Raster)
|
||||
);
|
||||
|
||||
#[cfg(feature = "vector")]
|
||||
{
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.svg")),
|
||||
Some(DocumentKind::Vector)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "portable")]
|
||||
{
|
||||
assert_eq!(
|
||||
factory.detect_kind(Path::new("test.pdf")),
|
||||
Some(DocumentKind::Portable)
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(factory.detect_kind(Path::new("test.txt")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_supported() {
|
||||
let factory = DocumentLoaderFactory::new();
|
||||
|
||||
assert!(factory.is_supported(Path::new("test.png")));
|
||||
assert!(!factory.is_supported(Path::new("test.txt")));
|
||||
}
|
||||
}
|
||||
15
src/infrastructure/loaders/mod.rs
Normal file
15
src/infrastructure/loaders/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/mod.rs
|
||||
//
|
||||
// Document loaders for various formats.
|
||||
|
||||
pub mod document_loader;
|
||||
|
||||
pub mod raster_loader;
|
||||
#[cfg(feature = "vector")]
|
||||
pub mod svg_loader;
|
||||
#[cfg(feature = "portable")]
|
||||
pub mod pdf_loader;
|
||||
|
||||
// Re-export main types
|
||||
pub use document_loader::DocumentLoaderFactory;
|
||||
50
src/infrastructure/loaders/pdf_loader.rs
Normal file
50
src/infrastructure/loaders/pdf_loader.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/pdf_loader.rs
|
||||
//
|
||||
// Loader for PDF portable documents.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::types::portable::PortableDocument;
|
||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
||||
|
||||
/// Loader for PDF portable documents.
|
||||
pub struct PdfLoader;
|
||||
|
||||
impl DocumentLoader for PdfLoader {
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let document = PortableDocument::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load PDF document: {e}"))?;
|
||||
|
||||
Ok(DocumentContent::Portable(document))
|
||||
}
|
||||
|
||||
fn supports(&self, path: &Path) -> bool {
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
ext_str == "pdf"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_supports() {
|
||||
let loader = PdfLoader;
|
||||
|
||||
assert!(loader.supports(Path::new("test.pdf")));
|
||||
assert!(loader.supports(Path::new("test.PDF")));
|
||||
assert!(loader.supports(Path::new("document.pdf")));
|
||||
assert!(!loader.supports(Path::new("test.png")));
|
||||
assert!(!loader.supports(Path::new("test.svg")));
|
||||
assert!(!loader.supports(Path::new("test.jpg")));
|
||||
assert!(!loader.supports(Path::new("test.txt")));
|
||||
}
|
||||
}
|
||||
46
src/infrastructure/loaders/raster_loader.rs
Normal file
46
src/infrastructure/loaders/raster_loader.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/raster_loader.rs
|
||||
//
|
||||
// Loader for raster image documents (PNG, JPEG, WebP, etc.).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::types::raster::RasterDocument;
|
||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
||||
|
||||
/// Loader for raster image documents.
|
||||
pub struct RasterLoader;
|
||||
|
||||
impl DocumentLoader for RasterLoader {
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let document = RasterDocument::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load raster document: {e}"))?;
|
||||
|
||||
Ok(DocumentContent::Raster(document))
|
||||
}
|
||||
|
||||
fn supports(&self, path: &Path) -> bool {
|
||||
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat;
|
||||
|
||||
ImageFormat::from_path(path).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_supports() {
|
||||
let loader = RasterLoader;
|
||||
|
||||
assert!(loader.supports(Path::new("test.png")));
|
||||
assert!(loader.supports(Path::new("test.jpg")));
|
||||
assert!(loader.supports(Path::new("test.jpeg")));
|
||||
assert!(loader.supports(Path::new("test.webp")));
|
||||
assert!(!loader.supports(Path::new("test.pdf")));
|
||||
assert!(!loader.supports(Path::new("test.svg")));
|
||||
}
|
||||
}
|
||||
49
src/infrastructure/loaders/svg_loader.rs
Normal file
49
src/infrastructure/loaders/svg_loader.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/loaders/svg_loader.rs
|
||||
//
|
||||
// Loader for SVG vector documents.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::document::core::content::DocumentContent;
|
||||
use crate::domain::document::core::document::DocResult;
|
||||
use crate::domain::document::types::vector::VectorDocument;
|
||||
use crate::infrastructure::loaders::document_loader::DocumentLoader;
|
||||
|
||||
/// Loader for SVG vector documents.
|
||||
pub struct SvgLoader;
|
||||
|
||||
impl DocumentLoader for SvgLoader {
|
||||
fn load(&self, path: &Path) -> DocResult<DocumentContent> {
|
||||
let document = VectorDocument::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load SVG document: {e}"))?;
|
||||
|
||||
Ok(DocumentContent::Vector(document))
|
||||
}
|
||||
|
||||
fn supports(&self, path: &Path) -> bool {
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext_str = ext.to_string_lossy().to_lowercase();
|
||||
ext_str == "svg" || ext_str == "svgz"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_supports() {
|
||||
let loader = SvgLoader;
|
||||
|
||||
assert!(loader.supports(Path::new("test.svg")));
|
||||
assert!(loader.supports(Path::new("test.SVG")));
|
||||
assert!(loader.supports(Path::new("test.svgz")));
|
||||
assert!(!loader.supports(Path::new("test.png")));
|
||||
assert!(!loader.supports(Path::new("test.pdf")));
|
||||
assert!(!loader.supports(Path::new("test.jpg")));
|
||||
}
|
||||
}
|
||||
13
src/infrastructure/mod.rs
Normal file
13
src/infrastructure/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/mod.rs
|
||||
//
|
||||
// Infrastructure layer: external dependencies, loaders, cache, and filesystem.
|
||||
|
||||
pub mod cache;
|
||||
pub mod filesystem;
|
||||
pub mod loaders;
|
||||
pub mod system;
|
||||
|
||||
// Re-export loader factory
|
||||
#[allow(unused_imports)]
|
||||
pub use loaders::DocumentLoaderFactory;
|
||||
9
src/infrastructure/system/mod.rs
Normal file
9
src/infrastructure/system/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/system/mod.rs
|
||||
//
|
||||
// System integration: wallpaper, desktop environment utilities.
|
||||
|
||||
pub mod wallpaper;
|
||||
|
||||
// Re-export wallpaper function
|
||||
pub use wallpaper::set_as_wallpaper;
|
||||
159
src/infrastructure/system/wallpaper.rs
Normal file
159
src/infrastructure/system/wallpaper.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/infrastructure/system/wallpaper.rs
|
||||
//
|
||||
// Set desktop wallpaper across different desktop environments.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Set an image as desktop wallpaper using multiple fallback methods.
|
||||
///
|
||||
/// Attempts the following methods in order:
|
||||
/// 1. COSMIC Desktop (direct config file modification)
|
||||
/// 2. wallpaper crate (KDE, XFCE, Windows, macOS)
|
||||
/// 3. gsettings (GNOME)
|
||||
/// 4. feh (tiling window managers)
|
||||
pub fn set_as_wallpaper(path: &Path) {
|
||||
// Canonicalize to absolute path.
|
||||
let abs_path = match path.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("Failed to canonicalize path {}: {}", path.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(path_str) = abs_path.to_str() else {
|
||||
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
|
||||
return;
|
||||
};
|
||||
|
||||
log::info!("Attempting to set wallpaper: {path_str}");
|
||||
|
||||
// Method 1: Try COSMIC Desktop (direct config file modification).
|
||||
if try_cosmic_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS).
|
||||
if try_wallpaper_crate(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 3: Try GNOME via gsettings.
|
||||
if try_gsettings_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 4: Try feh (common on tiling WMs like i3, sway).
|
||||
if try_feh_wallpaper(path_str) {
|
||||
return;
|
||||
}
|
||||
|
||||
log::error!("All methods failed to set wallpaper");
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via COSMIC config file.
|
||||
fn try_cosmic_wallpaper(path_str: &str) -> bool {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all");
|
||||
if !cosmic_config.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let config_content = format!(
|
||||
r#"(
|
||||
output: "all",
|
||||
source: Path("{path_str}"),
|
||||
filter_by_theme: true,
|
||||
rotation_frequency: 300,
|
||||
filter_method: Lanczos,
|
||||
scaling_mode: Zoom,
|
||||
sampling_method: Alphanumeric,
|
||||
)"#
|
||||
);
|
||||
|
||||
match std::fs::write(&cosmic_config, config_content) {
|
||||
Ok(()) => {
|
||||
log::info!("Wallpaper set via COSMIC config");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to write COSMIC config: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via wallpaper crate.
|
||||
fn try_wallpaper_crate(path_str: &str) -> bool {
|
||||
match wallpaper::set_from_path(path_str) {
|
||||
Ok(()) => {
|
||||
log::info!("Wallpaper set via wallpaper crate");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("wallpaper crate failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via GNOME gsettings.
|
||||
fn try_gsettings_wallpaper(path_str: &str) -> bool {
|
||||
let uri = format!("file://{path_str}");
|
||||
|
||||
let output = match std::process::Command::new("gsettings")
|
||||
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
log::warn!("gsettings command failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"gsettings failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
log::info!("Wallpaper set via gsettings");
|
||||
|
||||
// Also set dark mode wallpaper.
|
||||
let _ = std::process::Command::new("gsettings")
|
||||
.args([
|
||||
"set",
|
||||
"org.gnome.desktop.background",
|
||||
"picture-uri-dark",
|
||||
&uri,
|
||||
])
|
||||
.output();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Try setting wallpaper via feh.
|
||||
fn try_feh_wallpaper(path_str: &str) -> bool {
|
||||
let Ok(output) = std::process::Command::new("feh")
|
||||
.args(["--bg-scale", path_str])
|
||||
.output()
|
||||
else {
|
||||
log::warn!("feh not available");
|
||||
return false;
|
||||
};
|
||||
|
||||
if output.status.success() {
|
||||
log::info!("Wallpaper set via feh");
|
||||
true
|
||||
} else {
|
||||
log::warn!("feh failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
43
src/main.rs
Normal file
43
src/main.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/main.rs
|
||||
//
|
||||
// Application entry point.
|
||||
|
||||
mod ui;
|
||||
mod application;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
|
||||
mod config;
|
||||
mod i18n;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use cosmic::app::Settings;
|
||||
use crate::ui::NoctuaApp;
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(version, about)]
|
||||
pub struct Args {
|
||||
/// File to open on startup
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: Option<std::path::PathBuf>,
|
||||
|
||||
/// UI language (e.g. "en", "de")
|
||||
#[arg(short, long, default_value = "en")]
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Get the system's preferred languages.
|
||||
let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages();
|
||||
|
||||
// Enable localizations to be applied.
|
||||
i18n::init(&requested_languages);
|
||||
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
|
||||
cosmic::app::run::<NoctuaApp>(Settings::default(), ui::app::Flags::Args(args))
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
403
src/ui/app.rs
Normal file
403
src/ui/app.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/app/app.rs
|
||||
//
|
||||
// COSMIC application wiring and main app struct.
|
||||
|
||||
use super::message::AppMessage;
|
||||
use super::model::{AppModel, ViewMode};
|
||||
use super::update;
|
||||
use crate::ui::views;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::app::{context_drawer, Core};
|
||||
use cosmic::cosmic_config::{self, CosmicConfigEntry};
|
||||
use cosmic::iced::keyboard::{self, key::Named, Key, Modifiers};
|
||||
use cosmic::iced::time;
|
||||
use cosmic::iced::window;
|
||||
use cosmic::iced::Subscription;
|
||||
use cosmic::widget::nav_bar;
|
||||
use cosmic::{Action, Element, Task};
|
||||
|
||||
use crate::application::DocumentManager;
|
||||
use crate::config::AppConfig;
|
||||
use crate::Args;
|
||||
|
||||
/// Flags passed from `main` into the application.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Flags {
|
||||
Args(Args),
|
||||
}
|
||||
|
||||
/// Context page displayed in right drawer.
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub enum ContextPage {
|
||||
#[default]
|
||||
Properties,
|
||||
}
|
||||
|
||||
/// Main application type.
|
||||
pub struct NoctuaApp {
|
||||
core: Core,
|
||||
pub model: AppModel,
|
||||
nav: nav_bar::Model,
|
||||
context_page: ContextPage,
|
||||
pub config: AppConfig,
|
||||
config_handler: Option<cosmic_config::Config>,
|
||||
pub document_manager: DocumentManager,
|
||||
}
|
||||
|
||||
impl cosmic::Application for NoctuaApp {
|
||||
type Executor = cosmic::SingleThreadExecutor;
|
||||
type Flags = Flags;
|
||||
type Message = AppMessage;
|
||||
|
||||
const APP_ID: &'static str = "org.codeberg.wfx.Noctua";
|
||||
|
||||
fn core(&self) -> &Core {
|
||||
&self.core
|
||||
}
|
||||
|
||||
fn core_mut(&mut self) -> &mut Core {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
fn init(mut core: Core, flags: Self::Flags) -> (Self, Task<Action<Self::Message>>) {
|
||||
// Load persisted config.
|
||||
let (config, config_handler) =
|
||||
match cosmic_config::Config::new(Self::APP_ID, AppConfig::VERSION) {
|
||||
Ok(handler) => {
|
||||
let config = AppConfig::get_entry(&handler).unwrap_or_default();
|
||||
(config, Some(handler))
|
||||
}
|
||||
Err(_) => (AppConfig::default(), None),
|
||||
};
|
||||
|
||||
let Flags::Args(args) = flags;
|
||||
|
||||
// Determine initial path: CLI argument takes priority.
|
||||
// Fall back to configured default directory only if it exists.
|
||||
let initial_path = args.file.or_else(|| {
|
||||
config
|
||||
.default_image_dir
|
||||
.as_ref()
|
||||
.filter(|p| p.exists())
|
||||
.cloned()
|
||||
});
|
||||
|
||||
// Initialize document manager
|
||||
let mut document_manager = DocumentManager::new();
|
||||
|
||||
// Initialize model
|
||||
let mut model = AppModel::new(config.clone());
|
||||
|
||||
// Load initial document if provided
|
||||
if let Some(path) = initial_path {
|
||||
if let Err(e) = document_manager.open_document(&path) {
|
||||
log::error!("Failed to open initial path {}: {}", path.display(), e);
|
||||
} else {
|
||||
// Set initial view mode to Fit
|
||||
model.viewport.fit_mode = ViewMode::Fit;
|
||||
model.viewport.scale = 1.0;
|
||||
model.reset_pan();
|
||||
|
||||
// Cache initial render so image is displayed immediately
|
||||
if let Some(doc) = document_manager.current_document_mut() {
|
||||
use crate::domain::document::core::document::Renderable;
|
||||
match doc.render(model.viewport.scale as f64) {
|
||||
Ok(output) => {
|
||||
model.viewport.cached_image_handle = Some(output.handle);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to render initial document: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize nav bar model (required for COSMIC to show toggle icon).
|
||||
let nav = nav_bar::Model::default();
|
||||
|
||||
// Apply persisted panel states.
|
||||
core.window.show_context = config.context_drawer_visible;
|
||||
|
||||
// Auto-open nav bar for multi-page documents
|
||||
let should_show_nav = if let Some(doc) = document_manager.current_document() {
|
||||
doc.is_multi_page()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if should_show_nav {
|
||||
core.nav_bar_set_toggled(true);
|
||||
model.panels.left = Some(crate::ui::model::LeftPanel::Thumbnails);
|
||||
} else {
|
||||
core.nav_bar_set_toggled(config.nav_bar_visible);
|
||||
}
|
||||
|
||||
// Start thumbnail generation for initial document if applicable.
|
||||
let init_task = start_thumbnail_generation(&model);
|
||||
|
||||
(
|
||||
Self {
|
||||
core,
|
||||
model,
|
||||
nav,
|
||||
context_page: ContextPage::default(),
|
||||
config,
|
||||
config_handler,
|
||||
document_manager,
|
||||
},
|
||||
init_task,
|
||||
)
|
||||
}
|
||||
|
||||
fn on_close_requested(&self, _id: window::Id) -> Option<Self::Message> {
|
||||
None
|
||||
}
|
||||
|
||||
fn update(&mut self, message: Self::Message) -> Task<Action<Self::Message>> {
|
||||
match &message {
|
||||
AppMessage::ToggleNavBar => {
|
||||
use crate::ui::model::LeftPanel;
|
||||
|
||||
self.core.nav_bar_toggle();
|
||||
let is_visible = self.core.nav_bar_active();
|
||||
self.config.nav_bar_visible = is_visible;
|
||||
self.save_config();
|
||||
|
||||
if is_visible {
|
||||
// Opening nav bar - show thumbnails for multi-page docs
|
||||
if let Some(doc) = self.document_manager.current_document()
|
||||
&& doc.is_multi_page()
|
||||
{
|
||||
self.model.panels.left = Some(LeftPanel::Thumbnails);
|
||||
}
|
||||
} else {
|
||||
// Closing nav bar - hide left panel
|
||||
self.model.panels.left = None;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
AppMessage::OpenFormatPanel => {
|
||||
// Format panel is now part of Transform mode
|
||||
// Switch to Transform mode which shows format tools in right panel
|
||||
self.model.mode = crate::ui::model::AppMode::Transform {
|
||||
paper_format: None,
|
||||
orientation: crate::ui::model::Orientation::default(),
|
||||
};
|
||||
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
AppMessage::ToggleContextPage(page) => {
|
||||
if self.context_page == *page {
|
||||
self.core.window.show_context = !self.core.window.show_context;
|
||||
} else {
|
||||
self.context_page = *page;
|
||||
self.core.window.show_context = true;
|
||||
}
|
||||
self.config.context_drawer_visible = self.core.window.show_context;
|
||||
self.save_config();
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
AppMessage::OpenPath(_) | AppMessage::NextDocument | AppMessage::PrevDocument => {
|
||||
let result = update::update(self, &message);
|
||||
let thumb_task = start_thumbnail_generation_task(&self.model);
|
||||
return match result {
|
||||
update::UpdateResult::None => thumb_task,
|
||||
update::UpdateResult::Task(task) => Task::batch([task, thumb_task]),
|
||||
};
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match update::update(self, &message) {
|
||||
update::UpdateResult::None => Task::none(),
|
||||
update::UpdateResult::Task(task) => task,
|
||||
}
|
||||
}
|
||||
|
||||
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
|
||||
views::header::start(&self.model, &self.document_manager)
|
||||
}
|
||||
|
||||
fn header_end(&self) -> Vec<Element<'_, Self::Message>> {
|
||||
views::header::end(&self.model, &self.document_manager)
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Self::Message> {
|
||||
views::view(&self.model, &self.document_manager, &self.config)
|
||||
}
|
||||
|
||||
fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Self::Message>> {
|
||||
if !self.core.window.show_context {
|
||||
return None;
|
||||
}
|
||||
Some(context_drawer::context_drawer(
|
||||
views::panels::view(&self.model, &self.document_manager),
|
||||
AppMessage::ToggleContextPage(ContextPage::Properties),
|
||||
))
|
||||
}
|
||||
|
||||
fn nav_model(&self) -> Option<&nav_bar::Model> {
|
||||
Some(&self.nav)
|
||||
}
|
||||
|
||||
fn nav_bar(&self) -> Option<Element<'_, Action<Self::Message>>> {
|
||||
if !self.core.nav_bar_active() {
|
||||
return None;
|
||||
}
|
||||
views::nav_bar(&self.model, &self.document_manager)
|
||||
}
|
||||
|
||||
fn footer(&self) -> Option<Element<'_, Self::Message>> {
|
||||
Some(views::footer::view(&self.model, &self.document_manager))
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Self::Message> {
|
||||
Subscription::batch([
|
||||
keyboard::on_key_press(handle_key_press),
|
||||
thumbnail_refresh_subscription(self),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
impl NoctuaApp {
|
||||
/// Save current config to disk.
|
||||
fn save_config(&self) {
|
||||
if let Some(ref handler) = self.config_handler {
|
||||
let _ = self.config.write_entry(handler);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update nav bar visibility based on current document type.
|
||||
pub fn update_nav_bar_for_document(&mut self) {
|
||||
use crate::ui::model::LeftPanel;
|
||||
|
||||
if let Some(doc) = self.document_manager.current_document() {
|
||||
if doc.is_multi_page() {
|
||||
// Multi-page document: open nav bar and show thumbnails
|
||||
self.core.nav_bar_set_toggled(true);
|
||||
self.model.panels.left = Some(LeftPanel::Thumbnails);
|
||||
} else {
|
||||
// Single-page document: close nav bar
|
||||
self.core.nav_bar_set_toggled(false);
|
||||
self.model.panels.left = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map raw key presses + modifiers into high-level application messages.
|
||||
fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
|
||||
use AppMessage::{
|
||||
PanLeft, PanRight, PanUp, PanDown, OpenFormatPanel, NextDocument, PrevDocument,
|
||||
FlipHorizontal, FlipVertical, RotateCCW, RotateCW, ZoomIn, ZoomOut, ZoomReset, ZoomFit,
|
||||
ToggleCropMode, ToggleScaleMode, PanReset, ToggleContextPage, ToggleNavBar, SetAsWallpaper,
|
||||
};
|
||||
|
||||
// Handle Ctrl + arrow keys for panning.
|
||||
if modifiers.control() && !modifiers.shift() && !modifiers.alt() && !modifiers.logo() {
|
||||
return match key.as_ref() {
|
||||
Key::Named(Named::ArrowLeft) => Some(PanLeft),
|
||||
Key::Named(Named::ArrowRight) => Some(PanRight),
|
||||
Key::Named(Named::ArrowUp) => Some(PanUp),
|
||||
Key::Named(Named::ArrowDown) => Some(PanDown),
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(OpenFormatPanel),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
|
||||
// Ignore key presses when command-style modifiers are pressed.
|
||||
if modifiers.command() || modifiers.alt() || modifiers.logo() || modifiers.control() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match key.as_ref() {
|
||||
// Navigation with arrow keys (no modifiers).
|
||||
Key::Named(Named::ArrowRight) => Some(NextDocument),
|
||||
Key::Named(Named::ArrowLeft) => Some(PrevDocument),
|
||||
|
||||
// Transformations.
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("h") => Some(FlipHorizontal),
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("v") => Some(FlipVertical),
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("r") => {
|
||||
if modifiers.shift() {
|
||||
Some(RotateCCW)
|
||||
} else {
|
||||
Some(RotateCW)
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom.
|
||||
Key::Character("+" | "=") => Some(ZoomIn),
|
||||
Key::Character("-") => Some(ZoomOut),
|
||||
Key::Character("1") => Some(ZoomReset),
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("f") => Some(ZoomFit),
|
||||
|
||||
// Tool modes.
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("c") => Some(ToggleCropMode),
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("s") => Some(ToggleScaleMode),
|
||||
|
||||
// Crop mode actions (Enter/Escape handled via key press, validated in update).
|
||||
Key::Named(Named::Enter) => Some(AppMessage::ApplyCrop),
|
||||
Key::Named(Named::Escape) => Some(AppMessage::CancelCrop),
|
||||
|
||||
// Reset pan.
|
||||
Key::Character("0") => Some(PanReset),
|
||||
|
||||
// Toggle panels.
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("i") => {
|
||||
Some(ToggleContextPage(ContextPage::Properties))
|
||||
}
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("n") => Some(ToggleNavBar),
|
||||
|
||||
// Wallpaper.
|
||||
Key::Character(ch) if ch.eq_ignore_ascii_case("w") => Some(SetAsWallpaper),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Thumbnail Helpers
|
||||
// =============================================================================
|
||||
|
||||
fn start_thumbnail_generation(model: &AppModel) -> Task<Action<AppMessage>> {
|
||||
start_thumbnail_generation_task(model)
|
||||
}
|
||||
|
||||
fn start_thumbnail_generation_task(_model: &AppModel) -> Task<Action<AppMessage>> {
|
||||
// TODO: Re-enable when document is synced from DocumentManager
|
||||
// if let Some(doc) = &model.document {
|
||||
// let page_count = doc.page_count();
|
||||
// if page_count > 0 && !doc.thumbnails_ready() {
|
||||
// return Task::batch([
|
||||
// Task::done(Action::App(AppMessage::GenerateThumbnailPage(0))),
|
||||
// Task::done(Action::App(AppMessage::RefreshView)),
|
||||
// ]);
|
||||
// }
|
||||
// }
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn thumbnail_refresh_subscription(_app: &NoctuaApp) -> Subscription<AppMessage> {
|
||||
// TODO: Re-enable when document is synced from DocumentManager
|
||||
let needs_refresh = false;
|
||||
// let needs_refresh = app
|
||||
// .model
|
||||
// .document
|
||||
// .as_ref()
|
||||
// .is_some_and(|doc| doc.is_multi_page() && !doc.thumbnails_ready());
|
||||
|
||||
if needs_refresh {
|
||||
time::every(Duration::from_millis(100)).map(|_| AppMessage::RefreshView)
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
6
src/ui/components/mod.rs
Normal file
6
src/ui/components/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/components/mod.rs
|
||||
//
|
||||
// UI components: reusable widgets and controls.
|
||||
|
||||
// Crop functionality moved to ui/widgets
|
||||
102
src/ui/message.rs
Normal file
102
src/ui/message.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/message.rs
|
||||
//
|
||||
// Application messages: events, user actions, and internal signals.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ui::widgets::DragHandle;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppMessage {
|
||||
// File / navigation.
|
||||
#[allow(dead_code)]
|
||||
OpenPath(PathBuf),
|
||||
NextDocument,
|
||||
PrevDocument,
|
||||
GotoPage(usize),
|
||||
GenerateThumbnailPage(usize),
|
||||
|
||||
// Transformations.
|
||||
RotateCW,
|
||||
RotateCCW,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
|
||||
// View / zoom.
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
ZoomReset,
|
||||
ZoomFit,
|
||||
ViewerStateChanged {
|
||||
scale: f32,
|
||||
offset_x: f32,
|
||||
offset_y: f32,
|
||||
canvas_size: cosmic::iced::Size,
|
||||
image_size: cosmic::iced::Size,
|
||||
},
|
||||
|
||||
// Pan control.
|
||||
PanLeft,
|
||||
PanRight,
|
||||
PanUp,
|
||||
PanDown,
|
||||
PanReset,
|
||||
|
||||
// Tool modes.
|
||||
ToggleCropMode,
|
||||
ToggleScaleMode,
|
||||
|
||||
// Crop operations.
|
||||
StartCrop,
|
||||
CancelCrop,
|
||||
ApplyCrop,
|
||||
CropDragStart {
|
||||
x: f32,
|
||||
y: f32,
|
||||
handle: DragHandle,
|
||||
},
|
||||
CropDragMove {
|
||||
x: f32,
|
||||
y: f32,
|
||||
max_x: f32,
|
||||
max_y: f32,
|
||||
},
|
||||
|
||||
CropDragEnd,
|
||||
|
||||
// Panels.
|
||||
ToggleContextPage(crate::ui::app::ContextPage),
|
||||
ToggleNavBar,
|
||||
OpenFormatPanel,
|
||||
|
||||
// Menu.
|
||||
ToggleMainMenu,
|
||||
|
||||
// Format operations.
|
||||
SetPaperFormat(super::model::PaperFormat),
|
||||
SetOrientation(super::model::Orientation),
|
||||
|
||||
// Metadata.
|
||||
#[allow(dead_code)]
|
||||
RefreshMetadata,
|
||||
|
||||
// Save operations.
|
||||
SaveAs,
|
||||
|
||||
// Wallpaper.
|
||||
SetAsWallpaper,
|
||||
|
||||
// Errors.
|
||||
#[allow(dead_code)]
|
||||
ShowError(String),
|
||||
#[allow(dead_code)]
|
||||
ClearError,
|
||||
|
||||
// UI refresh.
|
||||
RefreshView,
|
||||
|
||||
// Fallback.
|
||||
#[allow(dead_code)]
|
||||
NoOp,
|
||||
}
|
||||
17
src/ui/mod.rs
Normal file
17
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/mod.rs
|
||||
//
|
||||
// UI layer: COSMIC application, views, and widgets.
|
||||
|
||||
pub mod app;
|
||||
pub mod message;
|
||||
pub mod model;
|
||||
pub mod update;
|
||||
pub mod components;
|
||||
pub mod views;
|
||||
pub mod widgets;
|
||||
|
||||
// Re-export main types
|
||||
pub use app::NoctuaApp;
|
||||
pub use message::AppMessage;
|
||||
pub use model::AppModel;
|
||||
280
src/ui/model.rs
Normal file
280
src/ui/model.rs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/model.rs
|
||||
//
|
||||
// UI state (view, tools, panels).
|
||||
//
|
||||
// AppModel contains ONLY UI-specific state.
|
||||
// Document state lives in DocumentManager (application layer).
|
||||
|
||||
use cosmic::iced::Size;
|
||||
|
||||
use crate::ui::widgets::CropSelection;
|
||||
use crate::config::AppConfig;
|
||||
|
||||
// =============================================================================
|
||||
// View Mode
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ViewMode {
|
||||
#[default]
|
||||
Fit,
|
||||
ActualSize,
|
||||
Custom,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Paper Format (for export/transform)
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaperFormat {
|
||||
UsLetter,
|
||||
IsoA0,
|
||||
IsoA1,
|
||||
IsoA2,
|
||||
IsoA3,
|
||||
IsoA4,
|
||||
IsoA5,
|
||||
IsoA6,
|
||||
}
|
||||
|
||||
impl PaperFormat {
|
||||
/// Returns (width, height) in millimeters
|
||||
pub fn dimensions_mm(self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::UsLetter => (216, 279), // 8.5 x 11 inches
|
||||
Self::IsoA0 => (841, 1189),
|
||||
Self::IsoA1 => (594, 841),
|
||||
Self::IsoA2 => (420, 594),
|
||||
Self::IsoA3 => (297, 420),
|
||||
Self::IsoA4 => (210, 297),
|
||||
Self::IsoA5 => (148, 210),
|
||||
Self::IsoA6 => (105, 148),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns display name
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::UsLetter => "US Letter",
|
||||
Self::IsoA0 => "A0 (841 × 1189 mm)",
|
||||
Self::IsoA1 => "A1",
|
||||
Self::IsoA2 => "A2",
|
||||
Self::IsoA3 => "A3",
|
||||
Self::IsoA4 => "A4",
|
||||
Self::IsoA5 => "A5 (148 × 210 mm)",
|
||||
Self::IsoA6 => "A6",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Orientation {
|
||||
Horizontal,
|
||||
#[default]
|
||||
Vertical,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Application Mode (combines tool + panel state)
|
||||
// =============================================================================
|
||||
|
||||
/// Application mode - unified tool and panel state.
|
||||
///
|
||||
/// Each mode determines:
|
||||
/// - Active tool behavior
|
||||
/// - Right panel content
|
||||
/// - Available shortcuts
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum AppMode {
|
||||
/// Normal viewing mode - no active tool
|
||||
View,
|
||||
|
||||
/// Crop mode with selection
|
||||
Crop { selection: CropSelection },
|
||||
|
||||
/// Transform/export mode
|
||||
Transform {
|
||||
paper_format: Option<PaperFormat>,
|
||||
orientation: Orientation,
|
||||
},
|
||||
|
||||
/// Fullscreen mode (all panels hidden)
|
||||
Fullscreen,
|
||||
}
|
||||
|
||||
impl Default for AppMode {
|
||||
fn default() -> Self {
|
||||
Self::View
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMode {
|
||||
/// Get the right panel that should be shown for this mode
|
||||
pub fn right_panel(&self) -> Option<RightPanel> {
|
||||
match self {
|
||||
Self::View => Some(RightPanel::Properties),
|
||||
Self::Crop { .. } => Some(RightPanel::CropTools),
|
||||
Self::Transform { .. } => Some(RightPanel::TransformTools),
|
||||
Self::Fullscreen => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if mode is an active tool (not View/Fullscreen)
|
||||
pub fn is_tool_active(&self) -> bool {
|
||||
matches!(self, Self::Crop { .. } | Self::Transform { .. })
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Viewport (zoom, pan, canvas)
|
||||
// =============================================================================
|
||||
|
||||
/// Viewport state - zoom, pan, canvas dimensions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Viewport {
|
||||
/// Current scale factor
|
||||
pub scale: f32,
|
||||
|
||||
/// Pan offset X
|
||||
pub pan_x: f32,
|
||||
|
||||
/// Pan offset Y
|
||||
pub pan_y: f32,
|
||||
|
||||
/// Canvas size (container)
|
||||
pub canvas_size: Size,
|
||||
|
||||
/// Image size (after scaling)
|
||||
pub image_size: Size,
|
||||
|
||||
/// Fit mode
|
||||
pub fit_mode: ViewMode,
|
||||
|
||||
/// Scroll container ID
|
||||
pub scroll_id: cosmic::widget::Id,
|
||||
|
||||
/// Cached image handle for rendering (updated when document or scale changes)
|
||||
pub cached_image_handle: Option<cosmic::widget::image::Handle>,
|
||||
}
|
||||
|
||||
impl Default for Viewport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scale: 1.0,
|
||||
pan_x: 0.0,
|
||||
pan_y: 0.0,
|
||||
canvas_size: Size::ZERO,
|
||||
image_size: Size::ZERO,
|
||||
fit_mode: ViewMode::Fit,
|
||||
scroll_id: cosmic::widget::Id::new("canvas-scroll"),
|
||||
cached_image_handle: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
/// Reset pan to center
|
||||
pub fn reset_pan(&mut self) {
|
||||
self.pan_x = 0.0;
|
||||
self.pan_y = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Panel State
|
||||
// =============================================================================
|
||||
|
||||
/// Panel visibility state.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PanelState {
|
||||
/// Left panel (thumbnails for multi-page)
|
||||
pub left: Option<LeftPanel>,
|
||||
|
||||
/// Right panel (context-dependent tools/properties)
|
||||
pub right: Option<RightPanel>,
|
||||
}
|
||||
|
||||
/// Left panel types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LeftPanel {
|
||||
/// Thumbnail navigation for multi-page documents
|
||||
Thumbnails,
|
||||
}
|
||||
|
||||
/// Right panel types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum RightPanel {
|
||||
/// Document properties and metadata
|
||||
Properties,
|
||||
|
||||
/// Crop mode tools
|
||||
CropTools,
|
||||
|
||||
/// Transform/export tools
|
||||
TransformTools,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AppModel (UI State Only)
|
||||
// =============================================================================
|
||||
|
||||
/// UI state for the application.
|
||||
///
|
||||
/// Contains ONLY UI-specific state:
|
||||
/// - Current mode (view/tool)
|
||||
/// - Viewport (zoom/pan)
|
||||
/// - Panel visibility
|
||||
/// - Transient UI state (errors, menu)
|
||||
///
|
||||
/// Document state (current file, metadata, etc.) lives in DocumentManager!
|
||||
pub struct AppModel {
|
||||
/// Current application mode
|
||||
pub mode: AppMode,
|
||||
|
||||
/// Viewport state
|
||||
pub viewport: Viewport,
|
||||
|
||||
/// Panel visibility
|
||||
pub panels: PanelState,
|
||||
|
||||
/// Error message to display
|
||||
pub error: Option<String>,
|
||||
|
||||
/// Is main menu open?
|
||||
pub menu_open: bool,
|
||||
|
||||
/// Tick counter for animations
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
impl AppModel {
|
||||
pub fn new(_config: AppConfig) -> Self {
|
||||
Self {
|
||||
mode: AppMode::default(),
|
||||
viewport: Viewport::default(),
|
||||
panels: PanelState::default(),
|
||||
error: None,
|
||||
menu_open: false,
|
||||
tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set error message
|
||||
pub fn set_error<S: Into<String>>(&mut self, msg: S) {
|
||||
self.error = Some(msg.into());
|
||||
}
|
||||
|
||||
/// Clear error message
|
||||
pub fn clear_error(&mut self) {
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
/// Reset viewport pan to center
|
||||
pub fn reset_pan(&mut self) {
|
||||
self.viewport.reset_pan();
|
||||
}
|
||||
}
|
||||
404
src/ui/update.rs
Normal file
404
src/ui/update.rs
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/update.rs
|
||||
//
|
||||
// Application update loop: applies messages to the global model state.
|
||||
|
||||
use cosmic::{Action, Task};
|
||||
|
||||
use super::NoctuaApp;
|
||||
use super::message::AppMessage;
|
||||
use super::model::{AppMode, ViewMode};
|
||||
use crate::application::commands::transform_document::{TransformDocumentCommand, TransformOperation};
|
||||
use crate::application::commands::crop_document::CropDocumentCommand;
|
||||
use crate::domain::document::core::document::Renderable;
|
||||
use crate::ui::widgets::{CropSelection, DragHandle};
|
||||
|
||||
// =============================================================================
|
||||
// Update Result
|
||||
// =============================================================================
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum UpdateResult {
|
||||
None,
|
||||
Task(Task<Action<AppMessage>>),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Update Function
|
||||
// =============================================================================
|
||||
|
||||
pub fn update(app: &mut NoctuaApp, msg: &AppMessage) -> UpdateResult {
|
||||
match msg {
|
||||
// ---- File / navigation ----------------------------------------------------
|
||||
AppMessage::OpenPath(path) => {
|
||||
if let Err(e) = app.document_manager.open_document(path) {
|
||||
app.model.set_error(format!("Failed to open document: {e}"));
|
||||
} else {
|
||||
app.model.reset_pan();
|
||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
||||
app.model.viewport.scale = 1.0;
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
|
||||
// Auto-toggle nav bar for multi-page documents
|
||||
app.update_nav_bar_for_document();
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::NextDocument => {
|
||||
// Ignore navigation in Crop mode
|
||||
if !matches!(app.model.mode, AppMode::Crop { .. })
|
||||
&& let Some(_path) = app.document_manager.next_document()
|
||||
{
|
||||
// Reset zoom when navigating to new document
|
||||
app.model.viewport.scale = 1.0;
|
||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
||||
app.model.reset_pan();
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
|
||||
// Auto-toggle nav bar for multi-page documents
|
||||
app.update_nav_bar_for_document();
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::PrevDocument => {
|
||||
// Ignore navigation in Crop mode
|
||||
if !matches!(app.model.mode, AppMode::Crop { .. })
|
||||
&& let Some(_path) = app.document_manager.previous_document()
|
||||
{
|
||||
// Reset zoom when navigating to new document
|
||||
app.model.viewport.scale = 1.0;
|
||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
||||
app.model.reset_pan();
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
|
||||
// Auto-toggle nav bar for multi-page documents
|
||||
app.update_nav_bar_for_document();
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::GotoPage(page) => {
|
||||
if let Some(doc) = app.document_manager.current_document_mut() {
|
||||
if let Err(e) = doc.go_to_page(*page) {
|
||||
log::error!("Failed to navigate to page {page}: {e}");
|
||||
} else {
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Thumbnail generation -------------------------------------------------
|
||||
AppMessage::GenerateThumbnailPage(_page) => {
|
||||
// TODO: Thumbnail generation via DocumentManager
|
||||
// Currently handled by DocumentManager.open_document()
|
||||
}
|
||||
|
||||
AppMessage::RefreshView => {
|
||||
app.model.tick += 1;
|
||||
}
|
||||
|
||||
// ---- View / zoom ---------------------------------------------------------
|
||||
AppMessage::ZoomIn => {
|
||||
app.model.viewport.scale = (app.model.viewport.scale * 1.2).min(10.0);
|
||||
app.model.viewport.fit_mode = ViewMode::Custom;
|
||||
}
|
||||
|
||||
AppMessage::ZoomOut => {
|
||||
app.model.viewport.scale = (app.model.viewport.scale / 1.2).max(0.1);
|
||||
app.model.viewport.fit_mode = ViewMode::Custom;
|
||||
}
|
||||
|
||||
AppMessage::ZoomReset => {
|
||||
app.model.viewport.scale = 1.0;
|
||||
app.model.viewport.fit_mode = ViewMode::ActualSize;
|
||||
app.model.reset_pan();
|
||||
}
|
||||
|
||||
AppMessage::ZoomFit => {
|
||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
||||
app.model.reset_pan();
|
||||
}
|
||||
|
||||
AppMessage::ViewerStateChanged {
|
||||
scale,
|
||||
offset_x,
|
||||
offset_y,
|
||||
canvas_size,
|
||||
image_size,
|
||||
} => {
|
||||
// Detect scale changes (zoom vs just pan)
|
||||
let old_scale = app.model.viewport.scale;
|
||||
|
||||
// Update model from viewer state
|
||||
app.model.viewport.scale = *scale;
|
||||
app.model.viewport.pan_x = *offset_x;
|
||||
app.model.viewport.pan_y = *offset_y;
|
||||
app.model.viewport.canvas_size = *canvas_size;
|
||||
app.model.viewport.image_size = *image_size;
|
||||
|
||||
// If scale changed, user zoomed -> switch to Custom mode and re-render
|
||||
// (Fit mode is only maintained when explicitly set via ZoomFit button)
|
||||
if (old_scale - *scale).abs() > 0.001 {
|
||||
app.model.viewport.fit_mode = ViewMode::Custom;
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Pan control ---------------------------------------------------------
|
||||
AppMessage::PanLeft => {
|
||||
app.model.viewport.pan_x -= 50.0;
|
||||
}
|
||||
AppMessage::PanRight => {
|
||||
app.model.viewport.pan_x += 50.0;
|
||||
}
|
||||
AppMessage::PanUp => {
|
||||
app.model.viewport.pan_y -= 50.0;
|
||||
}
|
||||
AppMessage::PanDown => {
|
||||
app.model.viewport.pan_y += 50.0;
|
||||
}
|
||||
AppMessage::PanReset => {
|
||||
app.model.reset_pan();
|
||||
}
|
||||
|
||||
// ---- Tool modes ----------------------------------------------------------
|
||||
AppMessage::ToggleCropMode => {
|
||||
app.model.mode = match &app.model.mode {
|
||||
AppMode::Crop { .. } => AppMode::View,
|
||||
_ => AppMode::Crop {
|
||||
selection: CropSelection::default(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
AppMessage::ToggleScaleMode => {
|
||||
// Scale mode -> Transform mode
|
||||
app.model.mode = match &app.model.mode {
|
||||
AppMode::Transform { .. } => AppMode::View,
|
||||
_ => AppMode::Transform {
|
||||
paper_format: None,
|
||||
orientation: Default::default(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Crop operations -----------------------------------------------------
|
||||
AppMessage::StartCrop => {
|
||||
if app.document_manager.current_document().is_some() {
|
||||
app.model.mode = AppMode::Crop {
|
||||
selection: CropSelection::default(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::CancelCrop => {
|
||||
// Only cancel if actually in Crop mode
|
||||
if matches!(app.model.mode, AppMode::Crop { .. }) {
|
||||
app.model.mode = AppMode::View;
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::ApplyCrop => {
|
||||
if let AppMode::Crop { selection } = &app.model.mode {
|
||||
// Get crop selection region
|
||||
if let Some(crop_region) = selection.to_crop_region() {
|
||||
// Create crop command from canvas selection
|
||||
let pan_offset = cosmic::iced::Vector::new(
|
||||
app.model.viewport.pan_x,
|
||||
app.model.viewport.pan_y,
|
||||
);
|
||||
|
||||
match CropDocumentCommand::from_canvas_selection(
|
||||
&crop_region,
|
||||
app.model.viewport.canvas_size,
|
||||
app.model.viewport.image_size,
|
||||
app.model.viewport.scale,
|
||||
pan_offset,
|
||||
) {
|
||||
Ok(cmd) => {
|
||||
// Execute crop command
|
||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
||||
app.model.set_error(format!("Crop failed: {e}"));
|
||||
} else {
|
||||
// Success - exit crop mode
|
||||
app.model.mode = AppMode::View;
|
||||
// Reset view to fit the cropped image
|
||||
app.model.viewport.scale = 1.0;
|
||||
app.model.viewport.fit_mode = ViewMode::Fit;
|
||||
app.model.reset_pan();
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.model.set_error(format!("Invalid crop region: {e}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.model.set_error("No crop region selected".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::CropDragStart { x, y, handle } => {
|
||||
if let AppMode::Crop { selection } = &mut app.model.mode {
|
||||
if *handle == DragHandle::None {
|
||||
selection.start_new_selection(*x, *y);
|
||||
} else {
|
||||
selection.start_handle_drag(*handle, *x, *y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::CropDragMove { x, y, max_x, max_y } => {
|
||||
if let AppMode::Crop { selection } = &mut app.model.mode {
|
||||
selection.update_drag(*x, *y, *max_x, *max_y);
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::CropDragEnd => {
|
||||
if let AppMode::Crop { selection } = &mut app.model.mode {
|
||||
selection.end_drag();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Save operations -----------------------------------------------------
|
||||
AppMessage::SaveAs => {
|
||||
save_as(&mut app.model);
|
||||
}
|
||||
|
||||
// ---- Document transformations --------------------------------------------
|
||||
AppMessage::FlipHorizontal => {
|
||||
// Ignore transformations in Crop mode (would invalidate selection)
|
||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
||||
let cmd = TransformDocumentCommand::new(TransformOperation::FlipHorizontal);
|
||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
||||
app.model.set_error(format!("Flip horizontal failed: {e}"));
|
||||
} else {
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::FlipVertical => {
|
||||
// Ignore transformations in Crop mode (would invalidate selection)
|
||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
||||
let cmd = TransformDocumentCommand::new(TransformOperation::FlipVertical);
|
||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
||||
app.model.set_error(format!("Flip vertical failed: {e}"));
|
||||
} else {
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::RotateCW => {
|
||||
// Ignore transformations in Crop mode (would invalidate selection)
|
||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
||||
let cmd = TransformDocumentCommand::new(TransformOperation::RotateCw);
|
||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
||||
app.model.set_error(format!("Rotate clockwise failed: {e}"));
|
||||
} else {
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::RotateCCW => {
|
||||
// Ignore transformations in Crop mode (would invalidate selection)
|
||||
if !matches!(app.model.mode, AppMode::Crop { .. }) {
|
||||
let cmd = TransformDocumentCommand::new(TransformOperation::RotateCcw);
|
||||
if let Err(e) = cmd.execute(&mut app.document_manager) {
|
||||
app.model.set_error(format!("Rotate CCW failed: {e}"));
|
||||
} else {
|
||||
cache_render(&mut app.model, &mut app.document_manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Metadata ------------------------------------------------------------
|
||||
AppMessage::RefreshMetadata => {
|
||||
// Metadata is managed by DocumentManager
|
||||
// Nothing to do here - views access it directly
|
||||
}
|
||||
|
||||
// ---- Format operations ---------------------------------------------------
|
||||
AppMessage::SetPaperFormat(format) => {
|
||||
if let AppMode::Transform { paper_format, .. } = &mut app.model.mode {
|
||||
*paper_format = Some(*format);
|
||||
}
|
||||
}
|
||||
|
||||
AppMessage::SetOrientation(orientation) => {
|
||||
if let AppMode::Transform {
|
||||
orientation: ori, ..
|
||||
} = &mut app.model.mode
|
||||
{
|
||||
*ori = *orientation;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Menu ----------------------------------------------------------------
|
||||
AppMessage::ToggleMainMenu => {
|
||||
app.model.menu_open = !app.model.menu_open;
|
||||
}
|
||||
|
||||
// ---- Wallpaper -----------------------------------------------------------
|
||||
AppMessage::SetAsWallpaper => {
|
||||
if let Some(path) = app.document_manager.current_path() {
|
||||
log::info!("Setting wallpaper to: {}", path.display());
|
||||
crate::infrastructure::system::set_as_wallpaper(path);
|
||||
} else {
|
||||
app.model.set_error("No image loaded".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Error handling ------------------------------------------------------
|
||||
AppMessage::ShowError(msg) => {
|
||||
app.model.set_error(msg.clone());
|
||||
}
|
||||
|
||||
AppMessage::ClearError => {
|
||||
app.model.clear_error();
|
||||
}
|
||||
|
||||
// ---- Handled elsewhere ---------------------------------------------------
|
||||
AppMessage::ToggleContextPage(_)
|
||||
| AppMessage::ToggleNavBar
|
||||
| AppMessage::OpenFormatPanel => {
|
||||
// These are handled in app.rs
|
||||
}
|
||||
|
||||
AppMessage::NoOp => {}
|
||||
}
|
||||
|
||||
UpdateResult::None
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/// Cache rendered image handle in viewport for view performance.
|
||||
fn cache_render(
|
||||
model: &mut super::model::AppModel,
|
||||
manager: &mut crate::application::DocumentManager,
|
||||
) {
|
||||
if let Some(doc) = manager.current_document_mut() {
|
||||
match doc.render(model.viewport.scale as f64) {
|
||||
Ok(output) => {
|
||||
model.viewport.cached_image_handle = Some(output.handle);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cache render: {e}");
|
||||
model.viewport.cached_image_handle = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
model.viewport.cached_image_handle = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn save_as(model: &mut super::model::AppModel) {
|
||||
// TODO: Implement file dialog for save path
|
||||
// For now, show error that this needs UI integration
|
||||
model.set_error("Save As: File dialog not yet implemented".to_string());
|
||||
}
|
||||
79
src/ui/views/canvas.rs
Normal file
79
src/ui/views/canvas.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/views/canvas.rs
|
||||
//
|
||||
// Render the center canvas area with the current document.
|
||||
|
||||
use cosmic::iced::widget::image::FilterMethod;
|
||||
use cosmic::iced::{ContentFit, Length};
|
||||
use cosmic::iced_widget::stack;
|
||||
use cosmic::widget::{container, text};
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::ui::widgets::{crop_overlay, Viewer};
|
||||
use crate::ui::model::{AppMode, ViewMode};
|
||||
use crate::ui::{AppMessage, AppModel};
|
||||
use crate::application::DocumentManager;
|
||||
use crate::config::AppConfig;
|
||||
use crate::fl;
|
||||
|
||||
/// Render the center canvas area with the current document.
|
||||
pub fn view<'a>(
|
||||
model: &'a AppModel,
|
||||
_manager: &'a DocumentManager,
|
||||
config: &'a AppConfig,
|
||||
) -> Element<'a, AppMessage> {
|
||||
// Use cached image handle from viewport
|
||||
if let Some(handle) = &model.viewport.cached_image_handle {
|
||||
// Determine content fit mode
|
||||
let content_fit = match model.viewport.fit_mode {
|
||||
ViewMode::Fit => ContentFit::Contain,
|
||||
ViewMode::ActualSize | ViewMode::Custom => ContentFit::None,
|
||||
};
|
||||
|
||||
// Check if we're in crop mode (to disable pan)
|
||||
let disable_pan = matches!(model.mode, AppMode::Crop { .. });
|
||||
|
||||
// Create image viewer
|
||||
let img_viewer = Viewer::new(handle.clone())
|
||||
.with_state(
|
||||
model.viewport.scale,
|
||||
model.viewport.pan_x,
|
||||
model.viewport.pan_y,
|
||||
)
|
||||
.on_state_change(|scale, offset_x, offset_y, canvas_size, image_size| {
|
||||
AppMessage::ViewerStateChanged {
|
||||
scale,
|
||||
offset_x,
|
||||
offset_y,
|
||||
canvas_size,
|
||||
image_size,
|
||||
}
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.content_fit(content_fit)
|
||||
.filter_method(FilterMethod::Linear)
|
||||
.min_scale(config.min_scale)
|
||||
.max_scale(config.max_scale)
|
||||
.scale_step(config.scale_step - 1.0)
|
||||
.disable_pan(disable_pan);
|
||||
|
||||
// Overlay crop UI when in crop mode
|
||||
if let AppMode::Crop { selection } = &model.mode {
|
||||
let overlay = crop_overlay(selection, config.crop_show_grid);
|
||||
stack![img_viewer, overlay].into()
|
||||
} else {
|
||||
container(img_viewer)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
// No document loaded
|
||||
container(text(fl!("no-document")))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
87
src/ui/views/footer.rs
Normal file
87
src/ui/views/footer.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/views/footer.rs
|
||||
//
|
||||
// Footer bar with zoom controls and document info.
|
||||
|
||||
use cosmic::iced::Alignment;
|
||||
use cosmic::widget::{button, icon, row, text};
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::ui::model::{AppModel, ViewMode};
|
||||
use crate::ui::AppMessage;
|
||||
use crate::application::DocumentManager;
|
||||
use crate::domain::document::core::document::Renderable;
|
||||
use crate::fl;
|
||||
|
||||
/// Build the footer element with zoom controls and document info.
|
||||
pub fn view<'a>(model: &'a AppModel, manager: &'a DocumentManager) -> Element<'a, AppMessage> {
|
||||
// Zoom level display
|
||||
let zoom_text = if model.viewport.fit_mode == ViewMode::Fit {
|
||||
fl!("status-zoom-fit")
|
||||
} else {
|
||||
let percent = (model.viewport.scale * 100.0).round() as i32;
|
||||
fl!("status-zoom-percent", percent: percent)
|
||||
};
|
||||
|
||||
// Document dimensions (from DocumentManager)
|
||||
let doc_info = if let Some(doc) = manager.current_document() {
|
||||
let info = doc.info();
|
||||
fl!("status-doc-dimensions", width: info.width, height: info.height)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Navigation position (from DocumentManager)
|
||||
let folder_count = manager.folder_entries().len();
|
||||
let nav_info = if folder_count == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
let current = manager.current_index().map_or(0, |i| i + 1);
|
||||
let total = folder_count;
|
||||
fl!("status-nav-position", current: current, total: total)
|
||||
};
|
||||
|
||||
row()
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.padding([4, 12])
|
||||
// Zoom out button
|
||||
.push(
|
||||
button::icon(icon::from_name("zoom-out-symbolic"))
|
||||
.on_press(AppMessage::ZoomOut)
|
||||
.padding(4),
|
||||
)
|
||||
// Zoom level text
|
||||
.push(text(zoom_text))
|
||||
// Zoom in button
|
||||
.push(
|
||||
button::icon(icon::from_name("zoom-in-symbolic"))
|
||||
.on_press(AppMessage::ZoomIn)
|
||||
.padding(4),
|
||||
)
|
||||
// Zoom reset button
|
||||
.push(
|
||||
button::icon(icon::from_name("zoom-original-symbolic"))
|
||||
.on_press(AppMessage::ZoomReset)
|
||||
.padding(4),
|
||||
)
|
||||
// Zoom fit button
|
||||
.push(
|
||||
button::icon(icon::from_name("zoom-fit-best-symbolic"))
|
||||
.on_press(AppMessage::ZoomFit)
|
||||
.padding(4),
|
||||
)
|
||||
// Document dimensions
|
||||
.push_maybe(if !doc_info.is_empty() {
|
||||
Some(text(doc_info))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
// Navigation info
|
||||
.push_maybe(if folder_count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(text(nav_info))
|
||||
})
|
||||
.into()
|
||||
}
|
||||
137
src/ui/views/format_panel.rs
Normal file
137
src/ui/views/format_panel.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/format_panel.rs
|
||||
//
|
||||
// Format panel for paper format and orientation selection.
|
||||
|
||||
use cosmic::widget::{column, radio, text};
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::ui::model::{AppMode, AppModel, Orientation, PaperFormat};
|
||||
use crate::ui::AppMessage;
|
||||
use crate::fl;
|
||||
|
||||
/// Build the format panel view for the navigation bar.
|
||||
pub fn view(model: &AppModel) -> Element<'static, AppMessage> {
|
||||
// Extract values from Transform mode
|
||||
let (paper_format, orientation) = match &model.mode {
|
||||
AppMode::Transform {
|
||||
paper_format,
|
||||
orientation,
|
||||
} => (*paper_format, *orientation),
|
||||
_ => (None, Orientation::default()),
|
||||
};
|
||||
|
||||
let mut content = column::with_capacity(20).spacing(12).padding(16);
|
||||
|
||||
// --- Format Section ---
|
||||
content = content
|
||||
.push(text::heading(fl!("format-section-title")))
|
||||
.push(text::caption(fl!("format-section-subtitle")));
|
||||
|
||||
// US Letter
|
||||
content = content.push(
|
||||
radio(
|
||||
"US Letter (216 × 279 mm)",
|
||||
PaperFormat::UsLetter,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
);
|
||||
|
||||
// ISO A formats
|
||||
content = content
|
||||
.push(text::body("ISO A"))
|
||||
.push(
|
||||
radio(
|
||||
PaperFormat::IsoA0.display_name(),
|
||||
PaperFormat::IsoA0,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
)
|
||||
.push(
|
||||
radio(
|
||||
PaperFormat::IsoA1.display_name(),
|
||||
PaperFormat::IsoA1,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
)
|
||||
.push(
|
||||
radio(
|
||||
PaperFormat::IsoA2.display_name(),
|
||||
PaperFormat::IsoA2,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
)
|
||||
.push(
|
||||
radio(
|
||||
PaperFormat::IsoA3.display_name(),
|
||||
PaperFormat::IsoA3,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
)
|
||||
.push(
|
||||
radio(
|
||||
PaperFormat::IsoA4.display_name(),
|
||||
PaperFormat::IsoA4,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
)
|
||||
.push(
|
||||
radio(
|
||||
PaperFormat::IsoA5.display_name(),
|
||||
PaperFormat::IsoA5,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
)
|
||||
.push(
|
||||
radio(
|
||||
PaperFormat::IsoA6.display_name(),
|
||||
PaperFormat::IsoA6,
|
||||
paper_format,
|
||||
AppMessage::SetPaperFormat,
|
||||
)
|
||||
.size(16),
|
||||
);
|
||||
|
||||
// --- Orientation Section ---
|
||||
content = content
|
||||
.push(cosmic::widget::vertical_space().height(16))
|
||||
.push(text::heading(fl!("orientation-section-title")));
|
||||
|
||||
// Horizontal
|
||||
content = content.push(
|
||||
radio(
|
||||
"Horizontal",
|
||||
Orientation::Horizontal,
|
||||
Some(orientation),
|
||||
AppMessage::SetOrientation,
|
||||
)
|
||||
.size(16),
|
||||
);
|
||||
|
||||
// Vertical
|
||||
content = content.push(
|
||||
radio(
|
||||
"Vertical",
|
||||
Orientation::Vertical,
|
||||
Some(orientation),
|
||||
AppMessage::SetOrientation,
|
||||
)
|
||||
.size(16),
|
||||
);
|
||||
|
||||
content.into()
|
||||
}
|
||||
91
src/ui/views/header.rs
Normal file
91
src/ui/views/header.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/header.rs
|
||||
//
|
||||
// Header bar content (navigation, rotation, flip).
|
||||
|
||||
use cosmic::iced::Length;
|
||||
use cosmic::widget::{button, horizontal_space, icon, row};
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::ui::message::AppMessage;
|
||||
use crate::ui::model::AppModel;
|
||||
use crate::ui::app::ContextPage;
|
||||
use crate::application::DocumentManager;
|
||||
use crate::fl;
|
||||
|
||||
/// Build the start (left) side of the header bar.
|
||||
pub fn start<'a>(
|
||||
_model: &'a AppModel,
|
||||
manager: &'a DocumentManager,
|
||||
) -> Vec<Element<'a, AppMessage>> {
|
||||
let has_doc = manager.current_document().is_some();
|
||||
|
||||
// Left section: Panel toggle + Menu + Navigation
|
||||
let left_controls = row()
|
||||
.spacing(4)
|
||||
.push(
|
||||
button::icon(icon::from_name("view-sidebar-start-symbolic"))
|
||||
.on_press(AppMessage::ToggleNavBar),
|
||||
//.tooltip(fl!("tooltip-nav-toggle")),
|
||||
)
|
||||
// .push(
|
||||
// button::icon(icon::from_name("open-menu-symbolic"))
|
||||
// .on_press(AppMessage::ToggleMainMenu),
|
||||
// //.tooltip(fl!("menu-main")),
|
||||
// )
|
||||
.push(
|
||||
button::icon(icon::from_name("go-previous-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::PrevDocument)),
|
||||
//.tooltip(fl!("tooltip-nav-previous")),
|
||||
)
|
||||
.push(
|
||||
button::icon(icon::from_name("go-next-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::NextDocument)),
|
||||
//.tooltip(fl!("tooltip-nav-next")),
|
||||
);
|
||||
|
||||
// Center section: Transformations
|
||||
let center_controls = row()
|
||||
.spacing(4)
|
||||
.push(
|
||||
button::icon(icon::from_name("object-rotate-left-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCCW)),
|
||||
//.tooltip(fl!("tooltip-rotate-ccw")),
|
||||
)
|
||||
.push(
|
||||
button::icon(icon::from_name("object-rotate-right-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::RotateCW)),
|
||||
//.tooltip(fl!("tooltip-rotate-cw")),
|
||||
)
|
||||
.push(horizontal_space().width(Length::Fixed(12.0)))
|
||||
.push(
|
||||
button::icon(icon::from_name("object-flip-horizontal-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipHorizontal)),
|
||||
//.tooltip(fl!("tooltip-flip-horizontal")),
|
||||
)
|
||||
.push(
|
||||
button::icon(icon::from_name("object-flip-vertical-symbolic"))
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::FlipVertical)),
|
||||
//.tooltip(fl!("tooltip-flip-vertical")),
|
||||
);
|
||||
|
||||
vec![
|
||||
left_controls.into(),
|
||||
center_controls.into(),
|
||||
horizontal_space().width(Length::Fill).into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Build the end (right) side of the header bar.
|
||||
pub fn end<'a>(
|
||||
_model: &'a AppModel,
|
||||
_manager: &'a DocumentManager,
|
||||
) -> Vec<Element<'a, AppMessage>> {
|
||||
vec![
|
||||
// Info panel toggle
|
||||
button::icon(icon::from_name("dialog-information-symbolic"))
|
||||
.on_press(AppMessage::ToggleContextPage(ContextPage::Properties))
|
||||
//.tooltip(fl!("tooltip-info-panel"))
|
||||
.into(),
|
||||
]
|
||||
}
|
||||
180
src/ui/views/meta_panel.rs
Normal file
180
src/ui/views/meta_panel.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/views/meta_panel.rs
|
||||
//
|
||||
// Metadata and properties panel for document information.
|
||||
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, divider, horizontal_space, icon, row, text};
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::application::DocumentManager;
|
||||
use crate::domain::document::core::document::Renderable;
|
||||
use crate::ui::{AppMessage, AppModel};
|
||||
use crate::fl;
|
||||
|
||||
/// Build the metadata/properties panel view.
|
||||
pub fn view(_model: &AppModel, manager: &DocumentManager) -> Element<'static, AppMessage> {
|
||||
let mut content = column::with_capacity(16).spacing(8).padding(12);
|
||||
|
||||
// Header with action icons
|
||||
content = content.push(panel_header(manager));
|
||||
|
||||
// Display document metadata if available
|
||||
if let Some(meta) = manager.current_metadata() {
|
||||
// --- Basic Information Section ---
|
||||
content = content
|
||||
.push(section_header(fl!("meta-section-file")))
|
||||
.push(meta_row(fl!("meta-filename"), meta.basic.file_name.clone()))
|
||||
.push(meta_row(fl!("meta-format"), meta.basic.format.clone()));
|
||||
|
||||
// Show dimensions - original from metadata, current if transformed
|
||||
let original_dims = (meta.basic.width, meta.basic.height);
|
||||
let current_dims = if let Some(doc) = manager.current_document() {
|
||||
let info = doc.info();
|
||||
(info.width, info.height)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
if original_dims != current_dims && current_dims != (0, 0) {
|
||||
// Dimensions changed (e.g., rotation) - show both
|
||||
content = content.push(meta_row(
|
||||
fl!("meta-dimensions"),
|
||||
format!(
|
||||
"{} × {} (original: {} × {})",
|
||||
current_dims.0, current_dims.1, original_dims.0, original_dims.1
|
||||
),
|
||||
));
|
||||
} else {
|
||||
// No transformation or no document loaded yet
|
||||
content = content.push(meta_row(
|
||||
fl!("meta-dimensions"),
|
||||
meta.basic.resolution_display(),
|
||||
));
|
||||
}
|
||||
|
||||
content = content
|
||||
.push(meta_row(
|
||||
fl!("meta-filesize"),
|
||||
meta.basic.file_size_display(),
|
||||
))
|
||||
.push(meta_row(
|
||||
fl!("meta-colortype"),
|
||||
meta.basic.color_type.clone(),
|
||||
));
|
||||
|
||||
// --- EXIF Section (if available) ---
|
||||
if let Some(ref exif) = meta.exif {
|
||||
let has_exif_data = exif.camera_display().is_some()
|
||||
|| exif.date_time.is_some()
|
||||
|| exif.exposure_time.is_some()
|
||||
|| exif.f_number.is_some()
|
||||
|| exif.iso.is_some()
|
||||
|| exif.focal_length.is_some()
|
||||
|| exif.gps_display().is_some();
|
||||
|
||||
if has_exif_data {
|
||||
content = content
|
||||
.push(divider::horizontal::light())
|
||||
.push(section_header(fl!("meta-section-exif")));
|
||||
|
||||
if let Some(camera) = exif.camera_display() {
|
||||
content = content.push(meta_row(fl!("meta-camera"), camera));
|
||||
}
|
||||
|
||||
if let Some(ref date) = exif.date_time {
|
||||
content = content.push(meta_row(fl!("meta-datetime"), date.clone()));
|
||||
}
|
||||
|
||||
if let Some(ref exposure) = exif.exposure_time {
|
||||
content = content.push(meta_row(fl!("meta-exposure"), exposure.clone()));
|
||||
}
|
||||
|
||||
if let Some(ref fnumber) = exif.f_number {
|
||||
content = content.push(meta_row(fl!("meta-aperture"), fnumber.clone()));
|
||||
}
|
||||
|
||||
if let Some(iso) = exif.iso {
|
||||
content = content.push(meta_row(fl!("meta-iso"), format!("ISO {}", iso)));
|
||||
}
|
||||
|
||||
if let Some(ref focal) = exif.focal_length {
|
||||
content = content.push(meta_row(fl!("meta-focal"), focal.clone()));
|
||||
}
|
||||
|
||||
if let Some(gps) = exif.gps_display() {
|
||||
content = content.push(meta_row(fl!("meta-gps"), gps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- File Path (at the bottom, less prominent) ---
|
||||
content = content
|
||||
.push(divider::horizontal::light())
|
||||
.push(meta_row_small(
|
||||
fl!("meta-path"),
|
||||
meta.basic.file_path.clone(),
|
||||
));
|
||||
} else {
|
||||
// No document loaded
|
||||
content = content
|
||||
.push(vertical_space())
|
||||
.push(text::body(fl!("no-document")))
|
||||
.push(vertical_space());
|
||||
}
|
||||
|
||||
content.into()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helper Components
|
||||
// =============================================================================
|
||||
|
||||
/// Panel header with title and action buttons.
|
||||
fn panel_header(manager: &DocumentManager) -> Element<'static, AppMessage> {
|
||||
let has_doc = manager.current_document().is_some();
|
||||
|
||||
row::with_capacity(5)
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center)
|
||||
.padding([0, 0, 8, 0])
|
||||
.push(text::title4(fl!("panel-properties")))
|
||||
.push(horizontal_space().width(Length::Fill))
|
||||
.push(
|
||||
button::icon(icon::from_name("image-x-generic-symbolic"))
|
||||
.tooltip(fl!("action-set-wallpaper"))
|
||||
.padding(4)
|
||||
.on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper)),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Section header for grouping metadata.
|
||||
fn section_header(label: String) -> Element<'static, AppMessage> {
|
||||
text::heading(label).size(14).into()
|
||||
}
|
||||
|
||||
/// Key-value metadata row.
|
||||
fn meta_row(label: String, value: String) -> Element<'static, AppMessage> {
|
||||
column::with_capacity(2)
|
||||
.spacing(2)
|
||||
.push(text::caption(format!("{}:", label)))
|
||||
.push(text::body(value))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Less prominent metadata row (smaller text).
|
||||
fn meta_row_small(label: String, value: String) -> Element<'static, AppMessage> {
|
||||
column::with_capacity(2)
|
||||
.spacing(2)
|
||||
.push(text::caption(format!("{}:", label)))
|
||||
.push(text::caption(value))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Vertical spacer helper.
|
||||
fn vertical_space() -> Element<'static, AppMessage> {
|
||||
cosmic::widget::vertical_space()
|
||||
.height(Length::Fixed(32.0))
|
||||
.into()
|
||||
}
|
||||
51
src/ui/views/mod.rs
Normal file
51
src/ui/views/mod.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/mod.rs
|
||||
//
|
||||
// View module exports.
|
||||
|
||||
pub mod canvas;
|
||||
pub mod footer;
|
||||
pub mod format_panel;
|
||||
pub mod header;
|
||||
pub mod meta_panel;
|
||||
pub mod pages_panel;
|
||||
pub mod panels;
|
||||
|
||||
use cosmic::iced::Length;
|
||||
use cosmic::widget::container;
|
||||
use cosmic::{Action, Element};
|
||||
|
||||
use crate::ui::model::LeftPanel;
|
||||
use crate::ui::{AppMessage, AppModel};
|
||||
use crate::application::DocumentManager;
|
||||
use crate::config::AppConfig;
|
||||
|
||||
/// Main application view (canvas area).
|
||||
pub fn view<'a>(
|
||||
model: &'a AppModel,
|
||||
manager: &'a DocumentManager,
|
||||
config: &'a AppConfig,
|
||||
) -> Element<'a, AppMessage> {
|
||||
canvas::view(model, manager, config)
|
||||
}
|
||||
|
||||
/// Navigation bar content (left panel).
|
||||
///
|
||||
/// Shows different panels based on panel state:
|
||||
/// - `LeftPanel::Thumbnails`: Page thumbnails (multi-page documents)
|
||||
/// - `None`: Hidden
|
||||
pub fn nav_bar<'a>(
|
||||
model: &'a AppModel,
|
||||
manager: &'a DocumentManager,
|
||||
) -> Option<Element<'a, Action<AppMessage>>> {
|
||||
match model.panels.left {
|
||||
None => None,
|
||||
Some(LeftPanel::Thumbnails) => pages_panel::view(model, manager).map(|panel| {
|
||||
container(panel.map(Action::App))
|
||||
.width(Length::Shrink)
|
||||
.height(Length::Fill)
|
||||
.max_width(250)
|
||||
.into()
|
||||
}),
|
||||
}
|
||||
}
|
||||
103
src/ui/views/pages_panel.rs
Normal file
103
src/ui/views/pages_panel.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/pages_panel.rs
|
||||
//
|
||||
// Page navigation panel for multi-page documents (PDF, multi-page TIFF, etc.).
|
||||
|
||||
/// Maximum width in pixels for page navigation thumbnails.
|
||||
const THUMBNAIL_MAX_WIDTH: f32 = 100.0;
|
||||
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, container, scrollable, text};
|
||||
use cosmic::widget::image as cosmic_image;
|
||||
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::application::DocumentManager;
|
||||
use crate::ui::{AppMessage, AppModel};
|
||||
use crate::fl;
|
||||
|
||||
/// Build the page navigation panel view.
|
||||
/// Returns None if the current document doesn't support multiple pages.
|
||||
pub fn view<'a>(
|
||||
_model: &'a AppModel,
|
||||
manager: &'a DocumentManager,
|
||||
) -> Option<Element<'a, AppMessage>> {
|
||||
// Get document and check if it's multi-page
|
||||
let doc = manager.current_document()?;
|
||||
let page_count = doc.page_count();
|
||||
|
||||
if page_count <= 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current_page = doc.current_page();
|
||||
let loaded = doc.thumbnails_loaded();
|
||||
|
||||
let mut content = column::with_capacity(page_count + 1)
|
||||
.spacing(12)
|
||||
.padding([12, 8])
|
||||
.align_x(Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Show loading progress if not all thumbnails are ready.
|
||||
if !doc.thumbnails_ready() {
|
||||
let loading_msg = fl!("loading-thumbnails", current: loaded, total: page_count);
|
||||
content = content.push(text::caption(loading_msg));
|
||||
}
|
||||
|
||||
// Build thumbnail list for pages that are already loaded.
|
||||
for page_index in 0..loaded {
|
||||
let is_current = page_index == current_page;
|
||||
|
||||
// Get cached thumbnail handle (read-only access).
|
||||
let thumbnail_element: Element<'static, AppMessage> =
|
||||
if let Some(handle) = manager.get_thumbnail_handle(page_index) {
|
||||
// Display the thumbnail image.
|
||||
cosmic_image::Image::new(handle)
|
||||
.width(Length::Fixed(THUMBNAIL_MAX_WIDTH))
|
||||
.into()
|
||||
} else {
|
||||
// Fallback: show page number if thumbnail not yet loaded.
|
||||
container(text(format!("Page {}", page_index + 1)))
|
||||
.width(Length::Fixed(THUMBNAIL_MAX_WIDTH))
|
||||
.height(Length::Fixed(THUMBNAIL_MAX_WIDTH * 1.4))
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
// Page number label.
|
||||
let page_label = text::caption(format!("{}", page_index + 1));
|
||||
|
||||
// Combine thumbnail and label in a column.
|
||||
let page_content = column::with_capacity(2)
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center)
|
||||
.push(thumbnail_element)
|
||||
.push(page_label);
|
||||
|
||||
// Wrap in button for navigation.
|
||||
let page_button = if is_current {
|
||||
// Current page: highlighted style.
|
||||
button::custom(page_content)
|
||||
.class(cosmic::theme::Button::Suggested)
|
||||
.padding(4)
|
||||
} else {
|
||||
// Other pages: clickable with standard style.
|
||||
button::custom(page_content)
|
||||
.class(cosmic::theme::Button::Standard)
|
||||
.padding(4)
|
||||
.on_press(AppMessage::GotoPage(page_index))
|
||||
};
|
||||
|
||||
content = content.push(page_button);
|
||||
}
|
||||
|
||||
// Wrap in scrollable container.
|
||||
Some(
|
||||
scrollable(content)
|
||||
.width(Length::Shrink)
|
||||
.height(Length::Fill)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
43
src/ui/views/panels.rs
Normal file
43
src/ui/views/panels.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/views/panels.rs
|
||||
//
|
||||
// Panel router - delegates to specific panel views.
|
||||
|
||||
use cosmic::Element;
|
||||
|
||||
use crate::application::DocumentManager;
|
||||
use crate::ui::model::{AppModel, RightPanel};
|
||||
use crate::ui::AppMessage;
|
||||
|
||||
use super::{format_panel, meta_panel};
|
||||
|
||||
/// Build the right panel view based on current panel state.
|
||||
///
|
||||
/// Returns the appropriate panel content:
|
||||
/// - `RightPanel::Properties`: Metadata and document properties (default)
|
||||
/// - `RightPanel::CropTools`: Crop tool controls (TODO)
|
||||
/// - `RightPanel::TransformTools`: Transform/export controls
|
||||
///
|
||||
/// Defaults to Properties panel if no panel is explicitly set.
|
||||
pub fn view(model: &AppModel, manager: &DocumentManager) -> Element<'static, AppMessage> {
|
||||
match model.panels.right.as_ref() {
|
||||
Some(RightPanel::Properties) | None => meta_panel::view(model, manager),
|
||||
Some(RightPanel::CropTools) => crop_tools_panel(model, manager),
|
||||
Some(RightPanel::TransformTools) => format_panel::view(model),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop tools panel (TODO: implement dedicated crop controls).
|
||||
fn crop_tools_panel(_model: &AppModel, _manager: &DocumentManager) -> Element<'static, AppMessage> {
|
||||
use cosmic::widget::{column, text};
|
||||
|
||||
column::with_capacity(4)
|
||||
.spacing(12)
|
||||
.padding(12)
|
||||
.push(text::title4("Crop Tools"))
|
||||
.push(text::body("Crop controls will be implemented here."))
|
||||
.push(text::caption(
|
||||
"For now, use the crop overlay on the canvas.",
|
||||
))
|
||||
.into()
|
||||
}
|
||||
210
src/ui/widgets/crop_model.rs
Normal file
210
src/ui/widgets/crop_model.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/widgets/crop_model.rs
|
||||
//
|
||||
// Crop UI model (drag state and logic).
|
||||
|
||||
use crate::domain::document::operations::CropRegion;
|
||||
|
||||
/// Drag handle for crop selection.
|
||||
///
|
||||
/// Identifies which part of the selection is being dragged.
|
||||
/// Pure UI concern - not part of domain.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DragHandle {
|
||||
#[default]
|
||||
None,
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
Move,
|
||||
}
|
||||
|
||||
/// Crop selection UI model.
|
||||
///
|
||||
/// Manages the interactive state of crop selection:
|
||||
/// - Current selection region (in screen/canvas coordinates)
|
||||
/// - Drag state (what's being dragged, where it started)
|
||||
/// - Drag logic (how to update region based on handle type)
|
||||
///
|
||||
/// This is UI-specific logic, not domain logic!
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CropSelection {
|
||||
/// Current selection region as (x, y, width, height) in screen coordinates
|
||||
pub region: Option<(f32, f32, f32, f32)>,
|
||||
|
||||
/// Is user currently dragging?
|
||||
pub is_dragging: bool,
|
||||
|
||||
/// Which handle/part is being dragged?
|
||||
pub drag_handle: DragHandle,
|
||||
|
||||
/// Where did the drag start? (for delta calculation)
|
||||
drag_start: Option<(f32, f32)>,
|
||||
|
||||
/// What was the region when drag started? (for resize calculation)
|
||||
drag_start_region: Option<(f32, f32, f32, f32)>,
|
||||
}
|
||||
|
||||
impl CropSelection {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Start a new selection (user clicks on empty area).
|
||||
pub fn start_new_selection(&mut self, x: f32, y: f32) {
|
||||
self.region = Some((x, y, 0.0, 0.0));
|
||||
self.is_dragging = true;
|
||||
self.drag_handle = DragHandle::None;
|
||||
self.drag_start = Some((x, y));
|
||||
self.drag_start_region = None;
|
||||
}
|
||||
|
||||
/// Start dragging a handle (user clicks on existing selection).
|
||||
pub fn start_handle_drag(&mut self, handle: DragHandle, x: f32, y: f32) {
|
||||
self.is_dragging = true;
|
||||
self.drag_handle = handle;
|
||||
self.drag_start = Some((x, y));
|
||||
self.drag_start_region = self.region;
|
||||
}
|
||||
|
||||
/// Update selection during drag.
|
||||
pub fn update_drag(&mut self, x: f32, y: f32, img_width: f32, img_height: f32) {
|
||||
if !self.is_dragging {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.drag_handle {
|
||||
DragHandle::None => {
|
||||
// Creating new selection - expand from start point
|
||||
if let Some((start_x, start_y)) = self.drag_start {
|
||||
let min_x = start_x.min(x).max(0.0);
|
||||
let min_y = start_y.min(y).max(0.0);
|
||||
let max_x = start_x.max(x).min(img_width);
|
||||
let max_y = start_y.max(y).min(img_height);
|
||||
|
||||
self.region = Some((min_x, min_y, max_x - min_x, max_y - min_y));
|
||||
}
|
||||
}
|
||||
DragHandle::Move => {
|
||||
// Moving entire selection
|
||||
if let (Some((start_x, start_y)), Some((rx, ry, rw, rh))) =
|
||||
(self.drag_start, self.drag_start_region)
|
||||
{
|
||||
let dx = x - start_x;
|
||||
let dy = y - start_y;
|
||||
let new_x = (rx + dx).max(0.0).min(img_width - rw);
|
||||
let new_y = (ry + dy).max(0.0).min(img_height - rh);
|
||||
self.region = Some((new_x, new_y, rw, rh));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Resizing from edge/corner
|
||||
if let Some((rx, ry, rw, rh)) = self.drag_start_region {
|
||||
let (new_x, new_y, new_w, new_h) =
|
||||
self.resize_region(rx, ry, rw, rh, x, y, img_width, img_height);
|
||||
self.region = Some((new_x, new_y, new_w, new_h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize region based on which handle is being dragged.
|
||||
fn resize_region(
|
||||
&self,
|
||||
rx: f32,
|
||||
ry: f32,
|
||||
rw: f32,
|
||||
rh: f32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
img_width: f32,
|
||||
img_height: f32,
|
||||
) -> (f32, f32, f32, f32) {
|
||||
const MIN_SIZE: f32 = 10.0;
|
||||
|
||||
let right = rx + rw;
|
||||
let bottom = ry + rh;
|
||||
let x = x.max(0.0).min(img_width);
|
||||
let y = y.max(0.0).min(img_height);
|
||||
|
||||
match self.drag_handle {
|
||||
DragHandle::TopLeft => {
|
||||
let new_x = x.min(right - MIN_SIZE);
|
||||
let new_y = y.min(bottom - MIN_SIZE);
|
||||
(new_x, new_y, right - new_x, bottom - new_y)
|
||||
}
|
||||
DragHandle::TopRight => {
|
||||
let new_right = x.max(rx + MIN_SIZE);
|
||||
let new_y = y.min(bottom - MIN_SIZE);
|
||||
(rx, new_y, new_right - rx, bottom - new_y)
|
||||
}
|
||||
DragHandle::BottomLeft => {
|
||||
let new_x = x.min(right - MIN_SIZE);
|
||||
let new_bottom = y.max(ry + MIN_SIZE);
|
||||
(new_x, ry, right - new_x, new_bottom - ry)
|
||||
}
|
||||
DragHandle::BottomRight => {
|
||||
let new_right = x.max(rx + MIN_SIZE);
|
||||
let new_bottom = y.max(ry + MIN_SIZE);
|
||||
(rx, ry, new_right - rx, new_bottom - ry)
|
||||
}
|
||||
DragHandle::Top => {
|
||||
let new_y = y.min(bottom - MIN_SIZE);
|
||||
(rx, new_y, rw, bottom - new_y)
|
||||
}
|
||||
DragHandle::Bottom => {
|
||||
let new_bottom = y.max(ry + MIN_SIZE);
|
||||
(rx, ry, rw, new_bottom - ry)
|
||||
}
|
||||
DragHandle::Left => {
|
||||
let new_x = x.min(right - MIN_SIZE);
|
||||
(new_x, ry, right - new_x, rh)
|
||||
}
|
||||
DragHandle::Right => {
|
||||
let new_right = x.max(rx + MIN_SIZE);
|
||||
(rx, ry, new_right - rx, rh)
|
||||
}
|
||||
_ => (rx, ry, rw, rh),
|
||||
}
|
||||
}
|
||||
|
||||
/// End drag operation.
|
||||
pub fn end_drag(&mut self) {
|
||||
self.is_dragging = false;
|
||||
self.drag_start = None;
|
||||
self.drag_start_region = None;
|
||||
}
|
||||
|
||||
/// Reset selection (cancel).
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// Check if there's a valid selection.
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.region
|
||||
.map(|(_, _, w, h)| w > 1.0 && h > 1.0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Convert to domain CropRegion for actual crop operation.
|
||||
pub fn to_crop_region(&self) -> Option<CropRegion> {
|
||||
self.region.and_then(|(x, y, w, h)| {
|
||||
if w > 1.0 && h > 1.0 {
|
||||
Some(CropRegion {
|
||||
x: x as u32,
|
||||
y: y as u32,
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
404
src/ui/widgets/crop_overlay.rs
Normal file
404
src/ui/widgets/crop_overlay.rs
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/widgets/crop_overlay.rs
|
||||
//
|
||||
// Simple crop overlay.
|
||||
|
||||
use cosmic::{
|
||||
Element, Renderer,
|
||||
iced::{
|
||||
Color, Length, Point, Rectangle, Size,
|
||||
advanced::{
|
||||
Clipboard, Layout, Shell, Widget,
|
||||
layout::{Limits, Node},
|
||||
renderer::{Quad, Renderer as QuadRenderer},
|
||||
widget::Tree,
|
||||
},
|
||||
event::{Event, Status},
|
||||
mouse::{self, Button, Cursor},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::ui::widgets::crop_model::{CropSelection, DragHandle};
|
||||
use crate::ui::AppMessage;
|
||||
|
||||
const HANDLE_SIZE: f32 = 12.0;
|
||||
const HANDLE_HIT_SIZE: f32 = 24.0;
|
||||
const OVERLAY_COLOR: Color = Color::from_rgba(0.0, 0.0, 0.0, 0.5);
|
||||
const HANDLE_COLOR: Color = Color::WHITE;
|
||||
const BORDER_COLOR: Color = Color::WHITE;
|
||||
const BORDER_WIDTH: f32 = 2.0;
|
||||
|
||||
pub struct CropOverlay {
|
||||
selection: CropSelection,
|
||||
show_grid: bool,
|
||||
last_click: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl CropOverlay {
|
||||
pub fn new(selection: &CropSelection, show_grid: bool) -> Self {
|
||||
Self {
|
||||
selection: selection.clone(),
|
||||
show_grid,
|
||||
last_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn hit_test_handle(&self, point: Point) -> DragHandle {
|
||||
let Some((x, y, w, h)) = self.selection.region else {
|
||||
return DragHandle::None;
|
||||
};
|
||||
|
||||
let handles = [
|
||||
(Point::new(x, y), DragHandle::TopLeft),
|
||||
(Point::new(x + w, y), DragHandle::TopRight),
|
||||
(Point::new(x, y + h), DragHandle::BottomLeft),
|
||||
(Point::new(x + w, y + h), DragHandle::BottomRight),
|
||||
(Point::new(x + w / 2.0, y), DragHandle::Top),
|
||||
(Point::new(x + w / 2.0, y + h), DragHandle::Bottom),
|
||||
(Point::new(x, y + h / 2.0), DragHandle::Left),
|
||||
(Point::new(x + w, y + h / 2.0), DragHandle::Right),
|
||||
];
|
||||
|
||||
for (pos, handle) in handles {
|
||||
if point_in_handle(point, pos) {
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
|
||||
if point.x >= x && point.x <= x + w && point.y >= y && point.y <= y + h {
|
||||
return DragHandle::Move;
|
||||
}
|
||||
|
||||
DragHandle::None
|
||||
}
|
||||
|
||||
fn draw_overlay(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
||||
let Some((x, y, w, h)) = self.selection.region else {
|
||||
draw_quad(renderer, bounds, OVERLAY_COLOR);
|
||||
return;
|
||||
};
|
||||
|
||||
// Convert relative coords to absolute screen coords
|
||||
let abs_x = bounds.x + x;
|
||||
let abs_y = bounds.y + y;
|
||||
let abs_right = abs_x + w;
|
||||
let abs_bottom = abs_y + h;
|
||||
|
||||
// Top
|
||||
if abs_y > bounds.y {
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(
|
||||
Point::new(bounds.x, bounds.y),
|
||||
Size::new(bounds.width, abs_y - bounds.y),
|
||||
),
|
||||
OVERLAY_COLOR,
|
||||
);
|
||||
}
|
||||
|
||||
// Bottom
|
||||
if abs_bottom < bounds.y + bounds.height {
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(
|
||||
Point::new(bounds.x, abs_bottom),
|
||||
Size::new(bounds.width, bounds.y + bounds.height - abs_bottom),
|
||||
),
|
||||
OVERLAY_COLOR,
|
||||
);
|
||||
}
|
||||
|
||||
// Left
|
||||
if abs_x > bounds.x {
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(
|
||||
Point::new(bounds.x, abs_y),
|
||||
Size::new(abs_x - bounds.x, h),
|
||||
),
|
||||
OVERLAY_COLOR,
|
||||
);
|
||||
}
|
||||
|
||||
// Right
|
||||
if abs_right < bounds.x + bounds.width {
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(
|
||||
Point::new(abs_right, abs_y),
|
||||
Size::new(bounds.x + bounds.width - abs_right, h),
|
||||
),
|
||||
OVERLAY_COLOR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_border(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
||||
let Some((x, y, w, h)) = self.selection.region else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Add bounds offset
|
||||
let abs_x = bounds.x + x;
|
||||
let abs_y = bounds.y + y;
|
||||
|
||||
// Top
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(Point::new(abs_x, abs_y), Size::new(w, BORDER_WIDTH)),
|
||||
BORDER_COLOR,
|
||||
);
|
||||
|
||||
// Bottom
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(
|
||||
Point::new(abs_x, abs_y + h - BORDER_WIDTH),
|
||||
Size::new(w, BORDER_WIDTH),
|
||||
),
|
||||
BORDER_COLOR,
|
||||
);
|
||||
|
||||
// Left
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(Point::new(abs_x, abs_y), Size::new(BORDER_WIDTH, h)),
|
||||
BORDER_COLOR,
|
||||
);
|
||||
|
||||
// Right
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(
|
||||
Point::new(abs_x + w - BORDER_WIDTH, abs_y),
|
||||
Size::new(BORDER_WIDTH, h),
|
||||
),
|
||||
BORDER_COLOR,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_handles(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
||||
let Some((x, y, w, h)) = self.selection.region else {
|
||||
return;
|
||||
};
|
||||
|
||||
let half = HANDLE_SIZE / 2.0;
|
||||
|
||||
// Add bounds offset
|
||||
let abs_x = bounds.x + x;
|
||||
let abs_y = bounds.y + y;
|
||||
|
||||
let handles = [
|
||||
Point::new(abs_x, abs_y),
|
||||
Point::new(abs_x + w, abs_y),
|
||||
Point::new(abs_x, abs_y + h),
|
||||
Point::new(abs_x + w, abs_y + h),
|
||||
Point::new(abs_x + w / 2.0, abs_y),
|
||||
Point::new(abs_x + w / 2.0, abs_y + h),
|
||||
Point::new(abs_x, abs_y + h / 2.0),
|
||||
Point::new(abs_x + w, abs_y + h / 2.0),
|
||||
];
|
||||
|
||||
for pos in handles {
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(
|
||||
Point::new(pos.x - half, pos.y - half),
|
||||
Size::new(HANDLE_SIZE, HANDLE_SIZE),
|
||||
),
|
||||
HANDLE_COLOR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_grid(&self, renderer: &mut Renderer, bounds: Rectangle) {
|
||||
if !self.show_grid {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some((x, y, w, h)) = self.selection.region else {
|
||||
return;
|
||||
};
|
||||
|
||||
if w <= 10.0 || h <= 10.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add bounds offset
|
||||
let abs_x = bounds.x + x;
|
||||
let abs_y = bounds.y + y;
|
||||
|
||||
let grid_color = Color::from_rgba(1.0, 1.0, 1.0, 0.3);
|
||||
let third_w = w / 3.0;
|
||||
let third_h = h / 3.0;
|
||||
|
||||
// 2 vertical
|
||||
for i in 1..3 {
|
||||
let line_x = abs_x + third_w * i as f32;
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(Point::new(line_x, abs_y), Size::new(1.0, h)),
|
||||
grid_color,
|
||||
);
|
||||
}
|
||||
|
||||
// 2 horizontal
|
||||
for i in 1..3 {
|
||||
let line_y = abs_y + third_h * i as f32;
|
||||
draw_quad(
|
||||
renderer,
|
||||
Rectangle::new(Point::new(abs_x, line_y), Size::new(w, 1.0)),
|
||||
grid_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget<AppMessage, cosmic::Theme, Renderer> for CropOverlay {
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(Length::Fill, Length::Fill)
|
||||
}
|
||||
|
||||
fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &Limits) -> Node {
|
||||
Node::new(limits.max())
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
_theme: &cosmic::Theme,
|
||||
_style: &cosmic::iced::advanced::renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: Cursor,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
self.draw_overlay(renderer, bounds);
|
||||
self.draw_border(renderer, bounds);
|
||||
self.draw_handles(renderer, bounds);
|
||||
self.draw_grid(renderer, bounds);
|
||||
}
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
_tree: &mut Tree,
|
||||
event: Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: Cursor,
|
||||
_renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, AppMessage>,
|
||||
_viewport: &Rectangle,
|
||||
) -> Status {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) => {
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
let handle = self.hit_test_handle(pos);
|
||||
|
||||
if handle == DragHandle::Move {
|
||||
use std::time::{Duration, Instant};
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last_click {
|
||||
if now.duration_since(last) < Duration::from_millis(400) {
|
||||
shell.publish(AppMessage::ApplyCrop);
|
||||
self.last_click = None;
|
||||
return Status::Captured;
|
||||
}
|
||||
}
|
||||
self.last_click = Some(now);
|
||||
}
|
||||
|
||||
shell.publish(AppMessage::CropDragStart {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
handle,
|
||||
});
|
||||
return Status::Captured;
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
if self.selection.is_dragging {
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
shell.publish(AppMessage::CropDragMove {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
max_x: bounds.width,
|
||||
max_y: bounds.height,
|
||||
});
|
||||
return Status::Captured;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) => {
|
||||
if self.selection.is_dragging {
|
||||
shell.publish(AppMessage::CropDragEnd);
|
||||
return Status::Captured;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Status::Ignored
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
_tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if let Some(pos) = cursor.position_in(bounds) {
|
||||
let handle = self.hit_test_handle(pos);
|
||||
return match handle {
|
||||
DragHandle::TopLeft | DragHandle::BottomRight => {
|
||||
mouse::Interaction::ResizingDiagonallyDown
|
||||
}
|
||||
DragHandle::TopRight | DragHandle::BottomLeft => {
|
||||
mouse::Interaction::ResizingDiagonallyUp
|
||||
}
|
||||
DragHandle::Top | DragHandle::Bottom => mouse::Interaction::ResizingVertically,
|
||||
DragHandle::Left | DragHandle::Right => mouse::Interaction::ResizingHorizontally,
|
||||
DragHandle::Move => mouse::Interaction::Grabbing,
|
||||
DragHandle::None => mouse::Interaction::Crosshair,
|
||||
};
|
||||
}
|
||||
|
||||
mouse::Interaction::None
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<CropOverlay> for Element<'a, AppMessage> {
|
||||
fn from(widget: CropOverlay) -> Self {
|
||||
Element::new(widget)
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in_handle(point: Point, handle_center: Point) -> bool {
|
||||
let half = HANDLE_HIT_SIZE / 2.0;
|
||||
point.x >= handle_center.x - half
|
||||
&& point.x <= handle_center.x + half
|
||||
&& point.y >= handle_center.y - half
|
||||
&& point.y <= handle_center.y + half
|
||||
}
|
||||
|
||||
fn draw_quad(renderer: &mut Renderer, bounds: Rectangle, color: Color) {
|
||||
renderer.fill_quad(
|
||||
Quad {
|
||||
bounds,
|
||||
..Quad::default()
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn crop_overlay<'a>(selection: &CropSelection, show_grid: bool) -> Element<'a, AppMessage> {
|
||||
CropOverlay::new(selection, show_grid).into()
|
||||
}
|
||||
559
src/ui/widgets/image_viewer.rs
Normal file
559
src/ui/widgets/image_viewer.rs
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/view/image_viewer.rs
|
||||
//
|
||||
// Zoom and pan image viewer widget with external state control.
|
||||
// Forked from cosmic::iced to support external state control.
|
||||
|
||||
use cosmic::iced::advanced::image as img_renderer;
|
||||
use cosmic::iced::advanced::layout;
|
||||
use cosmic::iced::advanced::renderer;
|
||||
use cosmic::iced::advanced::widget::tree::{self, Tree};
|
||||
use cosmic::iced::advanced::widget::Widget;
|
||||
use cosmic::iced::advanced::{Clipboard, Layout, Shell};
|
||||
use cosmic::iced::event::{self, Event};
|
||||
use cosmic::iced::mouse;
|
||||
use cosmic::iced::widget::image::FilterMethod;
|
||||
use cosmic::iced::{ContentFit, Element, Length, Pixels, Point, Radians, Rectangle, Size, Vector};
|
||||
|
||||
/// Tolerance for scale comparisons in widget state synchronization.
|
||||
const SCALE_EPSILON: f32 = 0.0001;
|
||||
|
||||
/// Tolerance for offset comparisons in widget state synchronization.
|
||||
const OFFSET_EPSILON: f32 = 0.01;
|
||||
|
||||
/// Callback type for notifying viewer state changes (scale, `offset_x`, `offset_y`, `canvas_size`, `image_size`).
|
||||
type StateChangeCallback<Message> = Box<dyn Fn(f32, f32, f32, Size, Size) -> Message>;
|
||||
|
||||
/// A frame that displays an image with the ability to zoom in/out and pan.
|
||||
#[allow(missing_debug_implementations)]
|
||||
pub struct Viewer<Handle, Message> {
|
||||
padding: f32,
|
||||
width: Length,
|
||||
height: Length,
|
||||
min_scale: f32,
|
||||
max_scale: f32,
|
||||
scale_step: f32,
|
||||
handle: Handle,
|
||||
filter_method: FilterMethod,
|
||||
content_fit: ContentFit,
|
||||
/// Optional external state to override internal state (scale, offset)
|
||||
external_state: Option<(f32, Vector)>,
|
||||
/// Optional callback to notify state changes
|
||||
on_state_change: Option<StateChangeCallback<Message>>,
|
||||
/// Disable pan interaction (for crop mode)
|
||||
disable_pan: bool,
|
||||
}
|
||||
|
||||
impl<Handle, Message> Viewer<Handle, Message> {
|
||||
/// Creates a new [`Viewer`] with the given handle.
|
||||
pub fn new<T: Into<Handle>>(handle: T) -> Self {
|
||||
Viewer {
|
||||
handle: handle.into(),
|
||||
padding: 0.0,
|
||||
width: Length::Shrink,
|
||||
height: Length::Shrink,
|
||||
min_scale: 0.25,
|
||||
max_scale: 10.0,
|
||||
scale_step: 0.10,
|
||||
filter_method: FilterMethod::default(),
|
||||
content_fit: ContentFit::default(),
|
||||
external_state: None,
|
||||
on_state_change: None,
|
||||
disable_pan: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set external state to control zoom and pan from outside.
|
||||
/// This allows keyboard/button controls to override the internal state.
|
||||
pub fn with_state(mut self, scale: f32, offset_x: f32, offset_y: f32) -> Self {
|
||||
self.external_state = Some((scale, Vector::new(offset_x, offset_y)));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set a callback to be notified when the state changes (for mouse interaction).
|
||||
pub fn on_state_change<F>(mut self, f: F) -> Self
|
||||
where
|
||||
F: 'static + Fn(f32, f32, f32, Size, Size) -> Message,
|
||||
{
|
||||
self.on_state_change = Some(Box::new(f));
|
||||
self
|
||||
}
|
||||
|
||||
/// Disable pan interaction (useful when overlaying crop tools).
|
||||
pub fn disable_pan(mut self, disable: bool) -> Self {
|
||||
self.disable_pan = disable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`FilterMethod`] of the [`Viewer`].
|
||||
pub fn filter_method(mut self, filter_method: FilterMethod) -> Self {
|
||||
self.filter_method = filter_method;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`ContentFit`] of the [`Viewer`].
|
||||
pub fn content_fit(mut self, content_fit: ContentFit) -> Self {
|
||||
self.content_fit = content_fit;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the padding of the [`Viewer`].
|
||||
pub fn padding(mut self, padding: impl Into<Pixels>) -> Self {
|
||||
self.padding = padding.into().0;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the width of the [`Viewer`].
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the height of the [`Viewer`].
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the max scale applied to the image of the [`Viewer`].
|
||||
///
|
||||
/// Default is `10.0`
|
||||
pub fn max_scale(mut self, max_scale: f32) -> Self {
|
||||
self.max_scale = max_scale;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the min scale applied to the image of the [`Viewer`].
|
||||
///
|
||||
/// Default is `0.25`
|
||||
pub fn min_scale(mut self, min_scale: f32) -> Self {
|
||||
self.min_scale = min_scale;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the percentage the image of the [`Viewer`] will be scaled by
|
||||
/// when zoomed in / out.
|
||||
///
|
||||
/// Default is `0.10`
|
||||
pub fn scale_step(mut self, scale_step: f32) -> Self {
|
||||
self.scale_step = scale_step;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer, Handle> Widget<Message, Theme, Renderer> for Viewer<Handle, Message>
|
||||
where
|
||||
Renderer: img_renderer::Renderer<Handle = Handle>,
|
||||
Handle: Clone,
|
||||
Message: Clone,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
let mut state = State::new();
|
||||
// Apply external state if provided at creation
|
||||
if let Some((scale, offset)) = self.external_state {
|
||||
state.scale = scale;
|
||||
state.current_offset = offset;
|
||||
state.starting_offset = offset;
|
||||
}
|
||||
tree::State::new(state)
|
||||
}
|
||||
|
||||
fn diff(&mut self, tree: &mut Tree) {
|
||||
// Sync external state into internal state when user is not dragging
|
||||
if let Some((ext_scale, ext_offset)) = self.external_state {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
// Only apply external state if user is not currently dragging
|
||||
if !state.is_cursor_grabbed() {
|
||||
// Check if external state differs significantly from current state
|
||||
let scale_changed = (state.scale - ext_scale).abs() > SCALE_EPSILON;
|
||||
let offset_changed = (state.current_offset.x - ext_offset.x).abs() > OFFSET_EPSILON
|
||||
|| (state.current_offset.y - ext_offset.y).abs() > OFFSET_EPSILON;
|
||||
|
||||
if scale_changed || offset_changed {
|
||||
state.scale = ext_scale;
|
||||
state.current_offset = ext_offset;
|
||||
state.starting_offset = ext_offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&self,
|
||||
_tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
let image_size = renderer.measure_image(&self.handle);
|
||||
let image_size = Size::new(image_size.width as f32, image_size.height as f32);
|
||||
|
||||
let raw_size = limits.resolve(self.width, self.height, image_size);
|
||||
let full_size = self.content_fit.fit(image_size, raw_size);
|
||||
|
||||
let final_size = Size {
|
||||
width: match self.width {
|
||||
Length::Shrink => f32::min(raw_size.width, full_size.width),
|
||||
_ => raw_size.width,
|
||||
},
|
||||
height: match self.height {
|
||||
Length::Shrink => f32::min(raw_size.height, full_size.height),
|
||||
_ => raw_size.height,
|
||||
},
|
||||
};
|
||||
|
||||
layout::Node::new(final_size)
|
||||
}
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) -> event::Status {
|
||||
let bounds = layout.bounds();
|
||||
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
|
||||
let Some(cursor_position) = cursor.position_over(bounds) else {
|
||||
return event::Status::Ignored;
|
||||
};
|
||||
|
||||
match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
let previous_scale = state.scale;
|
||||
|
||||
if y < 0.0 && previous_scale > self.min_scale
|
||||
|| y > 0.0 && previous_scale < self.max_scale
|
||||
{
|
||||
state.scale = (if y > 0.0 {
|
||||
state.scale * (1.0 + self.scale_step)
|
||||
} else {
|
||||
state.scale / (1.0 + self.scale_step)
|
||||
})
|
||||
.clamp(self.min_scale, self.max_scale);
|
||||
|
||||
let scale_factor = state.scale / previous_scale;
|
||||
|
||||
// Cursor position relative to the image center (not bounds center)
|
||||
// The image is centered in bounds, so bounds.center() is correct
|
||||
let cursor_to_center = cursor_position - bounds.center();
|
||||
|
||||
// Transform offset so the point under cursor stays stationary
|
||||
// Formula: new_offset = old_offset * scale_factor + cursor_to_center * (scale_factor - 1)
|
||||
let new_offset = Vector::new(
|
||||
state.current_offset.x * scale_factor
|
||||
+ cursor_to_center.x * (scale_factor - 1.0),
|
||||
state.current_offset.y * scale_factor
|
||||
+ cursor_to_center.y * (scale_factor - 1.0),
|
||||
);
|
||||
|
||||
// Clamp offset to valid range
|
||||
let scaled_size = scaled_image_size(
|
||||
renderer,
|
||||
&self.handle,
|
||||
state,
|
||||
bounds.size(),
|
||||
self.content_fit,
|
||||
);
|
||||
|
||||
state.current_offset =
|
||||
clamp_offset(new_offset, bounds.size(), scaled_size);
|
||||
|
||||
// Notify state change
|
||||
if let Some(ref on_change) = self.on_state_change {
|
||||
let image_size = renderer.measure_image(&self.handle);
|
||||
let image_size =
|
||||
Size::new(image_size.width as f32, image_size.height as f32);
|
||||
shell.publish(on_change(
|
||||
state.scale,
|
||||
state.current_offset.x,
|
||||
state.current_offset.y,
|
||||
bounds.size(),
|
||||
image_size,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event::Status::Captured
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if self.disable_pan {
|
||||
return event::Status::Ignored;
|
||||
}
|
||||
|
||||
let Some(cursor_position) = cursor.position_over(bounds) else {
|
||||
return event::Status::Ignored;
|
||||
};
|
||||
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
state.cursor_grabbed_at = Some(cursor_position);
|
||||
state.starting_offset = state.current_offset;
|
||||
|
||||
event::Status::Captured
|
||||
}
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if self.disable_pan {
|
||||
return event::Status::Ignored;
|
||||
}
|
||||
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
if state.cursor_grabbed_at.is_some() {
|
||||
state.cursor_grabbed_at = None;
|
||||
|
||||
// Notify final state after drag ends
|
||||
if let Some(ref on_change) = self.on_state_change {
|
||||
let image_size = renderer.measure_image(&self.handle);
|
||||
let image_size =
|
||||
Size::new(image_size.width as f32, image_size.height as f32);
|
||||
shell.publish(on_change(
|
||||
state.scale,
|
||||
state.current_offset.x,
|
||||
state.current_offset.y,
|
||||
bounds.size(),
|
||||
image_size,
|
||||
));
|
||||
}
|
||||
|
||||
event::Status::Captured
|
||||
} else {
|
||||
event::Status::Ignored
|
||||
}
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
if self.disable_pan {
|
||||
return event::Status::Ignored;
|
||||
}
|
||||
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
|
||||
if let Some(origin) = state.cursor_grabbed_at {
|
||||
let scaled_size = scaled_image_size(
|
||||
renderer,
|
||||
&self.handle,
|
||||
state,
|
||||
bounds.size(),
|
||||
self.content_fit,
|
||||
);
|
||||
|
||||
let delta = position - origin;
|
||||
|
||||
// Pan: subtract delta from starting offset
|
||||
let new_offset = Vector::new(
|
||||
state.starting_offset.x - delta.x,
|
||||
state.starting_offset.y - delta.y,
|
||||
);
|
||||
|
||||
state.current_offset = clamp_offset(new_offset, bounds.size(), scaled_size);
|
||||
|
||||
// Notify state change during pan
|
||||
if let Some(ref on_change) = self.on_state_change {
|
||||
let image_size = renderer.measure_image(&self.handle);
|
||||
let image_size =
|
||||
Size::new(image_size.width as f32, image_size.height as f32);
|
||||
shell.publish(on_change(
|
||||
state.scale,
|
||||
state.current_offset.x,
|
||||
state.current_offset.y,
|
||||
bounds.size(),
|
||||
image_size,
|
||||
));
|
||||
}
|
||||
|
||||
event::Status::Captured
|
||||
} else {
|
||||
event::Status::Ignored
|
||||
}
|
||||
}
|
||||
_ => event::Status::Ignored,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
_renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
let state = tree.state.downcast_ref::<State>();
|
||||
let bounds = layout.bounds();
|
||||
let is_mouse_over = cursor.is_over(bounds);
|
||||
|
||||
if state.is_cursor_grabbed() {
|
||||
mouse::Interaction::Grabbing
|
||||
} else if is_mouse_over {
|
||||
mouse::Interaction::Grab
|
||||
} else {
|
||||
mouse::Interaction::None
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
_theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_ref::<State>();
|
||||
let bounds = layout.bounds();
|
||||
|
||||
let scaled_size = scaled_image_size(
|
||||
renderer,
|
||||
&self.handle,
|
||||
state,
|
||||
bounds.size(),
|
||||
self.content_fit,
|
||||
);
|
||||
|
||||
// Calculate translation to center the image and apply offset
|
||||
let translation = {
|
||||
// How much space is left after placing the scaled image
|
||||
let diff_w = bounds.width - scaled_size.width;
|
||||
let diff_h = bounds.height - scaled_size.height;
|
||||
|
||||
// Base position: center the image in the viewport
|
||||
// For images smaller than viewport: center them (diff > 0)
|
||||
// For images larger than viewport: they extend beyond bounds (diff < 0)
|
||||
let center_offset = Vector::new(diff_w / 2.0, diff_h / 2.0);
|
||||
|
||||
// Apply pan offset (offset moves the "camera", so subtract it)
|
||||
// Positive offset = looking at right/bottom part = image moves left/up
|
||||
center_offset - state.current_offset
|
||||
};
|
||||
|
||||
let drawing_bounds = Rectangle::new(bounds.position(), scaled_size);
|
||||
|
||||
let render = |renderer: &mut Renderer| {
|
||||
renderer.with_translation(translation, |renderer| {
|
||||
renderer.draw_image(
|
||||
self.handle.clone(),
|
||||
self.filter_method,
|
||||
drawing_bounds,
|
||||
Radians(0.0),
|
||||
1.0,
|
||||
[0.0; 4],
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
renderer.with_layer(bounds, render);
|
||||
}
|
||||
}
|
||||
|
||||
/// The local state of a [`Viewer`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct State {
|
||||
scale: f32,
|
||||
starting_offset: Vector,
|
||||
current_offset: Vector,
|
||||
cursor_grabbed_at: Option<Point>,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scale: 1.0,
|
||||
starting_offset: Vector::default(),
|
||||
current_offset: Vector::default(),
|
||||
cursor_grabbed_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Creates a new [`State`].
|
||||
pub fn new() -> Self {
|
||||
State::default()
|
||||
}
|
||||
|
||||
/// Returns if the cursor is currently grabbed by the [`Viewer`].
|
||||
pub fn is_cursor_grabbed(&self) -> bool {
|
||||
self.cursor_grabbed_at.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps the offset to keep the image within reasonable bounds.
|
||||
///
|
||||
/// The offset represents how far the viewport's center is displaced from the image's center.
|
||||
/// - offset (0, 0) = image centered
|
||||
/// - positive offset = viewing right/bottom part of image
|
||||
/// - negative offset = viewing left/top part of image
|
||||
fn clamp_offset(offset: Vector, viewport_size: Size, image_size: Size) -> Vector {
|
||||
// Maximum allowed offset in each direction
|
||||
// When image is larger than viewport, allow panning up to image edge
|
||||
// When image is smaller than viewport, no panning needed (clamp to 0)
|
||||
let max_offset_x = ((image_size.width - viewport_size.width) / 2.0).max(0.0);
|
||||
let max_offset_y = ((image_size.height - viewport_size.height) / 2.0).max(0.0);
|
||||
|
||||
Vector::new(
|
||||
offset.x.clamp(-max_offset_x, max_offset_x),
|
||||
offset.y.clamp(-max_offset_y, max_offset_y),
|
||||
)
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer, Handle> From<Viewer<Handle, Message>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: 'a + img_renderer::Renderer<Handle = Handle>,
|
||||
Message: 'a + Clone,
|
||||
Handle: Clone + 'a,
|
||||
{
|
||||
fn from(viewer: Viewer<Handle, Message>) -> Element<'a, Message, Theme, Renderer> {
|
||||
Element::new(viewer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the scaled size of the image given current state.
|
||||
/// Calculate the scaled image size after applying content fit and zoom.
|
||||
///
|
||||
/// This is the canonical implementation used by the viewer widget.
|
||||
/// A simplified version exists in `document::utils::scaled_image_size`.
|
||||
pub fn scaled_image_size<Renderer>(
|
||||
renderer: &Renderer,
|
||||
handle: &<Renderer as img_renderer::Renderer>::Handle,
|
||||
state: &State,
|
||||
bounds: Size,
|
||||
content_fit: ContentFit,
|
||||
) -> Size
|
||||
where
|
||||
Renderer: img_renderer::Renderer,
|
||||
{
|
||||
let Size { width, height } = renderer.measure_image(handle);
|
||||
let image_size = Size::new(width as f32, height as f32);
|
||||
|
||||
let adjusted_fit = match content_fit {
|
||||
ContentFit::None => image_size,
|
||||
_ => content_fit.fit(image_size, bounds),
|
||||
};
|
||||
|
||||
Size::new(
|
||||
adjusted_fit.width * state.scale,
|
||||
adjusted_fit.height * state.scale,
|
||||
)
|
||||
}
|
||||
13
src/ui/widgets/mod.rs
Normal file
13
src/ui/widgets/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/ui/widgets/mod.rs
|
||||
//
|
||||
// Custom widgets module.
|
||||
|
||||
pub mod crop_model;
|
||||
pub mod crop_overlay;
|
||||
pub mod image_viewer;
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use crop_model::{CropSelection, DragHandle};
|
||||
pub use crop_overlay::crop_overlay;
|
||||
pub use image_viewer::Viewer;
|
||||
Loading…
Add table
Add a link
Reference in a new issue