commit f46992805fb627b8fa8c87fc6a423dd6e40c6597 Author: Piotr Grabowski Date: Sun Jul 10 14:34:22 2022 +0200 Initial public commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b7a368 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +main +target/ diff --git a/COPYING.md b/COPYING.md new file mode 100644 index 0000000..175443c --- /dev/null +++ b/COPYING.md @@ -0,0 +1,595 @@ +GNU General Public License +========================== + +_Version 3, 29 June 2007_ +_Copyright © 2007 Free Software Foundation, Inc. <>_ + +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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +<>. + +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 +<>. diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..b32372f --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: build clean dist + +BUILD_DATE := $(shell date +'%Y%m%d') +VERSION := $(shell support/get-version.sh) + +build: + go build -o target/gomicsv \ + -ldflags="-X main.buildDate=$(BUILD_DATE) -X main.versionString=$(VERSION)" \ + cmd/gomicsv/main.go + +clean: + rm -rf target/; \ + +dist: build + rm -rf target/dist; \ + rm -rf target/*.zip; \ + mkdir -p target/dist/gomicsv; \ + cp target/gomicsv target/dist/gomicsv/gomicsv; \ + cp README.md target/dist/gomicsv; \ + cp COPYING.md target/dist/gomicsv; \ + pushd target/dist; \ + zip -r ../Gomicsv_$(VERSION).zip gomicsv; \ + rm -rf gomicsv; \ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7859141 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ + + +# Gomics-v + +> A GTK comic viewer + +Gomics-v is a GTK comic and image archive viewer written in Go, available under +the GNU GPL v3 or later. + +**This is a personal fork of *Gomics* (without the *-v*). Please also consult the +main project’s README at **. + +This fork has been made solely with personal use for reading manga with +mouse-centered navigation in mind. Consequently, some aspects of other workflows +might have been broken. + +

+ +

+ +## Table of contents + +1. [Changes from Gomics](#changes-from-gomics) +2. [Installation](#installation) +3. [Building](#installation) +4. [License](#license) + +## Changes from Gomics + +* Add **Jumpmarks** + + Temporary page marks for quickly jumping around the currently open archive. + See the `Jumpmarks` menu in the menu bar for more information. + +* Add *experimental* **HTTP archive support** + + See `File › Open URL (experimental)` for a detailed explanation. + A HTTP archive can alternatively be loaded by providing the URL as a program + argument (see `./gomicsv --help`) or by pasting the URL into the program. + +* **Rework page navigation** + + * Click the left/right half of the image area to go to the previous/next page. + + * Drag with the middle mouse button to pan around the image. + +* Add integration with **[Kamite]** (a desktop Japanese immersion companion) + + Right-click on text block for automatic OCR. Right-hold-click to initialize + manual block recognition selection. *Must be first enabled in Preferences.* + +* Add **Remember reading position** + + Automatically saves the last reading position for each archive and resumes + reading at that position. Can be disabled in Preferences. + +* Add **Save image as…** + +* Add **Copy image to clipboard** + +* Add **Hide UI** (`Alt+M` to hide/unhide). + +* Polish the look of preferences UI. + +* Make the Background color preference functional. + +* Reorganize the codebase. + +* Various other minor tweaks (and breakages). + +[Kamite]: https://github.com/fauu/Kamite + +## Installation + +Download the latest release package from the [Releases] page and extract it to +the location where you want to keep the program files (e.g., `/opt/gomicsv`). + +Or build it from the source: + +[Releases]: https://github.com/fauu/gomicsv/releases + +## Building + +```sh +git clone "https://github.com/fauu/gomicsv" +cd gomicsv +make build +``` + +A self-contained Gomics-v executable will be produced at `target/gomicsv`. + +Building requires [go] and some GTK-related dependencies. See +[gomics: Requirements][gomics-requirements]. GTK-related build steps might take +up to 15 minutes on first compile. + +[go]: https://go.dev/ +[gomics-requirements]: https://github.com/salviati/gomics#requirements + +## License + +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 . diff --git a/about.jpg b/about.jpg new file mode 100644 index 0000000..19e83bc Binary files /dev/null and b/about.jpg differ diff --git a/app.go b/app.go new file mode 100644 index 0000000..84d6a9b --- /dev/null +++ b/app.go @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + _ "embed" + "fmt" + "log" + "os" + "path/filepath" + "unsafe" + + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/glib" + "github.com/gotk3/gotk3/gtk" + + "github.com/fauu/gomicsv/archive" + "github.com/fauu/gomicsv/imgdiff" + "github.com/fauu/gomicsv/pagecache" + "github.com/fauu/gomicsv/util" +) + +const ( + AppName = "gomicsv" + AppNameDisplay = "Gomics-v" + AppID = "com.github.fauu.gomicsv" +) + +type App struct { + S State + W Widgets + Config Config +} + +type State struct { + BuildInfo BuildInfo + GTKApplication *gtk.Application + Archive archive.Archive + ArchivePos int + ArchivePath string + ArchiveName string + PixbufL, PixbufR *gdk.Pixbuf + GoToThumbPixbuf *gdk.Pixbuf + Scale float64 + PageCache *pagecache.PageCache + ConfigDirPath string + UserDataDirPath string + ReadLaterDirPath string + ImageHashes map[int]imgdiff.Hash + Jumpmarks Jumpmarks + Cursor CursorsState + DragScroll DragScroll + SmartScrollInProgress bool + KamiteRightClickActionPending bool + RecentManager *gtk.RecentManager + BackgroundColorCssProvider *gtk.CssProvider + PageCacheTrimTimeoutHandle *glib.SourceHandle +} + +//go:embed about.jpg +var aboutImg []byte + +//go:embed icon.png +var iconImg []byte + +//go:embed gomicsv.ui +var uiDef string + +type AppStartupParams struct { + Referer string +} + +func (app *App) Init(nonFlagArgs []string, startupParams AppStartupParams, buildInfo BuildInfo) *gtk.Application { + application, err := gtk.ApplicationNew(AppID, glib.APPLICATION_HANDLES_OPEN) + if err != nil { + log.Panicf("creating GTK Aplication: %v", err) + } + glib.SetPrgname(AppID) + + app.S.BuildInfo = buildInfo + + application.Connect("startup", func(self *gtk.Application) { + app.ensureDirs() + + app.loadConfig() + + app.S.RecentManager, err = gtk.RecentManagerGetDefault() + if err != nil { + log.Panicf("getting default RecentManager: %v", err) + } + + app.uiInit() + + app.syncStateToConfig() + }) + + // Runs after startup if no files were provided + application.Connect("activate", func(_ *gtk.Application) { + // Do nothing + }) + + // Runs after startup if some files were provided + application.Connect("open", func(_ *gtk.Application, filesPtr unsafe.Pointer, _count int, _ string) { + // Get first file's path and load it, ignoring the rest + offset := uintptr(0) // FUTURE: To get the i-th file pointer, set this to `i * unsafe.Sizeof(uintptr(0))` + ptr := (*unsafe.Pointer)(unsafe.Pointer(uintptr(filesPtr) + offset)) + file := &glib.File{ + Object: &glib.Object{ + GObject: glib.ToGObject(*ptr), + }, + } + path := file.GetPath() + // `path` is empty when an URL is passed. In such case, try to get the URL directly from `args` + if path == "" && len(nonFlagArgs) >= 2 && util.IsLikelyHTTPURL(nonFlagArgs[1]) { + app.loadArchiveFromURL(nonFlagArgs[1], startupParams.Referer) + } else { + app.loadArchiveFromPath(path) + } + }) + + app.S.GTKApplication = application + + return application +} + +func (app *App) ensureDirs() { + configPath, err := getConfigLocation(AppName) + if err != nil { + log.Panicf("getting config location: %v", err) + } + userDataPath, err := getUserDataLocation(AppName) + if err != nil { + log.Panicf("getting user data location: %v", err) + } + + app.S.ConfigDirPath = configPath + app.S.UserDataDirPath = userDataPath + app.S.ReadLaterDirPath = filepath.Join(userDataPath, ReadLaterDir) + + if err := os.MkdirAll(app.S.ConfigDirPath, 0755); err != nil { + log.Panicf("creting config directory: %v", err) + } + + if err := os.MkdirAll(app.S.UserDataDirPath, 0755); err != nil { + log.Panicf("creating user data directory: %v", err) + } + + if err := os.MkdirAll(app.S.ReadLaterDirPath, 0755); err != nil { + log.Panicf("creating read later directory: %v", err) + } +} + +func (app *App) syncStateToConfig() { + app.setFullscreen(app.Config.Fullscreen) + app.setHideUI(app.Config.HideUI) + app.setZoomMode(app.Config.ZoomMode) + app.setDoublePage(app.Config.DoublePage) + app.setMangaMode(app.Config.MangaMode) + app.setBackgroundColor(app.Config.BackgroundColor) +} + +func (app *App) handleKeyPress(key uint, shift bool, ctrl bool) { + switch key { + case gdk.KEY_v: + if ctrl { + app.maybeLoadArchiveFromClipboardURL() + } + case gdk.KEY_Down: + if ctrl { + app.nextArchive() + } else if shift { + app.scroll(0, 1) + } else { + app.skipForward() + } + case gdk.KEY_Up: + if ctrl { + app.previousArchive() + } else if shift { + app.scroll(0, -1) + } else { + app.skipBackward() + } + case gdk.KEY_Right: + if ctrl { + app.nextScene() + } else if shift { + app.scroll(1, 0) + } else { + app.nextPage() + } + case gdk.KEY_Left: + if ctrl { + app.previousScene() + } else if shift { + app.scroll(-1, 0) + } else { + app.previousPage() + } + case gdk.KEY_space: + if ctrl { + app.W.MenuItemPreviousPage.Activate() + } else { + app.W.MenuItemNextPage.Activate() + } + case gdk.KEY_F11: + app.W.MenuItemFullscreen.Activate() + case gdk.KEY_KP_Home: + app.W.MenuItemFirstPage.Activate() + case gdk.KEY_KP_End: + app.W.MenuItemLastPage.Activate() + case gdk.KEY_KP_Page_Up: + if ctrl { + app.W.MenuItemPreviousArchive.Activate() + } else { + app.W.MenuItemPreviousPage.Activate() + } + case gdk.KEY_KP_Next: + if ctrl { + app.W.MenuItemNextArchive.Activate() + } else { + app.W.MenuItemNextPage.Activate() + } + } +} + +func (app *App) setStatus(msg string) { + contextID := app.W.Statusbar.GetContextId("main") + app.W.Statusbar.Push(contextID, msg) +} + +func (app *App) showError(msg string) { + app.notificationShow(fmt.Sprintf("Error: %s", msg), LongNotification) +} + +func (app *App) quit() { + app.Config.WindowWidth, app.Config.WindowHeight = app.W.MainWindow.GetSize() + + app.saveConfig() + app.maybeSaveReadingPosition() + + app.S.GTKApplication.Quit() +} diff --git a/archive.go b/archive.go new file mode 100644 index 0000000..7c6d212 --- /dev/null +++ b/archive.go @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "log" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/fauu/gomicsv/archive" + "github.com/fauu/gomicsv/imgdiff" + "github.com/fauu/gomicsv/pagecache" + "github.com/fauu/gomicsv/util" + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/glib" + "github.com/gotk3/gotk3/gtk" +) + +func (app *App) loadArchiveFromURL(url string, httpReferer string) { + app.doLoadArchive(url, true, httpReferer) +} + +func (app *App) loadArchiveFromPath(path string) { + app.doLoadArchive(path, false, "") +} + +func (app *App) doLoadArchive(path string, assumeHTTPURL bool, httpReferer string) { + if strings.TrimSpace(path) == "" { + return + } + + if assumeHTTPURL && !util.IsLikelyHTTPURL(path) { + // For cases when a non-fully qualified URL is provided + path = "https://" + path + } + + if !assumeHTTPURL && !filepath.IsAbs(path) { + wd, err := os.Getwd() + if err != nil { + log.Printf("Error getting current working directory: %v", err) + return + } + path = filepath.Join(wd, path) + } + + if app.archiveIsLoaded() { + app.archiveClose() + } + + app.S.ImageHashes = make(map[int]imgdiff.Hash) + + app.S.ArchivePath = path + if assumeHTTPURL { + app.S.ArchiveName = path + } else { + app.S.ArchiveName = filepath.Base(path) + } + + cache := pagecache.NewPageCache() + app.S.PageCache = &cache + interval := uint(pageCacheTrimInterval.Seconds()) + handle := glib.TimeoutSecondsAdd(interval, app.trimPageCache) + app.S.PageCacheTrimTimeoutHandle = &handle + + var err error + if app.S.Archive, err = archive.NewArchive(path, &cache, httpReferer); err != nil { + app.showError(fmt.Sprintf("Couldn't open %s: %v", path, err)) + return + } + + app.archiveHandleLenKnowledge(app.S.Archive.Len() != nil) + + app.W.ButtonNextArchive.SetSensitive(!assumeHTTPURL) + app.W.ButtonPreviousArchive.SetSensitive(!assumeHTTPURL) + + app.W.MenuItemCopyImageToClipboard.SetSensitive(true) + + startPage := 0 + if (!assumeHTTPURL && app.Config.RememberPosition) || (assumeHTTPURL && app.Config.RememberPositionHTTP) { + savedArchivePos, err := app.loadReadingPosition(path) + if err == nil { + startPage = savedArchivePos + } else if !os.IsNotExist(err) { + log.Printf("Error loading reading position: %v", err) + } + } + app.doSetPage(startPage) + + if !assumeHTTPURL { + err := os.Chdir(app.S.ArchivePath) // UNCLEAR(fau): Is this for relative paths to work or something? + if err != nil { + log.Println("Couldn't chdir into archive path") + return + } + + if app.Config.RememberRecent { + u := &url.URL{Path: path, Scheme: "file"} + ok := app.S.RecentManager.AddItem(u.String()) + if !ok { + log.Printf("Couldn't add %s as a recent item", path) + } + } + } +} + +func (app *App) archiveIsLoaded() bool { + return app.S.Archive != nil && !reflect.ValueOf(app.S.Archive).IsNil() && app.S.ArchivePath != "" +} + +func (app *App) archiveGetBaseName() string { + var name string + if app.S.Archive.Kind() == archive.HTTPKind { + name = app.S.ArchivePath + } else { + name = filepath.Base(app.S.ArchivePath) + if ext := filepath.Ext(name); len(ext) > 1 { + name = strings.TrimSuffix(name, ext) + } + } + return name +} + +func (app *App) archiveClose() { + if !app.archiveIsLoaded() { + return + } + + app.maybeSaveReadingPosition() + + app.S.Archive.Close() + + app.S.Archive = nil + app.S.ArchiveName = "" + app.S.ArchivePath = "" + app.S.ArchivePos = 0 + + app.S.PageCache = nil + + app.S.ImageHashes = nil + + app.clearJumpmarks() + + // Cancel page cache trim timeout + if app.S.PageCacheTrimTimeoutHandle != nil { + glib.SourceRemove(*app.S.PageCacheTrimTimeoutHandle) + app.S.PageCacheTrimTimeoutHandle = nil + } + + app.W.ImageL.Clear() + app.W.ImageR.Clear() + app.S.PixbufL = nil + app.S.PixbufR = nil + app.S.Cursor.reset() + app.W.MenuItemCopyImageToClipboard.SetSensitive(false) + app.setStatus("") + app.W.MainWindow.SetTitle(AppNameDisplay) + + util.GC() +} + +func (app *App) archiveHandleLenKnowledge(known bool) { + app.W.ButtonLastPage.SetSensitive(known) + app.W.MenuItemLastPage.SetSensitive(known) + app.W.MenuItemRandom.SetSensitive(known) +} + +func (app *App) maybeLoadArchiveFromClipboardURL() { + clipboard, err := gtk.ClipboardGet(gdk.GdkAtomIntern("CLIPBOARD", true)) + if err != nil { + log.Panicf("getting clipboard: %v", err) + } else { + text, err := clipboard.WaitForText() + if err != nil { + log.Panicf("getting clipboard text: %v", err) + } else { + if util.IsLikelyHTTPURL(text) { + app.loadArchiveFromURL(text, "") + } + } + } +} diff --git a/archive/archive.go b/archive/archive.go new file mode 100644 index 0000000..c04dcb8 --- /dev/null +++ b/archive/archive.go @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package archive + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/fauu/gomicsv/pagecache" + "github.com/fauu/gomicsv/util" + "github.com/gotk3/gotk3/gdk" +) + +var ( + ErrBounds = errors.New("Image index out of bounds.") +) + +type Archive interface { + Load(i int, autorotate bool, nPreload int) (*gdk.Pixbuf, error) + Kind() Kind + Name(i int) (string, error) + Len() *int // nil represents unknown length + Close() error +} + +const ( + MaxArchiveEntries = 4096 * 64 +) + +func NewArchive(path string, pageCache *pagecache.PageCache, httpReferer string) (Archive, error) { + if util.IsLikelyHTTPURL(path) { + return NewHTTP(path, pageCache, httpReferer) + } + + f, err := os.Stat(path) + if err != nil { + return nil, err + } + + if f.IsDir() { + return NewDir(path) + } + + ext := strings.ToLower(filepath.Ext(path))[1:] + switch ext { + case "zip", "cbz": + return NewZip(path) + case "7z", "rar", "tar", "tgz", "gz", "tbz2", "cb7", "cbr", "cbt", "lha": + return nil, errors.New("Archive type not supported, please unpack it first") + } + + return nil, errors.New("Unknown archive type") +} diff --git a/archive/dir.go b/archive/dir.go new file mode 100644 index 0000000..20764df --- /dev/null +++ b/archive/dir.go @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package archive + +import ( + "errors" + "os" + "path/filepath" + "sort" + + "github.com/fauu/gomicsv/pixbuf" + "github.com/gotk3/gotk3/gdk" +) + +type Dir struct { + filenames filenames + name string + path string +} + +/* Reads filenames from a directory, and sorts them */ +func NewDir(path string) (*Dir, error) { + var err error + + ar := new(Dir) + + ar.name = filepath.Base(path) + ar.path = path + + dir, err := os.Open(path) + if err != nil { + return nil, err + } + defer dir.Close() + + filenames, err := dir.Readdirnames(-1) + if err != nil { + return nil, err + } + ar.filenames = make([]string, 0, len(filenames)) + + for _, name := range filenames { + if !extensionMatches(name, imageExtensions) { + continue + } + ar.filenames = append(ar.filenames, name) + } + + if len(ar.filenames) == 0 { + return nil, errors.New(ar.name + ": no images in the directory") + } + + sort.Sort(ar.filenames) + + return ar, nil +} + +func (ar *Dir) checkbounds(i int) error { + if i < 0 || i >= len(ar.filenames) { + return ErrBounds + } + return nil +} + +func (ar *Dir) Load(i int, autorotate bool, _nPreload int) (*gdk.Pixbuf, error) { + if err := ar.checkbounds(i); err != nil { + return nil, err + } + + path := filepath.Join(ar.path, ar.filenames[i]) + f, err := os.Open(path) + if err != nil { + return nil, err + } + + defer f.Close() + return pixbuf.Load(f, autorotate) +} + +func (ar *Dir) Name(i int) (string, error) { + if err := ar.checkbounds(i); err != nil { + return "", err + } + + return ar.filenames[i], nil +} + +func (ar *Dir) Kind() Kind { + return Unpacked +} + +func (ar *Dir) Len() *int { + l := len(ar.filenames) + return &l +} + +func (ar *Dir) Close() error { + return nil +} diff --git a/archive/http.go b/archive/http.go new file mode 100644 index 0000000..f9ea212 --- /dev/null +++ b/archive/http.go @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package archive + +import ( + "errors" + "fmt" + "log" + "net/http" + "regexp" + "sync" + "time" + + "github.com/gotk3/gotk3/gdk" + + "github.com/fauu/gomicsv/pagecache" + "github.com/fauu/gomicsv/pixbuf" +) + +const ( + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36" +) + +type HTTP struct { + urlTemplate string + referer string + firstPageOffset int + pageCache *pagecache.PageCache + fetchInProgress map[int]bool + fetchInProgressMutex sync.Mutex +} + +func NewHTTP(url string, pageCache *pagecache.PageCache, referer string) (*HTTP, error) { + if !isPageURLTemplate(url) { + var ok bool + url, ok = tryMakeURLTemplateFromSampleURL(url) + if !ok { + return nil, errors.New("Couldn't determine the URL template from the sample URL") + } + } + + newHTTP := HTTP{ + urlTemplate: url, + referer: referer, + pageCache: pageCache, + fetchInProgress: make(map[int]bool), + fetchInProgressMutex: sync.Mutex{}, + } + + firstPageIdx := 0 + var firstPixbuf *gdk.Pixbuf + for firstPageIdx < 2 { + var err error + firstPixbuf, err = newHTTP.downloadImage(firstPageIdx, false) + if err != nil { + log.Printf("First image not located at index %d", firstPageIdx) + } else { + break + } + firstPageIdx++ + } + if firstPixbuf == nil { + return nil, errors.New("Couldn't locate the first image") + } + + newHTTP.firstPageOffset = firstPageIdx + pageCache.Insert(0, firstPixbuf, pagecache.KeepReasonPreload) + + return &newHTTP, nil +} + +func (ar *HTTP) Load(i int, autorotate bool, nPreload int) (*gdk.Pixbuf, error) { + var pixbuf *gdk.Pixbuf + var err error + cached, isCached := ar.pageCache.Get(i) + if !isCached { + if downloading := ar.getAndSetPageFetchInProgress(i, true); downloading { + // Wait until downloading done + var tries = 10 + for range time.Tick(time.Millisecond * 500) { + cached, isCached = ar.pageCache.Get(i) + if isCached { + break + } + if tries <= 0 { + return nil, fmt.Errorf("Ran out of tries when waiting for page %d to download", i) + } + tries-- + } + } else { + pixbuf, err = ar.downloadImage(i+ar.firstPageOffset, autorotate) + ar.setPageFetchInProgress(i, false) + if err != nil { + return nil, err + } + } + } + if pixbuf == nil { + pixbuf = cached.Pixbuf + } + + if i == 0 { + pixbuf, err = pixbuf.ApplyEmbeddedOrientation() + if err != nil { + return nil, err + } + } + + preloadStart := i - nPreload + preloadEnd := i + nPreload + for j := preloadStart; j <= preloadEnd; j++ { + if j < 0 || j == i { + continue + } + if _, ok := ar.pageCache.Get(j); !ok { + if downloading := ar.getAndSetPageFetchInProgress(j, true); !downloading { + go func(k int) { + pixbuf, err = ar.downloadImage(k+ar.firstPageOffset, autorotate) + ar.setPageFetchInProgress(k, false) + if err != nil { + log.Printf("Couldn't preload image: %v", err) + } + ar.pageCache.Insert(k, pixbuf, pagecache.KeepReasonPreload) + }(j) + } + } + } + + if !isCached { + ar.pageCache.Insert(i, pixbuf, pagecache.KeepReasonPreload) + } + + return pixbuf, nil +} + +func (ar *HTTP) Kind() Kind { + return HTTPKind +} + +func (ar *HTTP) Name(i int) (string, error) { + return ar.urlTemplate, nil +} + +func (ar *HTTP) Len() *int { + // Length unknown + return nil +} + +func (ar *HTTP) Close() error { + return nil +} + +func (ar *HTTP) getAndSetPageFetchInProgress(i int, value bool) bool { + ar.fetchInProgressMutex.Lock() + defer ar.fetchInProgressMutex.Unlock() + _, ok := ar.fetchInProgress[i] + ar.doSetPageFetchInProgress(i, value) + return ok +} + +func (ar *HTTP) setPageFetchInProgress(i int, value bool) { + ar.fetchInProgressMutex.Lock() + defer ar.fetchInProgressMutex.Unlock() + ar.doSetPageFetchInProgress(i, value) +} + +func (ar *HTTP) doSetPageFetchInProgress(i int, value bool) { + if value { + ar.fetchInProgress[i] = true + } else { + delete(ar.fetchInProgress, i) + } +} + +func (ar *HTTP) downloadImage(i int, autorotate bool) (*gdk.Pixbuf, error) { + url := fmt.Sprintf(ar.urlTemplate, i) + headers := map[string]string{"User-Agent": userAgent} + if ar.referer != "" { + headers["Referer"] = ar.referer + } + res, err := httpGet(url, headers) + if err != nil { + return nil, err + } + defer res.Body.Close() + + pixbuf, err := pixbuf.Load(res.Body, autorotate) + if err != nil { + return nil, err + } + + return pixbuf, nil +} + +var pagePlaceholderRegexp = regexp.MustCompile(`%\d{0,2}d`) + +func isPageURLTemplate(s string) bool { + return pagePlaceholderRegexp.MatchString(s) +} + +var pageParamRegexp = regexp.MustCompile(`(?:\/(\d{1,3})(?:\/|[^\d\w]))|(?:(\d{1,3})\.(?:jp|png|gif))`) + +func tryMakeURLTemplateFromSampleURL(url string) (template string, ok bool) { + matches := pageParamRegexp.FindAllStringSubmatchIndex(url, -1) + if len(matches) < 1 { + return "", false + } + lastMatch := matches[len(matches)-1] + if len(lastMatch) <= 2 { + return "", false + } + for i := 2; i < len(lastMatch); i += 2 { + if lastMatch[i] != -1 { + start, end := lastMatch[i], lastMatch[i+1] + return url[:start] + "%d" + url[end:], true + } + } + return "", false +} + +var httpClient = &http.Client{ + Timeout: time.Second * 10, +} + +func httpGet(reqURL string, headers map[string]string) (*http.Response, error) { + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %v", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + log.Printf("GET %s", reqURL) + res, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("performing request: %v", err) + } + if res.StatusCode != http.StatusOK { + log.Printf("Got status code: %d %s", res.StatusCode, res.Status) + } + + return res, nil +} diff --git a/archive/kind.go b/archive/kind.go new file mode 100644 index 0000000..a8489c2 --- /dev/null +++ b/archive/kind.go @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package archive + +type Kind = int + +const ( + Packed Kind = iota + Unpacked + HTTPKind +) diff --git a/archive/util.go b/archive/util.go new file mode 100644 index 0000000..0ca19eb --- /dev/null +++ b/archive/util.go @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package archive + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/gotk3/gotk3/gdk" + + "github.com/fauu/gomicsv/natsort" +) + +type Loader interface { + Load(i int) (*gdk.Pixbuf, error) + Name(i int) (string, error) + Len() int +} + +var archiveExtensions = []string{".zip", ".cbz"} +var imageExtensions []string + +func init() { + imageExtensions = make([]string, 0) + formats := gdk.PixbufGetFormats() + for _, format := range formats { + imageExtensions = append(imageExtensions, format.GetExtensions()...) + } + + for i := range imageExtensions { + imageExtensions[i] = "." + imageExtensions[i] // gdk pixbuf format extensions don't have the leading "." + } +} + +func extensionMatches(p string, extensions []string) bool { + pext := strings.ToLower(filepath.Ext(p)) + for _, ext := range extensions { + if pext == ext { + return true + } + } + return false +} + +type stringArray []string + +func (p stringArray) Len() int { return len(p) } +func (p stringArray) Less(i, j int) bool { return strings.ToLower(p[i]) < strings.ToLower(p[j]) } +func (p stringArray) Swap(i, j int) { p[i], p[j] = p[j], p[i] } + +func ListInDirectory(dir string) (anames []string, err error) { + file, err := os.Open(dir) + if err != nil { + return + } + defer file.Close() + + fi, err := file.Stat() + if err != nil { + return + } + + if !fi.IsDir() { + err = errors.New(dir + " is not a directory!") + return + } + + names, err := file.Readdirnames(-1) + if err != nil { + return + } + + anames = make([]string, 0, len(names)) + for _, name := range names { + var fi os.FileInfo + fi, err = os.Stat(filepath.Join(dir, name)) + if err != nil { + return + } + + if !extensionMatches(name, archiveExtensions) && !fi.IsDir() { + // TODO(utkan): Don't add empty archives + continue + } + anames = append(anames, name) + } + + sort.Sort(stringArray(anames)) // TODO(utkan): Can use natsort for archives as well + + return +} + +type File struct { + *os.File +} + +func NewFile(f *os.File) *File { + return &File{f} +} + +func (r *File) Size() (int64, error) { + fi, err := r.Stat() + if err != nil { + return 0, err + } + return fi.Size(), nil +} + +func (r *File) SetSize(n int64) error { + return r.Truncate(n) +} + +func (r *File) Ext() string { + ext := filepath.Ext(r.Name()) + if len(ext) <= 1 || ext[0] != '.' { + return "" + } + + return ext[1:] +} + +type Buffer struct { + bytes.Buffer +} + +func NewBuffer(data []byte) *Buffer { + return &Buffer{*bytes.NewBuffer(data)} +} + +func (b *Buffer) Seek(offset int64, whence int) (int64, error) { + return offset, nil +} + +func (b *Buffer) SetSize(int64) error { + return nil +} + +func (b *Buffer) Size() (int64, error) { + return int64(b.Len()), nil +} + +func strcmp(a, b string, nat bool) bool { + if nat { + return natsort.Less(a, b) + } + return a < b +} + +type filenames []string + +func (p filenames) Len() int { return len(p) } +func (p filenames) Less(i, j int) bool { return strcmp(p[i], p[j], true) } +func (p filenames) Swap(i, j int) { p[i], p[j] = p[j], p[i] } diff --git a/archive/zip.go b/archive/zip.go new file mode 100644 index 0000000..e757651 --- /dev/null +++ b/archive/zip.go @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package archive + +import ( + "archive/zip" + "errors" + "path/filepath" + "sort" + + "github.com/fauu/gomicsv/pixbuf" + "github.com/gotk3/gotk3/gdk" +) + +type Zip struct { + files []*zip.File // File elements sorted by their Names + reader *zip.ReadCloser + name string // Name of the Zip file +} + +type zipfile []*zip.File + +func (p zipfile) Len() int { return len(p) } +func (p zipfile) Less(i, j int) bool { return strcmp(p[i].Name, p[j].Name, true) } +func (p zipfile) Swap(i, j int) { p[i], p[j] = p[j], p[i] } + +// NewZip reads filenames from a given zip archive and sorts them +func NewZip(name string) (*Zip, error) { + var err error + + ar := new(Zip) + + ar.name = filepath.Base(name) + ar.files = make([]*zip.File, 0, MaxArchiveEntries) + ar.reader, err = zip.OpenReader(name) + if err != nil { + return nil, err + } + + for _, f := range ar.reader.File { + if !extensionMatches(f.Name, imageExtensions) { + continue + } + ar.files = append(ar.files, f) + } + + if len(ar.files) == 0 { + return nil, errors.New(ar.name + ": no images in the zip file") + } + + sort.Sort(zipfile(ar.files)) + + return ar, nil +} + +func (ar *Zip) checkbounds(i int) error { + if i < 0 || i >= len(ar.files) { + return ErrBounds + } + return nil +} + +func (ar *Zip) Load(i int, autorotate bool, _nPreload int) (*gdk.Pixbuf, error) { + if err := ar.checkbounds(i); err != nil { + return nil, err + } + + f, err := ar.files[i].Open() + if err != nil { + return nil, err + } + + defer f.Close() + return pixbuf.Load(f, autorotate) +} + +func (ar *Zip) Kind() Kind { + return Packed +} + +func (ar *Zip) Name(i int) (string, error) { + if err := ar.checkbounds(i); err != nil { + return "", err + } + + return ar.files[i].Name, nil +} + +func (ar *Zip) Len() *int { + l := len(ar.files) + return &l +} + +func (ar *Zip) Close() error { + return ar.reader.Close() +} diff --git a/bookmarks.go b/bookmarks.go new file mode 100644 index 0000000..f30ad21 --- /dev/null +++ b/bookmarks.go @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/gotk3/gotk3/gtk" + + "github.com/fauu/gomicsv/util" +) + +var bookmarkMenuItems []*gtk.MenuItem + +type Bookmark struct { + Path string + Page uint + TotalPages *uint + Added time.Time +} + +func (app *App) addBookmark() { + defer app.rebuildBookmarksMenu() + + for i := range app.Config.Bookmarks { + b := &app.Config.Bookmarks[i] + if b.Path == app.S.ArchivePath { + b.Page = uint(app.S.ArchivePos + 1) + b.TotalPages = util.IntPtrToUintPtr(app.S.Archive.Len()) + b.Added = time.Now() + return + } + } + + app.Config.Bookmarks = append(app.Config.Bookmarks, Bookmark{ + Path: app.S.ArchivePath, + TotalPages: util.IntPtrToUintPtr(app.S.Archive.Len()), + Page: uint(app.S.ArchivePos + 1), + Added: time.Now(), + }) +} + +func (app *App) rebuildBookmarksMenu() { + for i := range bookmarkMenuItems { + app.W.MenuBookmarks.Remove(bookmarkMenuItems[i]) + bookmarkMenuItems[i].Destroy() + } + bookmarkMenuItems = nil + util.GC() + + for i := range app.Config.Bookmarks { + bookmark := &app.Config.Bookmarks[i] + base := filepath.Base(bookmark.Path) + totalPages := "?" + if bookmark.TotalPages != nil { + totalPages = fmt.Sprint(*bookmark.TotalPages) + } + label := fmt.Sprintf("%s (%d/%s)", base, bookmark.Page, totalPages) + bookmarkMenuItem, err := gtk.MenuItemNewWithLabel(label) + if err != nil { + app.showError(err.Error()) + return + } + bookmarkMenuItem.Connect("activate", func() { + if app.S.ArchivePath != bookmark.Path { + app.loadArchiveFromPath(bookmark.Path) + } + app.setPage(int(bookmark.Page) - 1) + }) + bookmarkMenuItems = append(bookmarkMenuItems, bookmarkMenuItem) + app.W.MenuBookmarks.Append(bookmarkMenuItem) + } + app.W.MenuBookmarks.ShowAll() +} diff --git a/build_info.go b/build_info.go new file mode 100644 index 0000000..fdfa415 --- /dev/null +++ b/build_info.go @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ +package gomicsv + +type BuildInfo struct { + Version string + Date string +} diff --git a/cmd/gomicsv/main.go b/cmd/gomicsv/main.go new file mode 100644 index 0000000..a262c52 --- /dev/null +++ b/cmd/gomicsv/main.go @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package main + +import ( + "fmt" + "os" + + "github.com/fauu/gomicsv" + flag "github.com/spf13/pflag" +) + +var ( + buildDate = "" + versionString = "" + + referer = flag.String("referer", "", "HTTP Referer value to use for requests when the provided path is a URL") + help = flag.BoolP("help", "h", false, "Print usage message and exit") + version = flag.BoolP("version", "v", false, "Print program version and exit") +) + +func main() { + flag.Usage = func() { + fmt.Fprintf( + os.Stderr, + "Usage: %s [path] [options]:\n"+ + " path Path to the comic to load at startup. Could be filesystem path, a URL of one of the images or a URL template for all of the images\n"+ + " Options:\n", + gomicsv.AppName, + ) + flag.PrintDefaults() + } + flag.Parse() + + if *help { + flag.Usage() + os.Exit(0) + } + + if *version { + fmt.Fprintf(os.Stderr, "%s version %s", gomicsv.AppNameDisplay, versionString) + os.Exit(0) + } + + nonFlagArgs := []string{os.Args[0]} + nonFlagArgs = append(nonFlagArgs, flag.Args()...) + initParams := gomicsv.AppStartupParams{ + Referer: *referer, + } + + app := gomicsv.App{} + gtkApplication := app.Init(nonFlagArgs, initParams, gomicsv.BuildInfo{Version: versionString, Date: buildDate}) + os.Exit(gtkApplication.Run(nonFlagArgs)) +} diff --git a/color.go b/color.go new file mode 100644 index 0000000..5dc3d87 --- /dev/null +++ b/color.go @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "math" + + "github.com/gotk3/gotk3/gdk" +) + +type Color struct { + R byte `json:"R"` + G byte `json:"G"` + B byte `json:"B"` + A byte `json:"A"` +} + +func NewColorFromGdkRGBA(rgba *gdk.RGBA) Color { + return Color{ + R: colorFloat64ToByte(rgba.GetRed()), + G: colorFloat64ToByte(rgba.GetGreen()), + B: colorFloat64ToByte(rgba.GetBlue()), + A: colorFloat64ToByte(rgba.GetAlpha()), + } +} + +func (color Color) ToCSS() string { + return fmt.Sprintf("rgba(%d, %d, %d, %d)", color.R, color.G, color.B, color.A) +} + +func (color Color) ToPixelInt() uint32 { + return uint32(color.R)<<24 + uint32(color.G)<<16 + uint32(color.B)<<8 + uint32(color.A) +} + +func (color Color) ToGdkRGBA() gdk.RGBA { + return *gdk.NewRGBA( + colorByteToFloat64(color.R), + colorByteToFloat64(color.G), + colorByteToFloat64(color.B), + colorByteToFloat64(color.A), + ) +} + +func colorFloat64ToByte(f float64) byte { + return byte(math.Round(f * 255.0)) +} + +func colorByteToFloat64(b byte) float64 { + return float64(b) / 255.0 +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..74eeec9 --- /dev/null +++ b/config.go @@ -0,0 +1,325 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/user" + "path/filepath" + + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" +) + +const ( + ConfigFilename = "config" + ReadLaterDir = "read-later" +) + +type Config struct { + ZoomMode string + Enlarge bool + Shrink bool + LastDirectory string + Fullscreen bool + HideUI bool + WindowWidth int + WindowHeight int + Random bool + Seamless bool + HFlip bool + VFlip bool + DoublePage bool + MangaMode bool + BackgroundColor Color + NSkip int + NPreload int + RememberRecent bool + RememberPosition bool + RememberPositionHTTP bool + OneWide bool + EmbeddedOrientation bool + Interpolation int + ImageDiffThres float32 + SceneScanSkip int + SmartScroll bool + HideIdleCursor bool + KamiteEnabled bool + KamitePort int + Bookmarks []Bookmark +} + +func (app *App) configFilePath() string { + return filepath.Join(app.S.ConfigDirPath, ConfigFilename) +} + +func (app *App) loadConfig() { + app.Config.setDefaults() + + currentUser, err := user.Current() + if err != nil { + log.Panicf("getting the current user: %v", err) + } + app.Config.LastDirectory = currentUser.HomeDir + + if err := app.Config.load(app.configFilePath()); err != nil { + if !os.IsNotExist(err) { + log.Panicf("loading config: %v", err) + } + } +} + +func (app *App) saveConfig() { + if err := app.Config.save(app.configFilePath()); err != nil { + log.Printf("Error saving config: %v", err) + } +} + +func (c *Config) setDefaults() { + c.ZoomMode = "FitToWidth" + c.Shrink = true + c.Enlarge = true + c.WindowWidth = 640 + c.WindowHeight = 480 + c.NSkip = 10 + c.NPreload = 2 + c.Seamless = true + c.RememberRecent = true + c.RememberPosition = false + c.RememberPositionHTTP = false + c.BackgroundColor = Color{ + R: 0, + G: 0, + B: 0, + A: 255, + } + c.Interpolation = 2 + c.EmbeddedOrientation = true + c.ImageDiffThres = 0.4 + c.SceneScanSkip = 5 + c.SmartScroll = false + c.HideIdleCursor = true + c.KamiteEnabled = false + c.KamitePort = 4110 +} + +func (c *Config) load(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + d := json.NewDecoder(f) + if err = d.Decode(c); err != nil { + return err + } + + return nil +} + +func (c *Config) save(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer f.Close() + + data, err := json.MarshalIndent(c, "", "\t") + if err != nil { + return err + } + _, err = f.Write(data) + return err +} + +func (app *App) setZoomMode(mode string) { + switch mode { + case "FitToWidth": + app.W.MenuItemFitToWidth.SetActive(true) + case "FitToHeight": + app.W.MenuItemFitToHeight.SetActive(true) + case "BestFit": + app.W.MenuItemBestFit.SetActive(true) + default: + app.W.MenuItemOriginal.SetActive(true) + mode = "Original" + } + + app.Config.ZoomMode = mode + app.blit() + app.updateStatus() +} + +func (app *App) setHFlip(hflip bool) { + app.Config.HFlip = hflip + app.blit() +} + +func (app *App) setVFlip(vflip bool) { + app.Config.VFlip = vflip + app.blit() +} + +func (app *App) setFullscreen(fullscreen bool) { + app.Config.Fullscreen = fullscreen + if fullscreen { + app.W.Statusbar.Hide() + app.W.Toolbar.Hide() + //app.Menubar.Hide() // BUG: menubar visible on fullscreen + app.W.MainWindow.Fullscreen() + } else { + app.W.Statusbar.Show() + app.W.Toolbar.Show() + //app.Menubar.Show() + app.W.MainWindow.Unfullscreen() + } + app.W.MenuItemFullscreen.SetActive(fullscreen) +} + +func (app *App) setHideUI(hideUI bool) { + app.Config.HideUI = hideUI + + if hideUI { + app.W.Menubar.Hide() + app.W.Toolbar.Hide() + app.W.Statusbar.Hide() + } else { + app.W.Menubar.Show() + app.W.Toolbar.Show() + app.W.Statusbar.Show() + } + + // Force child size recalculation + // FIXME: This sometimes doesn't have an effect + app.W.MainContainer.Hide() + app.W.MainContainer.Show() + + app.W.MenuItemHideUI.SetActive(hideUI) +} + +func (app *App) setShrink(shrink bool) { + app.Config.Shrink = shrink + app.W.MenuItemShrink.SetActive(shrink) + app.blit() + app.updateStatus() +} + +func (app *App) setEnlarge(enlarge bool) { + app.Config.Enlarge = enlarge + app.W.MenuItemEnlarge.SetActive(enlarge) + app.blit() + app.updateStatus() +} + +func (app *App) setRandom(random bool) { + app.Config.Random = random + app.W.MenuItemRandom.SetActive(random) +} + +func (app *App) setSeamless(seamless bool) { + app.Config.Seamless = seamless + app.W.MenuItemSeamless.SetActive(seamless) +} + +func (app *App) setDoublePage(doublePage bool) { + app.W.ImageR.SetVisible(doublePage) + app.Config.DoublePage = doublePage + app.doSetPage(app.S.ArchivePos) +} + +func (app *App) setMangaMode(mangaMode bool) { + app.Config.MangaMode = mangaMode + app.blit() + app.updateStatus() +} + +func (app *App) setBackgroundColor(color Color) { + app.Config.BackgroundColor = color + + screen, err := gdk.ScreenGetDefault() + if err != nil { + log.Panicf("getting default screen: %v", err) + } + + if app.S.BackgroundColorCssProvider != nil { + gtk.RemoveProviderForScreen(screen, app.S.BackgroundColorCssProvider) + app.S.BackgroundColorCssProvider = nil + } + + provider, err := gtk.CssProviderNew() + if err != nil { + log.Panicf("creating CssProvider: %v", err) + } + err = provider.LoadFromData(fmt.Sprintf("#ScrolledWindow { background-color: %s; }", color.ToCSS())) + if err != nil { + log.Panicf("adding css to provider: %v", err) + } + gtk.AddProviderForScreen(screen, provider, 1) + app.S.BackgroundColorCssProvider = provider +} + +func (app *App) setInterpolation(interpolation int) { + app.Config.Interpolation = interpolation + app.blit() +} + +func (app *App) setRememberRecent(rememberRecent bool) { + app.Config.RememberRecent = rememberRecent +} + +func (app *App) setRememberPosition(rememberPosition bool) { + app.Config.RememberPosition = rememberPosition + // TODO: Clear remembered when unset +} + +func (app *App) setRememberPositionHTTP(rememberPositionHTTP bool) { + app.Config.RememberPositionHTTP = rememberPositionHTTP +} + +func (app *App) setOneWide(oneWide bool) { + app.Config.OneWide = oneWide + app.blit() + app.updateStatus() +} + +func (app *App) setSmartScroll(smartScroll bool) { + app.Config.SmartScroll = smartScroll +} + +func (app *App) setHideIdleCursor(hideIdleCursor bool) { + app.Config.HideIdleCursor = hideIdleCursor +} + +func (app *App) setEmbeddedOrientation(embeddedOrientation bool) { + app.Config.EmbeddedOrientation = embeddedOrientation + app.blit() + app.updateStatus() +} + +func (app *App) setKamiteEnabled(kamiteEnable bool) { + app.Config.KamiteEnabled = kamiteEnable +} + +func (app *App) setKamitePort(kamitePort int) { + app.Config.KamitePort = kamitePort +} diff --git a/cursor.go b/cursor.go new file mode 100644 index 0000000..980bbdf --- /dev/null +++ b/cursor.go @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "log" + "time" + + "github.com/gotk3/gotk3/gdk" +) + +type CursorsState struct { + Current Cursor + Cache CursorCache + LastMoved time.Time + Visible bool + ForceVisible bool +} + +const ( + cursorNone = iota + cursorDefault + cursorGrabbing +) + +type Cursor int + +type CursorCache = map[Cursor]*gdk.Cursor + +func (cursor *CursorsState) init() { + cursor.Cache = CursorCache{ + cursorNone: loadCursor("none"), + cursorDefault: loadCursor("default"), + cursorGrabbing: loadCursor("grabbing"), + } + cursor.Current = cursorDefault +} + +func (cursor *CursorsState) reset() { + cursor.LastMoved = time.Now() + cursor.Visible = false + cursor.ForceVisible = false +} + +func (app *App) setCursor(cursor Cursor) { + win, err := app.W.ImageViewport.GetWindow() + if err != nil { + log.Panicf("getting Image Viewport's Window: %v", err) + } + if cursor != cursorNone { + app.S.Cursor.Current = cursor + } + win.SetCursor(app.S.Cursor.Cache[cursor]) +} + +func (app *App) hideCursor() { + app.setCursor(cursorNone) + app.S.Cursor.Visible = false +} + +func (app *App) showCursor() { + app.setCursor(app.S.Cursor.Current) + app.S.Cursor.Visible = true +} + +func (app *App) updateCursorVisibility() bool { + shouldBeHidden := false + if !app.S.DragScroll.InProgress && app.Config.HideIdleCursor && !app.S.Cursor.ForceVisible { + shouldBeHidden = time.Since(app.S.Cursor.LastMoved).Seconds() > 1 + } + + if shouldBeHidden && app.S.Cursor.Visible { + app.hideCursor() + } else if !shouldBeHidden && !app.S.Cursor.Visible { + app.showCursor() + } + + return true +} + +func loadCursor(name string) *gdk.Cursor { + disp, err := gdk.DisplayGetDefault() + if err != nil { + log.Panicf("getting default display: %v", err) + } + c, err := gdk.CursorNewFromName(disp, name) + if err != nil { + log.Panicf("creating cursor: %v", err) + } + return c +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0e16e80 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/fauu/gomicsv + +replace github.com/fauu/gomicsv => ./ + +go 1.17 + +require ( + github.com/flytam/filenamify v1.1.0 + github.com/gotk3/gotk3 v0.6.1 + github.com/spf13/pflag v1.0.5 + golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6316356 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/diamondburned/gotk4 v0.0.3-0.20220418035147-8a785583d00a h1:u+75gz/SWdpIWJaD0TFR4lgmz+s0JUkKfLXgJesf+GQ= +github.com/diamondburned/gotk4 v0.0.3-0.20220418035147-8a785583d00a/go.mod h1:WbOkeM91aOubLbxqcbYwd7q/HCKxAmqObvT5Dhpzm1Y= +github.com/diamondburned/gotk4/pkg v0.0.0-20220418035147-8a785583d00a h1:UrzOwwQiOLckSyU1ETP66Qz9XV/vvVCn0RS1YTY1prk= +github.com/diamondburned/gotk4/pkg v0.0.0-20220418035147-8a785583d00a/go.mod h1:rLH6FHos690jFgAM/GYEpMykuE/9NmN6zOvFlr8JTvE= +github.com/flytam/filenamify v1.1.0 h1:iEOcC/1UgxJf4lp2E2CWtYO7TMyYmgb2RPSTou89FLs= +github.com/flytam/filenamify v1.1.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8= +github.com/gotk3/gotk3 v0.6.0 h1:Aqlq4/6VabNwtCyA9M9zFNad5yHAqCi5heWnZ9y+3dA= +github.com/gotk3/gotk3 v0.6.0/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= +github.com/gotk3/gotk3 v0.6.1 h1:GJ400a0ecEEWrzjBvzBzH+pB/esEMIGdB9zPSmBdoeo= +github.com/gotk3/gotk3 v0.6.1/go.mod h1:/hqFpkNa9T3JgNAE2fLvCdov7c5bw//FHNZrZ3Uv9/Q= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/gomicsv.ui b/gomicsv.ui new file mode 100644 index 0000000..3971b0c --- /dev/null +++ b/gomicsv.ui @@ -0,0 +1,1201 @@ + + + + + + + + application/zip + application/x-cbz + + + + + com.github.fauu.gomicsv + + + + false + Gomics-v + center + + + true + false + + + true + true + vertical + + + true + false + + + true + false + _File + true + + + true + false + + + true + false + Open + true + + + + + Open URL (experimental) + true + + + + + true + false + Recent Files + true + + + true + false + RecentFilter + 10 + + + + + + + true + false + Save image as… + true + + + + + true + false + Close + true + + + + + true + false + + + + + true + false + Quit + true + + + + + + + + + true + false + _Edit + true + + + true + false + + + true + false + Copy image to clipboard + true + false + + + + + true + false + + + + + true + false + Preferences + true + + + + + + + + + true + false + _View + true + + + true + false + + + true + false + Shrink large images + + + + + true + false + Enlarge small images + + + + + true + false + + + + + true + false + Best fit + true + true + + + + + true + false + Original size + true + true + MenuItemBestFit + + + + + true + false + Fit to width + true + true + MenuItemBestFit + + + + + true + false + Fit to height + true + true + MenuItemBestFit + + + + + true + false + + + + + true + false + Fullscreen + true + + + + + true + false + Hide UI + true + + + + + true + false + Seamless mode + true + + + + + true + false + Random ordering + true + + + + + true + false + + + + + true + false + Flip vertically + true + + + + + true + false + Flip horizontally + true + + + + + true + false + + + + + true + false + Manga mode + true + + + + + true + false + Double page + true + + + + + + + + + true + false + _Navigation + true + + + true + false + + + true + false + Previous page + true + + + + + true + false + Next page + true + + + + + true + false + + + + + true + false + Skip backward + true + + + + + true + false + Skip forward + true + + + + + true + false + + + + + true + false + First page + true + + + + + true + false + Last page + true + + + + + true + false + + + + + true + false + Previous archive + true + + + + + true + false + Next archive + true + + + + + true + false + + + + + true + false + Go to page + true + + + + + + + + + true + false + _Bookmarks + true + + + true + false + + + true + false + Add bookmark + true + + + + + true + false + + + + + + + + + true + false + _Jumpmarks + true + + + true + false + + + true + false + Mark current page + + + + + true + false + Cycle backward + + + + + true + false + Cycle forward + + + + + true + false + Return to the initial page + + + + + true + false + + + + + + + + + true + false + _Help + true + + + true + false + + + true + false + About + true + + + + + + + + + + + true + false + + + true + false + First page + First page + true + go-first + + + + + true + false + Skip backwards + Skip backwards + true + edit-undo + + + + + true + false + Previous page + Previous Page + true + go-previous + + + + + true + false + Next page + Next page + true + go-next + + + + + true + false + Skip forward + Skip forward + true + edit-redo + + + + + true + false + Last page + Last page + true + go-last + + + + + true + false + + + + + true + false + Previous archive + Previous archive + true + media-seek-backward + + + + + true + false + Next archive + Next archive + true + media-seek-forward + + + + + + + true + false + in + true + ScrolledWindow + + + true + false + + + true + false + center + center + + + true + false + + + + + true + false + + + + + + + + + + + true + false + vertical + 2 + + + + + + + true + false + center + start + + + true + false + start + 20 + + + true + false + + + + + + true + true + true + none + + + true + false + window-close-symbolic + + + + + + + + + + + + + + + false + 5 + About Gomics-v + center-on-parent + dialog + MainWindow + Gomics-v + Copyright (c) 2013–2021 Utkan Güngördü +Copyright (c) 2021–2022 Piotr Grabowski + Gomics-v is a GTK comic and image archive viewer written in Go, available under the GNU GPL v3 or later. + https://github.com/fauu/gomicsv + Utkan Güngördü, Piotr Grabowski + Hiroshi Seo (Ubunchu! artwork, licensed under Creative Commons: Attribution-NonCommercial-ShareAlike 2.1) + + true + gpl-3-0 + + + false + vertical + 2 + + + false + end + + + + + + + + + + false + 5 + Open archive + GtkFileChooserDialog + center-on-parent + document-open + dialog + MainWindow + FileFilterArchive + + + false + vertical + 2 + + + false + + + + + + + + + + false + 5 + Save image + GtkFileChooserDialog + center-on-parent + document-save-as + dialog + MainWindow + GTK_FILE_CHOOSER_ACTION_SAVE + + + false + vertical + 2 + + + false + + + + + + + + + + false + Go to page + center-on-parent + go-jump + dialog + MainWindow + false + + + false + vertical + 2 + + + false + end + + + + + + + + + + + true + false + + + true + true + false + number + true + true + true + + + + + true + false + false + 0 + 0 + 300 + true + + + + + + + 128 + 128 + true + false + image-missing + + + + + + + 400 + false + Open URL (experimental) + center-on-parent + document-open + dialog + MainWindow + + + false + vertical + 10 + + + true + URL or URL template: + GTK_ALIGN_START + 5 + + + + + true + 20 + true + + + + + true + true + 80 + true + + GTK_ALIGN_START + 20 + + + + + true + HTTP Referer: + GTK_ALIGN_START + 5 + + + + + true + 5 + true + + + + + + + + + + + + + + + + + false + 5 + Preferences + dialog + MainWindow + + + false + vertical + 2 + + + false + end + + + + + + + + + + + true + true + 5 + + + true + false + vertical + 10 + + + true + false + + + true + false + Background color: + true + GTK_ALIGN_START + + + + + true + true + true + + + + + + + + + true + false + Appearance + + + + + true + false + vertical + 10 + + + Remember recently opened files + true + true + false + true + 5 + + + + + Remember reading position + true + true + false + true + 5 + + + + + Remember reading position for HTTP archives + true + true + false + true + 20 + 5 + + + + + true + false + 5 + + + true + false + Number of pages to skip: + true + GTK_ALIGN_START + + + + + true + true + false + digits + true + + + + + + + true + false + 5 + + + true + false + Image interpolation algorithm: + true + GTK_ALIGN_START + + + + + true + false + 2 + + Nearest + Tiles + Bilinear + Hyper + + + + + + + + + + true + false + Behavior + + + + + true + false + vertical + 10 + + + Display one wide image in double-page mode + true + true + false + true + 5 + + + + + Automatically rotate images according to EXIF data + true + true + false + true + 5 + + + + + Use smart scroll + true + true + false + true + 5 + + + + + Hide the cursor while stationary + true + true + false + true + + + + + + + true + false + Display + + + + + true + false + vertical + 10 + + + Enable integration + true + true + false + true + 5 + + + + + true + false + 5 + + + true + false + Port: + true + GTK_ALIGN_START + + + + + true + true + false + digits + + + + + + + + + true + false + Kamite + + + + + + + + diff --git a/goto.go b/goto.go new file mode 100644 index 0000000..8db0075 --- /dev/null +++ b/goto.go @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "log" + + "github.com/fauu/gomicsv/util" + "github.com/gotk3/gotk3/gtk" +) + +func (app *App) goToDialogInit() { + _, err := app.W.GoToDialog.AddButton("_Cancel", gtk.RESPONSE_CANCEL) + checkDialogAddButtonErr(err) + goButton, err := app.W.GoToDialog.AddButton("_Go", gtk.RESPONSE_ACCEPT) + checkDialogAddButtonErr(err) + + app.W.GoToDialog.SetDefault(goButton) + + app.W.GoToSpinButton.Connect("value-changed", func() { + app.W.GoToScrollbar.SetValue(app.W.GoToSpinButton.GetValue()) + app.goToDialogUpdateThumbnail() + }) + + app.W.GoToScrollbar.Connect("value-changed", func() { + app.W.GoToSpinButton.SetValue(app.W.GoToScrollbar.GetValue()) + }) +} + +func (app *App) goToDialogUpdateThumbnail() { + n := int(app.W.GoToSpinButton.GetValue() - 1) + pixbuf, err := app.S.Archive.Load(n, app.Config.EmbeddedOrientation, 0) + if err != nil { + log.Printf("Error getting thumbnail: %v", err) + return + } + + w, h := util.Fit(pixbuf.GetWidth(), pixbuf.GetHeight(), 128, 128) + + scaled, err := pixbuf.ScaleSimple(w, h, interpolations[app.Config.Interpolation]) + if err != nil { + log.Printf("Error scaling thumbnail: %v", err) + return + } + + app.S.GoToThumbPixbuf = scaled + app.W.GoToThumbnailImage.SetFromPixbuf(scaled) + + util.GC() +} + +func (app *App) goToDialogRun() { + if !app.archiveIsLoaded() { + return + } + + if app.S.Archive.Len() == nil { + app.goToDialogUpdateSpinButton(9999) + app.W.GoToScrollbar.Hide() + } else { + app.goToDialogUpdateSpinButton(*app.S.Archive.Len()) + app.W.GoToScrollbar.SetRange(1, float64(*app.S.Archive.Len())) + app.W.GoToScrollbar.SetValue(float64(app.S.ArchivePos) + 1) + app.W.GoToScrollbar.SetIncrements(1, float64(*app.S.Archive.Len())) + app.W.GoToScrollbar.Show() + } + + app.W.GoToSpinButton.GrabFocus() + + app.goToDialogUpdateThumbnail() + + res := gtk.ResponseType(app.W.GoToDialog.Run()) + app.W.GoToDialog.Hide() + if res == gtk.RESPONSE_ACCEPT { + app.setPage(int(app.W.GoToSpinButton.GetValue()) - 1) + + app.W.GoToThumbnailImage.Clear() + app.S.GoToThumbPixbuf = nil + util.GC() + } +} + +func (app *App) goToDialogUpdateSpinButton(archiveLen int) { + app.W.GoToSpinButton.SetRange(1, float64(archiveLen)) + app.W.GoToSpinButton.SetValue(float64(app.S.ArchivePos) + 1) + app.W.GoToSpinButton.SetIncrements(1, float64(app.Config.NSkip)) +} diff --git a/gtk.go b/gtk.go new file mode 100644 index 0000000..2a72ca6 --- /dev/null +++ b/gtk.go @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "log" + + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" +) + +func getDefaultPointerDevice() (*gdk.Device, error) { + display, err := gdk.DisplayGetDefault() + if err != nil { + return nil, err + } + seat, err := display.GetDefaultSeat() + if err != nil { + return nil, err + } + pointerDevice, err := seat.GetPointer() + if err != nil { + return nil, err + } + return pointerDevice, nil +} + +func pixbufCopyPixels(src *gdk.Pixbuf, dst *gdk.Pixbuf, dstStartX int) { + nChannels := src.GetNChannels() // ASSUMPTION: Number of channels matches + srcRowstride, dstRowstride := src.GetRowstride(), dst.GetRowstride() + srcPixels, dstPixels := src.GetPixels(), dst.GetPixels() + for y := 0; y < src.GetHeight(); y++ { + for x := 0; x < src.GetWidth(); x++ { + xOffset := x * nChannels + srcIdxBase := y*srcRowstride + xOffset + dstIdxBase := y*dstRowstride + dstStartX*nChannels + xOffset + for i := 0; i < nChannels; i++ { + dstPixels[dstIdxBase+i] = srcPixels[srcIdxBase+i] + } + } + } +} + +type MenuWithAccels struct { + Menu *gtk.Menu + Path string + Items []MenuItemWithAccels +} + +type MenuItemWithAccels struct { + Item *gtk.MenuItem + Accel Accel +} + +type Accel struct { + Key uint + Mods gdk.ModifierType +} + +func setupMenuAccels(mainWindow *gtk.ApplicationWindow, menuSetups []MenuWithAccels) error { + for _, menuSetup := range menuSetups { + accelGroup, err := gtk.AccelGroupNew() + if err != nil { + return err + } + mainWindow.AddAccelGroup(accelGroup) + + menuSetup.Menu.SetAccelPath(menuSetup.Path) + menuSetup.Menu.SetAccelGroup(accelGroup) + + for _, item := range menuSetup.Items { + itemPath := fmt.Sprintf("%s/%s", menuSetup.Path, item.Item.GetLabel()) + gtk.AccelMapAddEntry(itemPath, item.Accel.Key, item.Accel.Mods) + accelGroup.ConnectByPath(itemPath, item.Item.Activate) + } + } + + return nil +} + +func checkDialogAddButtonErr(err error) { + if err != nil { + log.Panicf("adding a button to a dialog: %v", err) + } +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..4e40d9d Binary files /dev/null and b/icon.png differ diff --git a/image.go b/image.go new file mode 100644 index 0000000..41475bc --- /dev/null +++ b/image.go @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "log" + "path/filepath" + + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" + + "github.com/fauu/gomicsv/util" +) + +const savedImagePNGQuality = 9 + +var interpolations = []gdk.InterpType{gdk.INTERP_NEAREST, gdk.INTERP_TILES, gdk.INTERP_BILINEAR, gdk.INTERP_HYPER} + +func (app *App) pixbufLoaded() bool { + if app.Config.DoublePage && !app.shouldForceSinglePage() { + return app.S.PixbufL != nil && app.S.PixbufR != nil + } + return app.S.PixbufL != nil +} + +func (app *App) pixbufSize() (w, h int) { + if !app.pixbufLoaded() { + return 0, 0 + } + + s := &app.S + + if app.Config.DoublePage && !app.shouldForceSinglePage() { + return s.PixbufL.GetWidth() + s.PixbufR.GetWidth(), util.Max(s.PixbufL.GetHeight(), s.PixbufR.GetHeight()) + } + return s.PixbufL.GetWidth(), s.PixbufL.GetHeight() +} + +func (app *App) updateStatus() { + s := &app.S + + if !app.pixbufLoaded() { + return + } + + zoom := int(100 * app.S.Scale) + + lenStr := "?" + if s.Archive.Len() != nil { + lenStr = fmt.Sprint(*s.Archive.Len()) + } + + markedStr := "" + if app.currentPageIsJumpmarked() { + markedStr = " MARKED " + } + + var msg, title string + if app.Config.DoublePage && !app.shouldForceSinglePage() { + leftPath, _ := s.Archive.Name(s.ArchivePos) + left := filepath.Base(leftPath) + rightPath, _ := s.Archive.Name(s.ArchivePos + 1) + right := filepath.Base(rightPath) + + leftIndex := s.ArchivePos + 1 + rightIndex := s.ArchivePos + 2 + + leftw, lefth := s.PixbufL.GetWidth(), s.PixbufL.GetHeight() + rightw, righth := s.PixbufR.GetWidth(), s.PixbufR.GetHeight() + + if app.Config.MangaMode { + left, right = right, left + leftIndex, rightIndex = rightIndex, leftIndex + leftw, rightw = rightw, leftw + } + msg = fmt.Sprintf("%d+%d / %s %s | %dx%d - %dx%d (%d%%) | %s | %s - %s", leftIndex, rightIndex, lenStr, markedStr, leftw, lefth, rightw, righth, zoom, s.ArchiveName, left, right) + title = fmt.Sprintf("[%d+%d / %s] %s", leftIndex, rightIndex, lenStr, s.ArchiveName) + } else { + imgPath, _ := s.Archive.Name(s.ArchivePos) + w, h := s.PixbufL.GetWidth(), s.PixbufL.GetHeight() + msg = fmt.Sprintf("%d / %s %s | %dx%d (%d%%) | %s | %s", s.ArchivePos+1, lenStr, markedStr, w, h, zoom, s.ArchiveName, imgPath) + title = fmt.Sprintf("[%d / %s] %s", s.ArchivePos+1, lenStr, s.ArchiveName) + } + app.setStatus(msg) + + app.W.MainWindow.SetTitle(title) +} + +func (app *App) getImageAreaInnerSize() (width, height int) { + alloc := app.W.ScrolledWindow.GetAllocation() + return alloc.GetWidth() - 4, alloc.GetHeight() - 4 // 2u of padding from the ScrolledWindow and 2u from the Viewport +} + +func (app *App) getScaledSize() (scale float64) { + if !app.pixbufLoaded() { + return + } + + scrw, scrh := app.getImageAreaInnerSize() + + w, h := app.pixbufSize() + switch app.Config.ZoomMode { + case "FitToWidth": + needscale := (app.Config.Enlarge && w < scrw) || (app.Config.Shrink && w > scrw) + if needscale { + return float64(scrw) / float64(w) + } + case "FitToHeight": + return float64(scrh) / float64(h) + case "BestFit": + needscale := (app.Config.Enlarge && (w < scrw && h < scrh)) || (app.Config.Shrink && (w > scrw || h > scrh)) + if needscale { + fw, _ := util.Fit(w, h, scrw, scrh) + return float64(fw) / float64(w) + } + } + return 1 +} + +func (app *App) shouldForceSinglePage() bool { + if app.S.PixbufR == nil { + return true + } + return app.Config.OneWide && (app.S.PixbufL.GetWidth() > app.S.PixbufL.GetHeight() || app.S.PixbufR.GetWidth() > app.S.PixbufR.GetHeight()) +} + +func (app *App) blit() { + if !app.pixbufLoaded() { + return + } + + app.S.Scale = app.getScaledSize() + + // Check whether the scale of the left image is different from the old one? + + if app.Config.DoublePage && !app.shouldForceSinglePage() { + left := app.S.PixbufL + right := app.S.PixbufR + + if app.Config.MangaMode { + left, right = right, left + } + + if err := app.doBlit(app.W.ImageL, left, app.S.Scale); err != nil { + app.showError(err.Error()) + return + } + + if err := app.doBlit(app.W.ImageR, right, app.S.Scale); err != nil { + app.showError(err.Error()) + return + } + } else { + app.W.ImageR.Clear() + if err := app.doBlit(app.W.ImageL, app.S.PixbufL, app.S.Scale); err != nil { + app.showError(err.Error()) + return + } + } + + if app.S.Scale != 1 || app.Config.HFlip || app.Config.VFlip { + util.GC() + } +} + +func (app *App) doBlit(image *gtk.Image, pixbuf *gdk.Pixbuf, scale float64) (err error) { + image.Clear() + + if app.Config.HFlip { + pixbuf, err = pixbuf.Flip(true) + if err != nil { + return err + } + } + + if app.Config.VFlip { + pixbuf, err = pixbuf.Flip(false) + if err != nil { + return err + } + } + + if scale != 1 { + w, h := pixbuf.GetWidth(), pixbuf.GetHeight() + pixbuf, err = pixbuf.ScaleSimple(int(float64(w)*scale), int(float64(h)*scale), interpolations[app.Config.Interpolation]) + if err != nil { + return err + } + } + + image.SetFromPixbuf(pixbuf) + + return nil +} + +const saveImageErrorMsgTpl = "Couldn't save image: %v" + +func (app *App) saveImage(path string) { + if app.S.PixbufR == nil { + if app.S.PixbufL == nil { + return + } else { + if err := app.S.PixbufL.SavePNG(path, savedImagePNGQuality); err != nil { + app.showError(fmt.Sprintf(saveImageErrorMsgTpl, err)) + return + } + } + } + + // We know we're in double page mode + stichedPixbuf, err := app.getStichedPixbuf() + if err != nil { + app.showError(fmt.Sprintf(saveImageErrorMsgTpl, err)) + return + } + if err := stichedPixbuf.SavePNG(path, savedImagePNGQuality); err != nil { + app.showError(fmt.Sprintf(saveImageErrorMsgTpl, err)) + return + } + + app.notificationShow(fmt.Sprintf("Saved to %s", path), ShortNotification) +} + +const copyImageSuccessMsg = "Copied image to clipboard" + +func (app *App) copyImageToClipboard() { + clipboard, err := gtk.ClipboardGet(gdk.GdkAtomIntern("CLIPBOARD", true)) + if err != nil { + log.Panicf("getting clipboard: %v", err) + } + + if app.S.PixbufR == nil { + if app.S.PixbufL != nil { + clipboard.SetImage(app.S.PixbufL) + app.notificationShow(copyImageSuccessMsg, ShortNotification) + } + return + } + + // We know we're in double page mode + stichedPixbuf, err := app.getStichedPixbuf() + if err != nil { + log.Printf("Error stiching images: %v", err) + app.showError("Couldn't copy image to clipboard") + return + } + + clipboard.SetImage(stichedPixbuf) + app.notificationShow(copyImageSuccessMsg, ShortNotification) +} + +// getStichedPixbuf creates a Pixbuf combining the left and right image Pixbufs +func (app *App) getStichedPixbuf() (*gdk.Pixbuf, error) { + l, r := app.S.PixbufL, app.S.PixbufR + if app.Config.MangaMode { + l, r = r, l + } + + // ASSUMPTION: Both Pixbufs have the same parameters except for size + mergedPixbuf, err := gdk.PixbufNew( + l.GetColorspace(), + l.GetHasAlpha(), + l.GetBitsPerSample(), + l.GetWidth()+r.GetWidth(), + util.Max(l.GetHeight(), r.GetHeight()), + ) + if err != nil { + return nil, fmt.Errorf("creating pixbuf: %v", err) + } + + mergedPixbuf.Fill(app.Config.BackgroundColor.ToPixelInt()) + + // POLISH: If heights are unequal, center the smaller image vertically, just as it appears in the program + pixbufCopyPixels(l, mergedPixbuf, 0) + pixbufCopyPixels(r, mergedPixbuf, l.GetWidth()) + + return mergedPixbuf, nil +} diff --git a/image_area.go b/image_area.go new file mode 100644 index 0000000..b8f4068 --- /dev/null +++ b/image_area.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/glib" + "github.com/gotk3/gotk3/gtk" +) + +const kamiteLongRightClickDelay = 375 + +func (app *App) imageAreaInit() { + app.W.ScrolledWindow.SetEvents(app.W.ScrolledWindow.GetEvents() | int(gdk.BUTTON_PRESS_MASK) | int(gdk.BUTTON_RELEASE_MASK)) + + app.W.ScrolledWindow.Connect("scroll-event", func(self *gtk.ScrolledWindow, event *gdk.Event) { + se := &gdk.EventScroll{Event: event} + app.scroll(se.DeltaX(), se.DeltaY()) + }) + + app.W.ScrolledWindow.Connect("button-press-event", func(self *gtk.ScrolledWindow, event *gdk.Event) bool { + be := &gdk.EventButton{Event: event} + switch be.Button() { + case 1: + if (int)(be.X()) < self.GetAllocatedWidth()/2 { + app.previousPage() + } else { + app.nextPage() + } + case 3: + if app.Config.KamiteEnabled { + app.S.KamiteRightClickActionPending = true + glib.TimeoutAdd(kamiteLongRightClickDelay, func() bool { + if app.S.KamiteRightClickActionPending { + app.kamiteRecognizeManualBlock() + app.S.KamiteRightClickActionPending = false + } + return false + }) + } + // Empty + case 2: + app.dragScrollStart(be.X(), be.Y(), self.GetVAdjustment().GetValue(), self.GetHAdjustment().GetValue()) + } + return true + }) + + app.W.ScrolledWindow.Connect("button-release-event", func(_ *gtk.ScrolledWindow, event *gdk.Event) bool { + be := &gdk.EventButton{Event: event} + switch be.Button() { + case 1: + // Empty + case 3: + if app.Config.KamiteEnabled { + if app.S.KamiteRightClickActionPending { + app.kamiteRecognizeImageUnderCursorBlock() + app.S.KamiteRightClickActionPending = false + } + } + case 2: + app.dragScrollEnd() + } + return true + }) + + app.W.ScrolledWindow.Connect("motion-notify-event", func(sw *gtk.ScrolledWindow, event *gdk.Event) bool { + if app.S.DragScroll.InProgress { + app.dragScrollUpdate(sw, &gdk.EventButton{Event: event}) + } + return false // Let it be handled for MainWindow + }) + +} + +func (app *App) handleImageAreaResize() { + app.blit() + app.updateStatus() +} diff --git a/imgdiff/imgdiff.go b/imgdiff/imgdiff.go new file mode 100644 index 0000000..cc749b9 --- /dev/null +++ b/imgdiff/imgdiff.go @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package imgdiff + +import ( + "image" + "image/color" + "math/bits" + + "github.com/gotk3/gotk3/gdk" +) + +const ( + dhashImageWidth = 9 + dhashImageHeight = 8 +) + +type Hash uint64 // Assuming (dhashImageWidth-1)*dhashImageHeight <= 64 + +func Distance(h1, h2 Hash) int { + return bits.OnesCount64(uint64(h1 ^ h2)) +} + +func init() { + if dhashImageHeight*(dhashImageWidth-1) > 64 { + panic("dhashImageHeight is too large") + } +} + +func pixbufToGrayscaleImage(p *gdk.Pixbuf) *image.Gray { + nchan := p.GetNChannels() + data := p.GetPixels() + w, h := p.GetWidth(), p.GetHeight() + rowstride := p.GetRowstride() + im := image.NewGray(image.Rect(0, 0, w, h)) + + if nchan == 1 { + for ih := 0; ih < h; ih++ { + for iw := 0; iw < w; iw++ { + y := data[ih*rowstride+iw*nchan] + im.SetGray(iw, ih, color.Gray{Y: y}) + } + } + } else if nchan == 3 || nchan == 4 { + for ih := 0; ih < h; ih++ { + for iw := 0; iw < w; iw++ { + r := data[ih*rowstride+iw*nchan] + g := data[ih*rowstride+iw*nchan+1] + b := data[ih*rowstride+iw*nchan+2] + y := uint8((19595*uint32(r) + 38470*uint32(g) + 7471*uint32(b) + 1<<15) >> 16) + im.SetGray(iw, ih, color.Gray{Y: y}) + } + } + } else { + panic("unknown image depth") + } + + return im +} + +// http://www.hackerfactor.com/blog/?/archives/529-Kind-of-Like-That.html +func DHash(p *gdk.Pixbuf) Hash { + q, err := p.ScaleSimple(dhashImageWidth, dhashImageHeight, gdk.INTERP_TILES) + if err != nil { + panic(err.Error()) + } + gray := pixbufToGrayscaleImage(q) + + data := make([]byte, dhashImageWidth*dhashImageHeight) + for iy := 0; iy < dhashImageHeight; iy++ { + for ix := 0; ix < dhashImageWidth; ix++ { + data[iy*dhashImageWidth+ix] = gray.GrayAt(ix, iy).Y + } + } + + var hash Hash + + for iy := 0; iy < dhashImageHeight; iy++ { + for ix := 0; ix < dhashImageWidth-1; ix++ { + o := iy * dhashImageWidth + if data[o+ix+1] > data[o+ix] { + hash |= 1 << uint(iy*dhashImageHeight+ix) + } + } + } + + return hash +} diff --git a/jumpmarks.go b/jumpmarks.go new file mode 100644 index 0000000..2310b6f --- /dev/null +++ b/jumpmarks.go @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "sort" + + "github.com/gotk3/gotk3/gtk" + + "github.com/fauu/gomicsv/pagecache" + "github.com/fauu/gomicsv/util" +) + +type Jumpmarks struct { + list []int + cycle JumpmarksCycle +} + +type JumpmarksCycle struct { + page *int + returnPage *int + dontClear bool +} + +const ( + cycleDirectionBackward = iota + cycleDirectionForward +) + +type JumpmarkCycleDirection int + +func (jumpmarks Jumpmarks) has(page int) bool { + for _, mark := range jumpmarks.list { + // ASSUMPTION: The slice is sorted + if mark < page { + continue + } else if mark == page { + return true + } else { + break + } + } + return false +} + +func (jumpmarks Jumpmarks) size() int { + return len(jumpmarks.list) +} + +func (jumpmarks *Jumpmarks) toggle(page int) bool { + for i, mark := range jumpmarks.list { + // ASSUMPTION: The slice is sorted + if mark < page { + continue + } else if mark == page { + // Is in the marked list. Remove by swapping with the last element + jumpmarks.list[i] = jumpmarks.list[len(jumpmarks.list)-1] + jumpmarks.list = jumpmarks.list[:len(jumpmarks.list)-1] + sort.Ints(jumpmarks.list) + return false + } else { + break + } + } + // Isn't in the marked list. Add + jumpmarks.list = append(jumpmarks.list, page) + sort.Ints(jumpmarks.list) + return true +} + +func (app *App) clearJumpmarks() { + // The following isn't needed until we use this function elsewhere than during archive closing, which we currently don't + // for _, mark := range app.S.Jumpmarks.list { + // app.S.PageCache.UnforbidRemoval(mark) + // } + app.S.Jumpmarks = Jumpmarks{} +} + +var jumpmarkMenuItems []*gtk.MenuItem + +func (app *App) currentPageIsJumpmarked() bool { + return app.S.Jumpmarks.has(app.S.ArchivePos + 1) +} + +func (app *App) toggleJumpmark() { + page := app.S.ArchivePos + 1 + currentMarked := app.S.Jumpmarks.toggle(page) + + var prefix string + if currentMarked { + app.S.PageCache.Keep(app.S.ArchivePos, pagecache.KeepReasonJumpmark) + prefix = "Marked" + } else { + app.S.PageCache.DontKeep(app.S.ArchivePos, pagecache.KeepReasonJumpmark) + prefix = "Unmarked" + } + app.notificationShow(fmt.Sprintf("%s page %d", prefix, page), ShortNotification) + + app.updateJumpmarkToggleLabel(currentMarked) + app.updateJumpmarkCycleMenuItems() + app.rebuildJumpmarksMenuList() + app.updateStatus() +} + +func (app *App) cycleJumpmarks(direction JumpmarkCycleDirection) { + if app.S.Jumpmarks.size() == 0 { + return + } + nextPage := -1 + if app.S.Jumpmarks.cycle.page != nil { + nextPage = *app.S.Jumpmarks.cycle.page + } else { + curr := app.S.ArchivePos + 1 + app.S.Jumpmarks.cycle.returnPage = &curr + } + if direction == cycleDirectionForward { + nextPage++ + } else { + nextPage-- + } + if nextPage < 0 { + nextPage = app.S.Jumpmarks.size() - 1 + } else if nextPage >= app.S.Jumpmarks.size() { + nextPage = 0 + } + app.S.Jumpmarks.cycle.page = &nextPage + app.S.Jumpmarks.cycle.dontClear = true + app.setPage(app.S.Jumpmarks.list[nextPage] - 1) + app.S.Jumpmarks.cycle.dontClear = false +} + +func (app *App) returnFromCyclingJumpmarks() { + p := app.S.Jumpmarks.cycle.returnPage + if p != nil { + app.setPage(*p - 1) + } +} + +func (app *App) jumpmarksHandleSetPage(page int) { + jumpmarks := &app.S.Jumpmarks + if !jumpmarks.cycle.dontClear { + jumpmarks.cycle.page = nil + jumpmarks.cycle.returnPage = nil + } + app.updateJumpmarkToggleLabel(app.currentPageIsJumpmarked()) + app.updateJumpmarkCycleMenuItems() + + s := false + if jumpmarks.cycle.returnPage != nil && page != *jumpmarks.cycle.returnPage { + s = true + } + app.W.MenuItemJumpmarksReturnFromCycling.SetSensitive(s) +} + +func (app *App) updateJumpmarkToggleLabel(currentMarked bool) { + var w string + if currentMarked { + w = "Unmark" + } else { + w = "Mark" + } + label := fmt.Sprintf("%s current page", w) + app.W.MenuItemToggleJumpmark.SetLabel(label) +} + +func (app *App) updateJumpmarkCycleMenuItems() { + s := false + if app.S.Jumpmarks.size() > 0 { + s = true + } + app.W.MenuItemCycleJumpmarksBackward.SetSensitive(s) + app.W.MenuItemCycleJumpmarksForward.SetSensitive(s) +} + +func (app *App) rebuildJumpmarksMenuList() { + for _, item := range jumpmarkMenuItems { + app.W.MenuJumpmarks.Remove(item) + item.Destroy() + } + jumpmarkMenuItems = nil + util.GC() + + for _, mark := range app.S.Jumpmarks.list { + label := fmt.Sprintf("%d", mark) + menuItem, err := gtk.MenuItemNewWithLabel(label) + if err != nil { + app.showError(err.Error()) + return + } + page := mark - 1 // Make a new variable so that the correct value gets passed to the callback + menuItem.Connect("activate", func() { + app.setPage(page) + }) + jumpmarkMenuItems = append(jumpmarkMenuItems, menuItem) + app.W.MenuJumpmarks.Append(menuItem) + menuItem.Show() + } +} diff --git a/kamite.go b/kamite.go new file mode 100644 index 0000000..d76ccf1 --- /dev/null +++ b/kamite.go @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/gotk3/gotk3/gdk" +) + +var ( + kamiteRecognizeImageSnipScale = 0.8 + kamiteCMDEndpointBaseTpl = "http://localhost:%d/cmd/" + kamiteOCRImageEndpoint = "ocr/image" + kamiteOCRManualBlockEndpoint = "ocr/manual-block" +) + +func (app *App) kamiteRecognizeManualBlock() { + endpoint := kamiteMakeEndpointURL(app.Config.KamitePort, kamiteOCRManualBlockEndpoint) + _, err := http.PostForm(endpoint, nil) + if err != nil { + log.Printf("Error making HTTP request: %v", err) + } +} + +func (app *App) kamiteRecognizeImageUnderCursorBlock() { + // 1. Determine global pointer coordinates + pointerDevice, err := getDefaultPointerDevice() + if err != nil { + log.Panicf("getting the default pointer device: %v", err) + } + swx0, swy0, err := app.W.ScrolledWindow.Widget.TranslateCoordinates(app.W.MainWindow, 0, 0) + if err != nil { + log.Panicf("translating widget coordinates: %v", err) + } + mainWindowWindow, err := app.W.MainWindow.GetWindow() + if err != nil { + log.Panicf("getting GdkWindow of MainWindow widget: %v", err) + } + _, x, y, _ := mainWindowWindow.GetDevicePosition(pointerDevice) + + // 2. Check if cursor is over the image container + if (x < swx0 || x > swx0+app.W.ScrolledWindow.GetAllocatedWidth()) || + (y < swy0 || y > swy0+app.W.ScrolledWindow.GetAllocatedHeight()) { + return + } + + // 3. Determine over which of the images the cursor is and set the source Pixbuf accordingly + lx0, ly0, err := app.W.ImageL.Widget.TranslateCoordinates(app.W.ScrolledWindow, 0, 0) + if err != nil { + log.Panicf("translating widget coordinates: %v", err) + } + rx0, ry0, err := app.W.ImageR.Widget.TranslateCoordinates(app.W.ScrolledWindow, 0, 0) + if err != nil { + log.Panicf("translating widget coordinates: %v", err) + } + scrolledWindowWindow, err := app.W.ScrolledWindow.GetWindow() + if err != nil { + log.Panicf("getting GdkWindow of ScrolledWindow widget: %v", err) + } + _, x, y, _ = scrolledWindowWindow.GetDevicePosition(pointerDevice) + xOffset, yOffset := 0, 0 + var srcPixbuf *gdk.Pixbuf + if (x > lx0 && x < lx0+app.W.ImageL.GetAllocatedWidth()) && + (y > ly0 && y < ly0+app.W.ImageL.GetAllocatedHeight()) { + srcPixbuf = app.W.ImageL.GetPixbuf() + xOffset = lx0 + yOffset = ly0 + } else if (x > rx0 && x < rx0+app.W.ImageR.GetAllocatedWidth()) && + (y > ry0 && y < ry0+app.W.ImageR.GetAllocatedHeight()) { + srcPixbuf = app.W.ImageR.GetPixbuf() + xOffset = rx0 + yOffset = ry0 + } else { + return + } + targetX, targetY := x-xOffset, y-yOffset // Relative to the source Pixbuf + + srcW, srcH := srcPixbuf.GetWidth(), srcPixbuf.GetHeight() + srcNChannels := srcPixbuf.GetNChannels() + srcPixels := srcPixbuf.GetPixels() + srcRowstride := srcPixbuf.GetRowstride() + + // 4. Grab area around the cursor + snipDim := app.kamiteSnipDimension() + snipSourceX0, snipSourceY0 := targetX-(snipDim/2), targetY-(snipDim/2) + snipBytes := make([]int, snipDim*snipDim) + for y := 0; y < snipDim; y++ { + for x := 0; x < snipDim; x++ { + srcX, srcY := snipSourceX0+x, snipSourceY0+y + var r, g, b byte + if srcX < 0 || srcY < 0 || srcX >= srcW || srcY >= srcH { + // Beyond source Pixbuf bounds + r, g, b = 255, 255, 255 + } else { + idx := srcY*srcRowstride + srcX*srcNChannels + r, g, b = srcPixels[idx], srcPixels[idx+1], srcPixels[idx+2] + } + snipBytes[y*snipDim+x] = int(r)<<16 | int(g)<<8 | int(b) + } + } + + // 5. Send + go kamiteSendOCRImageCommand( + app.Config.KamitePort, + snipBytes, + snipDim, + snipDim, + ) +} + +func (app *App) kamiteSnipDimension() int { + w, _ := app.getImageAreaInnerSize() + return int(kamiteRecognizeImageSnipScale * float64(w)) +} + +type KamiteOCRImageCommandParams struct { + Pixels string `json:"pixels"` + Width int `json:"width"` + Height int `json:"height"` +} + +func kamiteSendOCRImageCommand(port int, imgBytes []int, w, h int) { + stringBytes := []string{} + for i := range imgBytes { + s := strconv.Itoa(imgBytes[i]) + stringBytes = append(stringBytes, s) + } + bytesString := strings.Join(stringBytes, ",") + + paramsJSON, err := json.Marshal(KamiteOCRImageCommandParams{ + Pixels: bytesString, + Width: w, + Height: h, + }) + if err != nil { + log.Printf("Error encoding Kamite command params: %v", err) + return + } + + _, err = http.PostForm( + kamiteMakeEndpointURL(port, kamiteOCRImageEndpoint), + url.Values{"params": {string(paramsJSON)}}, + ) + if err != nil { + log.Printf("Error making HTTP request: %v", err) + } +} + +func kamiteMakeEndpointURL(port int, suffix string) string { + // TODO(fau): Go 1.19+ url.JoinPath() + return fmt.Sprintf(kamiteCMDEndpointBaseTpl, port) + suffix +} diff --git a/md5.go b/md5.go new file mode 100644 index 0000000..af97e88 --- /dev/null +++ b/md5.go @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "crypto/md5" + "encoding/hex" +) + +func md5String(s string) string { + hash := md5.Sum([]byte(s)) + return hex.EncodeToString(hash[:]) +} diff --git a/menu.go b/menu.go new file mode 100644 index 0000000..3bdb431 --- /dev/null +++ b/menu.go @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "log" + "net/url" + "runtime" + "strings" + + "github.com/fauu/gomicsv/pixbuf" + "github.com/flytam/filenamify" + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" +) + +func (app *App) menuInit() { + app.menuInitOpenDialog() + app.menuInitOpenURLDialog() + app.menuInitSaveImageDialog() + + app.W.MenuItemQuit.Connect("activate", app.quit) + app.W.MenuItemClose.Connect("activate", app.archiveClose) + app.W.MenuItemNextPage.Connect("activate", app.nextPage) + app.W.MenuItemPreviousPage.Connect("activate", app.previousPage) + app.W.MenuItemFirstPage.Connect("activate", app.firstPage) + app.W.MenuItemLastPage.Connect("activate", app.lastPage) + app.W.MenuItemNextArchive.Connect("activate", app.nextArchive) + app.W.MenuItemPreviousArchive.Connect("activate", app.previousArchive) + app.W.MenuItemSkipForward.Connect("activate", app.skipForward) + app.W.MenuItemSkipBackward.Connect("activate", app.skipBackward) + + app.W.MenuItemEnlarge.Connect("toggled", func() { + app.setEnlarge(app.W.MenuItemEnlarge.GetActive()) + }) + + app.W.MenuItemShrink.Connect("toggled", func() { + app.setShrink(app.W.MenuItemShrink.GetActive()) + }) + + app.W.MenuItemFullscreen.Connect("toggled", func() { + app.setFullscreen(app.W.MenuItemFullscreen.GetActive()) + }) + + app.W.MenuItemHideUI.Connect("toggled", func() { + app.setHideUI(app.W.MenuItemHideUI.GetActive()) + }) + + app.W.MenuItemSeamless.Connect("toggled", func() { + app.setSeamless(app.W.MenuItemSeamless.GetActive()) + }) + + app.W.MenuItemRandom.Connect("toggled", func() { + app.setRandom(app.W.MenuItemRandom.GetActive()) + }) + + app.W.MenuItemHFlip.Connect("toggled", func() { + app.setHFlip(app.W.MenuItemHFlip.GetActive()) + }) + + app.W.MenuItemVFlip.Connect("toggled", func() { + app.setVFlip(app.W.MenuItemVFlip.GetActive()) + }) + + app.W.MenuItemMangaMode.Connect("toggled", func() { + app.setMangaMode(app.W.MenuItemMangaMode.GetActive()) + }) + + app.W.MenuItemDoublePage.Connect("toggled", func() { + app.setDoublePage(app.W.MenuItemDoublePage.GetActive()) + }) + + app.W.MenuItemOriginal.Connect("toggled", func() { + if app.W.MenuItemOriginal.GetActive() { + app.setZoomMode("Original") + } + }) + + app.W.MenuItemBestFit.Connect("toggled", func() { + if app.W.MenuItemBestFit.GetActive() { + app.setZoomMode("BestFit") + } + }) + + app.W.MenuItemFitToWidth.Connect("toggled", func() { + if app.W.MenuItemFitToWidth.GetActive() { + app.setZoomMode("FitToWidth") + } + }) + + app.W.MenuItemFitToHeight.Connect("toggled", func() { + if app.W.MenuItemFitToHeight.GetActive() { + app.setZoomMode("FitToHeight") + } + }) + + app.W.MenuItemCopyImageToClipboard.Connect("activate", func() { + app.copyImageToClipboard() + }) + + app.W.MenuItemAddBookmark.Connect("activate", app.addBookmark) + + app.W.MenuItemToggleJumpmark.Connect("activate", app.toggleJumpmark) + + app.W.MenuItemCycleJumpmarksBackward.Connect("activate", func() { + app.cycleJumpmarks(cycleDirectionBackward) + }) + + app.W.MenuItemCycleJumpmarksForward.Connect("activate", func() { + app.cycleJumpmarks(cycleDirectionForward) + }) + + app.W.MenuItemJumpmarksReturnFromCycling.Connect("activate", app.returnFromCyclingJumpmarks) + + app.W.MenuItemPreferences.Connect("activate", app.preferencesDialogRun) + + app.W.MenuItemAbout.Connect("activate", func() { + app.S.Cursor.ForceVisible = true + app.W.AboutDialog.Run() + app.W.AboutDialog.Hide() + app.S.Cursor.ForceVisible = false + }) + + app.W.AboutDialog.SetLogo(pixbuf.MustLoad(aboutImg)) + if len(app.S.BuildInfo.Version) >= 0 { + versionStr := fmt.Sprintf("Version: %s (built: %s)\nCompiler version: %s", app.S.BuildInfo.Version, app.S.BuildInfo.Date, runtime.Version()) + app.W.AboutDialog.SetVersion(versionStr) + } + + app.menuSetupAccels() + + app.rebuildBookmarksMenu() + + app.goToDialogInit() + + app.W.MenuItemGoTo.Connect("activate", app.goToDialogRun) + + app.W.RecentChooserMenu.Connect("item-activated", func() { + uri := app.W.RecentChooserMenu.GetCurrentUri() + u, err := url.Parse(uri) + if err != nil { + app.showError(err.Error()) + return + } + app.loadArchiveFromPath(u.Path) + }) +} + +var FILE_CHOOSER_RESPONSE_ACCEPT gtk.ResponseType = 100 + +func (app *App) menuInitOpenDialog() { + app.W.MenuItemOpen.Connect("activate", func() { + res := gtk.ResponseType(app.W.ArchiveFileChooserDialog.Run()) + app.W.ArchiveFileChooserDialog.Hide() + if res == FILE_CHOOSER_RESPONSE_ACCEPT { + filename := app.W.ArchiveFileChooserDialog.GetFilename() + if filename == "" { + var err error + filename, err = app.W.ArchiveFileChooserDialog.GetCurrentFolder() + if err != nil { + log.Println("Error getting FileChooser CurrentFolder") + } + } + if filename != "" { + app.loadArchiveFromPath(filename) + } + } + }) + + _, err := app.W.ArchiveFileChooserDialog.AddButton("_Open", FILE_CHOOSER_RESPONSE_ACCEPT) + checkDialogAddButtonErr(err) + _, err = app.W.ArchiveFileChooserDialog.AddButton("_Cancel", gtk.RESPONSE_CANCEL) + checkDialogAddButtonErr(err) +} + +func (app *App) menuInitOpenURLDialog() { + app.W.MenuItemOpenURL.Connect("activate", func() { + res := gtk.ResponseType(app.W.OpenURLDialog.Run()) + app.W.OpenURLDialog.Hide() + if res == gtk.RESPONSE_ACCEPT { + url, err := app.W.OpenURLDialogURLEntry.GetText() + if err != nil { + log.Panicf("getting Open URL Dialog URL Entry text: %v", err) + } + referer, err := app.W.OpenURLDialogRefererEntry.GetText() + if err != nil { + log.Panicf("getting Open URL Dialog Referer Entry text: %v", err) + } + app.loadArchiveFromURL(url, referer) + } + }) + + app.W.OpenURLDialogExplanationLabel.SetMarkup( + "Specify the direct URL of the image of one of the comic pages, for example http://my-source/comic-1234/4.png." + + " The URL must be in such format, that successive pages can be accessed by inserting their respective numbers into the URL." + + " Otherwise, the particular source is not currently supported.\n" + + " If the program fails to" + + " guess the pattern followed by the page URLs, try again, but this time manually specifying where the page number" + + " is in the URL, by replacing it with the placeholder %d. For example, if the URL for page 7 is" + + " http://my-source/comic-1234?page=7&full=true, specify http://my-source/comic-1234?page=%d&full=true" + + " above. If the number needs to be padded with zeroes, you can specify its width, for example %03d for (001, 002, …).\n" + + " Note that certain hosts might use various access restriction measures that could make this program unable to access" + + " the images, even if they’re accessible when viewed directly on the host’s website. Below are extra options that might" + + " be useful in this connection.", + ) + + _, err := app.W.OpenURLDialog.AddButton("_Cancel", gtk.RESPONSE_CANCEL) + checkDialogAddButtonErr(err) + okButton, err := app.W.OpenURLDialog.AddButton("_Open", gtk.RESPONSE_ACCEPT) + checkDialogAddButtonErr(err) + + app.W.OpenURLDialog.SetDefault(okButton) +} + +func (app *App) menuInitSaveImageDialog() { + app.W.MenuItemSaveImage.Connect("activate", func() { + baseName, err := filenamify.FilenamifyV2(app.archiveGetBaseName()) + baseName = strings.ReplaceAll(baseName, ".", "!") + if err != nil { + log.Panicf("filenamifying archive base name: %v", err) + } + filename := fmt.Sprintf("%s-%000d.png", baseName, app.S.ArchivePos+1) + app.W.SaveImageFileChooserDialog.SetCurrentName(filename) + + res := gtk.ResponseType(app.W.SaveImageFileChooserDialog.Run()) + app.W.SaveImageFileChooserDialog.Hide() + if res == gtk.RESPONSE_ACCEPT { + filename := app.W.SaveImageFileChooserDialog.GetFilename() + if filename != "" { + app.saveImage(filename) + } + } + }) + + _, err := app.W.SaveImageFileChooserDialog.AddButton("_Save", gtk.RESPONSE_ACCEPT) + checkDialogAddButtonErr(err) + _, err = app.W.SaveImageFileChooserDialog.AddButton("_Cancel", gtk.RESPONSE_CANCEL) + checkDialogAddButtonErr(err) +} + +func (app *App) menuSetupAccels() { + // NOTE: This can't be done in the glade file using the tag under the respective + // menu items, because then the bindings stop working when the menubar is hidden. + // Unfortunately, only primary keybindings can be set here. Auxilliary ones are bound under + // the MainWindow key-press-event signal handler. + accels := []MenuWithAccels{ + { + Menu: app.W.MenuFile, + Path: menuMakeAccelPath("File"), + Items: []MenuItemWithAccels{ + {app.W.MenuItemOpen, Accel{gdk.KEY_O, gdk.CONTROL_MASK}}, + {app.W.MenuItemOpenURL, Accel{gdk.KEY_O, gdk.CONTROL_MASK | gdk.SHIFT_MASK}}, + {app.W.MenuItemClose, Accel{gdk.KEY_W, gdk.CONTROL_MASK}}, + {app.W.MenuItemSaveImage, Accel{gdk.KEY_F9, 0}}, + {app.W.MenuItemQuit, Accel{gdk.KEY_Q, gdk.CONTROL_MASK}}, + }, + }, + { + Menu: app.W.MenuEdit, + Path: menuMakeAccelPath("Edit"), + Items: []MenuItemWithAccels{ + {app.W.MenuItemCopyImageToClipboard, Accel{gdk.KEY_C, gdk.CONTROL_MASK}}, + {app.W.MenuItemPreferences, Accel{gdk.KEY_P, gdk.CONTROL_MASK}}, + }, + }, + { + Menu: app.W.MenuView, + Path: menuMakeAccelPath("View"), + Items: []MenuItemWithAccels{ + {&app.W.MenuItemHideUI.MenuItem, Accel{gdk.KEY_M, gdk.MOD1_MASK}}, + {&app.W.MenuItemShrink.MenuItem, Accel{gdk.KEY_S, 0}}, + {&app.W.MenuItemEnlarge.MenuItem, Accel{gdk.KEY_E, 0}}, + {&app.W.MenuItemBestFit.MenuItem, Accel{gdk.KEY_B, 0}}, + {&app.W.MenuItemOriginal.MenuItem, Accel{gdk.KEY_O, 0}}, + {&app.W.MenuItemFitToWidth.MenuItem, Accel{gdk.KEY_W, 0}}, + {&app.W.MenuItemFitToHeight.MenuItem, Accel{gdk.KEY_H, 0}}, + {&app.W.MenuItemFullscreen.MenuItem, Accel{gdk.KEY_F, 0}}, + {&app.W.MenuItemRandom.MenuItem, Accel{gdk.KEY_R, 0}}, + {&app.W.MenuItemDoublePage.MenuItem, Accel{gdk.KEY_D, 0}}, + {&app.W.MenuItemVFlip.MenuItem, Accel{gdk.KEY_V, 0}}, + {&app.W.MenuItemHFlip.MenuItem, Accel{gdk.KEY_V, gdk.SHIFT_MASK}}, + {&app.W.MenuItemMangaMode.MenuItem, Accel{gdk.KEY_M, gdk.CONTROL_MASK}}, + }, + }, + { + Menu: app.W.MenuNavigation, + Path: menuMakeAccelPath("Navigation"), + Items: []MenuItemWithAccels{ + {app.W.MenuItemPreviousPage, Accel{gdk.KEY_Page_Up, 0}}, + {app.W.MenuItemNextPage, Accel{gdk.KEY_Page_Down, 0}}, + {app.W.MenuItemFirstPage, Accel{gdk.KEY_Home, 0}}, + {app.W.MenuItemLastPage, Accel{gdk.KEY_End, 0}}, + {app.W.MenuItemPreviousArchive, Accel{gdk.KEY_Page_Up, gdk.CONTROL_MASK}}, + {app.W.MenuItemNextArchive, Accel{gdk.KEY_Page_Down, gdk.CONTROL_MASK}}, + {app.W.MenuItemGoTo, Accel{gdk.KEY_G, 0}}, + }, + }, + { + Menu: app.W.MenuBookmarks, + Path: menuMakeAccelPath("Bookmarks"), + Items: []MenuItemWithAccels{ + {app.W.MenuItemAddBookmark, Accel{gdk.KEY_B, gdk.CONTROL_MASK}}, + }, + }, + { + Menu: app.W.MenuJumpmarks, + Path: menuMakeAccelPath("Jumpmarks"), + Items: []MenuItemWithAccels{ + {app.W.MenuItemToggleJumpmark, Accel{gdk.KEY_M, 0}}, + {app.W.MenuItemCycleJumpmarksBackward, Accel{gdk.KEY_bracketleft, 0}}, + {app.W.MenuItemCycleJumpmarksForward, Accel{gdk.KEY_bracketright, 0}}, + {app.W.MenuItemJumpmarksReturnFromCycling, Accel{gdk.KEY_BackSpace, 0}}, + }, + }, + { + Menu: app.W.MenuAbout, + Path: menuMakeAccelPath("About"), + Items: []MenuItemWithAccels{ + {app.W.MenuItemAbout, Accel{gdk.KEY_F1, 0}}, + }, + }, + } + + err := setupMenuAccels(app.W.MainWindow, accels) + if err != nil { + log.Panicf("setting up menu accels: %v", err) + } +} + +func menuMakeAccelPath(menuCategory string) string { + return fmt.Sprintf("/%s", menuCategory) +} diff --git a/natsort/sort.go b/natsort/sort.go new file mode 100644 index 0000000..ac3c962 --- /dev/null +++ b/natsort/sort.go @@ -0,0 +1,136 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package natsort provides an implementation of the natural sorting algorithm. +// See http://blog.codinghorror.com/sorting-for-humans-natural-sort-order/. +package natsort + +import ( + "errors" + "sort" +) + +// Strings sorts a slice of strings with Less. +func Strings(s []string) { + sort.Sort(stringSlice(s)) +} + +// Less reports whether s is less than t. +func Less(s, t string) bool { + return LessRunes([]rune(s), []rune(t)) +} + +// LessRunes reports whether s is less than t. +func LessRunes(s, t []rune) bool { + nprefix := commonPrefix(s, t) + if len(s) == nprefix && len(t) == nprefix { + // equal + return false + } + sEnd := leadDigits(s[nprefix:]) + nprefix + tEnd := leadDigits(t[nprefix:]) + nprefix + if sEnd > nprefix || tEnd > nprefix { + start := trailDigits(s[:nprefix]) + if sEnd-start > 0 && tEnd-start > 0 { + // TODO(light): log errors? + sn := atoi(s[start:sEnd]) + tn := atoi(t[start:tEnd]) + if sn != tn { + return sn < tn + } + } + } + switch { + case len(s) == nprefix: + return true + case len(t) == nprefix: + return false + default: + return s[nprefix] < t[nprefix] + } +} + +func isDigit(r rune) bool { + return '0' <= r && r <= '9' +} + +func atoi(r []rune) uint64 { + if len(r) < 1 { + panic(errors.New("atoi got an empty slice")) + } + const cutoff = uint64((1<<64-1)/10 + 1) + const maxVal = 1<<64 - 1 + + var n uint64 + for _, d := range r { + v := uint64(d - '0') + if n >= cutoff { + return 1<<64 - 1 + } + n *= 10 + n1 := n + v + if n1 < n || n1 > maxVal { + // n+v overflows + return 1<<64 - 1 + } + n = n1 + } + return n +} + +func commonPrefix(s, t []rune) int { + for i := range s { + if i >= len(t) { + return len(t) + } + if s[i] != t[i] { + return i + } + } + return len(s) +} + +func trailDigits(r []rune) int { + for i := len(r) - 1; i >= 0; i-- { + if !isDigit(r[i]) { + return i + 1 + } + } + return 0 +} + +func leadDigits(r []rune) int { + for i := range r { + if !isDigit(r[i]) { + return i + } + } + return len(r) +} + +type stringSlice []string + +func (ss stringSlice) Len() int { + return len(ss) +} + +func (ss stringSlice) Less(i, j int) bool { + return Less(ss[i], ss[j]) +} + +func (ss stringSlice) Swap(i, j int) { + ss[i], ss[j] = ss[j], ss[i] +} diff --git a/natsort/sort_test.go b/natsort/sort_test.go new file mode 100644 index 0000000..df024e4 --- /dev/null +++ b/natsort/sort_test.go @@ -0,0 +1,94 @@ +/* + * Copyright 2014 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package natsort + +import "testing" + +func TestLess(t *testing.T) { + tests := []struct { + s, t string + want cmp + }{ + {"", "", eq}, + {"a", "", gt}, + {"a", "a", eq}, + {"1", "10", lt}, + {"20", "3", gt}, + {"a1", "a10", lt}, + {"a2", "a10", lt}, + {"a5b2", "a5b7", lt}, + {"a50b2", "a6b7", gt}, + {"世20", "世界3", lt}, + {"50a", "50b", lt}, + {"a50", "a050", gt}, + {"a01b3", "a1b2", lt}, + {"thx1138", "thx1138", eq}, + {"thx1138a", "thx1138b", lt}, + {"thx1138a", "thx1138", gt}, + + // a < a0 < a1 < a1a < a1b < a2 < a10 < a20 + {"a", "a0", lt}, + {"a0", "a1", lt}, + {"a1", "a1a", lt}, + {"a1a", "a1b", lt}, + {"a2", "a10", lt}, + {"a10", "a20", lt}, + + // 1.001 < 1.002 < 1.010 < 1.02 < 1.1 < 1.3 + {"1.001", "1.002", lt}, + {"1.002", "1.010", lt}, + {"1.010", "1.02", gt}, // TODO(light): should this be lt? + {"1.02", "1.1", gt}, // TODO(light): should this be lt? + {"1.1", "1.3", lt}, + } + for _, test := range tests { + v := bit(Less(test.s, test.t)) + v |= bit(Less(test.t, test.s)) << 1 + if cmp(v) != test.want { + t.Errorf("%[1]q %[3]v %[2]q, want %[1]q %[4]v %[2]q", test.s, test.t, cmp(v), test.want) + } + } +} + +func bit(b bool) int { + if b { + return 1 + } else { + return 0 + } +} + +type cmp int + +const ( + eq cmp = iota + lt + gt +) + +func (c cmp) String() string { + switch c { + case lt: + return "<" + case gt: + return ">" + case eq: + return "==" + default: + return "<>" + } +} diff --git a/navigation.go b/navigation.go new file mode 100644 index 0000000..f932b2e --- /dev/null +++ b/navigation.go @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "errors" + "log" + "math/rand" + "os" + "path/filepath" + + "github.com/fauu/gomicsv/archive" + "github.com/fauu/gomicsv/imgdiff" +) + +func (app *App) randomPage() { + if !app.archiveIsLoaded() { + return + } + if app.S.Archive.Len() == nil { + return + } + + app.setPage(rand.Int() % *app.S.Archive.Len()) +} + +func (app *App) previousPage() { + if !app.archiveIsLoaded() { + if app.Config.Seamless { + app.previousArchive() + } + return + } + + if app.Config.Random && app.S.Archive.Len() != nil { + app.randomPage() + return + } + + n := 1 + if app.Config.DoublePage && app.S.ArchivePos > 1 { + n = 2 + } + + if app.Config.Seamless && app.S.ArchivePos+1 <= n { + app.previousArchive() + return + } + + app.setPage(app.S.ArchivePos - n) + + if app.Config.DoublePage && + app.shouldForceSinglePage() && + app.S.Archive.Len() != nil && + *app.S.Archive.Len()-app.S.ArchivePos > 1 { + + app.nextPage() + } +} + +func (app *App) nextPage() { + if !app.archiveIsLoaded() { + if app.Config.Seamless { + app.nextArchive() + } + return + } + + if app.Config.Random && app.S.Archive.Len() != nil { + app.randomPage() + return + } + + n := 1 + if app.Config.DoublePage && + !app.shouldForceSinglePage() && + app.S.Archive.Len() != nil && + *app.S.Archive.Len() > app.S.ArchivePos+2 { + + n = 2 + } + + if app.Config.Seamless && + app.S.Archive.Len() != nil && + *app.S.Archive.Len()-app.S.ArchivePos <= n { + + app.nextArchive() + return + } + + app.setPage(app.S.ArchivePos + n) +} + +func (app *App) firstPage() { + if !app.archiveIsLoaded() { + return + } + app.setPage(0) +} + +func (app *App) lastPage() { + if !app.archiveIsLoaded() { + return + } + + if app.S.Archive.Len() == nil { + return + } + + offset := -1 + if app.Config.DoublePage && *app.S.Archive.Len() >= 2 { + offset = -2 + } + app.setPage(*app.S.Archive.Len() + offset) +} + +func (app *App) imageHash(n int) (imgdiff.Hash, bool) { + if hash, ok := app.S.ImageHashes[n]; ok { + return hash, true + } + + pixbuf, err := app.S.Archive.Load(n, app.Config.EmbeddedOrientation, 0) + if err != nil { + app.showError(err.Error()) + return 0, false + } + + return imgdiff.DHash(pixbuf), true +} + +func (app *App) skipForward() { + app.setPage(app.S.ArchivePos + app.Config.NSkip) +} + +func (app *App) skipBackward() { + app.setPage(app.S.ArchivePos - app.Config.NSkip) +} + +func (app *App) nextScene() { + if !app.archiveIsLoaded() { + return + } + + if app.S.Archive.Len() == nil { + return + } + + if app.S.PixbufL == nil { + return + } + hash := imgdiff.DHash(app.S.PixbufL) + + dn := app.Config.SceneScanSkip + if *app.S.Archive.Len()-1-app.S.ArchivePos <= dn { + dn = 1 + } + + for n := app.S.ArchivePos + 1; n < *app.S.Archive.Len(); n += dn { + h, ok := app.imageHash(n) + if !ok { + return + } + distance := float32(imgdiff.Distance(hash, h)) / 64 + + if distance > app.Config.ImageDiffThres { + if dn == 1 || n == app.S.ArchivePos+1 { + app.doSetPage(n) + return + } + + // Did we go too fast? + for l := n - 1; l >= app.S.ArchivePos+1; l-- { + h, ok := app.imageHash(l) + if !ok { + return + } + d := float32(imgdiff.Distance(hash, h)) / 64 + if d <= app.Config.ImageDiffThres { + app.doSetPage(l + 1) + return + } + } + return + } + } +} + +func (app *App) previousScene() { + if !app.archiveIsLoaded() { + return + } + + if app.S.PixbufL == nil { + return + } + hash := imgdiff.DHash(app.S.PixbufL) + + dn := app.Config.SceneScanSkip + if app.S.ArchivePos <= dn { + dn = 1 + } + + for n := app.S.ArchivePos - 1; n >= 0; n -= dn { + h, ok := app.imageHash(n) + if !ok { + return + } + distance := float32(imgdiff.Distance(hash, h)) / 64 + + if distance > app.Config.ImageDiffThres { + if dn == 1 || n == app.S.ArchivePos-1 { + app.doSetPage(n) + return + } + + // Did we go too fast? + for l := n + 1; l <= app.S.ArchivePos-1; l++ { + h, ok := app.imageHash(l) + if !ok { + return + } + d := float32(imgdiff.Distance(hash, h)) / 64 + if d <= app.Config.ImageDiffThres { + app.doSetPage(l - 1) + return + } + } + return + } + } +} + +// TODO(fau): Distinguish a failiure from the "no next archive" condition and inform the user accordingly +func (app *App) nextArchive() bool { + newName, err := app.archiveNameRelativeToCurrent(1) + if err != nil { + log.Printf("Error getting next archive: %v", err) + return false + } + + app.loadArchiveFromPath(newName) + return true +} + +// TODO(fau): Distinguish a failiure from the "no previous archive" condition and inform the user accordingly +func (app *App) previousArchive() bool { + newName, err := app.archiveNameRelativeToCurrent(-1) + if err != nil { + log.Printf("Error getting previous archive: %v", err) + return false + } + + app.loadArchiveFromPath(newName) + app.lastPage() + return true +} + +// currentArchiveIdx determines the index of the current archive in the directory. We need to do +// this every time, since the filesystem is mutable +func (app *App) currentArchiveIdx() (idx int, err error) { + dir, name := filepath.Split(app.S.ArchivePath) + if dir == "" { + dir, err = os.Getwd() + if err != nil { + return + } + } + arNames, err := archive.ListInDirectory(dir) + if err != nil { + return + } + + idx = -1 + for i := 0; i < len(arNames); i++ { + if arNames[i] == name { + idx = i + } + } + if idx == -1 { + return 0, errors.New("Couldn't find the current archive in the current dir. Deleted, perhaps?") + } + return +} + +// archiveNameRelativeToCurrent gets the name of the archive in the current directory whose +// relative position with regards to the current archive is equal to relIdx +// TODO(utkan): Use inotify to avoid obtaining list from the scratch all the time +func (app *App) archiveNameRelativeToCurrent(relIdx int) (newName string, err error) { + dir, _ := filepath.Split(app.S.ArchivePath) + if dir == "" { + dir, err = os.Getwd() + if err != nil { + return + } + } + arNames, err := archive.ListInDirectory(dir) + if err != nil { + return + } + + currIdx, err := app.currentArchiveIdx() + if err != nil { + return "", nil + } + + idx := currIdx + relIdx + if idx < 0 || idx >= len(arNames) { + err = errors.New("No more archives in the directory") + return + } + + newName = filepath.Join(dir, arNames[idx]) + return +} diff --git a/notification.go b/notification.go new file mode 100644 index 0000000..573d75e --- /dev/null +++ b/notification.go @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import "github.com/gotk3/gotk3/glib" + +var ( + notificationCloseAfterS = map[NotificationLength]uint{ + ShortNotification: 2, + LongNotification: 8, + } + notificationCloseSourceHandle *glib.SourceHandle +) + +type NotificationLength = int + +const ( + ShortNotification = iota + LongNotification +) + +func (app *App) notificationInit() { + app.W.NotificationCloseButton.Connect("clicked", func() { + app.W.NotificationRevealer.SetRevealChild(false) + }) +} + +func (app *App) notificationShow(text string, length NotificationLength) { + app.W.NotificationLabel.SetText(text) + app.W.NotificationRevealer.SetRevealChild(true) + + // Cancel previous close timeout + if notificationCloseSourceHandle != nil { + glib.SourceRemove(*notificationCloseSourceHandle) + } + + // Set a timeout to automatically close the notificaiton + handle := glib.TimeoutSecondsAdd(notificationCloseAfterS[length], func() { + app.notificationHide() + }) + notificationCloseSourceHandle = &handle +} + +func (app *App) notificationHide() { + app.W.NotificationRevealer.SetRevealChild(false) + notificationCloseSourceHandle = nil +} diff --git a/page.go b/page.go new file mode 100644 index 0000000..ce0c4c8 --- /dev/null +++ b/page.go @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/fauu/gomicsv/pagecache" + "github.com/fauu/gomicsv/util" + "github.com/gotk3/gotk3/glib" +) + +const ( + pageCacheTrimInterval = 3 * time.Minute + preloadedPageKeepAtLeastFor = 7 * time.Minute +) + +func (app *App) setPage(n int) { + if !app.archiveIsLoaded() { + return + } + + if n < 0 { + n = 0 + } + + if app.S.Archive.Len() != nil && n >= *app.S.Archive.Len() { + n = *app.S.Archive.Len() - 1 + } + + if n == app.S.ArchivePos { + return + } + + isPrev := false + if n == app.S.ArchivePos-1 || (app.Config.DoublePage && n == app.S.ArchivePos-2) { + isPrev = true + } + + app.doSetPage(n) + + var scrollFunc func() + if isPrev { + scrollFunc = app.scrollToEnd + } else { + scrollFunc = app.scrollToStart + } + glib.TimeoutAdd(0, scrollFunc) +} + +func (app *App) doSetPage(n int) { + if !app.archiveIsLoaded() { + return + } + + app.jumpmarksHandleSetPage(n - 1) + + var err error + app.S.PixbufL, err = app.S.Archive.Load(n, app.Config.EmbeddedOrientation, app.Config.NPreload) + if err != nil { + return + } + + app.S.ArchivePos = n + + app.S.PixbufR = nil + if app.Config.DoublePage && (app.S.Archive.Len() == nil || *app.S.Archive.Len() > n+1) { + app.S.PixbufR, err = app.S.Archive.Load(n+1, app.Config.EmbeddedOrientation, app.Config.NPreload) + if err != nil { + app.showError(err.Error()) + return + } + } + + util.GC() + + app.blit() + app.updateStatus() +} + +func (app *App) trimPageCache() { + if app.S.PageCache == nil { + return + } + + // Mark pages as no longer needed for preload unless they're recently fetched or near the current page + var preloadDontKeep []int + nearKeepStart := app.S.ArchivePos - app.Config.NPreload + nearKeepEnd := app.S.ArchivePos + app.Config.NPreload + for i, entry := range app.S.PageCache.Pages { + if time.Since(entry.Time) > preloadedPageKeepAtLeastFor && (i < nearKeepStart || i > nearKeepEnd) { + preloadDontKeep = append(preloadDontKeep, i) + } + } + if len(preloadDontKeep) > 0 { + app.S.PageCache.DontKeepSlice(preloadDontKeep, pagecache.KeepReasonPreload) + } + + // Trim unneeded pages + app.S.PageCache.Trim() + + // Log cache status + var indices []int + for i := range app.S.PageCache.Pages { + indices = append(indices, i+1) + } + sort.Ints(indices) + var strIndices []string + for _, index := range indices { + strIndices = append(strIndices, fmt.Sprint(index)) + } + + var remainingStr string + if len(strIndices) == 0 { + remainingStr = "-" + } else { + remainingStr = strings.Join(strIndices, ", ") + } + log.Printf("Trimmed page cache. Remaining pages: %s", remainingStr) +} diff --git a/pagecache/page_cache.go b/pagecache/page_cache.go new file mode 100644 index 0000000..3c9c5fb --- /dev/null +++ b/pagecache/page_cache.go @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package pagecache + +import ( + "sync" + "time" + + "github.com/fauu/gomicsv/util" + "github.com/gotk3/gotk3/gdk" +) + +type KeepReason uint8 + +const ( + KeepReasonPreload KeepReason = 1 << iota + KeepReasonJumpmark +) + +type PageCache struct { + Pages map[int]CachedPage + pagesMutex sync.Mutex + keep map[int]KeepReason + keepMutex sync.Mutex +} + +func NewPageCache() PageCache { + return PageCache{ + make(map[int]CachedPage), + sync.Mutex{}, + make(map[int]KeepReason), + sync.Mutex{}, + } +} + +type CachedPage struct { + Pixbuf *gdk.Pixbuf + Time time.Time +} + +func NewCachedPage(pixbuf *gdk.Pixbuf) CachedPage { + return CachedPage{pixbuf, time.Now()} +} + +func (cache *PageCache) Get(i int) (*CachedPage, bool) { + cache.pagesMutex.Lock() + defer cache.pagesMutex.Unlock() + if page, ok := cache.Pages[i]; ok { + return &page, true + } else { + return nil, false + } +} + +func (cache *PageCache) Insert(i int, pixbuf *gdk.Pixbuf, keepReason KeepReason) { + cache.set(i, pixbuf) + cache.Keep(i, keepReason) +} + +func (cache *PageCache) Keep(i int, reason KeepReason) { + cache.keepMutex.Lock() + defer cache.keepMutex.Unlock() + cache.keep[i] |= reason +} + +func (cache *PageCache) DontKeep(i int, reason KeepReason) { + cache.keepMutex.Lock() + defer cache.keepMutex.Unlock() + cache.doDontKeep(i, reason) +} + +func (cache *PageCache) DontKeepSlice(indices []int, reason KeepReason) { + cache.keepMutex.Lock() + defer cache.keepMutex.Unlock() + for _, i := range indices { + cache.doDontKeep(i, reason) + } +} + +func (cache *PageCache) doDontKeep(i int, reason KeepReason) { + keep, ok := cache.keep[i] + if !ok { + return + } + keep &^= reason + if keep == 0 { + delete(cache.keep, i) + } else { + cache.keep[i] = keep + } +} + +func (cache *PageCache) Trim() { + var toRemove []int + for i := range cache.Pages { + if cache.keep[i] == 0 { + toRemove = append(toRemove, i) + } + } + if len(toRemove) > 0 { + cache.remove(toRemove) + } +} + +func (cache *PageCache) set(i int, pixbuf *gdk.Pixbuf) { + cache.pagesMutex.Lock() + defer cache.pagesMutex.Unlock() + if pixbuf == nil { + delete(cache.Pages, i) + } else { + cache.Pages[i] = NewCachedPage(pixbuf) + } +} + +func (cache *PageCache) remove(indices []int) { + cache.pagesMutex.Lock() + defer cache.pagesMutex.Unlock() + for _, i := range indices { + delete(cache.Pages, i) + } + util.GC() +} diff --git a/pixbuf/pixbuf.go b/pixbuf/pixbuf.go new file mode 100644 index 0000000..c19bb38 --- /dev/null +++ b/pixbuf/pixbuf.go @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package pixbuf + +import ( + "bytes" + "io" + "log" + + "github.com/gotk3/gotk3/gdk" +) + +func Load(r io.Reader, autorotate bool) (*gdk.Pixbuf, error) { + w, _ := gdk.PixbufLoaderNew() + defer w.Close() + _, err := io.Copy(w, r) + if err != nil { + return nil, err + } + + pixbuf, err := w.GetPixbuf() + if err != nil { + return nil, err + } + + if !autorotate { + return pixbuf, nil + } + + return pixbuf.ApplyEmbeddedOrientation() +} + +func MustLoad(data []byte) *gdk.Pixbuf { + pixbuf, err := Load(bytes.NewBuffer(data), true) + if err != nil { + log.Panicf("loading pixbuf: %v", err) + } + return pixbuf +} diff --git a/preferences.go b/preferences.go new file mode 100644 index 0000000..9f88208 --- /dev/null +++ b/preferences.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "log" + "strconv" + + "github.com/gotk3/gotk3/glib" + "github.com/gotk3/gotk3/gtk" +) + +func (app *App) preferencesInit() { + _, err := app.W.PreferencesDialog.AddButton("_OK", gtk.RESPONSE_ACCEPT) + checkDialogAddButtonErr(err) + + app.W.BackgroundColorButton.Connect("color-set", func(self *glib.Object) { + chooser := >k.ColorChooser{ + Object: self, + } + app.setBackgroundColor(NewColorFromGdkRGBA(chooser.GetRGBA())) + }) + + app.W.PagesToSkipSpinButton.SetRange(1, 100) + app.W.PagesToSkipSpinButton.SetIncrements(1, 10) + app.W.PagesToSkipSpinButton.SetValue(float64(app.Config.NSkip)) + app.W.PagesToSkipSpinButton.Connect("value-changed", func(self *gtk.SpinButton) { + app.Config.NSkip = int(self.GetValue()) + }) + + app.W.InterpolationComboBoxText.Connect("changed", func(self *gtk.ComboBoxText) { + app.setInterpolation(self.GetActive()) + }) + + app.W.RememberRecentCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setRememberRecent(self.GetActive()) + }) + + app.W.RememberPositionCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setRememberPosition(self.GetActive()) + app.W.RememberPositionHTTPCheckButton.SetSensitive(self.GetActive()) + if !self.GetActive() { + app.W.RememberPositionHTTPCheckButton.SetActive(false) + } + }) + + app.W.RememberPositionHTTPCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setRememberPositionHTTP(self.GetActive()) + }) + + app.W.OneWideCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setOneWide(self.GetActive()) + }) + + app.W.SmartScrollCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setSmartScroll(self.GetActive()) + }) + + app.W.EmbeddedOrientationCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setEmbeddedOrientation(self.GetActive()) + }) + + app.W.HideIdleCursorCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setHideIdleCursor(self.GetActive()) + }) + + app.W.KamiteEnabledCheckButton.Connect("toggled", func(self *gtk.CheckButton) { + app.setKamiteEnabled(self.GetActive()) + app.W.KamitePortContainer.SetSensitive(self.GetActive()) + }) + + app.W.KamitePortEntry.Connect("insert-text", func(self *gtk.Entry, text string) { + // Allow only digits + for _, c := range text { + if c < '0' || c > '9' { + self.StopEmission("insert-text") + return + } + } + }) + app.W.KamitePortEntry.Connect("changed", func(self *gtk.Entry) { + text, err := self.GetText() + if err != nil { + log.Panicf("getting Kamite port entry text: %v", err) + } + if text == "" { + // POLISH: Restore default value + return + } + port, err := strconv.Atoi(text) + if err != nil { + log.Panicf("parsing Kamite port entry as int: %v", err) + } + app.setKamitePort(port) + }) +} + +func (app *App) preferencesDialogRun() { + app.S.Cursor.ForceVisible = true + res := gtk.ResponseType(app.W.PreferencesDialog.Run()) + app.W.PreferencesDialog.Hide() + if res == gtk.RESPONSE_ACCEPT { + app.saveConfig() + } + app.S.Cursor.ForceVisible = false +} diff --git a/read_later.go b/read_later.go new file mode 100644 index 0000000..4b8ea3e --- /dev/null +++ b/read_later.go @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/fauu/gomicsv/archive" +) + +func (app *App) maybeSaveReadingPosition() { + var http = app.S.Archive.Kind() == archive.HTTPKind + if (!http && !app.Config.RememberPosition) || + (http && !app.Config.RememberPositionHTTP) { + return + } + + readLaterFilePath := app.readLaterFilePath(app.S.ArchivePath) + f, err := os.Create(readLaterFilePath) + if err != nil { + log.Printf("Error creating read later file '%s': %v", readLaterFilePath, err) + return + } + defer f.Close() + fmt.Fprintf(f, "archive-pos=%d", app.S.ArchivePos) +} + +func (app *App) loadReadingPosition(archivePath string) (int, error) { + readLaterFilePath := app.readLaterFilePath(archivePath) + f, err := os.Open(readLaterFilePath) + if err != nil { + return 0, fmt.Errorf("opening read later file '%s': %v", readLaterFilePath, err) + } + defer f.Close() + + var archivePos int + _, err = fmt.Fscanf(f, "archive-pos=%d", &archivePos) + if err != nil { + return 0, fmt.Errorf("reading 'archive-pos' from read later file '%s': %v", readLaterFilePath, err) + } + return archivePos, nil +} + +func (app *App) readLaterFilePath(archivePath string) string { + filename := strings.ToUpper(md5String(archivePath)) + return filepath.Join(app.S.ReadLaterDirPath, filename) +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..f774337 Binary files /dev/null and b/screenshot.png differ diff --git a/scrolling.go b/scrolling.go new file mode 100644 index 0000000..cca80cb --- /dev/null +++ b/scrolling.go @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/gtk" + + "github.com/fauu/gomicsv/util" +) + +type DragScroll struct { + InProgress bool + StartX float64 + StartY float64 + StartHAdjustmentVal float64 + StartVAdjustmentVal float64 +} + +func (app *App) scroll(dx, dy float64) { + if !app.archiveIsLoaded() { + return + } + + imgw, imgh := app.getImageAreaInnerSize() + + vadj := app.W.ScrolledWindow.GetVAdjustment() + hadj := app.W.ScrolledWindow.GetHAdjustment() + + vIncrement := vadj.GetMinimumIncrement() + vVal := vadj.GetValue() + vMin := vadj.GetLower() + vMax := vadj.GetUpper() - float64(imgh) - 2 + + hIncrement := hadj.GetMinimumIncrement() + hVal := hadj.GetValue() + hMin := hadj.GetLower() + hMax := hadj.GetUpper() - float64(imgw) - 2 + + if dy > 0 { + if vVal >= vMax { + if app.Config.SmartScroll { + if app.S.SmartScrollInProgress { + app.nextPage() + app.S.SmartScrollInProgress = false + } else { + app.S.SmartScrollInProgress = true + } + } + } else { + vadj.SetValue(util.Clamp(vVal+vIncrement, vMin, vMax)) + app.W.ScrolledWindow.SetVAdjustment(vadj) + app.S.SmartScrollInProgress = false + } + } else if dy < 0 { + if vVal <= vMin { + if app.Config.SmartScroll { + if app.S.SmartScrollInProgress { + app.previousPage() + app.S.SmartScrollInProgress = false + } else { + app.S.SmartScrollInProgress = true + } + } + } else { + vadj.SetValue(util.Clamp(vVal-vIncrement, vMin, vMax)) + app.W.ScrolledWindow.SetVAdjustment(vadj) + app.S.SmartScrollInProgress = false + } + } + + if dx > 0 && hVal < hMax { + hadj.SetValue(util.Clamp(hVal+hIncrement, hMin, hMax)) + app.W.ScrolledWindow.SetHAdjustment(hadj) + } else if dx < 0 && hVal > hMin { + hadj.SetValue(util.Clamp(hVal-hIncrement, hMin, hMax)) + app.W.ScrolledWindow.SetHAdjustment(hadj) + } +} + +func (app *App) scrollToStart() { + app.W.ScrolledWindow.GetVAdjustment().SetValue(0) // Vertical: top + + var newHadj float64 = 0 + if app.Config.MangaMode { + imgw, _ := app.getImageAreaInnerSize() + newHadj = float64(imgw) + } + app.W.ScrolledWindow.GetHAdjustment().SetValue(newHadj) // Horizontal: left (non-manga) or right (manga) edge +} + +func (app *App) scrollToEnd() { + imgw, imgh := app.getImageAreaInnerSize() + + app.W.ScrolledWindow.GetVAdjustment().SetValue(float64(imgh)) // Vertical: bottom + + var newHadj float64 = 0 + if !app.Config.MangaMode { + newHadj = float64(imgw) + } + app.W.ScrolledWindow.GetHAdjustment().SetValue(newHadj) // Horizontal: left (manga) or right (non-manga) edge +} + +func (app *App) dragScrollStart(x, y, vAdjustmentVal, hAdjustmentVal float64) { + app.S.DragScroll.InProgress = true + app.S.DragScroll.StartX = x + app.S.DragScroll.StartY = y + app.S.DragScroll.StartVAdjustmentVal = vAdjustmentVal + app.S.DragScroll.StartHAdjustmentVal = hAdjustmentVal + app.setCursor(cursorGrabbing) +} + +func (app *App) dragScrollUpdate(sw *gtk.ScrolledWindow, event *gdk.EventButton) { + deltaX := app.S.DragScroll.StartX - event.X() + sw.GetHAdjustment().SetValue(app.S.DragScroll.StartHAdjustmentVal + deltaX) + deltaY := app.S.DragScroll.StartY - event.Y() + sw.GetVAdjustment().SetValue(app.S.DragScroll.StartVAdjustmentVal + deltaY) +} + +func (app *App) dragScrollEnd() { + app.S.DragScroll.InProgress = false + app.setCursor(cursorDefault) +} diff --git a/standard_paths_linux.go b/standard_paths_linux.go new file mode 100644 index 0000000..fa5e9fd --- /dev/null +++ b/standard_paths_linux.go @@ -0,0 +1,63 @@ +//go:build linux +// +build linux + +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func getConfigLocation(appName string) (string, error) { + if appName == "" { + return "", errors.New("'appName' cannot be empty") + } + if envHome := os.Getenv("XDG_CONFIG_HOME"); envHome != "" { + return filepath.Join(envHome, appName), nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting user home directory: %v", err) + } + return filepath.Join(homeDir, ".config", appName), nil +} + +func getUserDataLocation(appName string) (string, error) { + if appName == "" { + return "", errors.New("'appName' cannot be empty") + } + if envHome := os.Getenv("XDG_DATA_HOME"); envHome != "" { + return filepath.Join(envHome, appName), nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting user home directory: %v", err) + } + dataRoot := filepath.Join(homeDir, ".local", "share") + if unix.Access(dataRoot, unix.W_OK) != nil { + return "", fmt.Errorf("accessing user data directory '%s'", dataRoot) + } + return filepath.Join(dataRoot, appName), nil +} diff --git a/support/get-version.sh b/support/get-version.sh new file mode 100755 index 0000000..a3a263b --- /dev/null +++ b/support/get-version.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +VERSION_TAG=$(git tag -l | grep "v" | cut -c2-); +if [[ ${#VERSION_TAG} -ne 0 ]]; then + echo "$VERSION_TAG"; +else + HASH=$(git rev-parse --short HEAD); + printf "git-%s" "$HASH" +fi; diff --git a/toolbar.go b/toolbar.go new file mode 100644 index 0000000..c3906f5 --- /dev/null +++ b/toolbar.go @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +func (app *App) toolbarInit() { + app.W.ButtonNextPage.Connect("clicked", app.nextPage) + app.W.ButtonPreviousPage.Connect("clicked", app.previousPage) + app.W.ButtonFirstPage.Connect("clicked", app.firstPage) + app.W.ButtonLastPage.Connect("clicked", app.lastPage) + app.W.ButtonNextArchive.Connect("clicked", app.nextArchive) + app.W.ButtonPreviousArchive.Connect("clicked", app.previousArchive) + app.W.ButtonSkipForward.Connect("clicked", app.skipForward) + app.W.ButtonSkipBackward.Connect("clicked", app.skipBackward) +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..4d5e929 --- /dev/null +++ b/ui.go @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "fmt" + "log" + "time" + + "github.com/fauu/gomicsv/pixbuf" + "github.com/gotk3/gotk3/gdk" + "github.com/gotk3/gotk3/glib" + "github.com/gotk3/gotk3/gtk" +) + +func (app *App) uiInit() { + if err := app.loadWidgets(); err != nil { + log.Panicf("loading widgets from the definition file: %v", err) + } + + app.S.Cursor.init() + + app.notificationInit() + + app.menuInit() + + app.preferencesInit() + + app.toolbarInit() + + app.imageAreaInit() + + app.W.MainWindow.SetApplication(app.S.GTKApplication) + app.W.MainWindow.SetDefaultSize(app.Config.WindowWidth, app.Config.WindowHeight) + app.W.MainWindow.SetIcon(pixbuf.MustLoad(iconImg)) + + var prevW, prevH int + app.W.MainWindow.Connect("size-allocate", func(_ glib.IObject, allocationPtr uintptr) { + alloc := gdk.WrapRectangle(allocationPtr) + w, h := alloc.GetWidth(), alloc.GetHeight() + if w == prevW && h == prevH { + return + } + prevW, prevH = w, h + app.handleImageAreaResize() + }) + + app.W.MainWindow.Connect("motion-notify-event", func(_ *gtk.ApplicationWindow, _ *gdk.Event) bool { + app.S.Cursor.LastMoved = time.Now() + return true + }) + glib.TimeoutAdd(250, app.updateCursorVisibility) + + app.W.MainWindow.Connect("key-press-event", func(_ *gtk.ApplicationWindow, event *gdk.Event) { + ke := &gdk.EventKey{Event: event} + shift := ke.State()&uint(gdk.SHIFT_MASK) != 0 + ctrl := ke.State()&uint(gdk.CONTROL_MASK) != 0 + app.handleKeyPress(ke.KeyVal(), shift, ctrl) + }) + + app.W.MainWindow.Connect("delete-event", app.quit) + + app.syncWidgetsToConfig() + + app.W.MainWindow.ShowAll() +} + +func (app *App) syncWidgetsToConfig() { + app.W.MenuItemEnlarge.SetActive(app.Config.Enlarge) + app.W.MenuItemShrink.SetActive(app.Config.Shrink) + app.W.MenuItemHFlip.SetActive(app.Config.HFlip) + app.W.MenuItemVFlip.SetActive(app.Config.VFlip) + app.W.MenuItemRandom.SetActive(app.Config.Random) + app.W.MenuItemSeamless.SetActive(app.Config.Seamless) + app.W.MenuItemDoublePage.SetActive(app.Config.DoublePage) + app.W.MenuItemMangaMode.SetActive(app.Config.MangaMode) + + switch app.Config.ZoomMode { + case "FitToWidth": + app.W.MenuItemFitToWidth.SetActive(true) + case "FitToHeight": + app.W.MenuItemFitToHeight.SetActive(true) + case "BestFit": + app.W.MenuItemBestFit.SetActive(true) + default: + app.W.MenuItemOriginal.SetActive(true) + } + + rgba := app.Config.BackgroundColor.ToGdkRGBA() + app.W.BackgroundColorButton.SetRGBA(&rgba) + + app.W.InterpolationComboBoxText.SetActive(app.Config.Interpolation) + app.W.OneWideCheckButton.SetActive(app.Config.OneWide) + app.W.RememberRecentCheckButton.SetActive(app.Config.RememberRecent) + app.W.RememberPositionCheckButton.SetActive(app.Config.RememberPosition) + app.W.RememberPositionHTTPCheckButton.SetActive(app.Config.RememberPositionHTTP) + app.W.RememberPositionHTTPCheckButton.SetSensitive(app.Config.RememberPosition && app.Config.RememberPositionHTTP) + app.W.EmbeddedOrientationCheckButton.SetActive(app.Config.EmbeddedOrientation) + app.W.HideIdleCursorCheckButton.SetActive(app.Config.HideIdleCursor) + app.W.KamiteEnabledCheckButton.SetActive(app.Config.KamiteEnabled) + app.W.KamitePortContainer.SetSensitive(app.Config.KamiteEnabled) + app.W.KamitePortEntry.SetText(fmt.Sprint(app.Config.KamitePort)) +} diff --git a/util/url.go b/util/url.go new file mode 100644 index 0000000..2c11d23 --- /dev/null +++ b/util/url.go @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package util + +import "strings" + +func IsLikelyHTTPURL(s string) bool { + return strings.HasPrefix(s, "http") +} diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..2ed4028 --- /dev/null +++ b/util/util.go @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package util + +import ( + "runtime" +) + +func Min(a, b int) int { + if a < b { + return a + } + return b +} + +func Max(a, b int) int { + if a > b { + return a + } + return b +} + +func Wrap(val, low, mod int) int { + val %= mod + if val < low { + val = mod + val + } + return val +} + +func Clamp(val, low, high float64) float64 { + if val < low { + val = low + } else if val > high { + val = high + } + + return val +} + +func Fit(sw, sh, fw, fh int) (int, int) { + r := float64(sw) / float64(sh) + + var nw, nh float64 + if float64(fw) >= float64(fh)*r { + nw, nh = float64(fh)*r, float64(fh) + } else { + nw, nh = float64(fw), float64(fw)/r + } + return int(nw), int(nh) +} + +func IntPtrToUintPtr(l *int) *uint { + if l != nil { + ul := uint(*l) + return &ul + } + return nil +} + +func GC() { + runtime.GC() + runtime.GC() +} diff --git a/widgets.go b/widgets.go new file mode 100644 index 0000000..0c8b558 --- /dev/null +++ b/widgets.go @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2013-2021 Utkan Güngördü + * Copyright (c) 2021-2022 Piotr Grabowski + * + * 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 . + */ + +package gomicsv + +import ( + "reflect" + + "github.com/gotk3/gotk3/gtk" +) + +type Widgets struct { + MainWindow *gtk.ApplicationWindow `build:"MainWindow"` + MainContainer *gtk.Box `build:"MainContainer"` + Menubar *gtk.MenuBar `build:"Menubar"` + ScrolledWindow *gtk.ScrolledWindow `build:"ScrolledWindow"` + ImageViewport *gtk.Viewport `build:"ImageViewport"` + ImageBox *gtk.Box `build:"ImageBox"` + ImageL *gtk.Image `build:"ImageL"` + ImageR *gtk.Image `build:"ImageR"` + NotificationRevealer *gtk.Revealer `build:"NotificationRevealer"` + NotificationLabel *gtk.Label `build:"NotificationLabel"` + NotificationCloseButton *gtk.Button `build:"NotificationCloseButton"` + MenuAbout *gtk.Menu `build:"MenuAbout"` + AboutDialog *gtk.AboutDialog `build:"AboutDialog"` + MenuFile *gtk.Menu `build:"MenuFile"` + MenuEdit *gtk.Menu `build:"MenuEdit"` + MenuView *gtk.Menu `build:"MenuView"` + MenuNavigation *gtk.Menu `build:"MenuNavigation"` + MenuBookmarks *gtk.Menu `build:"MenuBookmarks"` + MenuJumpmarks *gtk.Menu `build:"MenuJumpmarks"` + Statusbar *gtk.Statusbar `build:"Statusbar"` + MenuItemOpen *gtk.MenuItem `build:"MenuItemOpen"` + MenuItemOpenURL *gtk.MenuItem `build:"MenuItemOpenURL"` + MenuItemClose *gtk.MenuItem `build:"MenuItemClose"` + MenuItemQuit *gtk.MenuItem `build:"MenuItemQuit"` + MenuItemSaveImage *gtk.MenuItem `build:"MenuItemSaveImage"` + ArchiveFileChooserDialog *gtk.FileChooserDialog `build:"ArchiveFileChooserDialog"` + SaveImageFileChooserDialog *gtk.FileChooserDialog `build:"SaveImageFileChooserDialog"` + OpenURLDialog *gtk.Dialog `build:"OpenURLDialog"` + OpenURLDialogURLEntry *gtk.Entry `build:"OpenURLDialogURLEntry"` + OpenURLDialogExplanationLabel *gtk.Label `build:"OpenURLDialogExplanationLabel"` + OpenURLDialogRefererEntry *gtk.Entry `build:"OpenURLDialogRefererEntry"` + Toolbar *gtk.Toolbar `build:"Toolbar"` + ButtonNextPage *gtk.ToolButton `build:"ButtonNextPage"` + ButtonPreviousPage *gtk.ToolButton `build:"ButtonPreviousPage"` + ButtonLastPage *gtk.ToolButton `build:"ButtonLastPage"` + ButtonFirstPage *gtk.ToolButton `build:"ButtonFirstPage"` + ButtonNextArchive *gtk.ToolButton `build:"ButtonNextArchive"` + ButtonPreviousArchive *gtk.ToolButton `build:"ButtonPreviousArchive"` + ButtonSkipForward *gtk.ToolButton `build:"ButtonSkipForward"` + ButtonSkipBackward *gtk.ToolButton `build:"ButtonSkipBackward"` + MenuItemNextPage *gtk.MenuItem `build:"MenuItemNextPage"` + MenuItemPreviousPage *gtk.MenuItem `build:"MenuItemPreviousPage"` + MenuItemLastPage *gtk.MenuItem `build:"MenuItemLastPage"` + MenuItemFirstPage *gtk.MenuItem `build:"MenuItemFirstPage"` + MenuItemNextArchive *gtk.MenuItem `build:"MenuItemNextArchive"` + MenuItemPreviousArchive *gtk.MenuItem `build:"MenuItemPreviousArchive"` + MenuItemSkipForward *gtk.MenuItem `build:"MenuItemSkipForward"` + MenuItemSkipBackward *gtk.MenuItem `build:"MenuItemSkipBackward"` + MenuItemEnlarge *gtk.CheckMenuItem `build:"MenuItemEnlarge"` + MenuItemShrink *gtk.CheckMenuItem `build:"MenuItemShrink"` + MenuItemFullscreen *gtk.CheckMenuItem `build:"MenuItemFullscreen"` + MenuItemHideUI *gtk.CheckMenuItem `build:"MenuItemHideUI"` + MenuItemSeamless *gtk.CheckMenuItem `build:"MenuItemSeamless"` + MenuItemRandom *gtk.CheckMenuItem `build:"MenuItemRandom"` + MenuItemCopyImageToClipboard *gtk.MenuItem `build:"MenuItemCopyImageToClipboard"` + MenuItemPreferences *gtk.MenuItem `build:"MenuItemPreferences"` + MenuItemHFlip *gtk.CheckMenuItem `build:"MenuItemHFlip"` + MenuItemVFlip *gtk.CheckMenuItem `build:"MenuItemVFlip"` + MenuItemMangaMode *gtk.CheckMenuItem `build:"MenuItemMangaMode"` + MenuItemDoublePage *gtk.CheckMenuItem `build:"MenuItemDoublePage"` + MenuItemGoTo *gtk.MenuItem `build:"MenuItemGoTo"` + MenuItemBestFit *gtk.RadioMenuItem `build:"MenuItemBestFit"` + MenuItemOriginal *gtk.RadioMenuItem `build:"MenuItemOriginal"` + MenuItemFitToWidth *gtk.RadioMenuItem `build:"MenuItemFitToWidth"` + MenuItemFitToHeight *gtk.RadioMenuItem `build:"MenuItemFitToHeight"` + MenuItemAbout *gtk.MenuItem `build:"MenuItemAbout"` + GoToThumbnailImage *gtk.Image `build:"GoToThumbnailImage"` + GoToDialog *gtk.Dialog `build:"GoToDialog"` + GoToSpinButton *gtk.SpinButton `build:"GoToSpinButton"` + GoToScrollbar *gtk.Scrollbar `build:"GoToScrollbar"` + PreferencesDialog *gtk.Dialog `build:"PreferencesDialog"` + BackgroundColorButton *gtk.ColorButton `build:"BackgroundColorButton"` + PagesToSkipSpinButton *gtk.SpinButton `build:"PagesToSkipSpinButton"` + InterpolationComboBoxText *gtk.ComboBoxText `build:"InterpolationComboBoxText"` + RememberRecentCheckButton *gtk.CheckButton `build:"RememberRecentCheckButton"` + RememberPositionCheckButton *gtk.CheckButton `build:"RememberPositionCheckButton"` + RememberPositionHTTPCheckButton *gtk.CheckButton `build:"RememberPositionHTTPCheckButton"` + OneWideCheckButton *gtk.CheckButton `build:"OneWideCheckButton"` + SmartScrollCheckButton *gtk.CheckButton `build:"SmartScrollCheckButton"` + EmbeddedOrientationCheckButton *gtk.CheckButton `build:"EmbeddedOrientationCheckButton"` + HideIdleCursorCheckButton *gtk.CheckButton `build:"HideIdleCursorCheckButton"` + KamiteEnabledCheckButton *gtk.CheckButton `build:"KamiteEnabledCheckButton"` + KamitePortContainer *gtk.Box `build:"KamitePortContainer"` + KamitePortEntry *gtk.Entry `build:"KamitePortEntry"` + MenuItemAddBookmark *gtk.MenuItem `build:"AddBookmarkMenuItem"` + MenuItemToggleJumpmark *gtk.MenuItem `build:"ToggleJumpmarkMenuItem"` + MenuItemCycleJumpmarksBackward *gtk.MenuItem `build:"CycleJumpmarksBackwardMenuItem"` + MenuItemCycleJumpmarksForward *gtk.MenuItem `build:"CycleJumpmarksForwardMenuItem"` + MenuItemJumpmarksReturnFromCycling *gtk.MenuItem `build:"JumpmarksReturnFromCyclingMenuItem"` + RecentChooserMenu *gtk.RecentChooserMenu `build:"RecentChooserMenu"` +} + +// loadWidgets fills the Widgets struct based on the glade UI definition file +func (app *App) loadWidgets() (err error) { + defer func() { + if r := recover(); r != nil { + err = r.(error) + } + }() + + builder, err := gtk.BuilderNew() + if err != nil { + return err + } + + if err = builder.AddFromString(uiDef); err != nil { + return err + } + + widgets := &Widgets{} + + widgetsStruct := reflect.ValueOf(widgets).Elem() + + for i := 0; i < widgetsStruct.NumField(); i++ { + field := widgetsStruct.Field(i) + widget := widgetsStruct.Type().Field(i).Tag.Get("build") + if widget == "" { + continue + } + + obj, err := builder.GetObject(widget) + if err != nil { + return err + } + + w := reflect.ValueOf(obj).Convert(field.Type()) + field.Set(w) + } + + app.W = *widgets + + return nil +}