diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 0000000..e5ec365 --- /dev/null +++ b/Changelog.md @@ -0,0 +1,4 @@ +# Changelog + +## 0.1.0 (2017-10-13) +* Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 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/README.md b/README.md new file mode 100644 index 0000000..4a6d16e --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Ex-Mortis, a plugin for gedit + +Reopen closed windows and optionally restore windows between sessions + +0.1.0 + +All bug reports, feature requests and miscellaneous comments are welcome +at the [project issue tracker][]. + +## Requirements + +This plugin requires gedit 3.12 or newer. + +## Installation + +1. Download the source code (as [zip][] or [tar.gz][]) and extract. +2. Copy the `ex-mortis` folder and the `ex-mortis.plugin` file into + `~/.local/share/gedit/plugins` (create if it does not exist). +3. Restart gedit, then enable the plugin in the **Plugins** tab in + gedit's **Preferences** window. +4. Restart gedit again, preferably using **Quit** in the Application + menu or the File menu. This is necessary because the plugin cannot + reopen any windows that were open when the plugin was activated. + +## Usage + +* This plugin adds a new **Reopen Closed Window** menu item, following + **New Window** in either the Application menu or the File menu. + + Activating this menu item will reopen the most recently closed + window in the current session; if there are no closed windows, the + menu item will be disabled. + + This menu item can also be activated from the keyboard with + Ctrl+Shift+N + (Command+Shift+N on macOS). + +* If enabled in preferences, this plugin will also restore windows + between gedit sessions. + +Note that only saved files will be reopened. Unsaved files or unsaved +changes are not cached in any way. Closed windows with no saved files, +i.e. only unsaved or blank documents, will not be reopenable. + +## Preferences + +* `Restore windows between sessions` - If enabled, windows that were + open in the previous session will be reopened when gedit is started + again. (Default: Disabled) + +## Development + +The code in `ex-mortis/utils` comes from [python-gtk-utils][]; changes +should ideally be contributed to that project, then pulled back into +this one with `git subtree pull`. + +## Credits + +Inspired by: + +* [Restore Tabs][] by Quixotix + +## License + +Copyright © 2017 Jeffery To + +Available under GNU General Public License version 3 + + +[project issue tracker]: https://github.com/jefferyto/gedit-ex-mortis/issues +[zip]: https://github.com/jefferyto/gedit-ex-mortis/archive/master.zip +[tar.gz]: https://github.com/jefferyto/gedit-ex-mortis/archive/master.tar.gz +[python-gtk-utils]: https://github.com/jefferyto/python-gtk-utils +[Restore Tabs]: https://github.com/Quixotix/gedit-restore-tabs diff --git a/ex-mortis.plugin b/ex-mortis.plugin new file mode 100644 index 0000000..8bb23d1 --- /dev/null +++ b/ex-mortis.plugin @@ -0,0 +1,11 @@ +[Plugin] +Loader=python3 +Module=ex-mortis +IAge=3 +Name=Ex-Mortis +Description=Reopen closed windows and optionally restore windows between sessions +Icon=window-new +Authors=Jeffery To +Copyright=Copyright © 2017 Jeffery To +Website=https://github.com/jefferyto/gedit-ex-mortis +Version=0.1.0 diff --git a/ex-mortis/__init__.py b/ex-mortis/__init__.py new file mode 100644 index 0000000..3c8d478 --- /dev/null +++ b/ex-mortis/__init__.py @@ -0,0 +1,465 @@ +# -*- coding: utf-8 -*- +# +# __init__.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gedit', '3.0') + +import os.path +from gi.repository import GObject, Gtk, Gio, Gedit, PeasGtk +from .closingmixin import ClosingMixin +from .existingmixin import ExistingMixin +from .quittingmixin import QuittingMixin +from .settings import ExMortisSettings +from .windowmanager import ExMortisWindowManager +from .utils import connect_handlers, disconnect_handlers, create_bindings +from . import log + +BASE_PATH = os.path.dirname(os.path.realpath(__file__)) +LOCALE_PATH = os.path.join(BASE_PATH, 'locale') + +try: + import gettext + gettext.bindtextdomain('gedit-ex-mortis', LOCALE_PATH) + gettext.textdomain('gedit-ex-mortis') + _ = gettext.gettext +except: + _ = lambda s: s + + +class ExMortisAppActivatable( + ExistingMixin, ClosingMixin, QuittingMixin, + GObject.Object, Gedit.AppActivatable): + + __gtype_name__ = 'ExMortisAppActivatable' + + app = GObject.Property(type=Gedit.App) + + + def __init__(self): + GObject.Object.__init__(self) + + def do_activate(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + app = self.app + window_manager = ExMortisWindowManager() + settings = ExMortisSettings() + + # app + connect_handlers( + self, app, + ['window-added', 'window-removed', 'shutdown'], + 'app' + ) + + # window manager + connect_handlers( + self, window_manager, + ['tab-added', 'tab-removed', 'tabs-reordered'], + 'window_manager' + ) + + # settings + connect_handlers( + self, settings, + ['notify::restore-between-sessions'], + 'settings', + window_manager + ) + + # reopen action + reopen_action = Gio.SimpleAction.new('reopen-closed-window', None) + reopen_action.set_enabled(False) + connect_handlers( + self, reopen_action, + ['activate'], + 'reopen', + window_manager + ) + app.add_action(reopen_action) + + # reopen menu item + app.set_accels_for_action( + 'app.reopen-closed-window', ['N'] + ) + menu_ext = self.extend_menu('app-commands-section') + menu_item = Gio.MenuItem.new( + _("Reopen Closed _Window"), 'app.reopen-closed-window' + ) + menu_ext.append_menu_item(menu_item) + + # quit action + original_quit_action = app.lookup_action('quit') + custom_quit_action = Gio.SimpleAction.new('quit', None) + connect_handlers( + self, custom_quit_action, + ['activate'], + 'quit', + window_manager + ) + app.remove_action('quit') + app.add_action(custom_quit_action) + + self._window_manager = window_manager + self._settings = settings + self._reopen_action = reopen_action + self._menu_ext = menu_ext + self._original_quit_action = original_quit_action + self._custom_quit_action = custom_quit_action + + self.do_activate_existing() + self.do_activate_closing() + self.do_activate_quitting(settings.restore_between_sessions) + + # windows + windows = app.get_main_windows() + + self.restore_windows( + window_manager, settings, + settings.restore_between_sessions and not windows + ) + + if windows: + # plugin activated during existing session + for window in windows: + self.setup_window(window, True) + + def do_deactivate(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + app = self.app + window_manager = self._window_manager + settings = self._settings + + # windows + for window in app.get_main_windows(): + self.teardown_window(window) + + # quit action + app.remove_action('quit') + app.add_action(self._original_quit_action) + disconnect_handlers(self, self._custom_quit_action) + + # reopen menu item + app.set_accels_for_action('app.reopen-closed-window', []) + + # reopen action + app.remove_action('reopen-closed-window') + + # settings + disconnect_handlers(self, settings) + + # window manager + disconnect_handlers(self, window_manager) + + # app + disconnect_handlers(self, app) + + window_manager.cleanup() + settings.cleanup() + + self._window_manager = None + self._settings = None + self._reopen_action = None + self._menu_ext = None + self._original_quit_action = None + self._custom_quit_action = None + + self.do_deactivate_existing() + self.do_deactivate_closing() + self.do_deactivate_quitting() + + + # window setup + + def setup_window(self, window, is_existing=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, is_existing=%s", window, is_existing)) + + window_manager = self._window_manager + settings = self._settings + + if is_existing: + info_bar, quit_response_id = self.add_existing(window) + + connect_handlers( + self, info_bar, + ['response'], + 'existing_window_info_bar', + quit_response_id + ) + + self.show_existing_info_bar(window) + + connect_handlers( + self, window, + ['delete-event'], + 'window', + window_manager + ) + + window_manager.track_window(window) + + self.setup_restore_window(window) + + if self.is_saving_window_states(): + self.bind_window_settings(window_manager, settings, window) + + def teardown_window(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + window_manager = self._window_manager + settings = self._settings + + if self.is_existing(window): + info_bar = self.get_existing_info_bar(window) + disconnect_handlers(self, info_bar) + + self.remove_existing(window) + + disconnect_handlers(self, window) + + self.teardown_restore_window(window) + + if self.is_saving_window_states(): + self.unbind_window_settings(window_manager, settings, window) + + window_manager.untrack_window(window) + + + # start closing / quitting + + def on_window_delete_event(self, window, event, window_manager): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + # closing the only window also quits the app + if len(self.app.get_main_windows()) == 1: + self.start_quitting(window_manager) + + # this handler would not be called on an existing window anyway + # but for completeness sake... + if not self.is_existing(window): + self.start_closing(window_manager, window) + + return False + + def on_quit_activate(self, action, parameter, window_manager): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + try: + self.start_quitting(window_manager) + + for window in self.app.get_main_windows(): + if not self.is_existing(window): + self.start_closing(window_manager, window) + + finally: + self.really_quit() + + + # update and cancel closing / quitting + + def on_window_manager_tab_removed(self, window_manager, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + if not self.is_existing(window): + self.update_closing(window, tab) + + self.update_quitting(window, tab) + + def on_window_manager_tab_added(self, window_manager, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + if not self.is_existing(window): + self.cancel_closing(window) + + self.cancel_quitting() + + def on_window_manager_tabs_reordered(self, window_manager, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_existing(window): + self.cancel_closing(window) + + self.cancel_quitting() + + def on_app_window_added(self, app, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not isinstance(window, Gedit.Window): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not a main window")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("a main window")) + + self.cancel_quitting() + + self.setup_window(window) + + + # end closing / quitting + + def on_app_window_removed(self, app, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not isinstance(window, Gedit.Window): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not a main window")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("a main window")) + + if not self.is_existing(window): + self.end_closing(window) + self.update_reopen_action_enabled() + + self.teardown_window(window) + + def on_app_shutdown(self, app): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + settings = self._settings + + self.end_quitting(settings, settings.restore_between_sessions) + + + # toggled restore between sessions setting + + def on_settings_notify_restore_between_sessions(self, settings, pspec, window_manager): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + restore_between_sessions = settings.restore_between_sessions + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("restore-between-sessions=%s", restore_between_sessions)) + + if restore_between_sessions == self.is_saving_window_states(): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("setting has not changed")) + + return + + if restore_between_sessions: + self.start_saving_window_states(window_manager, settings) + else: + self.stop_saving_window_states(window_manager, settings) + + + # reopen closed window + + def on_reopen_activate(self, action, parameter, window_manager): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self.reopen_closed(window_manager) + self.update_reopen_action_enabled() + + + # existing window info bar response + + def on_existing_window_info_bar_response(self, info_bar, response_id, quit_response_id): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("response_id=%s", response_id)) + + info_bar.hide() + + if response_id == quit_response_id: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("quit selected")) + + self.app.activate_action('quit') + + else: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("quit not selected")) + + + # helpers + + def update_reopen_action_enabled(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + can_reopen = self.can_reopen() + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("can_reopen=%s", can_reopen)) + + self._reopen_action.set_enabled(can_reopen) + + def really_quit(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._original_quit_action.activate() + + +class ExMortisConfigurable(GObject.Object, PeasGtk.Configurable): + + __gtype_name__ = 'ExMortisConfigurable' + + def do_create_configure_widget(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + settings = ExMortisSettings() + + if settings.can_save: + widget = Gtk.CheckButton.new_with_label( + _("Restore windows between sessions") + ) + + create_bindings( + self, settings, widget, + {'restore_between_sessions': 'active'}, + GObject.BindingFlags.BIDIRECTIONAL + ) + + widget.set_active(settings.restore_between_sessions) + + else: + widget = Gtk.Box() + widget.add(Gtk.Label.new(_("Could not load settings schema"))) + + widget.set_border_width(5) + + widget._settings = settings + + return widget + diff --git a/ex-mortis/closingmixin.py b/ex-mortis/closingmixin.py new file mode 100644 index 0000000..bce6630 --- /dev/null +++ b/ex-mortis/closingmixin.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# +# closingmixin.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +from gi.repository import Gedit +from . import log + + +class ClosingMixin(object): + + def do_activate_closing(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._closing = {} + self._closed = [] + + def do_deactivate_closing(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._closing = None + self._closed = None + + + # closing + + def is_closing(self, window): + return window in self._closing + + def start_closing(self, window_manager, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if self.is_closing(window): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("already started closing window")) + + self._closing[window] = window_manager.export_window_state(window, True) + + # can be called on non-closing windows + def cancel_closing(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_closing(window): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not closing window")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("cancelling closing window")) + + del self._closing[window] + + # can be called on non-closing windows + def update_closing(self, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + if not self.is_closing(window): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not closing window")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("updating closing window")) + + state = self._closing[window] + + state.save_uri(window, tab) + state.forget_tab(tab) + + def end_closing(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_closing(window): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("end closing window without starting")) + + return + + state = self._closing[window] + + if state.restore_uris: + if log.query(log.MESSAGE): + Gedit.debug_plugin_message(log.format("caching window info")) + + self._closed.append(state) + + else: + if log.query(log.MESSAGE): + Gedit.debug_plugin_message(log.format("not caching window info")) + + del self._closing[window] + + + # reopening + + def can_reopen(self): + return len(self._closed) > 0 + + def reopen_closed(self, window_manager): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + if not self.can_reopen(): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("do not have closed windows to reopen")) + + return + + window_manager.open_new_window_with_window_state(self._closed.pop()) + diff --git a/ex-mortis/existingmixin.py b/ex-mortis/existingmixin.py new file mode 100644 index 0000000..e12c427 --- /dev/null +++ b/ex-mortis/existingmixin.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# +# existingmixin.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +import os.path +from gi.repository import Gtk, Gedit +from . import log + +BASE_PATH = os.path.dirname(os.path.realpath(__file__)) +LOCALE_PATH = os.path.join(BASE_PATH, 'locale') + +try: + import gettext + gettext.bindtextdomain('gedit-ex-mortis', LOCALE_PATH) + gettext.textdomain('gedit-ex-mortis') + _ = gettext.gettext +except: + _ = lambda s: s + + +class ExistingMixin(object): + + EXISTING_INFO_BAR_RESPONSE_QUIT = Gtk.ResponseType.YES + + EXISTING_INFO_BAR_RESPONSE_IGNORE = Gtk.ResponseType.CANCEL + + + def do_activate_existing(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._existing = {} + + def do_deactivate_existing(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._existing = None + + + # info bar + + def create_existing_info_bar(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + screen_settings = Gtk.Settings.get_default() + is_app_menu = not screen_settings.get_property('gtk-shell-shows-menubar') + + info_bar = Gtk.InfoBar.new() + info_bar.add_button(_("_Quit"), self.EXISTING_INFO_BAR_RESPONSE_QUIT) + info_bar.add_button(_("_Ignore"), self.EXISTING_INFO_BAR_RESPONSE_IGNORE) + info_bar.set_message_type(Gtk.MessageType.WARNING) + + hbox_content = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 8) + + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) + + hbox_content.pack_start(vbox, True, True, 0) + + primary_text = _("This window cannot be reopened if closed. Restart gedit to fully enable Ex-Mortis.") + primary_markup = "{}".format(primary_text) + + primary_label = Gtk.Label.new(primary_markup) + primary_label.set_use_markup(True) + primary_label.set_line_wrap(True) + primary_label.set_halign(Gtk.Align.START) + primary_label.set_can_focus(True) + primary_label.set_selectable(True) + + secondary_text_menu = _("Application menu") if is_app_menu else _("File menu") + secondary_text = _("To restore this window, enable \"Restore windows between sessions\" in Ex-Mortis' preferences, and quit gedit by selecting Quit in the {menu_name} or in this message.").format(menu_name=secondary_text_menu) + secondary_markup = "{}".format(secondary_text) + + secondary_label = Gtk.Label.new(secondary_markup) + secondary_label.set_use_markup(True) + secondary_label.set_line_wrap(True) + secondary_label.set_halign(Gtk.Align.START) + secondary_label.set_can_focus(True) + secondary_label.set_selectable(True) + + vbox.pack_start(primary_label, True, True, 0) + vbox.pack_start(secondary_label, True, True, 0) + + hbox_content.show_all() + + content_area = info_bar.get_content_area() + content_area.add(hbox_content) + + return info_bar + + def pack_existing_info_bar(self, window, info_bar): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + hpaned = window.get_template_child(Gedit.Window, 'hpaned') + main_box = hpaned.get_parent() + num_children = len(main_box.get_children()) + + main_box.pack_start(info_bar, False, False, 0) + # on DEs where there is a separate title bar, e.g. Unity + # the header bar is a child element here + # other DEs, e.g. GNOME Shell, the header bar is... somewhere else? + main_box.reorder_child(info_bar, num_children - 1) + + + # existing + + def is_existing(self, window): + return window in self._existing + + def add_existing(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if self.is_existing(window): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("window already added")) + + # disconnect handlers? + + self.remove_existing(window) + + info_bar = self.create_existing_info_bar() + + self._existing[window] = info_bar + + return (info_bar, self.EXISTING_INFO_BAR_RESPONSE_QUIT) + + def show_existing_info_bar(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_existing(window): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("window not existing")) + + return + + info_bar = self._existing[window] + + self.pack_existing_info_bar(window, info_bar) + + # must be done after the info bar is added to the window + info_bar.set_default_response(self.EXISTING_INFO_BAR_RESPONSE_IGNORE) + + info_bar.show() + + def get_existing_info_bar(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_existing(window): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("window not existing")) + + return None + + return self._existing[window] + + def remove_existing(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_existing(window): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("window not existing")) + + return + + info_bar = self._existing[window] + info_bar.destroy() + + del self._existing[window] + diff --git a/ex-mortis/log.py b/ex-mortis/log.py new file mode 100644 index 0000000..723cb9e --- /dev/null +++ b/ex-mortis/log.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# +# log.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +import os +from gi.repository import GLib +from .utils import debug_str + + +# for convenience, in decreasing order of severity +ERROR = GLib.LogLevelFlags.LEVEL_ERROR +CRITICAL = GLib.LogLevelFlags.LEVEL_CRITICAL +WARNING = GLib.LogLevelFlags.LEVEL_WARNING +MESSAGE = GLib.LogLevelFlags.LEVEL_MESSAGE +INFO = GLib.LogLevelFlags.LEVEL_INFO +DEBUG = GLib.LogLevelFlags.LEVEL_DEBUG + +LEVELS_TO_NAMES = { + ERROR: "error", + CRITICAL: "critical", + WARNING: "warning", + MESSAGE: "message", + INFO: "info", + DEBUG: "debug" +} + +NAMES_TO_LEVELS = {} + +for level, name in LEVELS_TO_NAMES.items(): + NAMES_TO_LEVELS[name] = level + +# messages equal or higher in severity will be printed +output_level = MESSAGE + +name = os.getenv('GEDIT_EX_MORTIS_DEBUG_LEVEL', '').lower() +if name in NAMES_TO_LEVELS: + output_level = NAMES_TO_LEVELS[name] + +# set by query(), used by prefix() +last_queried_level = None + + +def is_error(log_level): + return bool(log_level & ERROR) + +def is_critical(log_level): + return bool(log_level & CRITICAL) + +def is_warning(log_level): + return bool(log_level & WARNING) + +def is_message(log_level): + return bool(log_level & MESSAGE) + +def is_info(log_level): + return bool(log_level & INFO) + +def is_debug(log_level): + return bool(log_level & DEBUG) + +def highest(log_level): + if log_level < ERROR or is_error(log_level): + highest = ERROR + elif is_critical(log_level): + highest = CRITICAL + elif is_warning(log_level): + highest = WARNING + elif is_message(log_level): + highest = MESSAGE + elif is_info(log_level): + highest = INFO + else: + highest = DEBUG + + return highest + +def query(log_level): + global last_queried_level + last_queried_level = log_level + + return highest(log_level) <= output_level + +def prefix(log_level=None): + if log_level is None: + log_level = last_queried_level + + name = LEVELS_TO_NAMES[highest(log_level)] if log_level is not None else 'unknown' + + return '[' + name + '] ' + +def format(message, *args): + return prefix() + (message % tuple(debug_str(arg) for arg in args)) + diff --git a/ex-mortis/quittingmixin.py b/ex-mortis/quittingmixin.py new file mode 100644 index 0000000..2a9e7e0 --- /dev/null +++ b/ex-mortis/quittingmixin.py @@ -0,0 +1,468 @@ +# -*- coding: utf-8 -*- +# +# quittingmixin.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +from gi.repository import GObject, GLib, Gio, Gedit +from .utils import connect_handlers, disconnect_handlers +from . import log + + +class QuittingMixin(object): + + def do_activate_quitting(self, is_saving_window_states): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("is_saving_window_states=%s", is_saving_window_states)) + + self._window_ids = {} if is_saving_window_states else None + self._quitting = None + self._restore_windows = None + + def do_deactivate_quitting(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self.teardown_restore_windows() + + self._window_ids = None + self._quitting = None + self._restore_windows = None + + + # saving window states + + def is_saving_window_states(self): + return self._window_ids is not None + + def start_saving_window_states(self, window_manager, settings): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + if self.is_saving_window_states(): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("already saving window states")) + + return + + self._window_ids = {} + + app = Gedit.App.get_default() + + for window in app.get_main_windows(): + self.bind_window_settings(window_manager, settings, window) + + def stop_saving_window_states(self, window_manager, settings): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + if not self.is_saving_window_states(): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("not saving window states")) + + return + + app = Gedit.App.get_default() + + for window in app.get_main_windows(): + try: + self.unbind_window_settings(window_manager, settings, window) + except ValueError: # gedit 3.14 + pass + + self._window_ids = None + + def bind_window_settings(self, window_manager, settings, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_saving_window_states(): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("not saving window states")) + + return + + state = window_manager.get_window_state(window) + + if not state: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not get window state")) + + return + + window_id = settings.add_window() + self._window_ids[window] = window_id + + window_settings = settings.get_window_settings(window_id) + + if not window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not get window settings")) + + return + + try: + params = state.list_properties() + except AttributeError: # gedit 3.12 + params = GObject.list_properties(state) + + for param in params: + # this also immediately sets the settings based on the state values + window_settings.bind( + param.name, + state, param.name, + Gio.SettingsBindFlags.SET + ) + + connect_handlers( + self, state, + [ + 'uris-changed', + 'notebook-widths-changed' + ], + 'window_state', + window_settings + ) + + self.on_window_state_uris_changed(state, window_settings) + self.on_window_state_notebook_widths_changed(state, window_settings) + + def unbind_window_settings(self, window_manager, settings, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if not self.is_saving_window_states(): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("not saving window states")) + + return + + state = window_manager.get_window_state(window) + + if not state: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not get window state")) + + return + + if window not in self._window_ids: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not find window id")) + + return + + window_id = self._window_ids[window] + window_settings = settings.get_window_settings(window_id) + + if not window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not get window settings")) + + return + + try: + params = state.list_properties() + except AttributeError: # gedit 3.12 + params = GObject.list_properties(state) + + for param in params: + try: + window_settings.unbind(state, param.name) + except ValueError: # gedit 3.14 + pass + + disconnect_handlers(self, state) + + settings.remove_window(window_id) + + del self._window_ids[window] + + def on_window_state_uris_changed(self, state, window_settings): + window_settings['uris'] = state.restore_uris + + def on_window_state_notebook_widths_changed(self, state, window_settings): + window_settings['notebook-widths'] = state.restore_notebook_widths + + + # quitting + + def is_quitting(self): + return self._quitting is not None + + def start_quitting(self, window_manager): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + if self.is_quitting(): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("already started quitting")) + + app = Gedit.App.get_default() + + self._quitting = { + window : window_manager.export_window_state(window, True) + for window in app.get_main_windows() + } + + # can be called when not quitting + def cancel_quitting(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + if not self.is_quitting(): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not quitting")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("cancelling quitting")) + + self._quitting = None + + # can be called when not quitting + def update_quitting(self, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + if not self.is_quitting(): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not quitting")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("updating quitting")) + + if window not in self._quitting: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("unknown window")) + + return + + state = self._quitting[window] + + state.save_uri(window, tab) + state.forget_tab(tab) + + def end_quitting(self, settings, do_save): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("do_save=%s", do_save)) + + if not self.is_quitting(): + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("end quitting without starting")) + + return + + if do_save: + for window, state in self._quitting.items(): + if state.restore_uris: + window_id = settings.add_window() + window_settings = settings.get_window_settings(window_id) + + if not window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not get window settings")) + continue + + try: + params = state.list_properties() + except AttributeError: # gedit 3.12 + params = GObject.list_properties(state) + + for param in params: + window_settings[param.name] = state.get_property(param.name) + + window_settings['uris'] = state.restore_uris + window_settings['notebook-widths'] = state.restore_notebook_widths + + if log.query(log.MESSAGE): + Gedit.debug_plugin_message(log.format("saving %s windows", len(settings.restore_windows))) + + else: + if log.query(log.MESSAGE): + Gedit.debug_plugin_message(log.format("not saving windows")) + + self._quitting = None + + + # restoring + + def restore_windows(self, window_manager, settings, do_restore): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("do_restore=%s", do_restore)) + + states = [] + windows = [] + + for window_id in list(settings.restore_windows): + if do_restore: + state = window_manager.new_window_state() + window_settings = settings.get_window_settings(window_id) + + if not window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not get window settings")) + continue + + try: + params = state.list_properties() + except AttributeError: # gedit 3.12 + params = GObject.list_properties(state) + + for param in params: + state.set_property( + param.name, window_settings[param.name] + ) + + state.uris = window_settings['uris'] + state.notebook_widths = window_settings['notebook-widths'] + + if state.restore_uris: + states.append(state) + + settings.remove_window(window_id) + + if do_restore: + if log.query(log.MESSAGE): + Gedit.debug_plugin_message(log.format("restoring %s windows", len(states))) + + screen_width = window_manager.get_screen_width() + screen_height = window_manager.get_screen_height() + + for state in states: + # when gedit goes to open the first blank tab, + # it tries to find an active window first + # but it tests for windows in the current screen/workspace/viewport + # which is in part based on the size of the window + # so we need to shrink our windows here to fit the screen, + # otherwise gedit will think they are in a different viewport + # (if the window is too large for the screen, + # the window manager will probably resize the window to fit anyway) + if state.width > screen_width: + state.side_panel_size = round((state.side_panel_size / state.width) * screen_width) + state.width = screen_width + if state.height > screen_height: + state.bottom_panel_size = round((state.bottom_panel_size / state.height) * screen_height) + state.height = screen_height + + windows.append(window_manager.open_new_window_with_window_state(state)) + + self._restore_windows = {} + + # the window manager can choose to make another window active + # rather than the active window at the end of this process + # so listen for new tab on all windows + for window, state in zip(windows, states): + self.setup_restore_window(window, state) + + else: + if log.query(log.MESSAGE): + Gedit.debug_plugin_message(log.format("not restoring windows")) + + def setup_restore_window(self, window, state=None): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if self._restore_windows is None: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not handling restore windows")) + + return + + if window in self._restore_windows: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("restore window already set up")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("setting up restore window")) + + self._restore_windows[window] = window.connect( + 'tab-added', self.on_restore_window_tab_added, state + ) + + def teardown_restore_windows(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + if self._restore_windows is None: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not handling restore windows")) + + return + + for window in list(self._restore_windows.keys()): + self.teardown_restore_window(window) + + self._restore_windows = None + + def teardown_restore_window(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if self._restore_windows is None: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not handling restore windows")) + + return + + if window not in self._restore_windows: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("not restore window or restore window already torn down")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("tearing down restore window")) + + window.disconnect(self._restore_windows[window]) + + del self._restore_windows[window] + + def on_restore_window_tab_added(self, window, tab, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + if (tab.get_document().is_untouched() + and tab.get_state() == Gedit.TabState.STATE_NORMAL): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("closing untouched tab")) + + def close_tab(): + window.close_tab(tab) + + if not window.get_active_tab(): + window.close() + + elif state: + state.apply_active_uri(window) + state.apply_notebook_widths(window) + + return False + + GLib.idle_add(close_tab) + + else: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("new tab is not untouched")) + + self.teardown_restore_windows() + diff --git a/ex-mortis/schemas/com.thingsthemselves.gedit.plugins.ex-mortis.gschema.xml b/ex-mortis/schemas/com.thingsthemselves.gedit.plugins.ex-mortis.gschema.xml new file mode 100644 index 0000000..9a714aa --- /dev/null +++ b/ex-mortis/schemas/com.thingsthemselves.gedit.plugins.ex-mortis.gschema.xml @@ -0,0 +1,88 @@ + + + + + false + Restore windows between sessions + Whether ex-mortis should restore windows between sessions or not. + + + [] + Restore URIs + URIs (windows / tab groups / documents) to restore between sessions. + + + [] + Restore windows + List of windows to restore between sessions + + + + + + [] + Window URIs + URIs of open documents, grouped by notebook (tab group). + + + '' + Active URI + URI of the currently active document. + + + [] + Notebook widths + List of notebook (tab group) widths + + + 0 + Window width + Window width or 0 if not saved. + + + 0 + Window height + Window height or 0 if not saved. + + + false + Window is maximized + Whether the window is maximized or not. + + + false + Window is fullscreen + Whether the window is fullscreen or not. + + + '' + Side panel page name + Name of the currently visible side panel page. + + + 0 + Side panel size + Width of the side panel. + + + false + Side panel visible + Whether the side panel is visible or not. + + + '' + Bottom panel page name + Name of the currently visible bottom panel page. + + + 0 + Bottom panel size + Height of the bottom panel. + + + false + Bottom panel visible + Whether the bottom panel is visible or not. + + + diff --git a/ex-mortis/schemas/gschemas.compiled b/ex-mortis/schemas/gschemas.compiled new file mode 100644 index 0000000..d1b0acc Binary files /dev/null and b/ex-mortis/schemas/gschemas.compiled differ diff --git a/ex-mortis/settings.py b/ex-mortis/settings.py new file mode 100644 index 0000000..ba03193 --- /dev/null +++ b/ex-mortis/settings.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +# +# settings.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +import os.path +from gi.repository import GObject, Gio, Gedit +from . import log + +BASE_PATH = os.path.dirname(os.path.realpath(__file__)) +SCHEMAS_PATH = os.path.join(BASE_PATH, 'schemas') + + +class ExMortisSettings(GObject.Object): + + __gtype_name__ = 'ExMortisSettings' + + restore_between_sessions = GObject.Property(type=bool, default=False) + + restore_windows = GObject.Property(type=GObject.GType.from_name('GStrv'), default=[]) + + + def __init__(self): + GObject.Object.__init__(self) + + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + try: + schema_source = Gio.SettingsSchemaSource.new_from_directory( + SCHEMAS_PATH, + Gio.SettingsSchemaSource.get_default(), + False + ) + + except: + if log.query(log.CRITICAL): + Gedit.debug_plugin_message(log.format("could not load settings schema source from %s", SCHEMAS_PATH)) + + schema_source = None + + settings = get_settings( + schema_source, + 'com.thingsthemselves.gedit.plugins.ex-mortis', + '/com/thingsthemselves/gedit/plugins/ex-mortis/' + ) + + if settings: + try: + params = self.list_properties() + except AttributeError: # gedit 3.12 + params = GObject.list_properties(self) + + for param in params: + settings.bind( + param.name, + self, param.name, + Gio.SettingsBindFlags.DEFAULT + ) + + self._schema_source = schema_source + self._settings = settings + self._window_settings = {} + + for window_id in self.restore_windows: + self.init_window_settings(window_id) + + def cleanup(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + settings = self._settings + + if settings: + try: + params = self.list_properties() + except AttributeError: # gedit 3.12 + params = GObject.list_properties(self) + + for param in params: + try: + settings.unbind(self, param.name) + except ValueError: # gedit 3.14 + pass + + self._schema_source = None + self._settings = None + self._window_settings = None + + + @property + def can_save(self): + return bool(self._settings) + + + def add_window(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + window_id = self.find_unused_window_id() + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("adding window_id=%s", window_id)) + + self.init_window_settings(window_id) + + restore_windows = self.restore_windows + restore_windows.append(window_id) + self.restore_windows = restore_windows + + return window_id + + def remove_window(self, window_id): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("window_id=%s", window_id)) + + if window_id not in self._window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("unknown window id")) + + return + + self.reset_window_settings(window_id) + + restore_windows = self.restore_windows + restore_windows.remove(window_id) + self.restore_windows = restore_windows + + del self._window_settings[window_id] + + def find_unused_window_id(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + window_id_set = set(self.restore_windows) + counter = 0 + + while True: + window_id = 'window' + str(counter) + + if window_id not in window_id_set: + break + + counter += 1 + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("found window_id=%s", window_id)) + + return window_id + + def init_window_settings(self, window_id): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window_id)) + + if window_id in self._window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("already init")) + + return + + settings = get_settings( + self._schema_source, + 'com.thingsthemselves.gedit.plugins.ex-mortis.restore-window', + '/com/thingsthemselves/gedit/plugins/ex-mortis/restore-windows/' + window_id + '/' + ) + + self._window_settings[window_id] = settings + + def get_window_settings(self, window_id): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window_id)) + + if window_id not in self._window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("unknown window id")) + + return None + + return self._window_settings[window_id] + + def reset_window_settings(self, window_id): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window_id)) + + if window_id not in self._window_settings: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("unknown window id")) + + return + + settings = self._window_settings[window_id] + + for key in settings.keys(): + settings.reset(key) + + +def get_settings(schema_source, schema_id, settings_path): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("schema_id=%s, settings_path=%s", schema_id, settings_path)) + + if not schema_source: + if log.query(log.CRITICAL): + Gedit.debug_plugin_message(log.format("no schema source")) + + return None + + schema = schema_source.lookup(schema_id, False) + + if not schema: + if log.query(log.CRITICAL): + Gedit.debug_plugin_message(log.format("could not lookup '%s' in schema source", schema_id)) + + return None + + return Gio.Settings.new_full(schema, None, settings_path) + diff --git a/ex-mortis/utils/.editorconfig b/ex-mortis/utils/.editorconfig new file mode 100644 index 0000000..092187d --- /dev/null +++ b/ex-mortis/utils/.editorconfig @@ -0,0 +1,12 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/ex-mortis/utils/.gitattributes b/ex-mortis/utils/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/ex-mortis/utils/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/ex-mortis/utils/Changelog b/ex-mortis/utils/Changelog deleted file mode 100644 index 9f63c14..0000000 --- a/ex-mortis/utils/Changelog +++ /dev/null @@ -1,5 +0,0 @@ -2013-10-31 Jeffery To - - 0.1.0: - - * Initial release diff --git a/ex-mortis/utils/Changelog.md b/ex-mortis/utils/Changelog.md new file mode 100644 index 0000000..9784238 --- /dev/null +++ b/ex-mortis/utils/Changelog.md @@ -0,0 +1,8 @@ +# Changelog + +## 0.2.0 (2017-10-13) +* Added create_bindings / release_bindings +* Added to_name, debug_str + +## 0.1.0 (2013-10-31) +* Initial release diff --git a/ex-mortis/utils/README.md b/ex-mortis/utils/README.md index 3d9b017..5740d5d 100644 --- a/ex-mortis/utils/README.md +++ b/ex-mortis/utils/README.md @@ -1,14 +1,14 @@ -# python-gtk-utils # +# python-gtk-utils A collection of utilities ready to be `git subtree`-ed into a Python GTK+ project -0.1.0 +0.2.0 All bug reports, feature requests and miscellaneous comments are welcome at the [project issue tracker][]. -## Installation ## +## Installation Use `git subtree` to pull this sub-project into your project: @@ -29,13 +29,13 @@ Pull for updates: git subtree pull --prefix=path/to/code/utils --squash python-gtk-utils master ``` -## Documentation ## +## Documentation ...would be a good idea ;-) -## License ## +## License -Copyright © 2013 Jeffery To +Copyright © 2013, 2017 Jeffery To Available under GNU General Public License version 3 diff --git a/ex-mortis/utils/__init__.py b/ex-mortis/utils/__init__.py index 878d857..b2f9a08 100644 --- a/ex-mortis/utils/__init__.py +++ b/ex-mortis/utils/__init__.py @@ -3,7 +3,7 @@ # __init__.py # This file is part of python-gtk-utils # -# Copyright (C) 2013 Jeffery To +# Copyright (C) 2013, 2017 Jeffery To # https://github.com/jefferyto/python-gtk-utils # # This program is free software: you can redistribute it and/or modify @@ -19,12 +19,26 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from gi.repository import GObject + + +# from future.utils + +def _iteritems(obj, **kwargs): + func = getattr(obj, 'iteritems', None) + if not func: + func = obj.items + return func(**kwargs) + + +# signal handlers + def _get_handler_ids_name(ns): return ns.__class__.__name__ + 'HandlerIds' def _get_handler_ids(ns, target): name = _get_handler_ids_name(ns) - return getattr(target, name) if hasattr(target, name) else [] + return getattr(target, name, []) def _set_handler_ids(ns, target, ids): name = _get_handler_ids_name(ns) @@ -42,7 +56,8 @@ def connect_handlers(ns, target, signals, prefix_or_fn, *args): if hasattr(prefix_or_fn, '__call__'): fn = prefix_or_fn else: - fn = getattr(ns, 'on_%s_%s' % (prefix_or_fn, signal.replace('-', '_').replace('::', '_'))) + fn = getattr(ns, 'on_%s_%s' % (prefix_or_fn, to_name(signal))) + handler_ids.append(target.connect(signal, fn, *args)) _set_handler_ids(ns, target, handler_ids) @@ -60,3 +75,74 @@ def block_handlers(ns, target): def unblock_handlers(ns, target): for handler_id in _get_handler_ids(ns, target): target.handler_unblock(handler_id) + + +# bindings + +def _get_bindings_name(ns): + return ns.__class__.__name__ + 'Bindings' + +def _get_bindings(ns, source, target): + name = _get_bindings_name(ns) + binding_map = getattr(source, name, {}) + return binding_map[target] if target in binding_map else [] + +def _set_bindings(ns, source, target, bindings): + name = _get_bindings_name(ns) + binding_map = getattr(source, name, {}) + binding_map[target] = bindings + setattr(source, name, binding_map) + +def _del_bindings(ns, source, target): + name = _get_bindings_name(ns) + binding_map = getattr(source, name, {}) + if target in binding_map: + del binding_map[target] + if not binding_map and hasattr(source, name): + delattr(source, name) + +def create_bindings(ns, source, target, properties, *args): + bindings = _get_bindings(ns, source, target) + + if isinstance(properties, dict): + for (source_property, target_property) in _iteritems(properties): + binding = source.bind_property( + source_property, + target, target_property, + *args + ) + bindings.append(binding) + + else: + for prop in properties: + binding = source.bind_property( + prop, + target, prop, + flags, + transform_to, transform_from, user_data + ) + bindings.append(binding) + + _set_bindings(ns, source, target, bindings) + +def release_bindings(ns, source, target): + for binding in _get_bindings(ns, source, target): + GObject.Binding.unbind(binding) + + _del_bindings(ns, source, target) + + +# misc + +def to_name(value): + return str(value).replace('-', '_').replace('::', '_') + +def debug_str(value): + if isinstance(value, GObject.Object): + # hash(value) is the memory address of the underlying gobject + result = value.__gtype__.name + ': ' + hex(hash(value)) + else: + result = value + + return result + diff --git a/ex-mortis/windowmanager.py b/ex-mortis/windowmanager.py new file mode 100644 index 0000000..6be0883 --- /dev/null +++ b/ex-mortis/windowmanager.py @@ -0,0 +1,560 @@ +# -*- coding: utf-8 -*- +# +# windowmanager.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +from gi.repository import GObject, GLib, Gtk, Gdk, Gedit +from .windowstate import ExMortisWindowState +from .utils import connect_handlers, disconnect_handlers +from . import log + + +class ExMortisWindowManager(GObject.Object): + + __gtype_name__ = 'ExMortisWindowManager' + + + def __init__(self): + GObject.Object.__init__(self) + + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._windows = {} + self._debounce_ids = {} + + def cleanup(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + for window in list(self._windows.keys()): + self.untrack_window(window) + + self._windows = None + self._debounce_ids = None + + + # signals + + @GObject.Signal(arg_types=(Gedit.Window, Gedit.Tab)) + def tab_added(self, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + @GObject.Signal(arg_types=(Gedit.Window, Gedit.Tab)) + def tab_removed(self, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + @GObject.Signal(arg_types=(Gedit.Window,)) + def tabs_reordered(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + @GObject.Signal(arg_types=(Gedit.Window, Gedit.Tab)) + def active_tab_changed(self, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + @GObject.Signal(arg_types=(Gedit.Window, Gedit.Tab)) + def tab_updated(self, window, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + + # tracking / untracking windows + + def track_window(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if window in self._windows: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("window already being tracked")) + + return + + state = ExMortisWindowState() + state.save_window(window) + + multi_notebook = window.get_template_child(Gedit.Window, 'multi_notebook') + side_panel = window.get_side_panel() + bottom_panel = window.get_bottom_panel() + hpaned = window.get_template_child(Gedit.Window, 'hpaned') + vpaned = window.get_template_child(Gedit.Window, 'vpaned') + + connect_handlers( + self, window, + [ + 'tab-added', + 'tab-removed', + 'tabs-reordered', + 'active-tab-changed', + 'configure-event', + 'window-state-event', + ], + 'window', + state + ) + connect_handlers( + self, multi_notebook, + [ + 'notebook-added', + 'notebook-removed' + ], + 'multi_notebook', + window, state + ) + connect_handlers( + self, side_panel, + [ + 'notify::visible-child-name', + 'notify::visible' + ], + 'side_panel', + window, state + ) + connect_handlers( + self, bottom_panel, + [ + 'notify::visible-child-name', + 'notify::visible' + ], + 'bottom_panel', + window, state + ) + connect_handlers( + self, hpaned, + [ + 'notify::position' + ], + 'hpaned', + window, state + ) + connect_handlers( + self, vpaned, + [ + 'notify::position' + ], + 'vpaned', + window, state + ) + + self._windows[window] = ( + state, + { + 'multi_notebook': multi_notebook, + 'side_panel': side_panel, + 'bottom_panel': bottom_panel, + 'hpaned': hpaned, + 'vpaned': vpaned + } + ) + + for paned in self.find_paneds(multi_notebook): + self.track_paned(window, paned, state, multi_notebook) + + for document in window.get_documents(): + self.track_tab(window, Gedit.Tab.get_from_document(document), state) + + def untrack_window(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if window not in self._windows: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("unknown window")) + + return + + state, widgets = self._windows[window] + multi_notebook = widgets['multi_notebook'] + hpaned = widgets['hpaned'] + vpaned = widgets['vpaned'] + + for document in window.get_documents(): + self.untrack_tab(window, Gedit.Tab.get_from_document(document), state) + + for paned in self.find_paneds(multi_notebook): + self.untrack_paned(window, paned, state, multi_notebook) + + self.cancel_debounce(window) + self.cancel_debounce(multi_notebook) + self.cancel_debounce(hpaned) + self.cancel_debounce(vpaned) + + disconnect_handlers(self, window) + disconnect_handlers(self, multi_notebook) + disconnect_handlers(self, widgets['side_panel']) + disconnect_handlers(self, widgets['bottom_panel']) + disconnect_handlers(self, hpaned) + disconnect_handlers(self, vpaned) + + del self._windows[window] + + def track_paned(self, window, paned, state, multi_notebook): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, paned)) + + connect_handlers( + self, paned, + ['notify::position'], + 'paned', + window, state, multi_notebook + ) + + def untrack_paned(self, window, paned, state, multi_notebook): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, paned)) + + disconnect_handlers(self, paned) + + def track_tab(self, window, tab, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + connect_handlers(self, tab, ['notify::name'], 'tab', window, state) + + def untrack_tab(self, window, tab, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + disconnect_handlers(self, tab) + + def find_paneds(self, root): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", root)) + + stack = root.get_children() + results = [] + + while stack: + widget = stack.pop() + + if isinstance(widget, Gtk.Paned): + results.append(widget) + stack.extend(widget.get_children()) + + return results + + + # window state + + def new_window_state(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + return ExMortisWindowState() + + def get_window_state(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + if window not in self._windows: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("unknown window")) + + return None + + state, widgets = self._windows[window] + + return state + + def export_window_state(self, window, forget_notebooks=False, forget_tabs=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, forget_notebooks=%s, forget_tabs=%s", window, forget_notebooks, forget_tabs)) + + state = self.get_window_state(window) + + if not state: + return None + + export_state = ExMortisWindowState.clone(state) + + if forget_notebooks: + export_state.forget_notebooks() + + if forget_tabs: + export_state.forget_tabs() + + return export_state + + def import_window_state(self, window, import_state, is_new_window=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, is_new_window=%s", window, is_new_window)) + + state = self.get_window_state(window) + + if not state: + return + + import_state.apply_window(window, is_new_window) + + def save_to_window_state(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state = self.get_window_state(window) + + if state: + state.save_window(window) + + def restore_from_window_state(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state = self.get_window_state(window) + + if state: + state.apply_window(window) + + def open_new_window_with_window_state(self, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + app = Gedit.App.get_default() + window = Gedit.App.create_window(app, None) + + self.import_window_state(window, state, True) + + window.present() + + return window + + + # signal handlers + + def on_window_tab_added(self, window, tab, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + self.track_tab(window, tab, state) + + state.update_structure(window) + + self.emit('tab-added', window, tab) + + def on_window_tab_removed(self, window, tab, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + self.untrack_tab(window, tab, state) + + state.update_structure(window) + + self.emit('tab-removed', window, tab) + + def on_window_tabs_reordered(self, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.update_structure(window) + + self.emit('tabs-reordered', window) + + def on_window_active_tab_changed(self, window, tab, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + state.save_active_uri(window, tab) + + self.emit('active-tab-changed', window, tab) + + # this signal could be emitted frequently + def on_window_configure_event(self, window, event, state): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s", window)) + + self.debounce(window, self.debounce_save_window_size, state) + + def on_window_window_state_event(self, window, event, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_window_state(window, event.new_window_state) + + def on_multi_notebook_notebook_added(self, multi_notebook, notebook, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, notebook)) + + self.track_paned(window, notebook.get_parent(), state, multi_notebook) + + self.debounce(multi_notebook, self.debounce_save_notebook_widths, window, state) + + def on_multi_notebook_notebook_removed(self, multi_notebook, notebook, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, notebook)) + + # can't untrack_paned() since the notebook is already disconnected and the paned gone + + self.debounce(multi_notebook, self.debounce_save_notebook_widths, window, state) + + def on_side_panel_notify_visible_child_name(self, side_panel, pspec, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s",window)) + + state.save_side_panel_page_name(window) + + def on_side_panel_notify_visible(self, side_panel, pspec, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_side_panel_visible(window) + + def on_bottom_panel_notify_visible_child_name(self, bottom_panel, pspec, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_bottom_panel_page_name(window) + + def on_bottom_panel_notify_visible(self, bottom_panel, pspec, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_bottom_panel_visible(window) + + # this signal could be emitted frequently + def on_hpaned_notify_position(self, hpaned, pspec, window, state): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s", window)) + + self.debounce(hpaned, self.debounce_save_side_panel_size, window, state) + + # this signal could be emitted frequently + def on_vpaned_notify_position(self, vpaned, pspec, window, state): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s", window)) + + self.debounce(vpaned, self.debounce_save_bottom_panel_size, window, state) + + # this signal could be emitted frequently + def on_paned_notify_position(self, paned, pspec, window, state, multi_notebook): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s, %s", window, paned)) + + self.debounce(multi_notebook, self.debounce_save_notebook_widths, window, state) + + def on_tab_notify_name(self, tab, pspec, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, tab)) + + state.save_uri(window, tab) + + self.emit('tab-updated', window, tab) + + + # debounced handlers + + def debounce_save_window_size(self, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_size(window) + + self.done_debounce(window) + + return False + + def debounce_save_side_panel_size(self, hpaned, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_side_panel_size(window) + + self.done_debounce(hpaned) + + return False + + def debounce_save_bottom_panel_size(self, vpaned, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_bottom_panel_size(window) + + self.done_debounce(vpaned) + + return False + + def debounce_save_notebook_widths(self, multi_notebook, window, state): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + state.save_notebook_widths(window) + + self.done_debounce(multi_notebook) + + return False + + + # debouncing + + def debounce(self, obj, fn, *args): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s", obj)) + + self.cancel_debounce(obj) + + self._debounce_ids[obj] = GLib.timeout_add(1000, fn, obj, *args) + + def cancel_debounce(self, obj): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s", obj)) + + if obj in self._debounce_ids: + GLib.source_remove(self._debounce_ids[obj]) + del self._debounce_ids[obj] + + def done_debounce(self, obj): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s", obj)) + + if obj in self._debounce_ids: + del self._debounce_ids[obj] + + + # screen info + + def get_screen_width(self, screen=None): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", screen)) + + if not screen: + screen = Gdk.Screen.get_default() + + width = screen.get_width() + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("width=%s", width)) + + return width + + def get_screen_height(self, screen=None): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", screen)) + + if not screen: + screen = Gdk.Screen.get_default() + + height = screen.get_height() + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("height=%s", height)) + + return height + diff --git a/ex-mortis/windowstate.py b/ex-mortis/windowstate.py new file mode 100644 index 0000000..6ce1fd6 --- /dev/null +++ b/ex-mortis/windowstate.py @@ -0,0 +1,837 @@ +# -*- coding: utf-8 -*- +# +# windowstate.py +# This file is part of Ex-Mortis, a plugin for gedit +# +# Copyright (C) 2017 Jeffery To +# https://github.com/jefferyto/gedit-ex-mortis +# +# 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 . + +from gi.repository import GObject, Gtk, Gdk, Gio, Gedit +from . import log + + +class ExMortisWindowState(GObject.Object): + + __gtype_name__ = 'ExMortisWindowState' + + active_uri = GObject.Property(type=str, default='') + + width = GObject.Property(type=int, default=0) + + height = GObject.Property(type=int, default=0) + + maximized = GObject.Property(type=bool, default=False) + + fullscreen = GObject.Property(type=bool, default=False) + + side_panel_page_name = GObject.Property(type=str, default='') + + side_panel_size = GObject.Property(type=int, default=0) + + side_panel_visible = GObject.Property(type=bool, default=False) + + bottom_panel_page_name = GObject.Property(type=str, default='') + + bottom_panel_size = GObject.Property(type=int, default=0) + + bottom_panel_visible = GObject.Property(type=bool, default=False) + + + def __init__(self): + GObject.Object.__init__(self) + + self._notebook_map = {} + self._tab_map = {} + self._restore_filter = [] + self._uris = [] + self._restore_uris = [] + self._notebook_widths = [] + self._restore_notebook_widths = [] + self._active_tab = None + + + # class methods + + @classmethod + def clone(cls, source): + clone = cls() + + try: + params = cls.list_properties() + except AttributeError: # gedit 3.12 + params = GObject.list_properties(cls) + + for param in params: + clone.set_property(param.name, source.get_property(param.name)) + + clone._notebook_map = dict(source._notebook_map) + clone._tab_map = dict(source._tab_map) + clone._active_tab = source._active_tab + + clone.uris = source.uris + clone.notebook_widths = source.notebook_widths + + return clone + + + # properties + + @property + def uris(self): + return copy_uris(self._uris) + + @uris.setter + def uris(self, value): + uris = copy_uris(value) + + if uris != self._uris: + self._uris = uris + + self.emit('uris-changed') + + @property + def restore_uris(self): + return copy_uris(self._restore_uris) + + @property + def notebook_widths(self): + return list(self._notebook_widths) + + @notebook_widths.setter + def notebook_widths(self, value): + notebook_widths = list(value) + + if notebook_widths != self._notebook_widths: + self._notebook_widths = notebook_widths + + self.emit('notebook-widths-changed') + + @property + def restore_notebook_widths(self): + return list(self._restore_notebook_widths) + + + # signals + + @GObject.Signal + def uris_changed(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + filtered = [[uri for uri in uris if uri] for uris in self._uris] + + self._restore_uris = [uris for uris in filtered if uris] + + restore_filter = [bool(uris) for uris in filtered] + + if restore_filter != self._restore_filter: + self._restore_filter = restore_filter + + self.emit('notebook-widths-changed') + + @GObject.Signal + def notebook_widths_changed(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + zipped = zip(self._restore_filter, self._notebook_widths) + self._restore_notebook_widths = [width for can_restore, width in zipped if can_restore] + + + # saving / applying windows + + def save_window(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + self.update_structure(window) + self.save_active_uri(window, window.get_active_tab()) + + # window state affects whether size is saved or not + self.save_window_state(window) + self.save_size(window) + + self.save_side_panel_page_name(window) + self.save_side_panel_visible(window) + self.save_bottom_panel_page_name(window) + self.save_bottom_panel_visible(window) + + self.save_side_panel_size(window) + self.save_bottom_panel_size(window) + + def apply_window(self, window, is_new_window=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, is_new_window=%s", window, is_new_window)) + + # need to unmaximize/unfullscreen to set size + window.unmaximize() + window.unfullscreen() + + self.apply_size(window, is_new_window) + self.apply_window_state(window) + + self.apply_side_panel_page_name(window) + self.apply_side_panel_visible(window) + self.apply_bottom_panel_page_name(window) + self.apply_bottom_panel_visible(window) + + if is_new_window: + window.show() + + self.apply_side_panel_size(window) + self.apply_bottom_panel_size(window) + + self.apply_uris(window) + self.apply_active_uri(window) + + self.apply_notebook_widths(window) + + + # property helpers + + def save_property(self, property_name, value): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("%s=%s", property_name, value)) + + prev = self.get_property(property_name) + + if value == prev: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no change")) + + return False + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("prev %s=%s", property_name, prev)) + + self.set_property(property_name, value) + + return True + + + # window structure + + def update_structure(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + prev_uris = self._uris + prev_notebook_widths = self._notebook_widths + + notebook_map = {} + tab_map = {} + uris = [] + notebook_widths = [] + + for document in window.get_documents(): + tab = Gedit.Tab.get_from_document(document) + notebook = tab.get_parent() + + if notebook not in notebook_map: + notebook_map[notebook] = len(uris) + uris.append([]) + notebook_widths.append(0) + + notebook_index = notebook_map[notebook] + notebook_uris = uris[notebook_index] + + tab_index = len(notebook_uris) + notebook_uris.append('') + + tab_map[tab] = (notebook_index, tab_index) + + self._notebook_map = notebook_map + self._tab_map = tab_map + self._uris = uris + self._notebook_widths = notebook_widths + + self.save_uris(window, True) + self.save_notebook_widths(window, True) + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("uris=%s, notebook_widths=%s", uris, notebook_widths)) + + if uris == prev_uris and notebook_widths == prev_notebook_widths: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no change")) + + return False + + if uris != prev_uris: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("prev uris=%s", prev_uris)) + + self.emit('uris-changed') + + if notebook_widths != prev_notebook_widths: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("prev notebook_widths=%s", prev_notebook_widths)) + + self.emit('notebook-widths-changed') + + return True + + def forget_notebooks(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._notebook_map = {} + + def forget_notebook(self, notebook): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", notebook)) + + if notebook in self._notebook_map: + del self._notebook_map[notebook] + + def forget_tabs(self): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("")) + + self._tab_map = {} + self._active_tab = None + + def forget_tab(self, tab): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", tab)) + + if tab == self._active_tab: + self._active_tab = None + + if tab in self._tab_map: + del self._tab_map[tab] + + + # window uris + + def save_uris(self, window, bulk_update=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, bulk_update=%s", window, bulk_update)) + + results = [self.save_uri(window, tab, True) for tab in self._tab_map.keys()] + changed = any(results) + + if not bulk_update and changed: + self.emit('uris-changed') + + return changed + + def save_uri(self, window, tab, bulk_update=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s, bulk_update=%s", window, tab, bulk_update)) + + if not bulk_update and tab == self._active_tab: + self.save_active_uri(window) + + if tab not in self._tab_map: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("not in tab map")) + + return False + + notebook_index, tab_index = self._tab_map[tab] + + prev_uri = self._uris[notebook_index][tab_index] + + uri = get_tab_uri(tab) + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("uri=%s", uri)) + + if uri == prev_uri: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no change")) + + return False + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("prev uri=%s", prev_uri)) + + self._uris[notebook_index][tab_index] = uri + + if not bulk_update: + self.emit('uris-changed') + + return True + + def apply_uris(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + uris = self._restore_uris + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying uris=%s", uris)) + + if uris: + documents = window.get_documents() + create_notebook = False + + if documents: + window.set_active_tab(Gedit.Tab.get_from_document(documents[-1])) + + for notebook_uris in uris: + if create_notebook: + window.activate_action('new-tab-group') + + locations = [ + Gio.File.new_for_uri(uri) + for uri in notebook_uris + ] + + Gedit.commands_load_locations(window, locations, None, 0, 0) + + create_notebook = True + + + # window notebook widths + + def save_notebook_widths(self, window, bulk_update=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, bulk_update=%s", window, bulk_update)) + + results = [self.save_notebook_width(window, notebook, True) for notebook in self._notebook_map.keys()] + changed = any(results) + + if not bulk_update and changed: + self.emit('notebook-widths-changed') + + return changed + + def save_notebook_width(self, window, notebook, bulk_update=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s, bulk_update=%s", window, notebook, bulk_update)) + + if notebook not in self._notebook_map: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("not in notebook map")) + + return False + + notebook_index = self._notebook_map[notebook] + + prev_notebook_width = self._notebook_widths[notebook_index] + + notebook_width = notebook.get_allocation().width + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("notebook_width=%s", notebook_width)) + + if notebook_width == prev_notebook_width: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no change")) + + return False + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("prev notebook_width=%s", prev_notebook_width)) + + self._notebook_widths[notebook_index] = notebook_width + + if not bulk_update: + self.emit('notebook-widths-changed') + + return True + + def apply_notebook_widths(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + # this only works with the notebook structure created by apply_uris() + + notebook_widths = list(self._notebook_widths) + notebooks = [] + notebooks_set = set() + + if len(notebook_widths) < 2: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("have %s notebook widths, not enough to apply", len(notebook_widths))) + + return + + for document in window.get_documents(): + notebook = Gedit.Tab.get_from_document(document).get_parent() + + if notebook not in notebooks_set: + notebooks.append(notebook) + notebooks_set.add(notebook) + + if len(notebooks) < 2: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("have %s notebooks, not enough to apply", len(notebooks))) + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying notebook_widths=%s", notebook_widths)) + + min_len = min(len(notebooks), len(notebook_widths)) + + # we won't set the width of the last notebook + zipped = zip(notebooks[-min_len:-1], notebook_widths[-min_len:-1]) + + for notebook, notebook_width in zipped: + parent = notebook.get_parent() + + if not isinstance(parent, Gtk.Paned): + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("parent %s of notebook %s is not a Gtk.Paned", parent, notebook)) + + continue + + if parent.get_child2() == notebook: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("notebook %s is not the left child of parent %s", notebook, parent)) + + continue + + parent.set_position(notebook_width) + + + # window active uri + + def save_active_uri(self, window, new_active_tab=None): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, %s", window, new_active_tab)) + + if new_active_tab: + self._active_tab = new_active_tab + + active_tab = self._active_tab + + if not active_tab: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no active tab")) + + return False + + active_uri = get_tab_uri(active_tab) + + return self.save_property('active-uri', active_uri) + + def apply_active_uri(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + active_uri = self.active_uri + + if not active_uri: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no active uri")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying active_uri=%s", active_uri)) + + location = Gio.File.new_for_uri(active_uri) + tab = window.get_tab_from_location(location) + + if not tab: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("could not find tab for active uri")) + + return + + window.set_active_tab(tab) + + + # window size + + def save_size(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + # gedit should (always?) set a default size + # if it hasn't been set on this window yet, + # get_size() will return a wrong size + + default_width, default_height = window.get_default_size() + + if default_width == -1 and default_height == -1: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("default size not set")) + + return False + + width = 0 + height = 0 + + if not self.maximized and not self.fullscreen: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("using get_size()")) + + width, height = window.get_size() + + # if we haven't saved before, try default size + elif not self.width and not self.height: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("using get_default_size()")) + + width = default_width + height = default_height + + if not width or not height: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no size to save")) + + return False + + results = [ + self.save_property('width', width), + self.save_property('height', height) + ] + + return any(results) + + def apply_size(self, window, set_default_size=False): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, set_default_size=%s", window, set_default_size)) + + width = self.width + height = self.height + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying width=%s, height=%s", width, height)) + + if set_default_size: + window.set_default_size(width, height) + else: + window.resize(width, height) + + + # window state (maximized / fullscreen) + + def save_window_state(self, window, window_state=None): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s, window_state=%s", window, window_state)) + + if window_state is None: + gdk_window = window.get_window() + + if not gdk_window: + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("window not yet realized")) + + return False + + window_state = gdk_window.get_state() + + maximized = bool(window_state & Gdk.WindowState.MAXIMIZED) + fullscreen = bool(window_state & Gdk.WindowState.FULLSCREEN) + + results = [ + self.save_property('maximized', maximized), + self.save_property('fullscreen', fullscreen) + ] + + return any(results) + + def apply_window_state(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + maximized = self.maximized + fullscreen = self.fullscreen + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying maximized=%s, fullscreen=%s", maximized, fullscreen)) + + if maximized: + window.maximize() + else: + window.unmaximize() + + if fullscreen: + window.fullscreen() + else: + window.unfullscreen() + + + # side panel page name + + def save_side_panel_page_name(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + side_panel = window.get_side_panel() + page_name = side_panel.get_visible_child_name() + if not page_name: + page_name = '' + + return self.save_property('side-panel-page-name', page_name) + + def apply_side_panel_page_name(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + page_name = self.side_panel_page_name + + if not page_name: + if log.query(log.WARNING): + Gedit.debug_plugin_message(log.format("no page name")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying page_name=%s", page_name)) + + side_panel = window.get_side_panel() + side_panel.set_visible_child_name(page_name) + + + # side panel size + + def save_side_panel_size(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + hpaned = window.get_template_child(Gedit.Window, 'hpaned') + position = hpaned.get_position() + + return self.save_property('side-panel-size', position) + + def apply_side_panel_size(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + size = self.side_panel_size + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying size=%s", size)) + + hpaned = window.get_template_child(Gedit.Window, 'hpaned') + hpaned.set_position(size) + + + # side panel visible + + def save_side_panel_visible(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + side_panel = window.get_side_panel() + visible = side_panel.get_visible() + + return self.save_property('side-panel-visible', visible) + + def apply_side_panel_visible(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + visible = self.side_panel_visible + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying visible=%s", visible)) + + side_panel = window.get_side_panel() + side_panel.set_visible(visible) + + + # bottom panel page name + + def save_bottom_panel_page_name(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + bottom_panel = window.get_bottom_panel() + page_name = bottom_panel.get_visible_child_name() + if not page_name: + page_name = '' + + return self.save_property('bottom-panel-page-name', page_name) + + def apply_bottom_panel_page_name(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + page_name = self.bottom_panel_page_name + + if not page_name: + # it is possible there are no bottom panel pages + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("no page name")) + + return + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying page_name=%s", page_name)) + + bottom_panel = window.get_bottom_panel() + bottom_panel.set_visible_child_name(page_name) + + + # bottom panel size + + def save_bottom_panel_size(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + vpaned = window.get_template_child(Gedit.Window, 'vpaned') + height = vpaned.get_allocation().height + position = vpaned.get_position() + size = max(height - position, 50) + + return self.save_property('bottom-panel-size', size) + + def apply_bottom_panel_size(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + size = self.bottom_panel_size + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying size=%s", size)) + + vpaned = window.get_template_child(Gedit.Window, 'vpaned') + height = vpaned.get_allocation().height + position = max(height - size, 50) + vpaned.set_position(position) + + + # bottom panel visible + + def save_bottom_panel_visible(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + bottom_panel = window.get_bottom_panel() + visible = bottom_panel.get_visible() + + return self.save_property('bottom-panel-visible', visible) + + def apply_bottom_panel_visible(self, window): + if log.query(log.INFO): + Gedit.debug_plugin_message(log.format("%s", window)) + + visible = self.bottom_panel_visible + + if log.query(log.DEBUG): + Gedit.debug_plugin_message(log.format("applying visible=%s", visible)) + + bottom_panel = window.get_bottom_panel() + bottom_panel.set_visible(visible) + + +def copy_uris(source): + return [[uri for uri in uris] for uris in source] + +def get_tab_uri(tab): + document = tab.get_document() + try: + location = document.get_file().get_location() + except AttributeError: # gedit 3.12 + location = document.get_location() + return location.get_uri() if location else '' +