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 ''
+