diff options
author | Jesús Eduardo <heckyel@hyperbola.info> | 2017-05-31 18:08:31 -0500 |
---|---|---|
committer | Jesús Eduardo <heckyel@hyperbola.info> | 2017-05-31 18:08:31 -0500 |
commit | e1180428ed3e7634fe1596103511fbb1da05f228 (patch) | |
tree | 13de9592bcde7050b089b9644839668024c518b3 | |
download | librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.tar.lz librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.tar.xz librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.zip |
first commit
247 files changed, 31361 insertions, 0 deletions
@@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<http://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<http://www.gnu.org/philosophy/why-not-lgpl.html>.
\ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..947e61c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,11 @@ +include mvc/resources/converters/*.py +include *.sh +include LICENSE +include README.md +include README.widgets +include mvc/widgets/osx/fasttypes.c +include mvc/widgets/osx/Resources-Widgets/MainMenu.nib/*.nib +recursive-include helperscripts * +recursive-include setup-files * +recursive-include mvc/resources * +recursive-include test * diff --git a/README.md b/README.md new file mode 100644 index 0000000..58b6924 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Libre Video Converter # + +## Acerca ## + +Con [Libre Video Converter](https://notabug.org/Heckyel/LibreVideoConverter) ahora es súper sencillo convertir casi cualquier vídeo a formato WebM (VP8), MP4, Ogg Theora para cualquier dispositivo u ordenador. + +Este programa es una versión modificada de la aplicación existente [Miro Video Converter](https://github.com/pculture/mirovideoconverter3). + +## Requisitos ## + +* Python 2.7 +* FFmpeg +* FFmpeg2Theora +* GTK2 and its Python bindings (for the GTK UI) + +Una copia de [qtfaststart](https://github.com/danielgtaylor/qtfaststart) está incluido dentro de esta aplicación, es +bajo la licencia GPL versión 3. + +Puede instalar en derivados de Debian/Ubuntu como Trisquel GNU/Linux + + $ sudo apt-get install -y python2.7 ffmpeg ffmpeg2theora python-gtk2 + +Para instalar en derivados de Archlinux como Parabola GNU/Linux-Libre + + $ sudo pacman -S python2 ffmpeg ffmpeg2theora gtk2 + +## Ejecutando el programa ## + + $ git clone https://notabug.org/Heckyel/LibreVideoConverter + $ cd LibreVideoConverter + $ python2.7 test/runtests.py # para realizar pruebas. + $ python2.7 -m mvc.ui.widgets # Interfaz en GTK + $ python2.7 -m mvc.ui.console [filename to convert] [conversion type] # desde la terminal o consola + +## Contribuir ## + +LibreVideoConverter es una librería de [Software Libre](https://www.gnu.org/home.es.html), y apreciamos cualquier ayuda que estés dispuesto a dar. + +## Licencia ## + +LibreVideoConverter esta bajo la licencia GNU GPLv3+ [Ver el archivo de licencia](LICENSE) diff --git a/README.widgets b/README.widgets new file mode 100644 index 0000000..d58c305 --- /dev/null +++ b/README.widgets @@ -0,0 +1,17 @@ +For now the widgets are to be grown automatically. It is mirrored after +the installed structure of the LibreVideoConverter library so follow it please, so you can +do diff on it. + +General rule is that anything in gtk/cocoa is pretty much fair game, +there's usually some good reason for stuff in miro.frontends.widgets. + +For stuff in LibreVideoConverter namespace, think carefully before adding it, with +the exception if it is utilty functions (which we will further abstract +away) or a base class of a platform impementation (such as player.Player). + +Python is dynamically interpreted so it is generally safe to remove +the top-level imports and still have the thing run. + +As a convention, please prepend the line with ### when you are removing +something, and when you are adding something add ###XXXMVC on the line +before your first addition. diff --git a/build-windows.sh b/build-windows.sh new file mode 100755 index 0000000..2dba3cf --- /dev/null +++ b/build-windows.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +if [ ! -e mvc-env ] ; then + echo "LVC virtualenv is not present. Run " + echo + echo " python helperscripts/windows-virtualenv/ mvc-env" + echo + echo "to build it" + exit 1 +fi + +PYTHONPATH="." mvc-env/Scripts/python.exe setup.py bdist_nsis diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..7052441 --- /dev/null +++ b/build.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -e + +if [ -z ${SANDBOX_PATH} ]; then + echo "you must set SANDBOX_PATH to point to the built LibreVideoConverter sandbox" + exit 1 +fi + +if [ -z ${BKIT_PATH} ]; then + echo "you must set BKIT_PATH to point to the LibreVideoConverter binary kit" + exit +fi + +export MACOSX_DEPLOYMENT_TARGET=10.6 + +${SANDBOX_PATH}/Frameworks/Python.framework/Versions/2.7/bin/python setup.py develop + +${SANDBOX_PATH}/Frameworks/Python.framework/Versions/2.7/bin/python setup.py py2app + +if [ "$1" = "--sign" ]; then + source sign.sh +fi + +if [ "$2" = "--installer" ] ; then + source build_installer.sh +fi diff --git a/build_installer.sh b/build_installer.sh new file mode 100644 index 0000000..2d7e4d3 --- /dev/null +++ b/build_installer.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +productbuild \ + --component "dist/Libre Video Converter.app" /Applications \ + --sign 'Freedom System: Heckyel | 2017' \ + --product setup-files/osx/mvc3_definition.plist mvc3.pkg diff --git a/clean.sh b/clean.sh new file mode 100755 index 0000000..a38eb67 --- /dev/null +++ b/clean.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +rm -fr build +rm -fr dist +rm -fr librevideoconverter.egg-info diff --git a/helperscripts/debian_ubuntu.sh b/helperscripts/debian_ubuntu.sh new file mode 100755 index 0000000..57f8df0 --- /dev/null +++ b/helperscripts/debian_ubuntu.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# This script installs dependencies for building and running LVC on +# Debian 8.5 (Jessie) +# +# You run this sript AT YOUR OWN RISK. Read through the whole thing +# before running it! +# +# This script must be run with sudo. + +# Last updated: 2017-04-18 +# Last updated by: Jesús Eduardo + +apt-get install \ + python-gtk2 \ + ffmpeg \ + ffmpeg2theora diff --git a/helperscripts/windows-virtualenv/README.txt b/helperscripts/windows-virtualenv/README.txt new file mode 100644 index 0000000..3c164c5 --- /dev/null +++ b/helperscripts/windows-virtualenv/README.txt @@ -0,0 +1,20 @@ +Helper script to create a windows virtual environment. + +Dependencies: + - cygwin + - 7zip + +Instructions: + Run python <path to window-virtualenv-dir> <env-directory> + + After this <env-directory> will be a virtualenv directory that has pygtk and + ffmpeg loaded into it. + + Run: + + source <env-directory>/Scripts/activate + + To set your PATH and other env variables to use that virtualenv. + +Warning: using the ~ char didn't seem to work for me under cygwin + diff --git a/helperscripts/windows-virtualenv/__main__.py b/helperscripts/windows-virtualenv/__main__.py new file mode 100755 index 0000000..93ce533 --- /dev/null +++ b/helperscripts/windows-virtualenv/__main__.py @@ -0,0 +1,353 @@ +import contextlib +import hashlib +import os +import sys +import urllib +import urlparse +import shutil +import subprocess +import tarfile +import time +import zipfile +from optparse import OptionParser + +env_dir = build_dir = site_packages_dir = scripts_dir = python_dir = None +seven_zip_path = None +working_dir = downloads_dir = None +options = args = None + +# list of (md5 hash, url) tuples for all files that we download +download_info = [ + ('1694578c49e56eb1dce494408666e465', + 'http://lessmsi.googlecode.com/files/lessmsi-v1.0.8.zip'), + ('c846d7a5ed186707d3675564a9838cc2', + 'http://python.org/ftp/python/2.7.3/python-2.7.3.msi'), + ('788df97c3ceb11368c3a938e5acef0b2', + ('http://downloads.sourceforge.net' + '/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.zip')), + ('4bddf847f81d8de2d73048b113da3dd5', + ('http://ftp.gnome.org/pub/GNOME/binaries/win32/pygtk/2.24/' + 'pygtk-all-in-one-2.24.2.win32-py2.7.msi')), + ('9633cf25444f41b2fb78b0bb3f509ec3', + ('http://ffmpeg.zeranoe.com/builds/win32/static/' + 'ffmpeg-20120426-git-a4b58fd-win32-static.7z')), + ('d7e43beabc017a7d892a3d6663e988d4', + 'http://sourceforge.net/projects/nsis/files/' + 'NSIS%202/2.46/nsis-2.46.zip/download'), + ('9bd44a22bffe0e4e0b71b8b4cf3a80e2', + 'http://downloads.sourceforge.net/' + 'project/sevenzip/7-Zip/9.20/7z920.msi'), + ('9aa6c2d7229a37a3996270bff411ab22', + 'http://win32.libav.org/win32/libav-win32-20120821.7z'), + ('8f0347331b7023e343dc460378e23c4e', + 'http://downloads.sourceforge.net/' + 'project/winsparkle/0.3/WinSparkle-0.3.zip'), + ('05b59fdc6b6e6c4530154c0ac52f8a94', + 'http://downloads.sourceforge.net/' + 'project/gtk-win/GTK%2B%20Themes%20Package/2009-09-07/' + 'gtk2-themes-2009-09-07-win32_bin.zip'), + +] + +def get_download_hash(url): + """Get the md5 hash value for a downloaded file. + + :param url: url for the download + :returns: md5 hash of the contents of the file, or None if no file found + """ + download_path = get_download_path(url) + md5 = hashlib.md5() + block_size = 1024 * 10 + if not os.path.exists(download_path): + return None + with open(download_path, 'rb') as f: + data = f.read(block_size) + while data: + md5.update(data) + data = f.read(block_size) + return md5.hexdigest() + + +def download_files(): + if not os.path.exists(downloads_dir): + os.makedirs(downloads_dir) + for md5hash, url in download_info: + download_path = get_download_path(url) + if os.path.exists(download_path): + download_hash = get_download_hash(url) + if download_hash == md5hash: + continue + else: + writeout("md5 hash verification failed") + writeout(" %s", url) + writeout(" correct: %s", md5hash) + writeout(" downloaded: %s", download_hash) + download_url(url) + +def download_url(url): + """Download a url to the build directory.""" + + download_path = get_download_path(url) + basename = os.path.basename(download_path) + writeout("* Downloading %s", basename) + time_info = { 'last': 0} + def reporthook(block_count, block_size, total_size): + now = time.time() + if total_size > 0 and now - time_info['last'] > 0.5: + percent_complete = (block_count * block_size * 100.0) / total_size + writeout_and_stay(" %0.1f complete", percent_complete) + time_info['last'] = now + urllib.urlretrieve(url, download_path, reporthook) + writeout_and_stay(" 100.0%% complete") + + +def get_download_path(url): + """Get the path to a downloaded file. + + :param url: + """ + parsed_url = urlparse.urlparse(url) + if parsed_url.netloc == 'sourceforge.net': + # sourceforge adds an extra "/download" at the end of the url + basename = os.path.basename(os.path.dirname(parsed_url.path)) + else: + # default case + basename = os.path.basename(parsed_url.path) + return os.path.join(downloads_dir, basename) + + +def setup_global_dirs(parser_args): + global env_dir, working_dir, build_dir, site_packages_dir, scripts_dir + global python_dir, downloads_dir + + env_dir = os.path.abspath(parser_args[0]) + working_dir = os.getcwd() + downloads_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', '..', 'downloads', + 'windows-virtualenv')) + build_dir = os.path.abspath(os.path.join('mvc-env-build')) + site_packages_dir = os.path.join(env_dir, "Lib", "site-packages") + scripts_dir = os.path.join(env_dir, "Scripts") + python_dir = os.path.join(env_dir, "Python") + +def parse_args(): + global options, args + + usage = "usage: %prog [options] env-directory" + parser = OptionParser(usage) + + parser.add_option("-f", "--force", dest="force", + action="store_true", + help="overwrite env directory if it exists") + + (options, args) = parser.parse_args() + if len(args) < 1: + parser.error("must specify env-directory") + if len(args) > 1: + parser.error("can only specify one env-directory") + setup_global_dirs(args) + if os.path.exists(env_dir) and not options.force: + parser.error("%s exists. Use -f to overwrite" % env_dir) + +def writeout(msg, *args): + """write a line to stdout.""" + print msg % args + +def writeout_and_stay(msg, *args): + """write a line to stdout and stay on the same line.""" + + # clear out old line + print " " * 70 + "\r", + # write out new line + print (msg % args) + "\r", + sys.stdout.flush() + +def rmtree_if_exists(directory): + """Remove a directory tree, if it exists.""" + if os.path.exists(directory): + if not options.force: + raise AssertionError("%s exists and force not set" % directory) + writeout("Removing %s", directory) + shutil.rmtree(directory) + +def check_call(*command_line): + subprocess.check_call(command_line) + +@contextlib.contextmanager +def build_dir_context(): + """Context to create a build directory and delete it afterwards. + """ + + rmtree_if_exists(build_dir) + os.makedirs(build_dir) + yield + shutil.rmtree(build_dir) + +def movetree(source_dir, dest_dir): + """Move the contents of source_dir into dest_dir + + For each file/directory in source dir, copy it to dest_dir. If this would + overwrite a file/directory, then an IOError will be raised + """ + for name in os.listdir(source_dir): + source_child = os.path.join(source_dir, name) + writeout("* moving %s to %s", name, dest_dir) + shutil.move(source_child, os.path.join(dest_dir, name)) + +def extract_zip(zip_path, dest_dir): + writeout("* Extracting %s", zip_path) + archive = zipfile.ZipFile(zip_path, 'r') + for name in archive.namelist(): + writeout("** %s", name) + archive.extract(name, dest_dir) + archive.close() + +def extract_tarball(tarball_path, dest_dir): + writeout("* Extracting %s", tarball_path) + archive = tarfile.open(tarball_path, 'r') + archive.extractall(dest_dir) + archive.close() + +def run_pip_install(package_name, version): + pip_path = os.path.join(scripts_dir, 'pip.exe') + check_call(pip_path, 'install', "%s==%s" % (package_name, version)) + +def install_gtk_theme_files(): + gtk_themes_url = ('http://downloads.sourceforge.net/' + 'project/gtk-win/GTK%2B%20Themes%20Package/2009-09-07/' + 'gtk2-themes-2009-09-07-win32_bin.zip') + extract_zip(get_download_path(gtk_themes_url), env_dir) + +def install_nsis(): + url = ('http://sourceforge.net/projects/nsis/files/' + 'NSIS%202/2.46/nsis-2.46.zip/download') + zip_path = get_download_path(url) + # extract directory to the env directory since all files in the archive + # are inside nsis-2.46 directory + extract_zip(zip_path, env_dir) + +def install_lessmsi(): + url = "http://lessmsi.googlecode.com/files/lessmsi-v1.0.8.zip" + zip_path = get_download_path(url) + extract_zip(zip_path, os.path.join(build_dir, 'lessmsi')) + # make all files executable + for filename in ('lessmsi.exe', 'wix.dll', 'wixcab.dll'): + path = os.path.join(build_dir, 'lessmsi', filename) + check_call("chmod", "+x", path) + +def run_lessmsi(msi_path, output_dir): + writeout("* Extracting MSI %s", os.path.basename(msi_path)) + lessmsi_path = os.path.join(build_dir, 'lessmsi', 'lessmsi.exe') + check_call(lessmsi_path, "/x", msi_path, output_dir) + +def install_7zip(): + global seven_zip_path + url = 'http://downloads.sourceforge.net/project/sevenzip/7-Zip/9.20/7z920.msi' + msi_path = get_download_path(url) + build_path = os.path.join(build_dir, 'z7ip') + run_lessmsi(msi_path, build_path) + + seven_zip_path = os.path.join(build_path, "SourceDir", "Files", "7-Zip", "7z.exe") + +def make_env_dir(): + rmtree_if_exists(env_dir) + os.makedirs(env_dir) + +def install_virtualenv(): + virtualenv_path = os.path.join(os.path.dirname(__file__), "virtualenv.py") + python_path = os.path.join(python_dir, "python.exe") + check_call("python", virtualenv_path, "-p", python_path, '--clear', + env_dir) + +def install_python(): + url = "http://python.org/ftp/python/2.7.3/python-2.7.3.msi" + download_path = get_download_path(url) + build_path = os.path.join(build_dir, 'Python') + run_lessmsi(download_path, build_path) + shutil.move(os.path.join(build_path, "SourceDir"), python_dir) + +def install_py2exe(): + url = ("http://downloads.sourceforge.net" + "/project/py2exe/py2exe/0.6.9/py2exe-0.6.9.zip") + zip_path = get_download_path(url) + extract_zip(zip_path, os.path.join(build_dir, 'py2exe')) + writeout("* Installing py2exe") + os.chdir(os.path.join(build_dir, 'py2exe', 'py2exe-0.6.9')) + check_call(os.path.join(scripts_dir, "python.exe"), 'setup.py', 'install') + os.chdir(working_dir) + +def install_pygtk(): + url = ('http://ftp.gnome.org/pub/GNOME/binaries/win32/pygtk/2.24/' + 'pygtk-all-in-one-2.24.2.win32-py2.7.msi') + msi_path = get_download_path(url) + build_path = os.path.join(build_dir, 'pygtk-all-in-one') + run_lessmsi(msi_path, build_path) + + source_package_dir = os.path.join(build_path, "SourceDir", "Lib", + "site-packages") + writeout("* Copying pygtk site-packages") + movetree(source_package_dir, site_packages_dir) + +def install_ffmpeg(): + url = ("http://ffmpeg.zeranoe.com/builds/win32/static/" + "ffmpeg-20120426-git-a4b58fd-win32-static.7z") + download_path = get_download_path(url) + check_call(seven_zip_path, "x", download_path, '-o' + build_dir) + + archive_dir = os.path.join(build_dir, + os.path.splitext(os.path.basename(url))[0]) + dest_dir = os.path.join(env_dir, "ffmpeg") + + os.mkdir(dest_dir) + shutil.move(os.path.join(archive_dir, "presets"), + os.path.join(dest_dir, "presets")) + + for exe_name in ("ffmpeg.exe", "ffplay.exe", "ffprobe.exe"): + shutil.move(os.path.join(archive_dir, "bin", exe_name), + os.path.join(dest_dir, exe_name)) + +def install_avconv(): + # We use a nightly build of avconv because I (BDK) couldn't find any + # official builds after version 7.7, released in June 2011 + url = 'http://win32.libav.org/win32/libav-win32-20120821.7z' + download_path = get_download_path(url) + check_call(seven_zip_path, "x", download_path, '-o' + build_dir) + + archive_dir = os.path.join(build_dir, + os.path.splitext(os.path.basename(url))[0]) + dest_dir = os.path.join(env_dir, "avconv") + bin_dir = os.path.join(archive_dir, "usr", "bin") + lib_dir = os.path.join(archive_dir, "usr", "lib") + preset_dir = os.path.join(archive_dir, "usr", "share", "avconv") + + os.mkdir(dest_dir) + for src_dir in (bin_dir, lib_dir, preset_dir): + for filename in os.listdir(src_dir): + shutil.move(os.path.join(src_dir, filename), + os.path.join(dest_dir, filename)) + +def install_winsparkle(): + url = ('http://downloads.sourceforge.net/' + 'project/winsparkle/0.3/WinSparkle-0.3.zip') + download_path = get_download_path(url) + extract_zip(download_path, env_dir) + +def main(): + parse_args() + make_env_dir() + download_files() + with build_dir_context(): + install_lessmsi() + install_7zip() + install_python() + install_virtualenv() + install_py2exe() + install_pygtk() + install_ffmpeg() + install_avconv() + install_winsparkle() + install_gtk_theme_files() + install_nsis() + +if __name__ == '__main__': + main() diff --git a/helperscripts/windows-virtualenv/virtualenv.py b/helperscripts/windows-virtualenv/virtualenv.py new file mode 100755 index 0000000..9cff773 --- /dev/null +++ b/helperscripts/windows-virtualenv/virtualenv.py @@ -0,0 +1,2274 @@ +#!/usr/bin/env python +"""Create a "virtual" Python installation +""" + +# If you change the version here, change it in setup.py +# and docs/conf.py as well. +virtualenv_version = "1.7.1.2" + +import base64 +import sys +import os +import optparse +import re +import shutil +import logging +import tempfile +import zlib +import errno +import distutils.sysconfig +from distutils.util import strtobool + +try: + import subprocess +except ImportError: + if sys.version_info <= (2, 3): + print('ERROR: %s' % sys.exc_info()[1]) + print('ERROR: this script requires Python 2.4 or greater; or at least the subprocess module.') + print('If you copy subprocess.py from a newer version of Python this script will probably work') + sys.exit(101) + else: + raise +try: + set +except NameError: + from sets import Set as set +try: + basestring +except NameError: + basestring = str + +try: + import ConfigParser +except ImportError: + import configparser as ConfigParser + +join = os.path.join +py_version = 'python%s.%s' % (sys.version_info[0], sys.version_info[1]) + +is_jython = sys.platform.startswith('java') +is_pypy = hasattr(sys, 'pypy_version_info') +is_win = (sys.platform == 'win32') +abiflags = getattr(sys, 'abiflags', '') + +user_dir = os.path.expanduser('~') +if sys.platform == 'win32': + user_dir = os.environ.get('APPDATA', user_dir) # Use %APPDATA% for roaming + default_storage_dir = os.path.join(user_dir, 'virtualenv') +else: + default_storage_dir = os.path.join(user_dir, '.virtualenv') +default_config_file = os.path.join(default_storage_dir, 'virtualenv.ini') + +if is_pypy: + expected_exe = 'pypy' +elif is_jython: + expected_exe = 'jython' +else: + expected_exe = 'python' + + +REQUIRED_MODULES = ['os', 'posix', 'posixpath', 'nt', 'ntpath', 'genericpath', + 'fnmatch', 'locale', 'encodings', 'codecs', + 'stat', 'UserDict', 'readline', 'copy_reg', 'types', + 're', 'sre', 'sre_parse', 'sre_constants', 'sre_compile', + 'zlib'] + +REQUIRED_FILES = ['lib-dynload', 'config'] + +majver, minver = sys.version_info[:2] +if majver == 2: + if minver >= 6: + REQUIRED_MODULES.extend(['warnings', 'linecache', '_abcoll', 'abc']) + if minver >= 7: + REQUIRED_MODULES.extend(['_weakrefset']) + if minver <= 3: + REQUIRED_MODULES.extend(['sets', '__future__']) +elif majver == 3: + # Some extra modules are needed for Python 3, but different ones + # for different versions. + REQUIRED_MODULES.extend(['_abcoll', 'warnings', 'linecache', 'abc', 'io', + '_weakrefset', 'copyreg', 'tempfile', 'random', + '__future__', 'collections', 'keyword', 'tarfile', + 'shutil', 'struct', 'copy']) + if minver >= 2: + REQUIRED_FILES[-1] = 'config-%s' % majver + if minver == 3: + # The whole list of 3.3 modules is reproduced below - the current + # uncommented ones are required for 3.3 as of now, but more may be + # added as 3.3 development continues. + REQUIRED_MODULES.extend([ + #"aifc", + #"antigravity", + #"argparse", + #"ast", + #"asynchat", + #"asyncore", + "base64", + #"bdb", + #"binhex", + "bisect", + #"calendar", + #"cgi", + #"cgitb", + #"chunk", + #"cmd", + #"codeop", + #"code", + #"colorsys", + #"_compat_pickle", + #"compileall", + #"concurrent", + #"configparser", + #"contextlib", + #"cProfile", + #"crypt", + #"csv", + #"ctypes", + #"curses", + #"datetime", + #"dbm", + #"decimal", + #"difflib", + #"dis", + #"doctest", + #"dummy_threading", + "_dummy_thread", + #"email", + #"filecmp", + #"fileinput", + #"formatter", + #"fractions", + #"ftplib", + #"functools", + #"getopt", + #"getpass", + #"gettext", + #"glob", + #"gzip", + "hashlib", + "heapq", + "hmac", + #"html", + #"http", + #"idlelib", + #"imaplib", + #"imghdr", + #"importlib", + #"inspect", + #"json", + #"lib2to3", + #"logging", + #"macpath", + #"macurl2path", + #"mailbox", + #"mailcap", + #"_markupbase", + #"mimetypes", + #"modulefinder", + #"multiprocessing", + #"netrc", + #"nntplib", + #"nturl2path", + #"numbers", + #"opcode", + #"optparse", + #"os2emxpath", + #"pdb", + #"pickle", + #"pickletools", + #"pipes", + #"pkgutil", + #"platform", + #"plat-linux2", + #"plistlib", + #"poplib", + #"pprint", + #"profile", + #"pstats", + #"pty", + #"pyclbr", + #"py_compile", + #"pydoc_data", + #"pydoc", + #"_pyio", + #"queue", + #"quopri", + "reprlib", + "rlcompleter", + #"runpy", + #"sched", + #"shelve", + #"shlex", + #"smtpd", + #"smtplib", + #"sndhdr", + #"socket", + #"socketserver", + #"sqlite3", + #"ssl", + #"stringprep", + #"string", + #"_strptime", + #"subprocess", + #"sunau", + #"symbol", + #"symtable", + #"sysconfig", + #"tabnanny", + #"telnetlib", + #"test", + #"textwrap", + #"this", + #"_threading_local", + #"threading", + #"timeit", + #"tkinter", + #"tokenize", + #"token", + #"traceback", + #"trace", + #"tty", + #"turtledemo", + #"turtle", + #"unittest", + #"urllib", + #"uuid", + #"uu", + #"wave", + "weakref", + #"webbrowser", + #"wsgiref", + #"xdrlib", + #"xml", + #"xmlrpc", + #"zipfile", + ]) + +if is_pypy: + # these are needed to correctly display the exceptions that may happen + # during the bootstrap + REQUIRED_MODULES.extend(['traceback', 'linecache']) + +class Logger(object): + + """ + Logging object for use in command-line script. Allows ranges of + levels, to avoid some redundancy of displayed information. + """ + + DEBUG = logging.DEBUG + INFO = logging.INFO + NOTIFY = (logging.INFO+logging.WARN)/2 + WARN = WARNING = logging.WARN + ERROR = logging.ERROR + FATAL = logging.FATAL + + LEVELS = [DEBUG, INFO, NOTIFY, WARN, ERROR, FATAL] + + def __init__(self, consumers): + self.consumers = consumers + self.indent = 0 + self.in_progress = None + self.in_progress_hanging = False + + def debug(self, msg, *args, **kw): + self.log(self.DEBUG, msg, *args, **kw) + def info(self, msg, *args, **kw): + self.log(self.INFO, msg, *args, **kw) + def notify(self, msg, *args, **kw): + self.log(self.NOTIFY, msg, *args, **kw) + def warn(self, msg, *args, **kw): + self.log(self.WARN, msg, *args, **kw) + def error(self, msg, *args, **kw): + self.log(self.WARN, msg, *args, **kw) + def fatal(self, msg, *args, **kw): + self.log(self.FATAL, msg, *args, **kw) + def log(self, level, msg, *args, **kw): + if args: + if kw: + raise TypeError( + "You may give positional or keyword arguments, not both") + args = args or kw + rendered = None + for consumer_level, consumer in self.consumers: + if self.level_matches(level, consumer_level): + if (self.in_progress_hanging + and consumer in (sys.stdout, sys.stderr)): + self.in_progress_hanging = False + sys.stdout.write('\n') + sys.stdout.flush() + if rendered is None: + if args: + rendered = msg % args + else: + rendered = msg + rendered = ' '*self.indent + rendered + if hasattr(consumer, 'write'): + consumer.write(rendered+'\n') + else: + consumer(rendered) + + def start_progress(self, msg): + assert not self.in_progress, ( + "Tried to start_progress(%r) while in_progress %r" + % (msg, self.in_progress)) + if self.level_matches(self.NOTIFY, self._stdout_level()): + sys.stdout.write(msg) + sys.stdout.flush() + self.in_progress_hanging = True + else: + self.in_progress_hanging = False + self.in_progress = msg + + def end_progress(self, msg='done.'): + assert self.in_progress, ( + "Tried to end_progress without start_progress") + if self.stdout_level_matches(self.NOTIFY): + if not self.in_progress_hanging: + # Some message has been printed out since start_progress + sys.stdout.write('...' + self.in_progress + msg + '\n') + sys.stdout.flush() + else: + sys.stdout.write(msg + '\n') + sys.stdout.flush() + self.in_progress = None + self.in_progress_hanging = False + + def show_progress(self): + """If we are in a progress scope, and no log messages have been + shown, write out another '.'""" + if self.in_progress_hanging: + sys.stdout.write('.') + sys.stdout.flush() + + def stdout_level_matches(self, level): + """Returns true if a message at this level will go to stdout""" + return self.level_matches(level, self._stdout_level()) + + def _stdout_level(self): + """Returns the level that stdout runs at""" + for level, consumer in self.consumers: + if consumer is sys.stdout: + return level + return self.FATAL + + def level_matches(self, level, consumer_level): + """ + >>> l = Logger([]) + >>> l.level_matches(3, 4) + False + >>> l.level_matches(3, 2) + True + >>> l.level_matches(slice(None, 3), 3) + False + >>> l.level_matches(slice(None, 3), 2) + True + >>> l.level_matches(slice(1, 3), 1) + True + >>> l.level_matches(slice(2, 3), 1) + False + """ + if isinstance(level, slice): + start, stop = level.start, level.stop + if start is not None and start > consumer_level: + return False + if stop is not None and stop <= consumer_level: + return False + return True + else: + return level >= consumer_level + + #@classmethod + def level_for_integer(cls, level): + levels = cls.LEVELS + if level < 0: + return levels[0] + if level >= len(levels): + return levels[-1] + return levels[level] + + level_for_integer = classmethod(level_for_integer) + +# create a silent logger just to prevent this from being undefined +# will be overridden with requested verbosity main() is called. +logger = Logger([(Logger.LEVELS[-1], sys.stdout)]) + +def mkdir(path): + if not os.path.exists(path): + logger.info('Creating %s', path) + os.makedirs(path) + else: + logger.info('Directory %s already exists', path) + +def copyfileordir(src, dest): + if os.path.isdir(src): + shutil.copytree(src, dest, True) + else: + shutil.copy2(src, dest) + +def copyfile(src, dest, symlink=True): + if not os.path.exists(src): + # Some bad symlink in the src + logger.warn('Cannot find file %s (bad symlink)', src) + return + if os.path.exists(dest): + logger.debug('File %s already exists', dest) + return + if not os.path.exists(os.path.dirname(dest)): + logger.info('Creating parent directories for %s' % os.path.dirname(dest)) + os.makedirs(os.path.dirname(dest)) + if not os.path.islink(src): + srcpath = os.path.abspath(src) + else: + srcpath = os.readlink(src) + if symlink and hasattr(os, 'symlink') and not is_win: + logger.info('Symlinking %s', dest) + try: + os.symlink(srcpath, dest) + except (OSError, NotImplementedError): + logger.info('Symlinking failed, copying to %s', dest) + copyfileordir(src, dest) + else: + logger.info('Copying to %s', dest) + copyfileordir(src, dest) + +def writefile(dest, content, overwrite=True): + if not os.path.exists(dest): + logger.info('Writing %s', dest) + f = open(dest, 'wb') + f.write(content.encode('utf-8')) + f.close() + return + else: + f = open(dest, 'rb') + c = f.read() + f.close() + if c != content: + if not overwrite: + logger.notify('File %s exists with different content; not overwriting', dest) + return + logger.notify('Overwriting %s with new content', dest) + f = open(dest, 'wb') + f.write(content.encode('utf-8')) + f.close() + else: + logger.info('Content %s already in place', dest) + +def rmtree(dir): + if os.path.exists(dir): + logger.notify('Deleting tree %s', dir) + shutil.rmtree(dir) + else: + logger.info('Do not need to delete %s; already gone', dir) + +def make_exe(fn): + if hasattr(os, 'chmod'): + oldmode = os.stat(fn).st_mode & 0xFFF # 0o7777 + newmode = (oldmode | 0x16D) & 0xFFF # 0o555, 0o7777 + os.chmod(fn, newmode) + logger.info('Changed mode of %s to %s', fn, oct(newmode)) + +def _find_file(filename, dirs): + for dir in reversed(dirs): + if os.path.exists(join(dir, filename)): + return join(dir, filename) + return filename + +def _install_req(py_executable, unzip=False, distribute=False, + search_dirs=None, never_download=False): + + if search_dirs is None: + search_dirs = file_search_dirs() + + if not distribute: + setup_fn = 'setuptools-0.6c11-py%s.egg' % sys.version[:3] + project_name = 'setuptools' + bootstrap_script = EZ_SETUP_PY + source = None + else: + setup_fn = None + source = 'distribute-0.6.24.tar.gz' + project_name = 'distribute' + bootstrap_script = DISTRIBUTE_SETUP_PY + + if setup_fn is not None: + setup_fn = _find_file(setup_fn, search_dirs) + + if source is not None: + source = _find_file(source, search_dirs) + + if is_jython and os._name == 'nt': + # Jython's .bat sys.executable can't handle a command line + # argument with newlines + fd, ez_setup = tempfile.mkstemp('.py') + os.write(fd, bootstrap_script) + os.close(fd) + cmd = [py_executable, ez_setup] + else: + cmd = [py_executable, '-c', bootstrap_script] + if unzip: + cmd.append('--always-unzip') + env = {} + remove_from_env = [] + if logger.stdout_level_matches(logger.DEBUG): + cmd.append('-v') + + old_chdir = os.getcwd() + if setup_fn is not None and os.path.exists(setup_fn): + logger.info('Using existing %s egg: %s' % (project_name, setup_fn)) + cmd.append(setup_fn) + if os.environ.get('PYTHONPATH'): + env['PYTHONPATH'] = setup_fn + os.path.pathsep + os.environ['PYTHONPATH'] + else: + env['PYTHONPATH'] = setup_fn + else: + # the source is found, let's chdir + if source is not None and os.path.exists(source): + logger.info('Using existing %s egg: %s' % (project_name, source)) + os.chdir(os.path.dirname(source)) + # in this case, we want to be sure that PYTHONPATH is unset (not + # just empty, really unset), else CPython tries to import the + # site.py that it's in virtualenv_support + remove_from_env.append('PYTHONPATH') + else: + if never_download: + logger.fatal("Can't find any local distributions of %s to install " + "and --never-download is set. Either re-run virtualenv " + "without the --never-download option, or place a %s " + "distribution (%s) in one of these " + "locations: %r" % (project_name, project_name, + setup_fn or source, + search_dirs)) + sys.exit(1) + + logger.info('No %s egg found; downloading' % project_name) + cmd.extend(['--always-copy', '-U', project_name]) + logger.start_progress('Installing %s...' % project_name) + logger.indent += 2 + cwd = None + if project_name == 'distribute': + env['DONT_PATCH_SETUPTOOLS'] = 'true' + + def _filter_ez_setup(line): + return filter_ez_setup(line, project_name) + + if not os.access(os.getcwd(), os.W_OK): + cwd = tempfile.mkdtemp() + if source is not None and os.path.exists(source): + # the current working dir is hostile, let's copy the + # tarball to a temp dir + target = os.path.join(cwd, os.path.split(source)[-1]) + shutil.copy(source, target) + try: + call_subprocess(cmd, show_stdout=False, + filter_stdout=_filter_ez_setup, + extra_env=env, + remove_from_env=remove_from_env, + cwd=cwd) + finally: + logger.indent -= 2 + logger.end_progress() + if os.getcwd() != old_chdir: + os.chdir(old_chdir) + if is_jython and os._name == 'nt': + os.remove(ez_setup) + +def file_search_dirs(): + here = os.path.dirname(os.path.abspath(__file__)) + dirs = ['.', here, + join(here, 'virtualenv_support')] + if os.path.splitext(os.path.dirname(__file__))[0] != 'virtualenv': + # Probably some boot script; just in case virtualenv is installed... + try: + import virtualenv + except ImportError: + pass + else: + dirs.append(os.path.join(os.path.dirname(virtualenv.__file__), 'virtualenv_support')) + return [d for d in dirs if os.path.isdir(d)] + +def install_setuptools(py_executable, unzip=False, + search_dirs=None, never_download=False): + _install_req(py_executable, unzip, + search_dirs=search_dirs, never_download=never_download) + +def install_distribute(py_executable, unzip=False, + search_dirs=None, never_download=False): + _install_req(py_executable, unzip, distribute=True, + search_dirs=search_dirs, never_download=never_download) + +_pip_re = re.compile(r'^pip-.*(zip|tar.gz|tar.bz2|tgz|tbz)$', re.I) +def install_pip(py_executable, search_dirs=None, never_download=False): + if search_dirs is None: + search_dirs = file_search_dirs() + + filenames = [] + for dir in search_dirs: + filenames.extend([join(dir, fn) for fn in os.listdir(dir) + if _pip_re.search(fn)]) + filenames = [(os.path.basename(filename).lower(), i, filename) for i, filename in enumerate(filenames)] + filenames.sort() + filenames = [filename for basename, i, filename in filenames] + if not filenames: + filename = 'pip' + else: + filename = filenames[-1] + easy_install_script = 'easy_install' + if sys.platform == 'win32': + easy_install_script = 'easy_install-script.py' + cmd = [join(os.path.dirname(py_executable), easy_install_script), filename] + if sys.platform == 'win32': + cmd.insert(0, py_executable) + if filename == 'pip': + if never_download: + logger.fatal("Can't find any local distributions of pip to install " + "and --never-download is set. Either re-run virtualenv " + "without the --never-download option, or place a pip " + "source distribution (zip/tar.gz/tar.bz2) in one of these " + "locations: %r" % search_dirs) + sys.exit(1) + logger.info('Installing pip from network...') + else: + logger.info('Installing existing %s distribution: %s' % ( + os.path.basename(filename), filename)) + logger.start_progress('Installing pip...') + logger.indent += 2 + def _filter_setup(line): + return filter_ez_setup(line, 'pip') + try: + call_subprocess(cmd, show_stdout=False, + filter_stdout=_filter_setup) + finally: + logger.indent -= 2 + logger.end_progress() + +def filter_ez_setup(line, project_name='setuptools'): + if not line.strip(): + return Logger.DEBUG + if project_name == 'distribute': + for prefix in ('Extracting', 'Now working', 'Installing', 'Before', + 'Scanning', 'Setuptools', 'Egg', 'Already', + 'running', 'writing', 'reading', 'installing', + 'creating', 'copying', 'byte-compiling', 'removing', + 'Processing'): + if line.startswith(prefix): + return Logger.DEBUG + return Logger.DEBUG + for prefix in ['Reading ', 'Best match', 'Processing setuptools', + 'Copying setuptools', 'Adding setuptools', + 'Installing ', 'Installed ']: + if line.startswith(prefix): + return Logger.DEBUG + return Logger.INFO + + +class UpdatingDefaultsHelpFormatter(optparse.IndentedHelpFormatter): + """ + Custom help formatter for use in ConfigOptionParser that updates + the defaults before expanding them, allowing them to show up correctly + in the help listing + """ + def expand_default(self, option): + if self.parser is not None: + self.parser.update_defaults(self.parser.defaults) + return optparse.IndentedHelpFormatter.expand_default(self, option) + + +class ConfigOptionParser(optparse.OptionParser): + """ + Custom option parser which updates its defaults by by checking the + configuration files and environmental variables + """ + def __init__(self, *args, **kwargs): + self.config = ConfigParser.RawConfigParser() + self.files = self.get_config_files() + self.config.read(self.files) + optparse.OptionParser.__init__(self, *args, **kwargs) + + def get_config_files(self): + config_file = os.environ.get('VIRTUALENV_CONFIG_FILE', False) + if config_file and os.path.exists(config_file): + return [config_file] + return [default_config_file] + + def update_defaults(self, defaults): + """ + Updates the given defaults with values from the config files and + the environ. Does a little special handling for certain types of + options (lists). + """ + # Then go and look for the other sources of configuration: + config = {} + # 1. config files + config.update(dict(self.get_config_section('virtualenv'))) + # 2. environmental variables + config.update(dict(self.get_environ_vars())) + # Then set the options with those values + for key, val in config.items(): + key = key.replace('_', '-') + if not key.startswith('--'): + key = '--%s' % key # only prefer long opts + option = self.get_option(key) + if option is not None: + # ignore empty values + if not val: + continue + # handle multiline configs + if option.action == 'append': + val = val.split() + else: + option.nargs = 1 + if option.action in ('store_true', 'store_false', 'count'): + val = strtobool(val) + try: + val = option.convert_value(key, val) + except optparse.OptionValueError: + e = sys.exc_info()[1] + print("An error occured during configuration: %s" % e) + sys.exit(3) + defaults[option.dest] = val + return defaults + + def get_config_section(self, name): + """ + Get a section of a configuration + """ + if self.config.has_section(name): + return self.config.items(name) + return [] + + def get_environ_vars(self, prefix='VIRTUALENV_'): + """ + Returns a generator with all environmental vars with prefix VIRTUALENV + """ + for key, val in os.environ.items(): + if key.startswith(prefix): + yield (key.replace(prefix, '').lower(), val) + + def get_default_values(self): + """ + Overridding to make updating the defaults after instantiation of + the option parser possible, update_defaults() does the dirty work. + """ + if not self.process_default_values: + # Old, pre-Optik 1.5 behaviour. + return optparse.Values(self.defaults) + + defaults = self.update_defaults(self.defaults.copy()) # ours + for option in self._get_all_options(): + default = defaults.get(option.dest) + if isinstance(default, basestring): + opt_str = option.get_opt_string() + defaults[option.dest] = option.check_value(opt_str, default) + return optparse.Values(defaults) + + +def main(): + parser = ConfigOptionParser( + version=virtualenv_version, + usage="%prog [OPTIONS] DEST_DIR", + formatter=UpdatingDefaultsHelpFormatter()) + + parser.add_option( + '-v', '--verbose', + action='count', + dest='verbose', + default=0, + help="Increase verbosity") + + parser.add_option( + '-q', '--quiet', + action='count', + dest='quiet', + default=0, + help='Decrease verbosity') + + parser.add_option( + '-p', '--python', + dest='python', + metavar='PYTHON_EXE', + help='The Python interpreter to use, e.g., --python=python2.5 will use the python2.5 ' + 'interpreter to create the new environment. The default is the interpreter that ' + 'virtualenv was installed with (%s)' % sys.executable) + + parser.add_option( + '--clear', + dest='clear', + action='store_true', + help="Clear out the non-root install and start from scratch") + + parser.add_option( + '--no-site-packages', + dest='no_site_packages', + action='store_true', + help="Don't give access to the global site-packages dir to the " + "virtual environment") + + parser.add_option( + '--system-site-packages', + dest='system_site_packages', + action='store_true', + help="Give access to the global site-packages dir to the " + "virtual environment") + + parser.add_option( + '--unzip-setuptools', + dest='unzip_setuptools', + action='store_true', + help="Unzip Setuptools or Distribute when installing it") + + parser.add_option( + '--relocatable', + dest='relocatable', + action='store_true', + help='Make an EXISTING virtualenv environment relocatable. ' + 'This fixes up scripts and makes all .pth files relative') + + parser.add_option( + '--distribute', + dest='use_distribute', + action='store_true', + help='Use Distribute instead of Setuptools. Set environ variable ' + 'VIRTUALENV_DISTRIBUTE to make it the default ') + + default_search_dirs = file_search_dirs() + parser.add_option( + '--extra-search-dir', + dest="search_dirs", + action="append", + default=default_search_dirs, + help="Directory to look for setuptools/distribute/pip distributions in. " + "You can add any number of additional --extra-search-dir paths.") + + parser.add_option( + '--never-download', + dest="never_download", + action="store_true", + help="Never download anything from the network. Instead, virtualenv will fail " + "if local distributions of setuptools/distribute/pip are not present.") + + parser.add_option( + '--prompt=', + dest='prompt', + help='Provides an alternative prompt prefix for this environment') + + if 'extend_parser' in globals(): + extend_parser(parser) + + options, args = parser.parse_args() + + global logger + + if 'adjust_options' in globals(): + adjust_options(options, args) + + verbosity = options.verbose - options.quiet + logger = Logger([(Logger.level_for_integer(2-verbosity), sys.stdout)]) + + if options.python and not os.environ.get('VIRTUALENV_INTERPRETER_RUNNING'): + env = os.environ.copy() + interpreter = resolve_interpreter(options.python) + if interpreter == sys.executable: + logger.warn('Already using interpreter %s' % interpreter) + else: + logger.notify('Running virtualenv with interpreter %s' % interpreter) + env['VIRTUALENV_INTERPRETER_RUNNING'] = 'true' + file = __file__ + if file.endswith('.pyc'): + file = file[:-1] + popen = subprocess.Popen([interpreter, file] + sys.argv[1:], env=env) + raise SystemExit(popen.wait()) + + # Force --distribute on Python 3, since setuptools is not available. + if majver > 2: + options.use_distribute = True + + if os.environ.get('PYTHONDONTWRITEBYTECODE') and not options.use_distribute: + print( + "The PYTHONDONTWRITEBYTECODE environment variable is " + "not compatible with setuptools. Either use --distribute " + "or unset PYTHONDONTWRITEBYTECODE.") + sys.exit(2) + if not args: + print('You must provide a DEST_DIR') + parser.print_help() + sys.exit(2) + if len(args) > 1: + print('There must be only one argument: DEST_DIR (you gave %s)' % ( + ' '.join(args))) + parser.print_help() + sys.exit(2) + + home_dir = args[0] + + if os.environ.get('WORKING_ENV'): + logger.fatal('ERROR: you cannot run virtualenv while in a workingenv') + logger.fatal('Please deactivate your workingenv, then re-run this script') + sys.exit(3) + + if 'PYTHONHOME' in os.environ: + logger.warn('PYTHONHOME is set. You *must* activate the virtualenv before using it') + del os.environ['PYTHONHOME'] + + if options.relocatable: + make_environment_relocatable(home_dir) + return + + if options.no_site_packages: + logger.warn('The --no-site-packages flag is deprecated; it is now ' + 'the default behavior.') + + create_environment(home_dir, + site_packages=options.system_site_packages, + clear=options.clear, + unzip_setuptools=options.unzip_setuptools, + use_distribute=options.use_distribute, + prompt=options.prompt, + search_dirs=options.search_dirs, + never_download=options.never_download) + if 'after_install' in globals(): + after_install(options, home_dir) + +def call_subprocess(cmd, show_stdout=True, + filter_stdout=None, cwd=None, + raise_on_returncode=True, extra_env=None, + remove_from_env=None): + cmd_parts = [] + for part in cmd: + if len(part) > 45: + part = part[:20]+"..."+part[-20:] + if ' ' in part or '\n' in part or '"' in part or "'" in part: + part = '"%s"' % part.replace('"', '\\"') + if hasattr(part, 'decode'): + try: + part = part.decode(sys.getdefaultencoding()) + except UnicodeDecodeError: + part = part.decode(sys.getfilesystemencoding()) + cmd_parts.append(part) + cmd_desc = ' '.join(cmd_parts) + if show_stdout: + stdout = None + else: + stdout = subprocess.PIPE + logger.debug("Running command %s" % cmd_desc) + if extra_env or remove_from_env: + env = os.environ.copy() + if extra_env: + env.update(extra_env) + if remove_from_env: + for varname in remove_from_env: + env.pop(varname, None) + else: + env = None + try: + proc = subprocess.Popen( + cmd, stderr=subprocess.STDOUT, stdin=None, stdout=stdout, + cwd=cwd, env=env) + except Exception: + e = sys.exc_info()[1] + logger.fatal( + "Error %s while executing command %s" % (e, cmd_desc)) + raise + all_output = [] + if stdout is not None: + stdout = proc.stdout + encoding = sys.getdefaultencoding() + fs_encoding = sys.getfilesystemencoding() + while 1: + line = stdout.readline() + try: + line = line.decode(encoding) + except UnicodeDecodeError: + line = line.decode(fs_encoding) + if not line: + break + line = line.rstrip() + all_output.append(line) + if filter_stdout: + level = filter_stdout(line) + if isinstance(level, tuple): + level, line = level + logger.log(level, line) + if not logger.stdout_level_matches(level): + logger.show_progress() + else: + logger.info(line) + else: + proc.communicate() + proc.wait() + if proc.returncode: + if raise_on_returncode: + if all_output: + logger.notify('Complete output from command %s:' % cmd_desc) + logger.notify('\n'.join(all_output) + '\n----------------------------------------') + raise OSError( + "Command %s failed with error code %s" + % (cmd_desc, proc.returncode)) + else: + logger.warn( + "Command %s had error code %s" + % (cmd_desc, proc.returncode)) + + +def create_environment(home_dir, site_packages=False, clear=False, + unzip_setuptools=False, use_distribute=False, + prompt=None, search_dirs=None, never_download=False): + """ + Creates a new environment in ``home_dir``. + + If ``site_packages`` is true, then the global ``site-packages/`` + directory will be on the path. + + If ``clear`` is true (default False) then the environment will + first be cleared. + """ + home_dir, lib_dir, inc_dir, bin_dir = path_locations(home_dir) + + py_executable = os.path.abspath(install_python( + home_dir, lib_dir, inc_dir, bin_dir, + site_packages=site_packages, clear=clear)) + + install_distutils(home_dir) + + # use_distribute also is True if VIRTUALENV_DISTRIBUTE env var is set + # we also check VIRTUALENV_USE_DISTRIBUTE for backwards compatibility + if use_distribute or os.environ.get('VIRTUALENV_USE_DISTRIBUTE'): + install_distribute(py_executable, unzip=unzip_setuptools, + search_dirs=search_dirs, never_download=never_download) + else: + install_setuptools(py_executable, unzip=unzip_setuptools, + search_dirs=search_dirs, never_download=never_download) + + install_pip(py_executable, search_dirs=search_dirs, never_download=never_download) + + install_activate(home_dir, bin_dir, prompt) + +def path_locations(home_dir): + """Return the path locations for the environment (where libraries are, + where scripts go, etc)""" + # XXX: We'd use distutils.sysconfig.get_python_inc/lib but its + # prefix arg is broken: http://bugs.python.org/issue3386 + if sys.platform == 'win32': + # Windows has lots of problems with executables with spaces in + # the name; this function will remove them (using the ~1 + # format): + mkdir(home_dir) + if ' ' in home_dir: + try: + import win32api + except ImportError: + print('Error: the path "%s" has a space in it' % home_dir) + print('To handle these kinds of paths, the win32api module must be installed:') + print(' http://sourceforge.net/projects/pywin32/') + sys.exit(3) + home_dir = win32api.GetShortPathName(home_dir) + lib_dir = join(home_dir, 'Lib') + inc_dir = join(home_dir, 'Include') + bin_dir = join(home_dir, 'Scripts') + elif is_jython: + lib_dir = join(home_dir, 'Lib') + inc_dir = join(home_dir, 'Include') + bin_dir = join(home_dir, 'bin') + elif is_pypy: + lib_dir = home_dir + inc_dir = join(home_dir, 'include') + bin_dir = join(home_dir, 'bin') + else: + lib_dir = join(home_dir, 'lib', py_version) + inc_dir = join(home_dir, 'include', py_version + abiflags) + bin_dir = join(home_dir, 'bin') + return home_dir, lib_dir, inc_dir, bin_dir + + +def change_prefix(filename, dst_prefix): + prefixes = [sys.prefix] + + if sys.platform == "darwin": + prefixes.extend(( + os.path.join("/Library/Python", sys.version[:3], "site-packages"), + os.path.join(sys.prefix, "Extras", "lib", "python"), + os.path.join("~", "Library", "Python", sys.version[:3], "site-packages"))) + + if hasattr(sys, 'real_prefix'): + prefixes.append(sys.real_prefix) + prefixes = list(map(os.path.abspath, prefixes)) + filename = os.path.abspath(filename) + for src_prefix in prefixes: + if filename.startswith(src_prefix): + _, relpath = filename.split(src_prefix, 1) + assert relpath[0] == os.sep + relpath = relpath[1:] + return join(dst_prefix, relpath) + assert False, "Filename %s does not start with any of these prefixes: %s" % \ + (filename, prefixes) + +def copy_required_modules(dst_prefix): + import imp + # If we are running under -p, we need to remove the current + # directory from sys.path temporarily here, so that we + # definitely get the modules from the site directory of + # the interpreter we are running under, not the one + # virtualenv.py is installed under (which might lead to py2/py3 + # incompatibility issues) + _prev_sys_path = sys.path + if os.environ.get('VIRTUALENV_INTERPRETER_RUNNING'): + sys.path = sys.path[1:] + try: + for modname in REQUIRED_MODULES: + if modname in sys.builtin_module_names: + logger.info("Ignoring built-in bootstrap module: %s" % modname) + continue + try: + f, filename, _ = imp.find_module(modname) + except ImportError: + logger.info("Cannot import bootstrap module: %s" % modname) + else: + if f is not None: + f.close() + dst_filename = change_prefix(filename, dst_prefix) + copyfile(filename, dst_filename) + if filename.endswith('.pyc'): + pyfile = filename[:-1] + if os.path.exists(pyfile): + copyfile(pyfile, dst_filename[:-1]) + finally: + sys.path = _prev_sys_path + +def install_python(home_dir, lib_dir, inc_dir, bin_dir, site_packages, clear): + """Install just the base environment, no distutils patches etc""" + if sys.executable.startswith(bin_dir): + print('Please use the *system* python to run this script') + return + + if clear: + rmtree(lib_dir) + ## FIXME: why not delete it? + ## Maybe it should delete everything with #!/path/to/venv/python in it + logger.notify('Not deleting %s', bin_dir) + + if hasattr(sys, 'real_prefix'): + logger.notify('Using real prefix %r' % sys.real_prefix) + prefix = sys.real_prefix + else: + prefix = sys.prefix + mkdir(lib_dir) + fix_lib64(lib_dir) + stdlib_dirs = [os.path.dirname(os.__file__)] + if sys.platform == 'win32': + stdlib_dirs.append(join(os.path.dirname(stdlib_dirs[0]), 'DLLs')) + elif sys.platform == 'darwin': + stdlib_dirs.append(join(stdlib_dirs[0], 'site-packages')) + if hasattr(os, 'symlink'): + logger.info('Symlinking Python bootstrap modules') + else: + logger.info('Copying Python bootstrap modules') + logger.indent += 2 + try: + # copy required files... + for stdlib_dir in stdlib_dirs: + if not os.path.isdir(stdlib_dir): + continue + for fn in os.listdir(stdlib_dir): + bn = os.path.splitext(fn)[0] + if fn != 'site-packages' and bn in REQUIRED_FILES: + copyfile(join(stdlib_dir, fn), join(lib_dir, fn)) + # ...and modules + copy_required_modules(home_dir) + finally: + logger.indent -= 2 + mkdir(join(lib_dir, 'site-packages')) + import site + site_filename = site.__file__ + if site_filename.endswith('.pyc'): + site_filename = site_filename[:-1] + elif site_filename.endswith('$py.class'): + site_filename = site_filename.replace('$py.class', '.py') + site_filename_dst = change_prefix(site_filename, home_dir) + site_dir = os.path.dirname(site_filename_dst) + writefile(site_filename_dst, SITE_PY) + writefile(join(site_dir, 'orig-prefix.txt'), prefix) + site_packages_filename = join(site_dir, 'no-global-site-packages.txt') + if not site_packages: + writefile(site_packages_filename, '') + else: + if os.path.exists(site_packages_filename): + logger.info('Deleting %s' % site_packages_filename) + os.unlink(site_packages_filename) + + if is_pypy or is_win: + stdinc_dir = join(prefix, 'include') + else: + stdinc_dir = join(prefix, 'include', py_version + abiflags) + if os.path.exists(stdinc_dir): + copyfile(stdinc_dir, inc_dir) + else: + logger.debug('No include dir %s' % stdinc_dir) + + # pypy never uses exec_prefix, just ignore it + if sys.exec_prefix != prefix and not is_pypy: + if sys.platform == 'win32': + exec_dir = join(sys.exec_prefix, 'lib') + elif is_jython: + exec_dir = join(sys.exec_prefix, 'Lib') + else: + exec_dir = join(sys.exec_prefix, 'lib', py_version) + for fn in os.listdir(exec_dir): + copyfile(join(exec_dir, fn), join(lib_dir, fn)) + + if is_jython: + # Jython has either jython-dev.jar and javalib/ dir, or just + # jython.jar + for name in 'jython-dev.jar', 'javalib', 'jython.jar': + src = join(prefix, name) + if os.path.exists(src): + copyfile(src, join(home_dir, name)) + # XXX: registry should always exist after Jython 2.5rc1 + src = join(prefix, 'registry') + if os.path.exists(src): + copyfile(src, join(home_dir, 'registry'), symlink=False) + copyfile(join(prefix, 'cachedir'), join(home_dir, 'cachedir'), + symlink=False) + + mkdir(bin_dir) + py_executable = join(bin_dir, os.path.basename(sys.executable)) + if 'Python.framework' in prefix: + if re.search(r'/Python(?:-32|-64)*$', py_executable): + # The name of the python executable is not quite what + # we want, rename it. + py_executable = os.path.join( + os.path.dirname(py_executable), 'python') + + logger.notify('New %s executable in %s', expected_exe, py_executable) + pcbuild_dir = os.path.dirname(sys.executable) + pyd_pth = os.path.join(lib_dir, 'site-packages', 'virtualenv_builddir_pyd.pth') + if is_win and os.path.exists(os.path.join(pcbuild_dir, 'build.bat')): + logger.notify('Detected python running from build directory %s', pcbuild_dir) + logger.notify('Writing .pth file linking to build directory for *.pyd files') + writefile(pyd_pth, pcbuild_dir) + else: + pcbuild_dir = None + if os.path.exists(pyd_pth): + logger.info('Deleting %s (not Windows env or not build directory python)' % pyd_pth) + os.unlink(pyd_pth) + + if sys.executable != py_executable: + ## FIXME: could I just hard link? + executable = sys.executable + if sys.platform == 'cygwin' and os.path.exists(executable + '.exe'): + # Cygwin misreports sys.executable sometimes + executable += '.exe' + py_executable += '.exe' + logger.info('Executable actually exists in %s' % executable) + shutil.copyfile(executable, py_executable) + make_exe(py_executable) + if sys.platform == 'win32' or sys.platform == 'cygwin': + pythonw = os.path.join(os.path.dirname(sys.executable), 'pythonw.exe') + if os.path.exists(pythonw): + logger.info('Also created pythonw.exe') + shutil.copyfile(pythonw, os.path.join(os.path.dirname(py_executable), 'pythonw.exe')) + python_d = os.path.join(os.path.dirname(sys.executable), 'python_d.exe') + python_d_dest = os.path.join(os.path.dirname(py_executable), 'python_d.exe') + if os.path.exists(python_d): + logger.info('Also created python_d.exe') + shutil.copyfile(python_d, python_d_dest) + elif os.path.exists(python_d_dest): + logger.info('Removed python_d.exe as it is no longer at the source') + os.unlink(python_d_dest) + # we need to copy the DLL to enforce that windows will load the correct one. + # may not exist if we are cygwin. + py_executable_dll = 'python%s%s.dll' % ( + sys.version_info[0], sys.version_info[1]) + py_executable_dll_d = 'python%s%s_d.dll' % ( + sys.version_info[0], sys.version_info[1]) + pythondll = os.path.join(os.path.dirname(sys.executable), py_executable_dll) + pythondll_d = os.path.join(os.path.dirname(sys.executable), py_executable_dll_d) + pythondll_d_dest = os.path.join(os.path.dirname(py_executable), py_executable_dll_d) + if os.path.exists(pythondll): + logger.info('Also created %s' % py_executable_dll) + shutil.copyfile(pythondll, os.path.join(os.path.dirname(py_executable), py_executable_dll)) + if os.path.exists(pythondll_d): + logger.info('Also created %s' % py_executable_dll_d) + shutil.copyfile(pythondll_d, pythondll_d_dest) + elif os.path.exists(pythondll_d_dest): + logger.info('Removed %s as the source does not exist' % pythondll_d_dest) + os.unlink(pythondll_d_dest) + if is_pypy: + # make a symlink python --> pypy-c + python_executable = os.path.join(os.path.dirname(py_executable), 'python') + logger.info('Also created executable %s' % python_executable) + copyfile(py_executable, python_executable) + + if os.path.splitext(os.path.basename(py_executable))[0] != expected_exe: + secondary_exe = os.path.join(os.path.dirname(py_executable), + expected_exe) + py_executable_ext = os.path.splitext(py_executable)[1] + if py_executable_ext == '.exe': + # python2.4 gives an extension of '.4' :P + secondary_exe += py_executable_ext + if os.path.exists(secondary_exe): + logger.warn('Not overwriting existing %s script %s (you must use %s)' + % (expected_exe, secondary_exe, py_executable)) + else: + logger.notify('Also creating executable in %s' % secondary_exe) + shutil.copyfile(sys.executable, secondary_exe) + make_exe(secondary_exe) + + if '.framework' in prefix: + if 'Python.framework' in prefix: + logger.debug('MacOSX Python framework detected') + # Make sure we use the the embedded interpreter inside + # the framework, even if sys.executable points to + # the stub executable in ${sys.prefix}/bin + # See http://groups.google.com/group/python-virtualenv/ + # browse_thread/thread/17cab2f85da75951 + original_python = os.path.join( + prefix, 'Resources/Python.app/Contents/MacOS/Python') + if 'EPD' in prefix: + logger.debug('EPD framework detected') + original_python = os.path.join(prefix, 'bin/python') + shutil.copy(original_python, py_executable) + + # Copy the framework's dylib into the virtual + # environment + virtual_lib = os.path.join(home_dir, '.Python') + + if os.path.exists(virtual_lib): + os.unlink(virtual_lib) + copyfile( + os.path.join(prefix, 'Python'), + virtual_lib) + + # And then change the install_name of the copied python executable + try: + call_subprocess( + ["install_name_tool", "-change", + os.path.join(prefix, 'Python'), + '@executable_path/../.Python', + py_executable]) + except: + logger.fatal( + "Could not call install_name_tool -- you must have Apple's development tools installed") + raise + + # Some tools depend on pythonX.Y being present + py_executable_version = '%s.%s' % ( + sys.version_info[0], sys.version_info[1]) + if not py_executable.endswith(py_executable_version): + # symlinking pythonX.Y > python + pth = py_executable + '%s.%s' % ( + sys.version_info[0], sys.version_info[1]) + if os.path.exists(pth): + os.unlink(pth) + os.symlink('python', pth) + else: + # reverse symlinking python -> pythonX.Y (with --python) + pth = join(bin_dir, 'python') + if os.path.exists(pth): + os.unlink(pth) + os.symlink(os.path.basename(py_executable), pth) + + if sys.platform == 'win32' and ' ' in py_executable: + # There's a bug with subprocess on Windows when using a first + # argument that has a space in it. Instead we have to quote + # the value: + py_executable = '"%s"' % py_executable + cmd = [py_executable, '-c', """ +import sys +prefix = sys.prefix +if sys.version_info[0] == 3: + prefix = prefix.encode('utf8') +if hasattr(sys.stdout, 'detach'): + sys.stdout = sys.stdout.detach() +elif hasattr(sys.stdout, 'buffer'): + sys.stdout = sys.stdout.buffer +sys.stdout.write(prefix) +"""] + logger.info('Testing executable with %s %s "%s"' % tuple(cmd)) + try: + proc = subprocess.Popen(cmd, + stdout=subprocess.PIPE) + proc_stdout, proc_stderr = proc.communicate() + except OSError: + e = sys.exc_info()[1] + if e.errno == errno.EACCES: + logger.fatal('ERROR: The executable %s could not be run: %s' % (py_executable, e)) + sys.exit(100) + else: + raise e + + proc_stdout = proc_stdout.strip().decode("utf-8") + proc_stdout = os.path.normcase(os.path.abspath(proc_stdout)) + norm_home_dir = os.path.normcase(os.path.abspath(home_dir)) + if hasattr(norm_home_dir, 'decode'): + norm_home_dir = norm_home_dir.decode(sys.getfilesystemencoding()) + if proc_stdout != norm_home_dir: + logger.fatal( + 'ERROR: The executable %s is not functioning' % py_executable) + logger.fatal( + 'ERROR: It thinks sys.prefix is %r (should be %r)' + % (proc_stdout, norm_home_dir)) + logger.fatal( + 'ERROR: virtualenv is not compatible with this system or executable') + if sys.platform == 'win32': + logger.fatal( + 'Note: some Windows users have reported this error when they ' + 'installed Python for "Only this user" or have multiple ' + 'versions of Python installed. Copying the appropriate ' + 'PythonXX.dll to the virtualenv Scripts/ directory may fix ' + 'this problem.') + sys.exit(100) + else: + logger.info('Got sys.prefix result: %r' % proc_stdout) + + pydistutils = os.path.expanduser('~/.pydistutils.cfg') + if os.path.exists(pydistutils): + logger.notify('Please make sure you remove any previous custom paths from ' + 'your %s file.' % pydistutils) + ## FIXME: really this should be calculated earlier + + fix_local_scheme(home_dir) + + return py_executable + +def install_activate(home_dir, bin_dir, prompt=None): + home_dir = os.path.abspath(home_dir) + if sys.platform == 'win32' or is_jython and os._name == 'nt': + files = { + 'activate.bat': ACTIVATE_BAT, + 'deactivate.bat': DEACTIVATE_BAT, + 'activate.ps1': ACTIVATE_PS, + } + + # MSYS needs paths of the form /c/path/to/file + drive, tail = os.path.splitdrive(home_dir.replace(os.sep, '/')) + home_dir_msys = (drive and "/%s%s" or "%s%s") % (drive[:1], tail) + + # Run-time conditional enables (basic) Cygwin compatibility + home_dir_sh = ("""$(if [ "$OSTYPE" "==" "cygwin" ]; then cygpath -u '%s'; else echo '%s'; fi;)""" % + (home_dir, home_dir_msys)) + files['activate'] = ACTIVATE_SH.replace('__VIRTUAL_ENV__', home_dir_sh) + + else: + files = {'activate': ACTIVATE_SH} + + # suppling activate.fish in addition to, not instead of, the + # bash script support. + files['activate.fish'] = ACTIVATE_FISH + + # same for csh/tcsh support... + files['activate.csh'] = ACTIVATE_CSH + + files['activate_this.py'] = ACTIVATE_THIS + if hasattr(home_dir, 'decode'): + home_dir = home_dir.decode(sys.getfilesystemencoding()) + vname = os.path.basename(home_dir) + for name, content in files.items(): + content = content.replace('__VIRTUAL_PROMPT__', prompt or '') + content = content.replace('__VIRTUAL_WINPROMPT__', prompt or '(%s)' % vname) + content = content.replace('__VIRTUAL_ENV__', home_dir) + content = content.replace('__VIRTUAL_NAME__', vname) + content = content.replace('__BIN_NAME__', os.path.basename(bin_dir)) + writefile(os.path.join(bin_dir, name), content) + +def install_distutils(home_dir): + distutils_path = change_prefix(distutils.__path__[0], home_dir) + mkdir(distutils_path) + ## FIXME: maybe this prefix setting should only be put in place if + ## there's a local distutils.cfg with a prefix setting? + home_dir = os.path.abspath(home_dir) + ## FIXME: this is breaking things, removing for now: + #distutils_cfg = DISTUTILS_CFG + "\n[install]\nprefix=%s\n" % home_dir + writefile(os.path.join(distutils_path, '__init__.py'), DISTUTILS_INIT) + writefile(os.path.join(distutils_path, 'distutils.cfg'), DISTUTILS_CFG, overwrite=False) + +def fix_local_scheme(home_dir): + """ + Platforms that use the "posix_local" install scheme (like Ubuntu with + Python 2.7) need to be given an additional "local" location, sigh. + """ + try: + import sysconfig + except ImportError: + pass + else: + if sysconfig._get_default_scheme() == 'posix_local': + local_path = os.path.join(home_dir, 'local') + if not os.path.exists(local_path): + os.mkdir(local_path) + for subdir_name in os.listdir(home_dir): + if subdir_name == 'local': + continue + os.symlink(os.path.abspath(os.path.join(home_dir, subdir_name)), \ + os.path.join(local_path, subdir_name)) + +def fix_lib64(lib_dir): + """ + Some platforms (particularly Gentoo on x64) put things in lib64/pythonX.Y + instead of lib/pythonX.Y. If this is such a platform we'll just create a + symlink so lib64 points to lib + """ + if [p for p in distutils.sysconfig.get_config_vars().values() + if isinstance(p, basestring) and 'lib64' in p]: + logger.debug('This system uses lib64; symlinking lib64 to lib') + assert os.path.basename(lib_dir) == 'python%s' % sys.version[:3], ( + "Unexpected python lib dir: %r" % lib_dir) + lib_parent = os.path.dirname(lib_dir) + assert os.path.basename(lib_parent) == 'lib', ( + "Unexpected parent dir: %r" % lib_parent) + copyfile(lib_parent, os.path.join(os.path.dirname(lib_parent), 'lib64')) + +def resolve_interpreter(exe): + """ + If the executable given isn't an absolute path, search $PATH for the interpreter + """ + if os.path.abspath(exe) != exe: + paths = os.environ.get('PATH', '').split(os.pathsep) + for path in paths: + if os.path.exists(os.path.join(path, exe)): + exe = os.path.join(path, exe) + break + if not os.path.exists(exe): + logger.fatal('The executable %s (from --python=%s) does not exist' % (exe, exe)) + raise SystemExit(3) + if not is_executable(exe): + logger.fatal('The executable %s (from --python=%s) is not executable' % (exe, exe)) + raise SystemExit(3) + return exe + +def is_executable(exe): + """Checks a file is executable""" + return os.access(exe, os.X_OK) + +############################################################ +## Relocating the environment: + +def make_environment_relocatable(home_dir): + """ + Makes the already-existing environment use relative paths, and takes out + the #!-based environment selection in scripts. + """ + home_dir, lib_dir, inc_dir, bin_dir = path_locations(home_dir) + activate_this = os.path.join(bin_dir, 'activate_this.py') + if not os.path.exists(activate_this): + logger.fatal( + 'The environment doesn\'t have a file %s -- please re-run virtualenv ' + 'on this environment to update it' % activate_this) + fixup_scripts(home_dir) + fixup_pth_and_egg_link(home_dir) + ## FIXME: need to fix up distutils.cfg + +OK_ABS_SCRIPTS = ['python', 'python%s' % sys.version[:3], + 'activate', 'activate.bat', 'activate_this.py'] + +def fixup_scripts(home_dir): + # This is what we expect at the top of scripts: + shebang = '#!%s/bin/python' % os.path.normcase(os.path.abspath(home_dir)) + # This is what we'll put: + new_shebang = '#!/usr/bin/env python%s' % sys.version[:3] + activate = "import os; activate_this=os.path.join(os.path.dirname(os.path.realpath(__file__)), 'activate_this.py'); execfile(activate_this, dict(__file__=activate_this)); del os, activate_this" + if sys.platform == 'win32': + bin_suffix = 'Scripts' + else: + bin_suffix = 'bin' + bin_dir = os.path.join(home_dir, bin_suffix) + home_dir, lib_dir, inc_dir, bin_dir = path_locations(home_dir) + for filename in os.listdir(bin_dir): + filename = os.path.join(bin_dir, filename) + if not os.path.isfile(filename): + # ignore subdirs, e.g. .svn ones. + continue + f = open(filename, 'rb') + try: + try: + lines = f.read().decode('utf-8').splitlines() + except UnicodeDecodeError: + # This is probably a binary program instead + # of a script, so just ignore it. + continue + finally: + f.close() + if not lines: + logger.warn('Script %s is an empty file' % filename) + continue + if not lines[0].strip().startswith(shebang): + if os.path.basename(filename) in OK_ABS_SCRIPTS: + logger.debug('Cannot make script %s relative' % filename) + elif lines[0].strip() == new_shebang: + logger.info('Script %s has already been made relative' % filename) + else: + logger.warn('Script %s cannot be made relative (it\'s not a normal script that starts with %s)' + % (filename, shebang)) + continue + logger.notify('Making script %s relative' % filename) + lines = [new_shebang+'\n', activate+'\n'] + lines[1:] + f = open(filename, 'wb') + f.write('\n'.join(lines).encode('utf-8')) + f.close() + +def fixup_pth_and_egg_link(home_dir, sys_path=None): + """Makes .pth and .egg-link files use relative paths""" + home_dir = os.path.normcase(os.path.abspath(home_dir)) + if sys_path is None: + sys_path = sys.path + for path in sys_path: + if not path: + path = '.' + if not os.path.isdir(path): + continue + path = os.path.normcase(os.path.abspath(path)) + if not path.startswith(home_dir): + logger.debug('Skipping system (non-environment) directory %s' % path) + continue + for filename in os.listdir(path): + filename = os.path.join(path, filename) + if filename.endswith('.pth'): + if not os.access(filename, os.W_OK): + logger.warn('Cannot write .pth file %s, skipping' % filename) + else: + fixup_pth_file(filename) + if filename.endswith('.egg-link'): + if not os.access(filename, os.W_OK): + logger.warn('Cannot write .egg-link file %s, skipping' % filename) + else: + fixup_egg_link(filename) + +def fixup_pth_file(filename): + lines = [] + prev_lines = [] + f = open(filename) + prev_lines = f.readlines() + f.close() + for line in prev_lines: + line = line.strip() + if (not line or line.startswith('#') or line.startswith('import ') + or os.path.abspath(line) != line): + lines.append(line) + else: + new_value = make_relative_path(filename, line) + if line != new_value: + logger.debug('Rewriting path %s as %s (in %s)' % (line, new_value, filename)) + lines.append(new_value) + if lines == prev_lines: + logger.info('No changes to .pth file %s' % filename) + return + logger.notify('Making paths in .pth file %s relative' % filename) + f = open(filename, 'w') + f.write('\n'.join(lines) + '\n') + f.close() + +def fixup_egg_link(filename): + f = open(filename) + link = f.read().strip() + f.close() + if os.path.abspath(link) != link: + logger.debug('Link in %s already relative' % filename) + return + new_link = make_relative_path(filename, link) + logger.notify('Rewriting link %s in %s as %s' % (link, filename, new_link)) + f = open(filename, 'w') + f.write(new_link) + f.close() + +def make_relative_path(source, dest, dest_is_directory=True): + """ + Make a filename relative, where the filename is dest, and it is + being referred to from the filename source. + + >>> make_relative_path('/usr/share/something/a-file.pth', + ... '/usr/share/another-place/src/Directory') + '../another-place/src/Directory' + >>> make_relative_path('/usr/share/something/a-file.pth', + ... '/home/user/src/Directory') + '../../../home/user/src/Directory' + >>> make_relative_path('/usr/share/a-file.pth', '/usr/share/') + './' + """ + source = os.path.dirname(source) + if not dest_is_directory: + dest_filename = os.path.basename(dest) + dest = os.path.dirname(dest) + dest = os.path.normpath(os.path.abspath(dest)) + source = os.path.normpath(os.path.abspath(source)) + dest_parts = dest.strip(os.path.sep).split(os.path.sep) + source_parts = source.strip(os.path.sep).split(os.path.sep) + while dest_parts and source_parts and dest_parts[0] == source_parts[0]: + dest_parts.pop(0) + source_parts.pop(0) + full_parts = ['..']*len(source_parts) + dest_parts + if not dest_is_directory: + full_parts.append(dest_filename) + if not full_parts: + # Special case for the current directory (otherwise it'd be '') + return './' + return os.path.sep.join(full_parts) + + + +############################################################ +## Bootstrap script creation: + +def create_bootstrap_script(extra_text, python_version=''): + """ + Creates a bootstrap script, which is like this script but with + extend_parser, adjust_options, and after_install hooks. + + This returns a string that (written to disk of course) can be used + as a bootstrap script with your own customizations. The script + will be the standard virtualenv.py script, with your extra text + added (your extra text should be Python code). + + If you include these functions, they will be called: + + ``extend_parser(optparse_parser)``: + You can add or remove options from the parser here. + + ``adjust_options(options, args)``: + You can change options here, or change the args (if you accept + different kinds of arguments, be sure you modify ``args`` so it is + only ``[DEST_DIR]``). + + ``after_install(options, home_dir)``: + + After everything is installed, this function is called. This + is probably the function you are most likely to use. An + example would be:: + + def after_install(options, home_dir): + subprocess.call([join(home_dir, 'bin', 'easy_install'), + 'MyPackage']) + subprocess.call([join(home_dir, 'bin', 'my-package-script'), + 'setup', home_dir]) + + This example immediately installs a package, and runs a setup + script from that package. + + If you provide something like ``python_version='2.4'`` then the + script will start with ``#!/usr/bin/env python2.4`` instead of + ``#!/usr/bin/env python``. You can use this when the script must + be run with a particular Python version. + """ + filename = __file__ + if filename.endswith('.pyc'): + filename = filename[:-1] + f = open(filename, 'rb') + content = f.read() + f.close() + py_exe = 'python%s' % python_version + content = (('#!/usr/bin/env %s\n' % py_exe) + + '## WARNING: This file is generated\n' + + content) + return content.replace('##EXT' 'END##', extra_text) + +##EXTEND## + +def convert(s): + b = base64.b64decode(s.encode('ascii')) + return zlib.decompress(b).decode('utf-8') + +##file site.py +SITE_PY = convert(""" +eJzFPf1z2zaWv/OvwMqTIZXKdD66nR2n7o2TOK333MTbpLO5dT1aSoIs1hTJEqRl7c3d337vAwAB +kvLHpp3TdGKJBB4eHt43HtDRaHRcljJfiHWxaDIplEyq+UqUSb1SYllUol6l1WK/TKp6C0/n18mV +VKIuhNqqGFvFQfD0Cz/BU/FplSqDAnxLmrpYJ3U6T7JsK9J1WVS1XIhFU6X5lUjztE6TLP0XtCjy +WDz9cgyC01zAzLNUVuJGVgrgKlEsxfm2XhW5iJoS5/w8/nPycjwRal6lZQ0NKo0zUGSV1EEu5QLQ +hJaNAlKmtdxXpZyny3RuG26KJluIMkvmUvzznzw1ahqGgSrWcrOSlRQ5IAMwJcAqEQ/4mlZiXixk +LMRrOU9wAH7eEitgaBNcM4VkzAuRFfkVzCmXc6lUUm1FNGtqAkQoi0UBOKWAQZ1mWbApqms1hiWl +9djAI5Ewe/iTYfaAeeL4fc4BHD/kwc95ejth2MA9CK5eMdtUcpneigTBwk95K+dT/SxKl2KRLpdA +g7weY5OAEVAiS2cHJS3Ht3qFvjsgrCxXJjCGRJS5Mb+kHnFwWoskU8C2TYk0UoT5WzlLkxyokd/A +cAARSBoMjbNIVW3HodmJAgBUuI41SMlaiWidpDkw64/JnND+e5ovio0aEwVgtZT4tVG1O/9ogADQ +2iHAJMDFMqvZ5Fl6LbPtGBD4BNhXUjVZjQKxSCs5r4sqlYoAAGpbIW8B6YlIKqlJyJxp5HZC9Cea +pDkuLAoYCjy+RJIs06umIgkTyxQ4F7ji3YefxNuT16fH7zWPGWAss1drwBmg0EI7OMEA4qBR1UFW +gEDHwRn+EcligUJ2heMDXm2Dg3tXOohg7mXc7eMsOJBdL64eBuZYgzKhsQLq99/QZaJWQJ//uWe9 +g+B4F1Vo4vxtsypAJvNkLcUqYf5Czgi+1XC+i8t69Qq4QSGcGkilcHEQwRThAUlcmkVFLkUJLJal +uRwHQKEZtfVXEVjhfZHv01p3OAEgVEEOL51nYxoxlzDRPqxXqC9M4y3NTDcJ7Dqvi4oUB/B/Pidd +lCX5NeGoiKH420xepXmOCCEvBOFeSAOr6xQ4cRGLM2pFesE0EiFrL26JItEALyHTAU/K22RdZnLC +4ou69W41QoPJWpi1zpjjoGVN6pVWrZ3qIO+9iD93uI7QrFeVBODNzBO6ZVFMxAx0NmFTJmsWr3pT +EOcEA/JEnZAnqCX0xe9A0WOlmrW0L5FXQLMQQwXLIsuKDZDsMAiE2MNGxij7zAlv4R38C3Dx30zW +81UQOCNZwBoUIr8LFAIBkyBzzdUaCY/bNCt3lUyas6YoqoWsaKiHEfuAEX9gY5xr8L6otVHj6eIq +F+u0RpU00yYzZYuXhzXrx1c8b5gGWG5FNDNNWzqtcXpZuUpm0rgkM7lESdCL9MouO4wZDIxJtrgW +a7Yy8A7IIlO2IMOKBZXOspbkBAAMFr4kT8smo0YKGUwkMNC6JPjrBE16oZ0lYG82ywEqJDbfc7A/ +gNu/QIw2qxToMwcIoGFQS8HyzdK6Qgeh1UeBb/RNfx4fOPV0qW0TD7lM0kxb+SQPTunhSVWR+M5l +ib0mmhgKZpjX6Npd5UBHFPPRaBQExh3aKvO1UEFdbQ+BFYQZZzqdNSkavukUTb3+oQIeRTgDe91s +OwsPNITp9B6o5HRZVsUaX9u5fQRlAmNhj2BPnJOWkewge5z4CsnnqvTSNEXb7bCzQD0UnP908u70 +88lHcSQuWpU26eqzSxjzJE+ArckiAFN1hm11GbRExZei7hPvwLwTU4A9o94kvjKpG+BdQP1T1dBr +mMbcexmcvD9+fXYy/fnjyU/Tj6efTgBBsDMy2KMpo3lswGFUMQgHcOVCxdq+Br0e9OD18Uf7IJim +alpuyy08AEMJLFxFMN+JCPHhVNvgaZovi3BMjX9lJ/yI1Yr2uC4Ov74UR0ci/DW5ScIAvJ62KS/i +jyQAn7alhK41/IkKNQ6ChVyCsFxLFKnoKXmyY+4ARISWhbasvxZpbt4zH7lDkMRH1ANwmE7nWaIU +Np5OQyAtdRj4QIeY3WGUkwg6llu361ijgp9KwlLk2GWC/wygmMyoH6LBKLpdTCMQsPU8UZJb0fSh +33SKWmY6jfSAIH7E4+AiseIIhWmCWqZKwRMlXkGtM1NFhj8RPsotiQwGQ6jXcJF0sBPfJFkjVeRM +CogYRR0yompMFXEQOBUR2M526cbjLjUNz0AzIF9WgN6rOpTDzx54KKBgTNiFoRlHS0wzxPSvHBsQ +DuAkhqiglepAYX0mzk/OxctnL/bRAYEocWGp4zVHm5rmjbQPl7BaV7J2EOZe4YSEYezSZYmaEZ8e +3g1zHduV6bPCUi9xJdfFjVwAtsjAziqLn+gNxNIwj3kCqwiamCw4Kz3j6SUYOfLsQVrQ2gP11gTF +rL9Z+j0O32WuQHVwKEyk1nE6G6+yKm5SdA9mW/0SrBuoN7RxxhUJnIXzmAyNGGgI8FtzpNRGhqDA +qoZdTMIbQaKGX7SqMCZwZ6hbL+nrdV5s8inHrkeoJqOxZV0ULM282KBdgj3xDuwGIFlAKNYSjaGA +ky5QtvYBeZg+TBcoS9EAAALTrCjAcmCZ4IymyHEeDoswxq8ECW8l0cLfmCEoODLEcCDR29g+MFoC +IcHkrIKzqkEzGcqaaQYDOyTxue4s5qDRB9ChYgyGLtLQuJGh38UhKGdx5iolpx/a0M+fPzPbqBVl +RBCxGU4ajf6SzFtcbsEUpqATjA/F+RVigw24owCmUZo1xf5HUZTsP8F6nmvZBssN8Vhdl4cHB5vN +Jtb5gKK6OlDLgz//5Ztv/vKMdeJiQfwD03GkRSfH4gN6hz5o/K2xQN+ZlevwY5r73EiwIkl+FDmP +iN/3TbooxOH+2OpP5OLWsOK/xvkABTI1gzKVgbajFqMnav9J/FKNxBMRuW2jMXsS2qRaK+ZbXehR +F2C7wdOYF01eh44iVeIrsG4QUy/krLkK7eCejTQ/YKoop5Hlgf3nl4iBzxmGr4wpnqKWILZAi++Q +/idmm4T8Ga0hkLxoonrx7nZYixniLh4u79Y7dITGzDBVyB0oEX6TBwugbdyXHPxoZxTtnuOMmo9n +CIylDwzzalcwQsEhXHAtJq7UOVyNPipI04ZVMygYVzWCgga3bsbU1uDIRoYIEr0bE57zwuoWQKdO +rs9E9GYVoIU7Ts/adVnB8YSQB47Ec3oiwak97L17xkvbZBmlYDo86lGFAXsLjXa6AL6MDICJGFU/ +j7ilCSw+dBaF12AAWMFZG2SwZY+Z8I3rA472RgPs1LP6u3ozjYdA4CJFnD16EHRC+YhHqBRIUxn5 +PXexuCVuf7A7LQ4xlVkmEmm1Q7i6ymNQqO40TMs0R93rLFI8zwrwiq1WJEZq3/vOAkUu+HjImGkJ +1GRoyeE0OiJvzxPAULfDhNdVg6kBN3OCGK1TRdYNybSCf8CtoIwEpY+AlgTNgnmolPkT+x1kzs5X +f9nBHpbQyBBu011uSM9iaDjm/Z5AMur8CUhBDiTsCyO5jqwOMuAwZ4E84YbXcqd0E4xYgZw5FoTU +DOBOL70AB5/EuGdBEoqQb2slS/GVGMHydUX1Ybr7d+VSkzaInAbkKuh8w5Gbi3DyEEedvITP0H5G +gnY3ygI4eAYuj5uad9ncMK1Nk4Cz7ituixRoZMqcjMYuqpeGMG76909HTouWWGYQw1DeQN4mjBlp +HNjl1qBhwQ0Yb827Y+nHbsYC+0ZhoV7I9S3Ef2GVqnmhQgxwe7kL96O5ok8bi+1ZOhvBH28BRuNL +D5LMdP4Csyz/xiChBz0cgu5NFtMii6TapHlICkzT78hfmh4elpSekTv4SOHUAUwUc5QH7yoQENqs +PABxQk0AUbkMlXb7+2DvnOLIwuXuI89tvjh8edkn7mRXhsd+hpfq5LauEoWrlfGisVDgavUNOCpd +mFySb/V2o96OxjChKhREkeLDx88CCcGZ2E2yfdzUW4ZHbO6dk/cxqINeu5dcndkRuwAiqBWRUQ7C +x3Pkw5F97OTumNgjgDyKYe5YFANJ88m/A+euhYIx9hfbHPNoXZWBH3j9zdfTgcyoi+Q3X4/uGaVD +jCGxjzqeoB2ZygDE4LRNl0omGfkaTifKKuYt79g25ZgVOsV/mskuB5xO/Jj3xmS08HvNe4Gj+ewR +PSDMLma/QrCqdH7rJkkzSsoDGvv7qOdMnM2pg2F8PEh3o4w5KfBYnk0GQyF18QwWJuTAftyfjvaL +jk3udyAgNZ8yUX1U9vQGfLt/5G2qu3uHfajamBgeesaZ/hcDWsKb8ZBd/xINh5/fRRlYYB4NRkNk +9xzt/+9ZPvtjJvnAqZht39/RMD0S0O81E9bjDE3r8XHHIA4tu2sCDbAHWIodHuAdHlp/aN7oWxo/ +i1WSEk9Rdz0VG9rrpzQnbtoAlAW7YANwcBn1jvGbpqp435dUYCmrfdzLnAgsczJOGFVP9cEcvJc1 +YmKbzSlt7BTFFENqJNSJYDuTsHXhh+VsVZj0kcxv0gr6gsKNwh8+/HgS9hlAD4OdhsG562i45OEm +HOE+gmlDTZzwMX2YQo/p8u9LVTeK8AlqttNNclaTbdA++DlZE9IPr8E9yRlv75T3qDFYnq/k/Hoq +ad8d2RS7OvnpN/gaMbHb8X7xlEqWVAEGM5lnDdKKfWAs3Vs2+Zy2KmoJro6us8W6G9pN50zcMkuu +RESdF5gF0txIiaKbpNKOYFkVWNkpmnRxcJUuhPytSTKMsOVyCbjgPpJ+FfPwlAwSb7kggCv+lJw3 +VVpvgQSJKvQ2HNUOOA1nW55o5CHJOy5MQKwmOBQfcdr4ngm3MOQycbq/+YCTxBAYO5h9UuQueg7v +82KKo06pQHbCSPW3yOlx0B2hAAAjAArzH411Es1/I+mVu9dHa+4SFbWkR0o36C/IGUMo0RiTDvyb +fvqM6PLWDiyvdmN5dTeWV10srwaxvPKxvLobS1ckcGFt/shIwlAOqbvDMFis4qZ/eJiTZL7idlg4 +iQWSAFGUJtY1MsX1w16SibfaCAipbWfvlx62xScpV2RWBWejNUjkftxP0nG1qfx2OlMpi+7MUzHu +7K4CHL/vQRxTndWMurO8LZI6iT25uMqKGYitRXfSApiIbi0Opy3zm+mME60dSzU6/69PP3x4j80R +1MhUGlA3XEQ0LDiV6GlSXam+NLVxWAnsSC39mhjqpgHuPTDJxaPs8T9vqdgCGUdsqFigECV4AFQS +ZZu5hUNh2HmuK4z0c2Zy3vc5EqO8HrWT2kGk4/Pzt8efjkeUfRv978gVGENbXzpcfEwL26Dvv7nN +LcWxDwi1TjO1xs+dk0frliPut7EGbM+H7zx48RCDPRix+7P8QykFSwKEinUe9jGEenAM9EVhQo8+ +hhF7lXPuJhc7K/adI3uOi+KI/tAOQHcAf98RY4wpEEC7UJGJDNpgqqP0rXm9g6IO0Af6el8cgnVD +r24k41PUTmLAAXQoa5vtdv+8LRM2ekrWr0++P31/dvr6/PjTD44LiK7ch48HL8TJj58FlWqgAWOf +KMEqhRqLgsCwuKeExKKA/xrM/CyamvO10Ovt2ZneNFnjOREsHEabE8Nzriiy0Dh9xQlh+1CXAiFG +mQ6QnAM5VDlDB3YwXlrzYRBV6OJiOuczQ2e10aGXPmhlDmTRFnMM0geNXVIwCK72gldUAl6bqLDi +zTh9SGkAKW2jbY1GRum53s69sxVlNjq8nCV1hidtZ63oL0IX1/AyVmWWQiT3KrSypLthpUrLOPqh +3WtmvIY0oNMdRtYNedY7sUCr9Srkuen+45bRfmsAw5bB3sK8c0mVGlS+jHVmIsRGvKkSylv4apde +r4GCBcM9txoX0TBdCrNPILgWqxQCCODJFVhfjBMAQmcl/Nz8oZMdkAUWSoRv1ov9v4WaIH7rX34Z +aF5X2f4/RAlRkOCqnnCAmG7jtxD4xDIWJx/ejUNGjqpkxd8arK0Hh4QSoI60UykRb2ZPIyWzpS71 +8PUBvtB+Ar3udK9kWenuw65xiBLwREXkNTxRhn4hVl5Z2BOcyrgDGo8NWMzw+J1bEWA+e+LjSmaZ +LhY/fXt2Ar4jnmRACeItsBMYjvMluJut6+D4eGAHFO51w+sK2bhCF5bqHRax12wwaY0iR729Egm7 +TpQY7vfqZYGrJFUu2hFOm2GZWvwYWRnWwiwrs3anDVLYbUMUR5lhlpieV1RL6vME8DI9TTgkglgJ +z0mYDDxv6KZ5bYoHs3QOehRULijUCQgJEhcPAxLnFTnnwItKmTNE8LDcVunVqsZ9Bugc0/kFbP7j +8eez0/dU0//iZet1DzDnhCKBCddzHGG1HmY74ItbgYdcNZ0O8ax+hTBQ+8Cf7isuFDniAXr9OLGI +f7qv+BDXkRMJ8gxAQTVlVzwwAHC6DclNKwuMq42D8eNW47WY+WAoF4lnRnTNhTu/Pifalh1TQnkf +8/IRGzjLUtMwMp3d6rDuR89xWeKO0yIabgRvh2TLfGbQ9br3ZlcdmvvpSSGeJwWM+q39MUyhVq+p +no7DbLu4hcJabWN/yZ1cqdNunqMoAxEjt/PYZbJhJaybMwd6Fc09YOJbja6RxEFVPvolH2kPw8PE +ErsXp5iOdKKEjABmMqQ+ONOAD4UWARQIFeJGjuROxk9feHN0rMH9c9S6C2zjD6AIdVksHbcoKuBE ++PIbO478itBCPXooQsdTyWVe2JIt/GxW6FU+9+c4KAOUxESxq5L8SkYMa2JgfuUTe0cKlrStR+qL +9HLIsIhTcE5vd3B4Xy6GN04Mah1G6LW7ltuuOvLJgw0GT2XcSTAffJVsQPeXTR3xSg6L/PBBtN1Q +74eIhYDQVO+DRyGmY34Ld6xPC3iQGhoWeni/7diF5bUxjqy1j50DRqF9oT3YeQWhWa1oW8Y52Wd8 +UesFtAb3qDX5I/tU1+zY3wNHtpyckAXKg7sgvbmNdINOOmHEJ4f42GVKlentwRb9biFvZFaA6wVR +HR48+NUePBjHNp0yWJL1xdidb8+3w7jRmxazQ3MyAj0zVcL6xbmsDxCdwYzPXZi1yOBS/6JDkiS/ +Ji/5zd9PJ+LN+5/g39fyA8RVeHJwIv4BaIg3RQXxJR99pTsJ8FBFzYFj0Sg8XkjQaKuCr29At+3c +ozNui+jTHv4xD6spBRa4Vmu+MwRQ5AnScfDWTzBnGOC3OWTV8UaNpzi0KCP9Emmw+9wJntU40C3j +Vb3O0F44WZJ2NS9GZ6dvTt5/PInrW+Rw83PkZFH82iicjt4jrnA/bCLsk3mDTy4dx/kHmZUDfrMO +Os0ZFgw6RQhxSWkDTb6PIrHBRVJh5kCU20Uxj7ElsDwfm6s34EiPnfjyXkPvWVmEFY31LlrrzeNj +oIb4pauIRtCQ+ug5UU9CKJnh+S1+HI+GTfFEUGob/jy93izczLg+iEMT7GLazjryu1tduGI6a3iW +kwivI7sM5mxmliZqPZu7Z/Y+5EJfJwJajvY55DJpslrIHCSXgny61wE0vXvMjiWEWYXNGZ09ozRN +tkm2yilCSpQY4agjOpqOGzKUMYQY/Mfkmu0Bnv8TDR8kBuiEKMVPhdNVNfMVSzCHRES9gcKDTZq/ +dOt5NIV5UI6Q560jC/NEt5ExupK1nj8/iMYXz9tKB8pKz71DtvMSrJ7LJnugOsunT5+OxH/c7/0w +KnFWFNfglgHsQa/ljF7vsNx6cna1+p69eRMDP85X8gIeXFL23D5vckpN3tGVFkTavwZGiGsTWmY0 +7Tt2mZN2FW80cwvesNKW4+c8pUuDMLUkUdnqu5cw7WSkiVgSFEOYqHmahpymgPXYFg2ej8M0o+YX +eQscnyKYCb7FHTIOtVfoYVItq+Uei86RGBHgEdWW8Wh0wJhOiAGe0/OtRnN6mqd1e7Tjmbt5qg/S +1/YuIM1XItmgZJh5dIjhHLX0WLX1sIs7WdSLWIr5hZtw7MySX9+HO7A2SFqxXBpM4aFZpHkhq7kx +p7hi6TytHTCmHcLhznQFElmfOBhAaQTqnazCwkq0ffsnuy4uph9oH3nfjKTLh2p7rRQnh5K8U2AY +x+34lIayhLR8a76MYZT3lNbWnoA3lviTTqpiXb93+4V7xLDJ9a0WXL/RXnUBcOgmJasgLTt6OsK5 +vsvCZ6bdcRcFfihEJ9xu0qpukmyqL0+YosM2tRvrGk97NO3OQ5fWWwEnvwAPeF9X0YPjYKpskJ5Y +BGtOSRyJpU5RxO5pL/9gVFmgl/eCfSXwKZAyi6k5o2ySSBeWXe3hT12z6ah4BPWVOVD0EJtgjrX0 +ToS405hQ0VM47la59lrhBos5tmA9725k8KghO7B8L95MsHunhfjuSETPJ+LPnUBsXm7xViYgw5NF +/GQR+j4hdb04fNHauX7g24GwE8jLy0dPN0tnNL1wqPz8/r666BED0DXI7jKVi/0nCrFjnL8UqobS +zms3p9KM8XT6nq260gez2+MqdCptBlHFplVojmoz/q8dxJz41nqID8ei0mALaA/0m8KXTvGhvXQN +CxM1ev7KopRMhzbH8BtenALvNUFdodq5aaor7C3YgZyAPkbJW2Btw4Gg8BE8FNIlL7RoX3W2hf/I +xeOi/V2biz0sv/n6LjxdAR88sTBAUI+YTqs/kKl2ssxjF+YB+/X389/Dee8uvns0lXSvYVphKIWF +zKuE36BJbMpDm2owIolbQZFb3oaf+nrwTAyLI+qm+jq8a/rc/6656xaBnbnZ3e3N3T/75tJA993N +L0M04DBPE+JBNeOtwA7rAleMJ7qoYDhlqT9IfrcTznSHVrgPjClhwAQosanG3mjNdTJ3v2OFzD5f +7+oedRzU1Z1p985+djn+IYqWqwHwuT39TCUeC82B7DfSfV1TLhqcyqsrNU3wrrgpBRtU4NLzIo37 ++o6u+pKJ2hqvEy9UARCGm3QpolttDIwBAQ3fWcv1Ic7NGYKGpipKpyxTpQvOIGkXF8DFnDmi/iYz +yXWVo0xiwk81VVlBVDDSN5ty4cJQrWcL1CQy1om6NqibHhN90SUOwdUy5ngk56s40vCoA4TgU1PO +tU1cqDyd2nfAL8/aY+DpxDKEzJu1rJK6vQLF3yZNxXfOCHQoFhfYSVW0ktnhFBex1PKHgxQmC+z3 +r7ST7QUZd5z9Hlut93C2oh46BfaYY+WO7THcnN7aK9Dcq3cWdGGua+Rts5b77LUvsBTmPi/SlTp3 +wG/1HUN8cyVnNtFNcPgI5N49kuaX51q1xk6KRcN55iqG/qUyeKqZbPHQXXE9LujfCtdx9O34vt6w +zNILDXY0tlTUrtWg4mlHG7cRNVbS3RNR+9XSj4yoPfgPjKj1zX5gcDQ+Wh8M1k/fE3qzmnCvyWsZ +AfpMgUi4s9e5ZM2YzMitRoawN70d2WtqWWc6R5yMmUCO7N+fRCD4Ojzllm5611WZcYciWl+66PH3 +Zx9eH58RLabnx2/+8/h7qlbB9HHHZj045ZAX+0ztfa8u1k0/6AqDocFbbAfuneTDHRpC731vc3YA +wvBBnqEF7Soy9/WuDr0DEf1OgPjd0+5A3aWyByH3/DNdfO/WFXQKWAP9lKsNzS9ny9Y8MjsXLA7t +zoR53yaTtYz2cm27Fs6p++urE+236psKd+QBx7b6lFYAc8jIXzaFbI4S2EQlOyrd/3kAlcziMSxz +ywdI4Vw6t83RRXMMqvb/LwUVKLsE98HYYZzYG3+pHafLlb3KGvfC5jI2BPHOQY3683OFfSGzHVQI +AlZ4+i41RsToP73BZLdjnyhxsU8nLvdR2VzaX7hm2sn9e4qbrrW9k0hx5QZvO0HjZZO5G6m2T68D +OX+UnS+WTok/aL4DoHMrngrYG30mVoizrQghkNQbhlg1SHTUF4o5yKPddLA3tHom9nedx3PPownx +fHfDRefIm+7xgnuoe3qoxpx6ciwwlq/tOmgnviPIvL0j6BIiz/nAPUV99y18vbl4fmiTrcjv+NpR +JFRmM3IM+4VTpnbnxXdOd2KWakJ1TBizOcc0dYtLByr7BLtinF6t/o44yOz7MqSR9364yMf08C70 +HnUxtax3CFMS0RM1pmk5pxs07vbJuD/dVm31gfBJjQcA6alAgIVgerrRqZzbcvlr9ExHhbOGrgx1 +M+6hIxVUReNzBPcwvl+LX7c7nbB8UHdG0fTnBl0O1EsOws2+A7caeymR3SahO/WWD3a4AHxYdbj/ +8wf079d32e4v7vKrbauXgwek2JfFkkCslOiQyDyOwciA3oxIW2MduRF0vJ+jpaPLUO3ckC/Q8aMy +Q7wQmAIMcman2gOwRiH4P2ts6wE= +""") + +##file ez_setup.py +EZ_SETUP_PY = convert(""" +eJzNWmtv49a1/a5fwSgwJGE0NN8PDzRFmkyBAYrcIo8CFx5XPk+LHYpUSWoctch/v+ucQ1KkZDrt +RT6UwcQ2ebjPfq6195G+/upwanZlMZvP538sy6ZuKnKwatEcD01Z5rWVFXVD8pw0GRbNPkrrVB6t +Z1I0VlNax1qM16qnlXUg7DN5EovaPLQPp7X192PdYAHLj1xYzS6rZzLLhXql2UEI2QuLZ5VgTVmd +rOes2VlZs7ZIwS3CuX5BbajWNuXBKqXZqZN/dzebWbhkVe4t8c+tvm9l+0NZNUrL7VlLvW58a7m6 +sqwS/zhCHYtY9UGwTGbM+iKqGk5Qe59fXavfsYqXz0VeEj7bZ1VVVmurrLR3SGGRvBFVQRrRLzpb +utabMqzipVWXFj1Z9fFwyE9Z8TRTxpLDoSoPVaZeLw8qCNoPj4+XFjw+2rPZT8pN2q9Mb6wkCqs6 +4vdamcKq7KDNa6OqtTw8VYQP42irZJi1zqtP9ey7D3/65uc//7T964cffvz4P99bG2vu2BFz3Xn/ +6Ocf/qz8qh7tmuZwd3t7OB0y2ySXXVZPt21S1Lc39S3+63e7nVs3ahe79e/9nf8wm+15uOWkIRD4 +Lx2xxfmNt9icum8PJ8/2bfH0tLizFknieYzI1HG90OFJkNA0jWgsvZBFImJksX5FStBJoXFKEhI4 +vghCx5OUJqEQvnTTwI39kNEJKd5YlzAK4zhMeUIinkgWBE7skJQ7sRd7PE1fl9LrEsAAknA3SrlH +RRS5kvgeiUToiUAm3pRF/lgXSn2XOZLFfpqSyA/jNI1DRngqQ+JEbvKqlF4XPyEJw10eCcY9zwti +6capjDmJolQSNiElGOsSeU4QEi8QPBCuoCyOpXD8lJBARDIW4atSzn5h1CNuEkKPhBMmJfW4C30c +n/rUZcHLUthFvlBfejQM/ZRHiGss44DwOHU9CCKpk0xYxC7zBfZwweHJKOYe96QUbuA4qR8F0iPB +RKSZ64yVYXCHR2jIfeJ4YRSEEeLDXD9xHBI7qfO6mF6bMOZ4ETFKaeLEscfClIQ+SQLfJyHnk54x +YsJODBdBRFgCX6YxS9IwjD0RiiREOgqasPh1MVGvTSJQSURIJ4KDPCaiwA0gzYORcPhEtAEqY994 +lAiCGnZ9jvdRRl4iYkpCGhJoxMXrYs6R4pGfypQ6EBawwAvS2PEDLpgnmMO8yUi5Y99EAUsD6VMZ +kxhZ6AuW+MKhHsIdByn1XhfT+4ZKknqu41COMHHUBCQJzn0EPgqcJJoQc4Ez0nGigMqIEI/G3IFa +8GyAxHYSN2beVKAucCZyIzf1hGB+KINYIGpuxHhEXA9SvXhKygXOSDcBQAF8uUSqEC9MWQop0uUx +jRM5gVbsAmeEI3gcRInH0jShksbwdOIgex3EPHangu2Pg0SokG4kOYdhYRi6QRK4LAZ+8TRJo3BK +ygVaUYemru8SRqjvOXAGcC6WQcBCAEXsylel9BYhSST2jHggqfRRUVSmQcQcuAqoJ6YSJhhblCi0 +BvD7HuM0ZbFHmQwAX14kvYTIKbQKxxYJkUqeOFAHBYmMlb4ApocxAIMnbjQV6XBsEZHAKi7BKm7s +uELAuTHIKaQMhEeiKZQJL2KUcF9GAISAMUKS2A2QONyPKWPc5yGfkBKNLULBJGD5xHUjMFGSBLEH +EWDMMEhR2lPAGV2wGwsjIsOYwr/oHlANkQNDgsBHgYVkChuisUXUkwmJQw9kD9ilPkjaQai5CCVa +idCfkBJfwJ2DGMmUcOaTyA1F6LohyhAtRQIInMyX+IIJSCLTMAALcGC5I2kUM+lKD2HAI2+qAuKx +RQE4lgBvJVoGFGDgB67rSi4S38W/eEqX5KIbclQv5KXwSMrBHyoFAeCJ76jGynldSm8Ro8RPgA3o +OYLEZ47KWWQbnM3ALJM0kIwtcmPPjQFyCHTKmRs6YeqQMKG+QJ2n4VSk07FF0J0FDpoZV3mYBmkk +AiapcBLYypypSKcXyIAkQ2MHbvWThEdAJyKEEwG8WOQHU/1dK6W3SAqE1hchcWPqegxhYmHg0hjc +C+YXU0ySjvmIEZSNKxVqEk9wAJOb+mC2mIaphx4HUn6dDSYCjDf1rKlOd2bg2pF6l2e0m7fQu8/E +L0xg1Pio73xQI1G7Fg+H62ZcSGv7heQZun2xxa0ldNoWmAfXlhoAVnfagExa3X01M3bjgXmoLp5h +tmgwLigR+kV7J34xdzHfdcsgp1351aaXct+JfjjLUxfmLkyD79+r6aRuuKgw1y1HK9Q1Vya1FrTz +4Q2mMIIxjH9lWcu/lHWd0Xww/mGkw9/7P6zmV8JuejNHj1ajv5Q+4pesWXrmfoXgVoV2l3HoxXCo +F7Xj1eZimFv3am0pqcVmMNCtMSluMapuytpmxwq/mWTqX+AiJ6eNG87aIGFs/ObYlHv4gWG6PGEU +Lfhtb/bgpEDN9XvyGbHE8PwFriLKQXCeMu1Amp0Z5x9bpR+telcec66mWWJ8PZTWTebFcU9FZTU7 +0lgYhHvBWpaagAvlXUti6u2VOhZcvyKsx5EjHi010i6fdxnbdbsLaK2OJow8a3G7WNlQ0njpUW2p +5AyOMXaiGh2QPGeYuek5EwRfIyNNgmuVixL+yCtB+OmsPvb4KAfqabfr7dqzCS2mabXU0qjQqrQO +0ScWrCx4bXzTqXEgSBTlVHhElVXWZAhd8TQ4zzARb+0vC6HPE8zZCDd6wallrnz44vmI0rI9bBCt +MH2WU5VH7CSMKqbOiLUXdU2ehDngOBfd46POl4pktbB+PNWN2H/4RfmrMIEoLNLgnjnZIFRBizJe +paAyxpx62F2G6p/PpN4aFIL9G2tx+Py0rURdHism6oVCGLX9vuTHXNTqlGQAoJePTU2g6jjyoHXb +cnVGEpVym3PRDOqy9dhFCXZlt74otDMGdEViw7OiapbOWm0yALkWqPud3g1Pd2h3zLdtA7PVwLxR +MkyAAOyXskYO0g9fQPj+pQ6Qhg5pH13vMBJtt8m1nJ81fr+Zv2ldtXrXyh6qMBbwV7Py27KQecaa +QRxgokFOBstluVzduw9DYhgmxX9KBPOfdufCmCiF5fvNTb3qy7wrb33K+akYc8GckWLRqGrrqwdw +ok72dPm0J3mqkI5FgSy3rb/kAsnTLb+Sp8pLVTmwScCWTkOZVXWzBmGoSllAwqnLCuvtzwPlF/aF +vE/Fp2L57bGqIA1IbwTcVBeUtgKhndNc2KR6qu+dh9fp7MWwfpchZzN6VBT7fdn8qQRwD3KI1PWs +LcR8/OZ6WKv3F5X+oF75Gk7RXFB+HtHpMHsNr75UxL83uapSR6aOWPW7FyhUFy05U4CVl8w0IBos +jQ1ZY86DdUPxX0qpBpDViX9Hqb/FqOqe2vWaTg3KP54ZcoIFS8N9HfUpCmHNkeRnI1pKGdNG94FC +BWahHjJrh3zMTdJ23enGGkDX25sanfZNrRrt+bAWLg68TeJD7pAplM+sN+OGsCZfBLTfoAE3FPD3 +MiuWHWF0S424umJKnO6Kvwd3d420Qp/uddRd3dRLI3Z1p4rhmy9lphLoIIhix06dui+2EXqrS6ci +hyDljbrzUl4+jVap1lvFZfyuurDSfiZVsVR+fvv7XebzkBYrW3CuX8ryG50S6nOSpfgiCvUHzDlA +2dlO5AfV5X002TboNPpUQSui8l99krNUrpgB5dcWoGqmbu1RzoWAI/EK6lD1uQBd8awglmB4rWv9 +9hDWNSjbs3ZLoHHb0Zx3hMq8y2Z7NlsCEcWd8rAWsydsp5orXgrDNTuEF0o0z2X1ud10bR0MYZS0 +Ie2ncAopNErcAEwVisADTPfoegEknyuxrZxKtAQ0NMBe/Z5RRFKsr1JmALpX7ZPOsrWqpqvX0D/o +ZG0yNUe2bVIuxOGd+bG86LTG2dnBsKa6eq63uKAyXXItPtj4WR5Esbxa9rX1A1r82+cqawA+iDH8 +q5trYPjntfog8FlFT3UArFJlCGhkZVUddXLk4kKYjvswPVTP3Qi9vsPE7mo/VJsauWGArcaP5Wqs +sUERbY3BivX8mc7hTjywtR1m6O5fwuinRsC7SwjABnd6F5aXtViuriCibu600OHzls060IKCufql +g63Zv3Mp/t4j05foQb6spxj7zLkfX/uIVHPsB3RL7aqOIF5qnS8+en6tbzajQo/VVxLPa14fJ/Rc +7lx3WeOhYTQz6Jip0hhMCqzc72GoPWoLu8Mb0o5f3dXGSLs4BxdoP6/eqLOVh5VO02exqHRaC0vR ++G+mirJU+fmCq5Ta1xyCRccC897nZW+WyGsxiMawF7e329Zb2621wQDo2I7tLv7jrv9/AfAaXNUU +TOsyF6jViUG46+NBJqZXv+rRK7Evv2i81ZEw33DQ8y6YowH05r+BuxfN92SX3RbVP8bNymDOGnY7 +16PfvzG+4ecrzfzkjPZya/H/ScnXyqwX/JtSrrL5pbrryu1hPKFrZzsrJD6sUuyPwDGdKerJyxmq +dvmdHNCrrzU/+2W0pQ6gSvPl/Mertmi+7hBlDhB80kRUqcNeJCGapHNCz1cvCFwsf0A/Ne++jGMf +TuOJcm6+ZnP9TRR7tWjHreOhZ6huiKnPAP2zfmqpIqHHLG/emnNhyHxSs+JJYfIwj6t2AlLdVneO +3Is9u0R33ef+Wv2pVizPfbUW0rGhps1FRRfnZ/2xsnr3oT2Slh2tvngsLXu6M0OgIen7ufrjprrD +vzXQAgNE22ualqzbyAb97uvl6qF/2a5hcU+eBzVWzOdmVjA0PXQMQoAhsulmBv39oU13134SjSlb +dX85nKW3umfYbtu8713Sylhb2i3v2qaoc8C7S2P3pME8uIGedi1IxXbL+adi+P2fT8Xy/m+/PrxZ +/TrXDcpqOMjotwdo9AJmg8r1N7BySygc+Gp+XaYdJhpV8f/7Oy3Y1s330l09YBDTjnyjn5qHGF7x +6O7hZfMXz21OyLZB6lUfOGAGMzo/bjaL7VaV7Ha76D/1yJVEqKmr+L2nCbH7+959wDtv38JZplQG +BDaonX65d/fwEjNqlDjLVIvM9X+XVxF7 +""") + +##file distribute_setup.py +DISTRIBUTE_SETUP_PY = convert(""" +eJztG2tz2zbyu34FTh4PqYSi7TT3GM+pM2nj9DzNJZnYaT8kHhoiIYk1X+XDsvrrb3cBkCAJyc61 +dzM3c7qrIxGLxWLfuwCP/lTs6k2eTabT6Xd5Xld1yQsWxfBvvGxqweKsqnmS8DoGoMnliu3yhm15 +VrM6Z00lWCXqpqjzPKkAFkdLVvDwjq+FU8lBv9h57JemqgEgTJpIsHoTV5NVnCB6+AFIeCpg1VKE +dV7u2DauNyyuPcaziPEoogm4IMLWecHylVxJ4z8/n0wYfFZlnhrUBzTO4rTIyxqpDTpqCb7/yJ2N +dliKXxsgi3FWFSKMV3HI7kVZATOQhm6qh98BKsq3WZLzaJLGZZmXHstL4hLPGE9qUWYceKqBuh17 +tGgIUFHOqpwtd6xqiiLZxdl6gpvmRVHmRRnj9LxAYRA/bm+HO7i99SeTa2QX8TekhRGjYGUD3yvc +SljGBW1PSZeoLNYlj0x5+qgUE8W8vNLfql37tY5Tob+vspTX4aYdEmmBFLS/eUk/Wwk1dYwqI0eT +fD2Z1OXuvJNiFaP2yeFPVxcfg6vL64uJeAgFkH5Jzy+QxXJKC8EW7F2eCQObJrtZAgtDUVVSVSKx +YoFU/iBMI/cZL9fVTE7BD/4EZC5s1xcPImxqvkyEN2PPaaiFK4FfZWag90PgqEvY2GLBTid7iT4C +RQfmg2hAihFbgRQkQeyF/80fSuQR+7XJa1AmfNykIquB9StYPgNd7MDgEWIqwNyBmBTJdwDmmxdO +t6QmCxEK3OasP6bwOPA/MG4YHw8bbHOmx9XUYccIOIJTMMMhtenPHQXEOviiVqxuhtLJK78qOFid +C98+BD+/urz22IBp7Jkps9cXb159ensd/HTx8ery/TtYb3rq/8V/8XLaDn36+BYfb+q6OD85KXZF +7EtR+Xm5PlFOsDqpwFGF4iQ66fzSyXRydXH96cP1+/dvr4I3r368eD1YKDw7m05MoA8//hBcvnvz +Hsen0y+Tf4qaR7zm85+kOzpnZ/7p5B340XPDhCft6HE1uWrSlINVsAf4TP6Rp2JeAIX0e/KqAcpL +8/tcpDxO5JO3cSiySoG+FtKBEF58AASBBPftaDKZkBorX+OCJ1jCvzNtA+IBYk5IyknuXQ7TYJ0W +4CJhy9qb+OldhN/BU+M4uA1/y8vMdS46JKADx5XjqckSME+iYBsBIhD/WtThNlIYWi9BUGC7G5jj +mlMJihMR0oX5eSGydhctTKD2obbYm+yHSV4JDC+dQa5zRSxuug0ELQD4E7l1IKrg9cb/BeAVYR4+ +TECbDFo/n97MxhuRWLqBjmHv8i3b5uWdyTENbVCphIZhaIzjsh1kr1vddmamO8nyuufAHB2xYTlH +IXcGHqRb4Ap0FEI/4N+Cy2LbMoevUVNqXTGTE99YeIBFCIIW6HlZCi4atJ7xZX4v9KRVnAEemypI +zZlpJV42MTwQ67UL/3laWeFLHiDr/q/T/wM6TTKkWJgxkKIF0XcthKHYCNsJQsq749Q+HZ//in+X +6PtRbejRHH/Bn9JA9EQ1lDuQUU1rVymqJqn7ygNLSWBlg5rj4gGWrmi4W6XkMaSol+8pNXGd7/Mm +iWgWcUraznqNtqKsIAKiVQ7rqnTYa7PaYMkroTdmPI5EwndqVWTlUA0UvNOFyflxNS92x5EP/0fe +WRMJ+ByzjgoM6uoHRJxVDjpkeXh2M3s6e5RZAMHtXoyMe8/+99E6+OzhUqdXjzgcAqScDckHfyjK +2j31WCd/lf326x4jyV/qqk8H6IDS7wWZhpT3oMZQO14MUqQBBxZGmmTlhtzBAlW8KS1MWJz92QPh +BCt+JxbXZSNa75pyMvGqgcJsS8kz6ShfVnmChoq8mHRLGJoGIPiva3Jvy6tAckmgN3WKu3UAJkVZ +W0VJLPI3zaMmERVWSl/a3TgdV4aAY0/c+2GIprdeH0Aq54ZXvK5LtwcIhhJERtC1JuE4W3HQnoXT +UL8CHoIo59DVLi3EvrKmnSlz79/jLfYzr8cMX5Xp7rRjybeL6XO12sxC1nAXfXwqbf4+z1ZJHNb9 +pQVoiawdQvIm7gz8yVBwplaNeY/TIdRBRuJvSyh03RHE9Jo8O20rMnsORm/G/XZxDAUL1PooaH4P +6TpVMl+y6RgftlJCnjk11pvK1AHzdoNtAuqvqLYAfCubDKOLzz4kAsRjxadbB5yleYmkhpiiaUJX +cVnVHpgmoLFOdwDxTrscNv9k7MvxLfBfsi+Z+31TlrBKspOI2XE5A+Q9/y98rOIwcxirshRaXLsv ++mMiqSz2ARrIBiZn2PfngZ+4wSkYmamxk9/tK2a/xhqeFEP2WYxVr9tsBlZ9l9dv8iaLfrfRPkqm +jcRRqnPIXQVhKXgtht4qwM2RBbZZFIarA1H698Ys+lgCl4pXygtDPfy6a/G15kpxtW0kgu0leUil +C7U5FePjWnbuMqjkZVJ4q2i/ZdWGMrMltiPveRL3sGvLy5p0KUqwaE6m3HoFwoXtP0p6qWPS9iFB +C2iKYLc9ftwy7HG44CPCjV5dZJEMm9ij5cw5cWY+u5U8ucUVe7k/+BdRCp1Ctv0uvYqIfLlH4mA7 +Xe2BOqxhnkXU6yw4BvqlWKG7wbZmWDc86TqutL8aK6na12L4jyQMvVhEQm1KqIKXFIUEtrlVv7lM +sKyaGNZojZUGihe2ufX6twDVAVs/veTYxzJs/Rs6QCV92dQue7kqCpI9b7HI/I/fC2DpnhRcg6rs +sgwRHexLtVYNax3kzRLt7Bx5/uo+j1GrC7TcqCWny3BGIb0tXlrrIR9fTT3cUt9lS6IUl9zR8BH7 +KHh0QrGVYYCB5AxIZ0swuTsPO+xbVEKMhtK1gCaHeVmCuyDrGyCD3ZJWa3uJ8ayjFgSvVVh/sCmH +CUIZgj7waJBRSTYS0ZJZHptul9MRkEoLEFk3NvKZShKwliXFAAJ0iT6AB/yWcAeLmvBd55QkDHtJ +yBKUjFUlCO66Au+1zB/cVZOF6M2UE6Rhc5zaqx579uxuOzuQFcvmf1efqOnaMF5rz3Ilnx9KmIew +mDNDIW1LlpHa+ziXraRRm938FLyqRgPDlXxcBwQ9ft4u8gQcLSxg2j+vwGMXKl2wSHpCYtNNeMMB +4Mn5/HDefhkq3dEa0RP9o9qslhnTfZhBVhFYkzo7pKn0pt4qRSeqAvQNLpqBB+4CPEBWdyH/Z4pt +PLxrCvIWK5lYi0zuCCK7DkjkLcG3BQqH9giIeGZ6DeDGGHahl+44dAQ+DqftNPMsPa1XfQizXap2 +3WlDN+sDQmMp4OsJkE1ibAjIGRDFMp8zNwGGtnVswVK5Nc07eya4svkh0u2JIQZYz/Quxoj2TXio +rNlmFZp2cUPeGzxWqEZ7lggysdWRGZ9ClHX8929f+8cVHmnh6aiPf0ad3Y+ITgY3DCS57ClKEjVO +1eTF2hZ/urZRtQH9sCU2ze8hWQbTCMwOuVskPBQbUHahO9WDMB5X2Gscg/Wp/5TdQSDsNd8h8VJ7 +MObu168V1h09/4PpqL4QYDSC7aQA1eq02Vf/ujjXM/sxz7BjOMfiYOju9eIjb7kE6d+ZbFn1y6OO +A12HlFJ489DcXHfAgMlIC0BOqAUiEfJINm9qTHrRe2z5rrM5XecMEzaDPR6Tqq/IH0hUzTc40Tlz +ZTlAdtCDla6qF0FGk6Q/VDM8ZjmvVJ1txdGRb++4AabAhy7KY31qrMp0BJi3LBG1UzFU/Nb5DvnZ +KpriN+qaa7bwvEHzT7Xw8SYCfjW4pzEckoeC6R2HDfvMCmRQ7ZreZoRlHNNteglOVTbuga2aWMWJ +PW1056q7yBMZbQJnsJO+P97na4beeR+c9tV8Bel0e0SM6yumGAEMQdobK23burWRjvdYrgAGPBUD +/5+mQESQL39xuwNHX/e6CygJoe6Ske2xLkPPuUm6v2ZKz+Wa5IJKWoqpx9ywRdiaObqxMHZBxKnd +PfEITE5FKvfJpyayIuw2qiKxYUXq0Kbq/CAs8KWnc+6+qwKepO0rnN6AlJH/07wcO0Cr55HgB/zO +0Id/j/KXkXw0q0uJWgd5OC2yuk8C2J8iSVbVbU60n1WGjHyY4AyTksFW6o3B0W4r6vFjW+mRYXTK +hvJ6fH+PmdjQ0zwCPuvl823Q63K6IxVKIAKFd6hKMf6y5dd7FVRmwBc//DBHEWIIAXHK71+hoPEo +hT0YZ/fFhKfGVcO3d7F1T7IPxKd3Ld/6jw6yYvaIaT/Kuf+KTRms6JUdSlvslYca1Pol+5RtRBtF +s+9kH3NvOLOczCnM1KwNilKs4gdXe/ouuLRBjkKDOpSE+vveOO839oa/1YU6DfhZf4EoGYkHI2w+ +Pzu/abMoGvT0tTuRNakoubyQZ/ZOEFTeWJX51nxewl7lPQi5iWGCDpsAHD6sWdYVtplRiRcYRiQe +S2OmzgslGZpZJHHtOrjOwpl9ng9O5wwWaPaZiylcwyMiSRWWhpIK64FrApopbxF+K/lj7yH1yK0+ +E+RzC5VfS2lHIzC3qUTp0NFCdzlWHRViG9fasbGt0s62GIbUyJGqDpX9KuR0oGicO+rrkTbb3Xsw +fqhDdcS2wgGLCoEES5A3sltQSONWT5QLyZRKiBTPGczj0XGXhH5u0Vz6pYK6d4RsGG/IiEOYmMLk +beVj1tY/0/c/yvNeTLbBK5bgjHrliT1xH2gLxXzEsCA3rjyu4tz1rhAjvmGr0jhIevXh8g8mfNYV +gUOEoJB9ZTRvc5nvFpgliSzM7aI5YpGohbo1h8EbT+LbCIiaGg1z2PYYbjEkz9dDQ30233kwih65 +NGi3bodYVlG8oEMF6QtRIckXxg9EbFHm93EkIvn6Q7xS8OaLFpXRfIjUhbvU6w41dMfRrDj6gcNG +mV0KChsw1BsSDIjkWYjtHuhYW+WNcKBlA/XH/hqll4aBVUo5VuZ1PbUlyyZ8kUUqaNCdsT2byuby +Nl8nvB4daN/7+2hWqerJijTAYfOwlqaKceFzP0n7MiYLKYcTKEWiuy//RJ3rdyO+Igfdm4QeaD4P +eNOfN24/m7rRHt2hWdP5snR/dNZr+PtMDEXbz/5/rzwH9NJpZyaMhnnCmyzcdClc92QYKT+qkd6e +MbSxDcfWFr6RJCGo4NdvtEioIi5Yyss7PMvPGacDWN5NWDat8bSp3vk3N5gufHbmoXkjm7IzvGKT +iLlqAczFA72/BDnzPOUZxO7IuTFCnMZ4etP2A7BpZiaYn/tvXNyw5+20icZB93OsL9O03DMuJVci +WcnG+WLqTz2WCrw4UC0wpnQnM+oiNR0EKwh5zEiXAErgtmQt/gzlFSN9j1jvr7vQgD4Z3/XKtxlW +1Wke4Vth0v9js58AClGmcVXRa1rdkZ1GEoMSUsMLZB5VPrvFDTjtxRB8RQuQrgQRMrpGDYQqDsBX +mKx25KAnlqkpT4iIFF+5o8siwE8imRqAGg/22JUWg8Yud2wtaoXLnfVvUKiELMyLnfkbCjHI+NWN +QMlQeZ1cAyjGd9cGTQ6APty0eYEWyygf0AMYm5PVpK0+YCXyhxBRFEivclbDqv898EtHmrAePepC +S8VXAqUqBsf6HaTPC6hAI1et0Xdlmq4FccvHPwcB8T4Z9m1evvwb5S5hnIL4qGgC+k7/enpqJGPJ +ylei1zil8rc5xUeB1ipYhdw3STYN3+zpsb8z94XHXhocQhvD+aJ0AcOZh3hezKzlQpgWBONjk0AC ++t3p1JBtiNSVmO0ApaTetR09jBDdid1CK6CPx/2gvkizgwQ4M48pbPLqsGYQZG500QNwtRbcWi2q +LokDU7kh8wZKZ4z3iKRzQGtbQwu8z6DR2TlJOdwAcZ2MFd7ZGLCh88UnAIYb2NkBQFUgmBb7b9x6 +lSqKkxPgfgJV8Nm4AqYbxYPq2nZPgZAF0XLtghJOlWvBN9nwwpPQ4SDlMdXc9x7bc8mvCwSXh153 +JRW44NVOQWnnd/j6v4rxw5fbgLiY7r9g8hRQRR4ESGoQqHcpie42ap6d38wm/wIwBuVg +""") + +##file activate.sh +ACTIVATE_SH = convert(""" +eJytVVFvokAQfudXTLEP2pw1fW3jg01NNGm1KV4vd22zrDDIJrhrYJHay/33m0VEKGpyufIg7s63 +M9/OfDO0YBaKBAIRISzTRMMcIU3Qh0zoEOxEpbGHMBeyxz0t1lyjDRdBrJYw50l4YbVgo1LwuJRK +Q5xKEBp8EaOno41l+bg7Be0O/LaAnhbEmKAGFfmAci1iJZcoNax5LPg8wiRHiQBeoCvBPmfT+zv2 +PH6afR/cs8fBbGTDG9yADlHmSPOY7f4haInA95WKdQ4s91JpeDQO5fZAnKTxczaaTkbTh+EhMqWx +QWl/rEGsNJ2kV0cRySKleRGTUKWUVB81pT+vD3Dpw0cSfoMsFF4IIV8jcHqRyVPLpTHrkOu89IUr +EoDHo4gkoBUsiAFVlP4FKjaLFSeNFEeTS4AfJBOV6sKshVwUbmpAkyA4N8kFL+RygQlkpDfum58N +GO1QWNLFipij/yn1twOHit5V29UvZ8Seh0/OeDo5kPz8at24lp5jRXSuDlXPuWqUjYCNejlXJwtV +mHcUtpCddTh53hM7I15EpA+2VNLHRMep6Rn8xK0FDkYB7ABnn6J3jWnXbLvQfyzqz61dxDFGVP1a +o1Xasx7bsipU+zZjlSVjtlUkoXofq9FHlMZtDxaLCrrH2O14wiaDhyFj1wWs2qIl773iTbZohyza +iD0TUQQBF5HZr6ISgzKKNZrD5UpvgO5FwoT2tgkIMec+tcYm45sO+fPytqGpBy75aufpTG/gmhRb ++u3AjQtC5l1l7QV1dBAcadt+7UhFGpXONprZRviAWtbY3dgZ3N4P2ePT9OFxdjJiruJSuLk7+31f +x60HKiWc9eH9SBc04XuPGCVYce1SXlDyJcJrjfKr7ebSNpEaQVpg+l3wiAYOJZ9GCAxoR9JMWAiv ++IyoWBSfhOIIIoRar657vSzLLj9Q0xRZX9Kk6SUq0BmPsceNl179Mi8Vii65Pkj21XXf4MAlSy/t +Exft7A8WX4/iVRkZprZfNK2/YFL/55T+9wm9m86Uhr8A0Hwt +""") + +##file activate.fish +ACTIVATE_FISH = convert(""" +eJydVm1v4jgQ/s6vmA1wBxUE7X2stJVYlVWR2lK13d6d9laRk0yIr8HmbIe0++tvnIQQB9pbXT5A +Ys/LM55nZtyHx5RrSHiGsMm1gRAh1xhDwU0Kng8hFzMWGb5jBv2E69SDs0TJDdj3MxilxmzPZzP7 +pVPMMl+q9bjXh1eZQ8SEkAZULoAbiLnCyGSvvV6SC7IoBcS4Nw0wjcFbvJDcjiuTswzFDpiIQaHJ +lQAjQUi1YRmUboC2uZJig8J4PaCnT5IaDcgsbm/CjinOwgx1KcUTMEhhTgV4g2B1fRk8Le8fv86v +g7v545UHpZB9rKnp+gXsMhxLunIIpwVQxP/l9c/Hq9Xt1epm4R27bva6AJqN92G4YhbMG2i+LB+u +grv71c3dY7B6WtzfLy9bePbp0taDTXSwJQJszUnnp0y57mvpPcrF7ZODyhswtd59+/jdgw+fwBNS +xLSscksUPIDqwwNmCez3PpxGeyBYg6HE0YdcWBxcKczYzuVJi5Wu915vn5oWePCCoPUZBN5B7IgV +MCi54ZDLG7TUZ0HweXkb3M5vFmSpFm/gthhBx0UrveoPpv9AJ9unIbQYdUoe21bKg2q48sPFGVwu +H+afrxd1qvclaNlRFyh1EQ2sSccEuNAGWQwysfVpz1tPajUqbqJUnEcIJkWo6OXDaodK8ZiLdbmM +L1wb+9H0D+pcyPSrX5u5kgWSygRYXCnJUi/KKcuU4cqsAyTKZBiissLc7NFwizvjxtieKBVCIdWz +fzilzPaYyljZN0cGN1v7NnaIPNCGmVy3GKuJaQ6iVjE1Qfm+36hglErwmnAD8hu0dDy4uICBA8ZV +pQr/q/+O0KFW2kjelu9Dgb9SDBsWV4F4x5CswgS0zBVlk5tDMP5bVtUGpslbm81Lu2sdKq7uNMGh +MVQ4fy9xhogC1lS5guhISa0DlBWv0O8odT6/LP+4WZzDV6FzIkEqC0uolGZSZoMnlpxplmD2euaT +O4hkTpPnbztDccey0bhjDaBIqaWQa0uwEtQEwtyU56i4fq54F9IE3ORR6mKriODM4XOYZwaVYLYz +7SPbKkz4i7VkB6/Ot1upDE3znNqYKpM8raa0Bx8vfvntJ32UENsM4aI6gJL+jJwhxhh3jVIDOcpi +m0r2hmEtS8XXXNBk71QCDXTBNhhPiHX2LtHkrVIlhoEshH/EZgdq53Eirqs5iFKMnkOmqZTtr3Xq +djvPTWZT4S3NT5aVLgurMPUWI07BRVYqkQrmtCKohNY8qu9EdACoT6ki0a66XxVF4f9AQ3W38yO5 +mWmZmIIpnDFrbXakvKWeZhLwhvrbUH8fahhqD0YUcBDJjEBMQwiznE4y5QbHrbhHBOnUAYzb2tVN +jJa65e+eE2Ya30E2GurxUP8ssA6e/wOnvo3V78d3vTcvMB3n7l3iX1JXWqk= +""") + +##file activate.csh +ACTIVATE_CSH = convert(""" +eJx9U11vmzAUffevOCVRu+UB9pws29Kl0iq1aVWllaZlcgxciiViItsQdb9+xiQp+dh4QOB7Pu49 +XHqY59IgkwVhVRmLmFAZSrGRNkdgykonhFiqSCRW1sJSmJg8wCDT5QrucRCyHn6WFRKhVGmhKwVp +kUpNiS3emup3TY6XIn7DVNQyJUwlrgthJD6n/iCNv72uhCzCpFx9CRkThRQGKe08cWXJ9db/yh/u +pvzl9mn+PLnjj5P5D1yM8QmXlzBkSdXwZ0H/BBc0mEo5FE5qI2jKhclHOOvy9HD/OO/6YO1mX9vx +sY0H/tPIV0dtqel0V7iZvWyNg8XFcBA0ToEqVeqOdNUEQFvN41SumAv32VtJrakQNSmLWmgp4oJM +yDoBHgoydtoEAs47r5wHHnUal5vbJ8oOI+9wI86vb2d8Nrm/4Xy4RZ8R85E4uTZPB5EZPnTaaAGu +E59J8BE2J8XgrkbLeXMlVoQxznEYFYY8uFFdxsKQRx90Giwx9vSueHP1YNaUSFG4vTaErNSYuBOF +lXiVyXa9Sy3JdClEyK1dD6Nos9mEf8iKlOpmqSNTZnYjNEWiUYn2pKNB3ttcLJ3HmYYXy6Un76f7 +r8rRsC1TpTJj7f19m5sUf/V3Ir+x/yjtLu8KjLX/CmN/AcVGUUo= +""") + +##file activate.bat +ACTIVATE_BAT = convert(""" +eJyFUkEKgzAQvAfyhz0YaL9QEWpRqlSjWGspFPZQTevFHOr/adQaU1GaUzI7Mzu7ZF89XhKkEJS8 +qxaKMMsvboQ+LxxE44VICSW1gEa2UFaibqoS0iyJ0xw2lIA6nX5AHCu1jpRsv5KRjknkac9VLVug +sX9mtzxIeJDE/mg4OGp47qoLo3NHX2jsMB3AiDht5hryAUOEifoTdCXbSh7V0My2NMq/Xbh5MEjU +ZT63gpgNT9lKOJ/CtHsvT99re3pX303kydn4HeyOeAg5cjf2EW1D6HOPkg9NGKhu +""") + +##file deactivate.bat +DEACTIVATE_BAT = convert(""" +eJxzSE3OyFfIT0vj4spMU0hJTcvMS01RiPf3cYkP8wwKCXX0iQ8I8vcNCFHQ4FIAguLUEgWIgK0q +FlWqXJpcICVYpGzx2BAZ4uHv5+Hv6wq1BWINXBTdKriEKkI1DhW2QAfhttcxxANiFZCBbglQSJUL +i2dASrm4rFz9XLgAwJNbyQ== +""") + +##file activate.ps1 +ACTIVATE_PS = convert(""" +eJylWdmS40Z2fVeE/oHT6rCloNUEAXDThB6wAyQAEjsB29GBjdgXYiWgmC/zgz/Jv+AEWNVd3S2N +xuOKYEUxM+/Jmzfvcm7W//zXf/+wUMOoXtyi1F9kbd0sHH/hFc2iLtrK9b3FrSqyxaVQwr8uhqJd +uHaeg9mqzRdR8/13Pyy8qPLdJh0+LMhi0QCoXxYfFh9WtttEnd34H8p6/f1300KauwrULws39e18 +0ZaLNm9rgN/ZVf3h++/e124Vlc0vKsspHy+Yyi5+XbzPhijvCtduoiL/kA1ukWV27n0o7Sb8LIFj +CvWR5GQgUJdp1Pw8TS9+rPy6SDv/+e3d+0+4qw8f3v20+PliV37efEYBAB9FTKC+RHn/Cfxn3rdv +00Fube5O+iyCtHDs9BfPfz3q4sfFv9d91Ljhfy7ei0VO+nVTtdOkv/jpt0l2AX6iG1jXgKnnDuD4 +ke2k/i8fzzz5UedkVcP4pwF+Wvz2FJl+3vt598urXf5Y6LNA5WcFOP7r0sW7b9a+W/xcu0Xpv5zk +Kfq3P9Dz9di/fCxS72MXVU1rpx9L4Bxl85Wmn5a+zP76Zuh3pL9ROWr87PN+//GHIl+oOtvn9XSU +qH+p0gQBFnx1uV+JLH5O5zv+PXW+WepXVVHZT0+oQezkIATcIm+ivPV/z5J/+cYj3ir4w0Lx09vC +e5n/y5/Y5LPPfdrqb88ga/PabxZRVfmp39l588m/6u+/e+OpP+dF7n1WZpJ9//Z4v372fDDz9eHB +7Juvs/BLMHzrxL9+9twXpJfhd1/DrpQ5Euu/vlss3wp9HXC/54C/Ld69m6zwdx3tC0d8daSv0V8B +n4b9YYF53sJelJV/ix6LZspw/sJtqyl5LJ5r/23htA1Imfm/gt9R7dqVB1LjhydAX4Gb+zksQF59 +9+P7H//U+376afFuvh2/T6P85Xr/5c8C6OXyFY4BGuN+EE0+GeR201b+wkkLN5mmBY5TfMw8ngqL +CztXxCSXKMCYrRIElWkEJlEPYsSOeKBVZCAQTKBhApMwRFQzmCThE0YQu2CdEhgjbgmk9GluHpfR +/hhwJCZhGI5jt5FsAkOrObVyE6g2y1snyhMGFlDY1x+BoHpCMulTj5JYWNAYJmnKpvLxXgmQ8az1 +4fUGxxcitMbbhDFcsiAItg04E+OSBIHTUYD1HI4FHH4kMREPknuYRMyhh3AARWMkfhCketqD1CWJ +mTCo/nhUScoQcInB1hpFhIKoIXLo5jLpwFCgsnLCx1QlEMlz/iFEGqzH3vWYcpRcThgWnEKm0QcS +rA8ek2a2IYYeowUanOZOlrbWSJUC4c7y2EMI3uJPMnMF/SSXdk6E495VLhzkWHps0rOhKwqk+xBI +DhJirhdUCTamMfXz2Hy303hM4DFJ8QL21BcPBULR+gcdYxoeiDqOFSqpi5B5PUISfGg46gFZBPo4 +jdh8lueaWuVSMTURfbAUnLINr/QYuuYoMQV6l1aWxuZVTjlaLC14UzqZ+ziTGDzJzhiYoPLrt3uI +tXkVR47kAo09lo5BD76CH51cTt1snVpMOttLhY93yxChCQPI4OBecS7++h4p4Bdn4H97bJongtPk +s9gQnXku1vzsjjmX4/o4YUDkXkjHwDg5FXozU0fW4y5kyeYW0uJWlh536BKr0kMGjtzTkng6Ep62 +uTWnQtiIqKnEsx7e1hLtzlXs7Upw9TwEnp0t9yzCGgUJIZConx9OHJArLkRYW0dW42G9OeR5Nzwk +yk1mX7du5RGHT7dka7N3AznmSif7y6tuKe2N1Al/1TUPRqH6E2GLVc27h9IptMLkCKQYRqPQJgzV +2m6WLsSipS3v3b1/WmXEYY1meLEVIU/arOGVkyie7ZsH05ZKpjFW4cpY0YkjySpSExNG2TS8nnJx +nrQmWh2WY3cP1eISP9wbaVK35ZXc60yC3VN/j9n7UFoK6zvjSTE2+Pvz6Mx322rnftfP8Y0XKIdv +Qd7AfK0nexBTMqRiErvCMa3Hegpfjdh58glW2oNMsKeAX8x6YJLZs9K8/ozjJkWL+JmECMvhQ54x +9rsTHwcoGrDi6Y4I+H7yY4/rJVPAbYymUH7C2D3uiUS3KQ1nrCAUkE1dJMneDQIJMQQx5SONxoEO +OEn1/Ig1eBBUeEDRuOT2WGGGE4bNypBLFh2PeIg3bEbg44PHiqNDbGIQm50LW6MJU62JHCGBrmc9 +2F7WBJrrj1ssnTAK4sxwRgh5LLblhwNAclv3Gd+jC/etCfyfR8TMhcWQz8TBIbG8IIyAQ81w2n/C +mHWAwRzxd3WoBY7BZnsqGOWrOCKwGkMMNfO0Kci/joZgEocLjNnzgcmdehPHJY0FudXgsr+v44TB +I3jnMGnsK5veAhgi9iXGifkHMOC09Rh9cAw9sQ0asl6wKMk8mpzFYaaDSgG4F0wisQDDBRpjCINg +FIxhlhQ31xdSkkk6odXZFpTYOQpOOgw9ugM2cDQ+2MYa7JsEirGBrOuxsQy5nPMRdYjsTJ/j1iNw +FeSt1jY2+dd5yx1/pzZMOQXUIDcXeAzR7QlDRM8AMkUldXOmGmvYXPABjxqkYKO7VAY6JRU7kpXr ++Epu2BU3qFFXClFi27784LrDZsJwbNlDw0JzhZ6M0SMXE4iBHehCpHVkrQhpTFn2dsvsZYkiPEEB +GSEAwdiur9LS1U6P2U9JhGp4hnFpJo4FfkdJHcwV6Q5dV1Q9uNeeu7rV8PAjwdFg9RLtroifOr0k +uOiRTo/obNPhQIf42Fr4mtThWoSjitEdAmFW66UCe8WFjPk1YVNpL9srFbond7jrLg8tqAasIMpy +zkH0SY/6zVAwJrEc14zt14YRXdY+fcJ4qOd2XKB0/Kghw1ovd11t2o+zjt+txndo1ZDZ2T+uMVHT +VSXhedBAHoJIID9xm6wPQI3cXY+HR7vxtrJuCKh6kbXaW5KkVeJsdsjqsYsOwYSh0w5sMbu7LF8J +5T7U6LJdiTx+ca7RKlulGgS5Z1JSU2Llt32cHFipkaurtBrvNX5UtvNZjkufZ/r1/XyLl6yOpytL +Km8Fn+y4wkhlqZP5db0rooqy7xdL4wxzFVTX+6HaxuQJK5E5B1neSSovZ9ALB8091dDbbjVxhWNY +Ve5hn1VnI9OF0wpvaRm7SZuC1IRczwC7GnkhPt3muHV1YxUJfo+uh1sYnJy+vI0ZwuPV2uqWJYUH +bmBsi1zmFSxHrqwA+WIzLrHkwW4r+bad7xbOzJCnKIa3S3YvrzEBK1Dc0emzJW+SqysQfdEDorQG +9ZJlbQzEHQV8naPaF440YXzJk/7vHGK2xwuP+Gc5xITxyiP+WQ4x18oXHjFzCBy9kir1EFTAm0Zq +LYwS8MpiGhtfxiBRDXpxDWxk9g9Q2fzPPAhS6VFDAc/aiNGatUkPtZIStZFQ1qD0IlJa/5ZPAi5J +ySp1ETDomZMnvgiysZSBfMikrSDte/K5lqV6iwC5q7YN9I1dBZXUytDJNqU74MJsUyNNLAPopWK3 +tzmLkCiDyl7WQnj9sm7Kd5kzgpoccdNeMw/6zPVB3pUwMgi4C7hj4AMFAf4G27oXH8NNT9zll/sK +S6wVlQwazjxWKWy20ZzXb9ne8ngGalPBWSUSj9xkc1drsXkZ8oOyvYT3e0rnYsGwx85xZB9wKeKg +cJKZnamYwiaMymZvzk6wtDUkxmdUg0mPad0YHtvzpjEfp2iMxvORhnx0kCVLf5Qa43WJsVoyfEyI +pzmf8ruM6xBr7dnBgzyxpqXuUPYaKahOaz1LrxNkS/Q3Ae5AC+xl6NbxAqXXlzghZBZHmOrM6Y6Y +ctAkltwlF7SKEsShjVh7QHuxMU0a08/eiu3x3M+07OijMcKFFltByXrpk8w+JNnZpnp3CfgjV1Ax +gUYCnWwYow42I5wHCcTzLXK0hMZN2DrPM/zCSqe9jRSlJnr70BPE4+zrwbk/xVIDHy2FAQyHoomT +Tt5jiM68nBQut35Y0qLclLiQrutxt/c0OlSqXAC8VrxW97lGoRWzhOnifE2zbF05W4xuyhg7JTUL +aqJ7SWDywhjlal0b+NLTpERBgnPW0+Nw99X2Ws72gOL27iER9jgzj7Uu09JaZ3n+hmCjjvZpjNst +vOWWTbuLrg+/1ltX8WpPauEDEvcunIgTxuMEHweWKCx2KQ9DU/UKdO/3za4Szm2iHYL+ss9AAttm +gZHq2pkUXFbV+FiJCKrpBms18zH75vax5jSo7FNunrVWY3Chvd8KKnHdaTt/6ealwaA1x17yTlft +8VBle3nAE+7R0MScC3MJofNCCkA9PGKBgGMYEwfB2QO5j8zUqa8F/EkWKCzGQJ5EZ05HTly1B01E +z813G5BY++RZ2sxbQS8ZveGPJNabp5kXAeoign6Tlt5+L8i5ZquY9+S+KEUHkmYMRFBxRrHnbl2X +rVemKnG+oB1yd9+zT+4c43jQ0wWmQRR6mTCkY1q3VG05Y120ZzKOMBe6Vy7I5Vz4ygPB3yY4G0FP +8RxiMx985YJPXsgRU58EuHj75gygTzejP+W/zKGe78UQN3yOJ1aMQV9hFH+GAfLRsza84WlPLAI/ +9G/5JdcHftEfH+Y3/fHUG7/o8bv98dzzy3e8S+XCvgqB+VUf7sH0yDHpONdbRE8tAg9NWOzcTJ7q +TuAxe/AJ07c1Rs9okJvl1/0G60qvbdDzz5zO0FuPFQIHNp9y9Bd1CufYVx7dB26mAxwa8GMNrN/U +oGbNZ3EQ7inLzHy5tRg9AXJrN8cB59cCUBeCiVO7zKM0jU0MamhnRThkg/NMmBOGb6StNeD9tDfA +7czsAWopDdnGoXUHtA+s/k0vNPkBcxEI13jVd/axp85va3LpwGggXXWw12Gwr/JGAH0b8CPboiZd +QO1l0mk/UHukud4C+w5uRoNzpCmoW6GbgbMyaQNkga2pQINB18lOXOCJzSWPFOhZcwzdgrsQnne7 +nvjBi+7cP2BbtBeDOW5uOLGf3z94FasKIguOqJl+8ss/6Kumns4cuWbqq5592TN/RNIbn5Qo6qbi +O4F0P9txxPAwagqPlftztO8cWBzdN/jz3b7GD6JHYP/Zp4ToAMaA74M+EGSft3hEGMuf8EwjnTk/ +nz/P7SLipB/ogQ6xNX0fDqNncMCfHqGLCMM0ZzFa+6lPJYQ5p81vW4HkCvidYf6kb+P/oB965g8K +C6uR0rdjX1DNKc5pOSTquI8uQ6KXxYaKBn+30/09tK4kMpJPgUIQkbENEPbuezNPPje2Um83SgyX +GTCJb6MnGVIpgncdQg1qz2bvPfxYD9fewCXDomx9S+HQJuX6W3VAL+v5WZMudRQZk9ZdOk6GIUtC +PqEb/uwSIrtR7/edzqgEdtpEwq7p2J5OQV+RLrmtTvFwFpf03M/VrRyTZ73qVod7v7Jh2Dwe5J25 +JqFOU2qEu1sP+CRotklediycKfLjeIZzjJQsvKmiGSNQhxuJpKa+hoWUizaE1PuIRGzJqropwgVB +oo1hr870MZLgnXF5ZIpr6mF0L8aSy2gVnTAuoB4WEd4d5NPVC9TMotYXERKlTcwQ2KiB/C48AEfH +Qbyq4CN8xTFnTvf/ebOc3isnjD95s0QF0nx9s+y+zMmz782xL0SgEmRpA3x1w1Ff9/74xcxKEPdS +IEFTz6GgU0+BK/UZ5Gwbl4gZwycxEw+Kqa5QmMkh4OzgzEVPnDAiAOGBFaBW4wkDmj1G4RyElKgj +NlLCq8zsp085MNh/+R4t1Q8yxoSv8PUpTt7izZwf2BTHZZ3pIZpUIpuLkL1nNL6sYcHqcKm237wp +T2+RCjgXweXd2Zp7ZM8W6dG5bZsqo0nrJBTx8EC0+CQQdzEGnabTnkzofu1pYkWl4E7XSniECdxy +vLYavPMcL9LW5SToJFNnos+uqweOHriUZ1ntIYZUonc7ltEQ6oTRtwOHNwez2sVREskHN+bqG3ua +eaEbJ8XpyO8CeD9QJc8nbLP2C2R3A437ISUNyt5Yd0TbDNcl11/DSsOzdbi/VhCC0KE6v1vqVNkq +45ZnG6fiV2NwzInxCNth3BwL0+8814jE6+1W1EeWtpWbSZJOJNYXmWRXa7vLnAljE692eHjZ4y5u +y1u63De0IzKca7As48Z3XshVF+3XiLNz0JIMh/JOpbiNLlMi672uO0wYzOCZjRxcxj3D+gVenGIE +MvFUGGXuRps2RzMcgWIRolHXpGUP6sMsQt1hspUBnVKUn/WQj2u6j3SXd9Xz0QtEzoM7qTu5y7gR +q9gNNsrlEMLdikBt9bFvBnfbUIh6voTw7eDsyTmPKUvF0bHqWLbHe3VRHyRZnNeSGKsB73q66Vsk +taxWYmwz1tYVFG/vOQhlM0gUkyvIab3nv2caJ1udU1F3pDMty7stubTE4OJqm0i0ECfrJIkLtraC +HwRWKzlqpfhEIqYH09eT9WrOhQyt8YEoyBlnXtAT37WHIQ03TIuEHbnRxZDdLun0iok9PUC79prU +m5beZzfQUelEXnhzb/pIROKx3F7qCttYIFGh5dXNzFzID7u8vKykA8Uejf7XXz//S4nKvW//ofS/ +QastYw== +""") + +##file distutils-init.py +DISTUTILS_INIT = convert(""" +eJytV92L4zYQf/dfMU0ottuse7RvC6FQrg8Lxz2Ugz4si9HacqKuIxlJ2ST313dG8odkO9d7aGBB +luZLv/nNjFacOqUtKJMIvzK3cXlhWgp5MDBsqK5SNYftsBAGpLLA4F1oe2Ytl+9wUvW55TswCi4c +KibhbFDSglXQCFmDPXIwtm7FawLRbwtPzg2T9gf4gupKv4GS0N262w7V0NvpbCy8cvTo3eAus6C5 +ETU3ICQZX1hFTw/dzR6V/AW1RCN4/XAtbsVXqIXmlVX6liS4lOzEYY9QFB2zx6LfoSNjz1a0pqT9 +QOIfJWQ2E888NEVZNqLlZZnvIB0NpHkimlFdKn2iRRY7yGG/CCJb6Iz280d34SFXBS2yEYPNF0Q7 +yM7oCjpWvbEDQmnhRwOs6zjThpKE8HogwRAgraqYFZgGZvzmzVh+mgz9vskT3hruwyjdFcqyENJw +bbMPO5jdzonxK68QKT7B57CMRRG5shRSWDTX3dI8LzRndZbnSWL1zfvriUmK4TcGWSnZiEPCrxXv +bM+sP7VW2is2WgWXCO3sAu3Rzysz3FiNCA8WPyM4gb1JAAmCiyTZbhFjWx3h9SzauuRXC9MFoVbc +yNTCm1QXOOIfIn/g1kGMhDUBN72hI5XCBQtIXQw8UEEdma6Jaz4vJIJ51Orc15hzzmu6TdFp3ogr +Aof0c98tsw1SiaiWotHffk3XYCkqdToxWRfTFXqgpg2khcLluOHMVC0zZhLKIomesfSreUNNgbXi +Ky9VRzwzkBneNoGQyyvGjbsFQqOZvpWIjqH281lJ/jireFgR3cPzSyTGWzQpDNIU+03Fs4XKLkhp +/n0uFnuF6VphB44b3uWRneSbBoMSioqE8oeF0JY+qTvYfEK+bPLYdoR4McfYQ7wMZj39q0kfP8q+ +FfsymO0GzNlPh644Jje06ulqHpOEQqdJUfoidI2O4CWx4qOglLye6RrFQirpCRXvhoRqXH3sYdVJ +AItvc+VUsLO2v2hVAWrNIfVGtkG351cUMNncbh/WdowtSPtCdkzYFv6mwYc9o2Jt68ud6wectBr8 +hYAulPSlgzH44YbV3ikjrulEaNJxt+/H3wZ7bXSXje/YY4tfVVrVmUstaDwwOBLMg6iduDB0lMVC +UyzYx7Ab4kjCqdViEJmDcdk/SKbgsjYXgfMznUWcrtS4z4fmJ/XOM1LPk/iIpqass5XwNbdnLb1Y +8h3ERXSWZI6rZJxKs1LBqVH65w0Oy4ra0CBYxEeuOMbDmV5GI6E0Ha/wgVTtkX0+OXvqsD02CKLf +XHbeft85D7tTCMYy2Njp4DJP7gWJr6paVWXZ1+/6YXLv/iE0M90FktiI7yFJD9e7SOLhEkkaMTUO +azq9i2woBNR0/0eoF1HFMf0H8ChxH/jgcB34GZIz3Qn4/vid+VEamQrOVqAPTrOfmD4MPdVh09tb +8dLLjvh/61lEP4yW5vJaH4vHcevG8agXvzPGoOhhXNncpTr99PTHx6e/UvffFLaxUSjuSeP286Dw +gtEMcW1xKr/he4/6IQ6FUXP+0gkioHY5iwC9Eyx3HKO7af0zPPe+XyLn7fAY78k4aiR387bCr5XT +5C4rFgwLGfMvJuAMew== +""") + +##file distutils.cfg +DISTUTILS_CFG = convert(""" +eJxNj00KwkAMhfc9xYNuxe4Ft57AjYiUtDO1wXSmNJnK3N5pdSEEAu8nH6lxHVlRhtDHMPATA4uH +xJ4EFmGbvfJiicSHFRzUSISMY6hq3GLCRLnIvSTnEefN0FIjw5tF0Hkk9Q5dRunBsVoyFi24aaLg +9FDOlL0FPGluf4QjcInLlxd6f6rqkgPu/5nHLg0cXCscXoozRrP51DRT3j9QNl99AP53T2Q= +""") + +##file activate_this.py +ACTIVATE_THIS = convert(""" +eJyNU01v2zAMvetXEB4K21jmDOstQA4dMGCHbeihlyEIDMWmG62yJEiKE//7kXKdpN2KzYBt8euR +fKSyLPs8wiEo8wh4wqZTGou4V6Hm0wJa1cSiTkJdr8+GsoTRHuCotBayiWqQEYGtMCgfD1KjGYBe +5a3p0cRKiAe2NtLADikftnDco0ko/SFEVgEZ8aRC5GLux7i3BpSJ6J1H+i7A2CjiHq9z7JRZuuQq +siwTIvpxJYCeuWaBpwZdhB+yxy/eWz+ZvVSU8C4E9FFZkyxFsvCT/ZzL8gcz9aXVE14Yyp2M+2W0 +y7n5mp0qN+avKXvbsyyzUqjeWR8hjGE+2iCE1W1tQ82hsCZN9UzlJr+/e/iab8WfqsmPI6pWeUPd +FrMsd4H/55poeO9n54COhUs+sZNEzNtg/wanpjpuqHJaxs76HtZryI/K3H7KJ/KDIhqcbJ7kI4ar +XL+sMgXnX0D+Te2Iy5xdP8yueSlQB/x/ED2BTAtyE3K4SYUN6AMNfbO63f4lBW3bUJPbTL+mjSxS +PyRfJkZRgj+VbFv+EzHFi5pKwUEepa4JslMnwkowSRCXI+m5XvEOvtuBrxHdhLalG0JofYBok6qj +YdN2dEngUlbC4PG60M1WEN0piu7Nq7on0mgyyUw3iV1etLo6r/81biWdQ9MWHFaePWZYaq+nmp+t +s3az+sj7eA0jfgPfeoN1 +""") + +if __name__ == '__main__': + main() + +## TODO: +## Copy python.exe.manifest +## Monkeypatch distutils.sysconfig diff --git a/make_disk_image.sh b/make_disk_image.sh new file mode 100755 index 0000000..b6cda2c --- /dev/null +++ b/make_disk_image.sh @@ -0,0 +1,15 @@ +#!/bin/sh +#echo "Building app..." +#python2.7 setup.py py2app +echo "Building disk image..." +imgName=`date '+mvc-%Y-%m-%d.dmg'` +imgDirName="dist/img" +imgPath="dist/$imgName" +rm -rf $imgDirName $imgPath +mkdir $imgDirName +cp -r dist/Libre\ Video\ Converter.app $imgDirName/ +ln -s /Applications $imgDirName/Applications +echo "Creating DMG file... " +hdiutil create -srcfolder $imgDirName -volname mvc -format UDZO dist/mvc.tmp.dmg +hdiutil convert -format UDZO -imagekey zlib-level=9 -o $imgPath dist/mvc.tmp.dmg +rm dist/mvc.tmp.dmg diff --git a/mvc/__init__.py b/mvc/__init__.py new file mode 100644 index 0000000..94760ce --- /dev/null +++ b/mvc/__init__.py @@ -0,0 +1,37 @@ +import os + +import multiprocessing +from mvc import converter +from mvc import conversion +from mvc import signals +from mvc import video + +VERSION = '3.0a' + +class Application(signals.SignalEmitter): + + def __init__(self, simultaneous=None): + signals.SignalEmitter.__init__(self) + if simultaneous is None: + try: + simultaneous = multiprocessing.cpu_count() + except NotImplementedError: + pass + self.converter_manager = converter.ConverterManager() + self.conversion_manager = conversion.ConversionManager(simultaneous) + self.started = False + + def startup(self): + if self.started: + return + self.converter_manager.startup() + self.started = True + + def start_conversion(self, filename, converter_id): + self.startup() + converter = self.converter_manager.get_by_id(converter_id) + v = video.VideoFile(filename) + return self.conversion_manager.start_conversion(v, converter) + + def run(self): + raise NotImplementedError diff --git a/mvc/__init__.pyc b/mvc/__init__.pyc Binary files differnew file mode 100644 index 0000000..3ee8029 --- /dev/null +++ b/mvc/__init__.pyc diff --git a/mvc/__main__.py b/mvc/__main__.py new file mode 100644 index 0000000..1992c4e --- /dev/null +++ b/mvc/__main__.py @@ -0,0 +1,9 @@ +if __name__ == "__main__": + try: + from mvc.ui.widgets import Application + except ImportError: + from mvc.ui.console import Application + from mvc.widgets import app + from mvc.widgets import initialize + app.widgetapp = Application() + initialize(app.widgetapp) diff --git a/mvc/basicconverters.py b/mvc/basicconverters.py new file mode 100644 index 0000000..16347fe --- /dev/null +++ b/mvc/basicconverters.py @@ -0,0 +1,132 @@ +import logging +import re + +from mvc import converter + +class WebM_HD(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'webm' + parameters = ('-f webm -vcodec libvpx -g 120 -lag-in-frames 16 ' + '-deadline good -cpu-used 0 -vprofile 0 -qmax 51 -qmin 11 ' + '-slices 4 -b:v 2M -acodec libvorbis -ab 112k ' + '-ar 44100') + +class WebM_SD(converter.FFmpegConverterInfo480p): + media_type = 'format' + extension = 'webm' + parameters = ('-f webm -vcodec libvpx -g 120 -lag-in-frames 16 ' + '-deadline good -cpu-used 0 -vprofile 0 -qmax 53 -qmin 0 ' + '-b:v 768k -acodec libvorbis -ab 112k ' + '-ar 44100') + +class MP4(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'mp4' + parameters = ('-acodec aac -ab 96k -vcodec libx264 -preset slow ' + '-f mp4 -crf 22') + +class MP3(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'mp3' + parameters = '-f mp3 -ac 2' + audio_only = True + +class OggVorbis(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'ogg' + parameters = '-f ogg -vn -acodec libvorbis -aq 60' + audio_only = True + +class OggTheora(converter.FFmpegConverterInfo): + media_type = 'format' + extension = 'ogv' + parameters = '-f ogg -codec:v libtheora -qscale:v 7 -codec:a libvorbis -qscale:a 5' + +class DNxHD_1080(converter.FFmpegConverterInfo1080p): + media_type = 'format' + extension = 'mov' + parameters = ('-r 23.976 -f mov -vcodec dnxhd -b:v ' + '175M -acodec pcm_s16be -ar 48000') + +class DNxHD_720(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'mov' + parameters = ('-r 23.976 -f mov -vcodec dnxhd -b:v ' + '175M -acodec pcm_s16be -ar 48000') + +class PRORES_720(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec prores -profile 2 ' + '-acodec pcm_s16be -ar 48000') + +class PRORES_1080(converter.FFmpegConverterInfo1080p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec prores -profile 2 ' + '-acodec pcm_s16be -ar 48000') + +class AVC_INTRA_1080(converter.FFmpegConverterInfo1080p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec libx264 -pix_fmt yuv422p ' + '-crf 0 -intra -b:v 100M -acodec pcm_s16be -ar 48000') + +class AVC_INTRA_720(converter.FFmpegConverterInfo720p): + media_type = 'format' + extension = 'mov' + parameters = ('-f mov -vcodec libx264 -pix_fmt yuv422p ' + '-crf 0 -intra -b:v 100M -acodec pcm_s16be -ar 48000') + +class NullConverter(converter.FFmpegConverterInfo): + media_type = 'format' + extension = None + + def get_parameters(self, video): + params = [] + if not video.audio_only and self.should_copy_video_size(video): + # -vcodec copy copies the video data exactly. Only use it if the + # output video is the same size as the input video (#19664) + params.extend(['-vcodec', 'copy']) + params.extend(['-acodec', 'copy']) + return params + + def should_copy_video_size(self, video): + if self.width is None or self.height is None: + return True + return (video.width == self.width and video.height == self.height) + + def get_extra_arguments(self, video, output): + if not video.container: + logging.warn("sameformat: video.container is None. Using mp4") + container = 'mp4' + elif isinstance(video.container, list): + # XXX: special case mov,mp4,m4a,3gp,3g2,mj2 + container = 'mp4' + else: + container = video.container + return ['-f', container] + +mp3 = MP3('MP3') +ogg_vorbis = OggVorbis('Ogg Vorbis') +audio_formats = ('Audio', [mp3, ogg_vorbis]) + +webm_hd = WebM_HD('WebM HD') +webm_sd = WebM_SD('WebM SD') +mp4 = MP4('MP4') +theora = OggTheora('Ogg Theora') + +video_formats = ('Video', [webm_hd, webm_sd, mp4, theora]) + +dnxhd_1080 = DNxHD_1080('DNxHD 1080p') +dnxhd_720 = DNxHD_720('DNxHD 720p') +prores_1080 = PRORES_1080('Prores Ingest 1080p') +prores_720 = PRORES_720('Prores Ingest 720p') +avc_intra_1080 = PRORES_1080('AVC Intra 1080p') +avc_intra_720 = PRORES_720('AVC Intra 720p') + +ingest_formats = ('Ingest Formats', [dnxhd_1080, dnxhd_720, prores_1080, + prores_720, avc_intra_1080, avc_intra_720]) +null_converter = NullConverter('Same Format') + +converters = [video_formats, audio_formats, ingest_formats, null_converter] diff --git a/mvc/conversion.py b/mvc/conversion.py new file mode 100644 index 0000000..c7aa883 --- /dev/null +++ b/mvc/conversion.py @@ -0,0 +1,313 @@ +import collections +import errno +import os +import time +import tempfile +import threading +import shutil +import logging + +from mvc import execute +from mvc.utils import line_reader +from mvc.video import get_thumbnail_synchronous +from mvc.widgets import get_conversion_directory + +logger = logging.getLogger(__name__) + +class Conversion(object): + def __init__(self, video, converter, manager, output_dir=None): + self.video = video + self.manager = manager + if output_dir is None: + output_dir = get_conversion_directory() + self.output_dir = output_dir + self.lines = [] + self.thread = None + self.popen = None + self.status = 'initialized' + self.temp_output = None + self.error = None + self.started_at = None + self.duration = None + self.progress = None + self.progress_percent = None + self.create_thumbnail = False + self.eta = None + self.listeners = set() + self.set_converter(converter) + logger.info('created %r', self) + + def set_converter(self, converter): + if self.status != 'initialized': + raise RuntimeError("can't change converter after starting") + self.converter = converter + self.output = os.path.join(self.output_dir, + converter.get_output_filename(self.video)) + + def __repr__(self): + return unicode(self) + + def __str__(self): + return unicode(self).encode('utf8') + + def __unicode__(self): + return u'<Conversion (%s) %r -> %r>' % ( + self.converter.name, self.video.filename, self.output) + + def listen(self, f): + self.listeners.add(f) + + def unlisten(self, f): + self.listeners.remove(f) + + def notify_listeners(self): + self.manager.notify_queue.add(self) + + def run(self): + logger.info('starting %r', self) + try: + self.temp_output = tempfile.mktemp( + dir=os.path.dirname(self.output)) + except EnvironmentError,e : + logger.exception('while creating temp file for %r', + self.output) + self.error = str(e) + self.finalize() + return + logger.info('commandline: %r', ' '.join( + self.get_subprocess_arguments(self.temp_output))) + self.thread = threading.Thread(target=self._thread, + name="Thread:%s" % (self,)) + self.thread.setDaemon(True) + self.thread.start() + + def stop(self): + logger.info('stopping %r', self) + self.error = 'manually stopped' + if self.popen is None: + status = 'canceled' + try: + self.manager.remove(self) + except ValueError: + status = 'failed' + logger.exception('not running and not waiting %s' % (self,)) + self.status = status + return + else: + try: + self.popen.kill() + self.popen.wait() + # set the status transition last, if we had hit an exception + # then we will transition the next state to 'failed' in + # finalize() + self.status = 'canceled' + except EnvironmentError, e: + logger.exception('while stopping %s' % (self,)) + self.error = str(e) + self.popen = None + self.manager.conversion_finished(self) + + def _thread(self): + try: + commandline = self.get_subprocess_arguments(self.temp_output) + self.popen = execute.Popen(commandline, bufsize=1) + self.process_output() + if self.popen: + # if we stop the thread, we can get here after `.stop()` + # finishes. + self.popen.wait() + except OSError, e: + if e.errno == errno.ENOENT: + self.error = '%r does not exist' % ( + self.converter.get_executable(),) + else: + logger.exception('OSError in %s' % (self.thread.name,)) + self.error = str(e) + except Exception, e: + logger.exception('in %s' % (self.thread.name,)) + self.error = str(e) + + if self.create_thumbnail: + self.write_thumbnail_file() + self.finalize() + + def write_thumbnail_file(self): + try: + self._write_thumbnail_file() + except StandardError: + logging.warn("Error writing thumbnail", exc_info=True) + + def _write_thumbnail_file(self): + if self.video.audio_only: + logging.warning("write_thumbnail_file: audio_only=True " + "not writing thumbnail %s", self.video.filename) + return + output_basename = os.path.splitext(os.path.basename(self.output))[0] + logging.info("td: %s ob: %s", self._get_thumbnail_dir(), + output_basename) + thumbnail_path = os.path.join(self._get_thumbnail_dir(), + output_basename + '.png') + logging.info("creating thumbnail: %s", thumbnail_path) + width, height = self.converter.get_target_size(self.video) + get_thumbnail_synchronous(self.video.filename, width, height, + thumbnail_path) + if os.path.exists(thumbnail_path): + logging.info("thumbnail successful: %s", thumbnail_path) + else: + logging.warning("get_thumbnail_synchronous() succeeded, but the " + "thumbnail file is missing!") + + def _get_thumbnail_dir(self): + """Get the directory to store thumbnails in it. + + This method will create the directory if it doesn't exist + """ + thumbnail_dir = os.path.join(self.output_dir, 'thumbnails') + if not os.path.exists(thumbnail_dir): + os.mkdir(thumbnail_dir) + return thumbnail_dir + + def calc_progress_percent(self): + if not self.duration: + return 0.0 + + if self.create_thumbnail: + # assume that thumbnail creation takes as long as 2 seconds of + # video processing + effective_duration = self.duration + 2.0 + else: + effective_duration = self.duration + return self.progress / effective_duration + + def process_output(self): + self.started_at = time.time() + self.status = 'converting' + # We use line_reader, rather than just iterating over the file object, + # because iterating over the file object gives us all the lines when + # the process ends, and we're looking for real-time updates. + for line in line_reader(self.popen.stdout): + self.lines.append(line) # for debugging, if needed + try: + status = self.converter.process_status_line(self.video, line) + except StandardError: + logging.warn("error in process_status_line()", exc_info=True) + continue + if status is None: + continue + updated = set() + if 'finished' in status: + self.error = status.get('error', None) + break + if 'duration' in status: + updated.update(('duration', 'progress')) + self.duration = float(status['duration']) + if self.progress is None: + self.progress = 0.0 + if 'progress' in status: + updated.add('progress') + self.progress = min(float(status['progress']), + self.duration) + if 'eta' in status: + updated.add('eta') + self.eta = float(status['eta']) + + if updated: + self.progress_percent = self.calc_progress_percent() + if 'eta' not in updated: + if self.duration and 0 < self.progress_percent < 1.0: + progress = self.progress_percent * 100 + elapsed = time.time() - self.started_at + time_per_percent = elapsed / progress + self.eta = float( + time_per_percent * (100 - progress)) + else: + self.eta = 0.0 + + self.notify_listeners() + + def finalize(self): + self.progress = self.duration + self.progress_percent = 1.0 + self.eta = 0 + if self.error is None: + self.status = 'staging' + self.notify_listeners() + try: + self.converter.finalize(self.temp_output, self.output) + except EnvironmentError, e: + logger.exception('while trying to move %r to %r after %s', + self.temp_output, self.output, self) + self.error = str(e) + self.status = 'failed' + else: + self.status = 'finished' + else: + if self.temp_output is not None: + try: + os.unlink(self.temp_output) + except EnvironmentError: + pass # ignore errors removing temp files; they may not have + # been created + if self.status != 'canceled': + self.status = 'failed' + if self.status != 'canceled': + self.notify_listeners() + logger.info('finished %r; status: %s', self, self.status) + + def get_subprocess_arguments(self, output): + return ([self.converter.get_executable()] + + list(self.converter.get_arguments(self.video, output))) + +class ConversionManager(object): + def __init__(self, simultaneous=None): + self.notify_queue = set() + self.in_progress = set() + self.waiting = collections.deque() + self.simultaneous = simultaneous + self.running = False + self.create_thumbnails = False + + def get_conversion(self, video, converter, **kwargs): + return Conversion(video, converter, self, **kwargs) + + def remove(self, conversion): + self.waiting.remove(conversion) + + def start_conversion(self, video, converter): + return self.run_conversion(self.get_conversion(video, converter)) + + def run_conversion(self, conversion): + if (self.simultaneous is not None and + len(self.in_progress) >= self.simultaneous): + self.waiting.append(conversion) + else: + self._start_conversion(conversion) + self.running = True + return conversion + + def _start_conversion(self, conversion): + self.in_progress.add(conversion) + conversion.create_thumbnail = self.create_thumbnails + conversion.run() + + def check_notifications(self): + if not self.running: + # don't bother checking if we're not running + return + + self.notify_queue, changed = set(), self.notify_queue + + for conversion in changed: + if conversion.status in ('canceled', 'finished', 'failed'): + self.conversion_finished(conversion) + for listener in conversion.listeners: + listener(conversion) + + def conversion_finished(self, conversion): + self.in_progress.discard(conversion) + while (self.waiting and self.simultaneous is not None and + len(self.in_progress) < self.simultaneous): + c = self.waiting.popleft() + self._start_conversion(c) + if not self.in_progress: + self.running = False diff --git a/mvc/conversion.pyc b/mvc/conversion.pyc Binary files differnew file mode 100644 index 0000000..bcb424d --- /dev/null +++ b/mvc/conversion.pyc diff --git a/mvc/converter.py b/mvc/converter.py new file mode 100644 index 0000000..ef3d5c5 --- /dev/null +++ b/mvc/converter.py @@ -0,0 +1,278 @@ +import json +import logging +import os +import re +import shutil + +from mvc import resources, settings, utils +from mvc.utils import hms_to_seconds + +from mvc.qtfaststart import processor +from mvc.qtfaststart.exceptions import FastStartException + +logger = logging.getLogger(__name__) + +NON_WORD_CHARS = re.compile(r"[^a-zA-Z0-9]+") + +class ConverterInfo(object): + """Describes a particular output converter + + ConverterInfo is the base class for all converters. Subclasses must + implement get_executable() and get_arguments() + + :attribue name: user-friendly name for this converter + :attribute identifier: unique id for this converter + :attribute width: output width for this converter, or None to copy the + input width. This attribute is set to a default on construction, but can + be changed to reflect the user overriding the default. + :attribute height: output height for this converter. Works just like + width + :attribute dont_upsize: should we allow upsizing for conversions? + """ + media_type = None + bitrate = None + extension = None + audio_only = False + + def __init__(self, name, width=None, height=None, dont_upsize=True): + self.name = name + self.identifier = NON_WORD_CHARS.sub("", name).lower() + self.width = width + self.height = height + self.dont_upsize = dont_upsize + + def get_executable(self): + raise NotImplementedError + + def get_arguments(self, video, output): + raise NotImplementedError + + def get_output_filename(self, video): + basename = os.path.basename(video.filename) + name, ext = os.path.splitext(basename) + if ext and ext[0] == '.': + ext = ext[1:] + extension = self.extension if self.extension else ext + return '%s.%s.%s' % (name, self.identifier, extension) + + def get_output_size_guess(self, video): + if not self.bitrate or not video.duration: + return None + if video.duration: + return self.bitrate * video.duration / 8 + + def finalize(self, temp_output, output): + err = None + needs_remove = False + if self.media_type == 'format' and self.extension == 'mp4': + needs_remove = True + logging.debug('generic mp4 format detected. ' + 'Running qtfaststart...') + try: + processor.process(temp_output, output) + except FastStartException: + logging.exception('qtfaststart: exception occurred') + err = EnvironmentError('qtfaststart exception') + else: + try: + shutil.move(temp_output, output) + except EnvironmentError, e: + needs_remove = True + err = e + # If it didn't work for some reason try to clean up the stale stuff. + # And if that doesn't work ... just log, and re-raise the original + # error. + if needs_remove: + try: + os.remove(temp_output) + except EnvironmentError, e: + logging.error('finalize(): cannot remove stale file %r', + temp_output) + if err: + logging.error('finalize(): removal was in response to ' + 'error: %s', str(err)) + raise err + + def get_target_size(self, video): + """Get the size that we will convert to for a given video. + + :returns: (width, height) tuple + """ + return utils.rescale_video((video.width, video.height), + (self.width, self.height), + dont_upsize=self.dont_upsize) + + def process_status_line(self, line): + raise NotImplementedError + +class FFmpegConverterInfo(ConverterInfo): + """Base class for all ffmpeg-based conversions. + + Subclasses must override the parameters attribute and supply it with the + ffmpeg command line for the conversion. parameters can either be a list + of arguments, or a string in which case split() will be called to create + the list. + """ + DURATION_RE = re.compile(r'\W*Duration: (\d\d):(\d\d):(\d\d)\.(\d\d)' + '(, start:.*)?(, bitrate:.*)?') + PROGRESS_RE = re.compile(r'(?:frame=.* fps=.* q=.* )?size=.* time=(.*) ' + 'bitrate=(.*)') + LAST_PROGRESS_RE = re.compile(r'frame=.* fps=.* q=.* Lsize=.* time=(.*) ' + 'bitrate=(.*)') + + extension = None + parameters = None + + def get_executable(self): + return settings.get_ffmpeg_executable_path() + + def get_arguments(self, video, output): + args = ['-i', utils.convert_path_for_subprocess(video.filename), + '-strict', 'experimental'] + args.extend(settings.customize_ffmpeg_parameters( + self.get_parameters(video))) + if not (self.audio_only or video.audio_only): + width, height = self.get_target_size(video) + args.append("-s") + args.append('%ix%i' % (width, height)) + args.extend(self.get_extra_arguments(video, output)) + args.append(self.convert_output_path(output)) + return args + + def convert_output_path(self, output_path): + """Convert our output path so that it can be passed to ffmpeg.""" + # this is a bit tricky, because output_path doesn't exist on windows + # yet, so we can't just call convert_path_for_subprocess(). Instead, + # call convert_path_for_subprocess() on the output directory, and + # assume that the filename only contains safe characters + output_dir = os.path.dirname(output_path) + output_filename = os.path.basename(output_path) + return os.path.join(utils.convert_path_for_subprocess(output_dir), + output_filename) + + def get_extra_arguments(self, video, output): + """Subclasses can override this to add argumenst to the ffmpeg command + line. + """ + return [] + + def get_parameters(self, video): + if self.parameters is None: + raise ValueError("%s: parameters is None" % self) + elif isinstance(self.parameters, basestring): + return self.parameters.split() + else: + return list(self.parameters) + + @staticmethod + def _check_for_errors(line): + if line.startswith('Unknown'): + return line + if line.startswith("Error"): + if not line.startswith("Error while decoding stream"): + return line + + @classmethod + def process_status_line(klass, video, line): + error = klass._check_for_errors(line) + if error: + return {'finished': True, 'error': error} + + match = klass.DURATION_RE.match(line) + if match is not None: + hours, minutes, seconds, centi = [ + int(m) for m in match.groups()[:4]] + return {'duration': hms_to_seconds(hours, minutes, + seconds + 0.01 * centi)} + + match = klass.PROGRESS_RE.match(line) + if match is not None: + t = match.group(1) + if ':' in t: + hours, minutes, seconds = [float(m) for m in t.split(':')[:3]] + return {'progress': hms_to_seconds(hours, minutes, seconds)} + else: + return {'progress': float(t)} + + match = klass.LAST_PROGRESS_RE.match(line) + if match is not None: + return {'finished': True} + +class FFmpegConverterInfo1080p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 1920, 1080) + +class FFmpegConverterInfo720p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 1080, 720) + +class FFmpegConverterInfo480p(FFmpegConverterInfo): + def __init__(self, name): + FFmpegConverterInfo.__init__(self, name, 720, 480) + +class ConverterManager(object): + def __init__(self): + self.converters = {} + # converter -> brand reverse map. XXX: this code, really, really sucks + # and not very scalable. + self.brand_rmap = {} + self.brand_map = {} + + def add_converter(self, converter): + self.converters[converter.identifier] = converter + + def startup(self): + self.load_simple_converters() + self.load_converters(resources.converter_scripts()) + + def brand_to_converters(self, brand): + try: + return self.brand_map[brand] + except KeyError: + return None + + def converter_to_brand(self, converter): + try: + return self.brand_rmap[converter] + except KeyError: + return None + + def load_simple_converters(self): + from mvc import basicconverters + for converter in basicconverters.converters: + if isinstance(converter, tuple): + brand, realconverters = converter + for realconverter in realconverters: + self.brand_rmap[realconverter] = brand + self.brand_map.setdefault(brand, []).append(realconverter) + self.add_converter(realconverter) + else: + self.brand_rmap[converter] = None + self.brand_map.setdefault(None, []).append(converter) + self.add_converter(converter) + + def load_converters(self, converters): + for converter_file in converters: + global_dict = {} + execfile(converter_file, global_dict) + if 'converters' in global_dict: + for converter in global_dict['converters']: + if isinstance(converter, tuple): + brand, realconverters = converter + for realconverter in realconverters: + self.brand_rmap[realconverter] = brand + self.brand_map.setdefault(brand, []).append(realconverter) + self.add_converter(realconverter) + else: + self.brand_rmap[converter] = None + self.brand_map.setdefault(None, []).append(converter) + self.add_converter(converter) + logger.info('load_converters: loaded %i from %r', + len(global_dict['converters']), + converter_file) + + def list_converters(self): + return self.converters.values() + + def get_by_id(self, id_): + return self.converters[id_] diff --git a/mvc/converter.pyc b/mvc/converter.pyc Binary files differnew file mode 100644 index 0000000..0c0ab1d --- /dev/null +++ b/mvc/converter.pyc diff --git a/mvc/errors.py b/mvc/errors.py new file mode 100644 index 0000000..504948b --- /dev/null +++ b/mvc/errors.py @@ -0,0 +1,89 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""``miro.errors`` -- Miro exceptions. +""" + +class ActionUnavailableError(ValueError): + """The action attempted can not be done in the current state.""" + def __init__(self, reason): + self.reason = reason + +class WidgetActionError(ActionUnavailableError): + """The widget is not in the right state to perform the requested action. + This usually is not serious, but if not handled the UI will likely be in an + incorrect state. + """ + +class WidgetDomainError(WidgetActionError): + """The widget element requested is not available at this time. This may be a + temporary condition or a result of permanent changes. + """ + def __init__(self, domain, needle, haystack, details=None): + self.domain = domain + self.needle = needle + self.haystack = haystack + self.details = details + + @property + def reason(self): + reason = "looked for {0} in {2}, but found only {1}".format( + repr(self.needle), repr(self.haystack), self.domain) + if self.details: + reason += ": " + self.details + return reason + +class WidgetRangeError(WidgetDomainError): + """Class to handle neat display of ranges in WidgetDomainErrors. Handlers + should generally catch a parent of this. + """ + def __init__(self, domain, needle, start_range, end_range, details=None): + haystack = "{0} to {1}".format(repr(start_range), repr(end_range)) + WidgetDomainError.__init__(self, domain, needle, haystack, details) + +class WidgetNotReadyError(WidgetActionError): + """The widget is not ready to perfom the action given; this must be a + temporary condition that will be resolved when the widget finishes setting + up. + """ + def __init__(self, waiting_for): + self.waiting_for = waiting_for + + @property + def reason(self): + return "waiting for {0}".format(self.waiting_for) + +class UnexpectedWidgetError(ActionUnavailableError): + """The Spanish Inquisition of widget errors. A widget was asked to do + something, had every reason to do so, yet refused. This should always cause + at least a soft_failure; the UI is now in an incorrect state. + """ + +class WidgetUsageError(UnexpectedWidgetError): + """A widget error that is likely the result of incorrect widget usage.""" diff --git a/mvc/errors.pyc b/mvc/errors.pyc Binary files differnew file mode 100644 index 0000000..35c8eef --- /dev/null +++ b/mvc/errors.pyc diff --git a/mvc/execute.py b/mvc/execute.py new file mode 100644 index 0000000..893d356 --- /dev/null +++ b/mvc/execute.py @@ -0,0 +1,49 @@ +"""execute.py -- Run executable programs. + +mvc.execute wraps the standard subprocess module in for MVC. +""" + +import os +import subprocess +import sys + +CalledProcessError = subprocess.CalledProcessError + +def default_popen_args(): + retval = { + 'stdin': open(os.devnull, 'rb'), + 'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, + } + if sys.platform == 'win32': + retval['startupinfo'] = subprocess.STARTUPINFO() + retval['startupinfo'].dwFlags |= subprocess.STARTF_USESHOWWINDOW + return retval + +class Popen(subprocess.Popen): + """subprocess.Popen subclass that adds MVC default behavior. + + By default we: + - Use a /dev/null equivilent for stdin + - Use a pipe for stdout + - Redirect stderr to stdout + - use STARTF_USESHOWWINDOW to not open a console window on win32 + + These are just defaults though, they can be overriden by passing different + values to the constructor + """ + def __init__(self, commandline, **kwargs): + final_args = default_popen_args() + final_args.update(kwargs) + subprocess.Popen.__init__(self, commandline, **final_args) + +def check_output(commandline, **kwargs): + """MVC version of subprocess.check_output. + + This performs the same default behavior as the Popen class. + """ + final_args = default_popen_args() + # check_output doesn't use stdout + del final_args['stdout'] + final_args.update(kwargs) + return subprocess.check_output(commandline, **final_args) diff --git a/mvc/execute.pyc b/mvc/execute.pyc Binary files differnew file mode 100644 index 0000000..5ac124c --- /dev/null +++ b/mvc/execute.pyc diff --git a/mvc/openfiles.py b/mvc/openfiles.py new file mode 100644 index 0000000..1025719 --- /dev/null +++ b/mvc/openfiles.py @@ -0,0 +1,46 @@ +"""openfiles.py -- open files/folders.""" + +import logging +import os +import subprocess +import sys + + +# To open paths we use an OS-specific command. The approach is from: +# http://stackoverflow.com/questions/6631299/python-opening-a-folder-in-explorer-nautilus-mac-thingie + +def check_kde(): + return os.environ.get("KDE_FULL_SESSION", None) != None + +def _open_path_osx(path): + subprocess.call(['open', '--', path]) + +def _open_path_kde(path): + subprocess.call(["kfmclient", "exec", "file://" + path]) + +def _open_path_gnome(path): + subprocess.call(["gnome-open", path]) + +def _open_path_windows(path): + subprocess.call(['explorer', path]) + +def _open_path(path): + if sys.platform == 'darwin': + _open_path_osx(path) + elif sys.platform == 'linux2': + if check_kde(): + _open_path_kde(path) + else: + _open_path_gnome(path) + elif sys.platform == 'win32': + _open_path_windows(path) + else: + logging.warn("unknown platform: %s", sys.platform) + +def reveal_folder(path): + """Show a folder in the desktop shell (finder/explorer/nautilous, etc).""" + logging.info("reveal_folder: %s", path) + if os.path.isdir(path): + _open_path(path) + else: + _open_path(os.path.dirname(path)) diff --git a/mvc/openfiles.pyc b/mvc/openfiles.pyc Binary files differnew file mode 100644 index 0000000..bd00c5f --- /dev/null +++ b/mvc/openfiles.pyc diff --git a/mvc/osx/__init__.py b/mvc/osx/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/mvc/osx/__init__.py diff --git a/mvc/osx/app_main.py b/mvc/osx/app_main.py new file mode 100644 index 0000000..ef52ff6 --- /dev/null +++ b/mvc/osx/app_main.py @@ -0,0 +1,12 @@ +import os +import sys + +from mvc.osx import autoupdate +from mvc.widgets import app +from mvc.widgets import initialize +from mvc.ui.widgets import Application + +# run the app +autoupdate.initialize() +app.widgetapp = Application() +initialize(app.widgetapp) diff --git a/mvc/osx/autoupdate.py b/mvc/osx/autoupdate.py new file mode 100644 index 0000000..7b17d47 --- /dev/null +++ b/mvc/osx/autoupdate.py @@ -0,0 +1,9 @@ +from Foundation import * + +def load_sparkle_framework(): + bundlePath = '%s/Sparkle.framework' % Foundation.NSBundle.mainBundle().privateFrameworksPath() + objc.loadBundle('Sparkle', globals(), bundle_path=bundlePath) + +def initialize(): + load_sparkle_framework() + SUUpdater.sharedUpdater().setAutomaticallyChecksForUpdates_(YES) diff --git a/mvc/qtfaststart/__init__.py b/mvc/qtfaststart/__init__.py new file mode 100644 index 0000000..f985b5c --- /dev/null +++ b/mvc/qtfaststart/__init__.py @@ -0,0 +1 @@ +VERSION = "1.6" diff --git a/mvc/qtfaststart/__init__.pyc b/mvc/qtfaststart/__init__.pyc Binary files differnew file mode 100644 index 0000000..9ab2c4c --- /dev/null +++ b/mvc/qtfaststart/__init__.pyc diff --git a/mvc/qtfaststart/exceptions.py b/mvc/qtfaststart/exceptions.py new file mode 100644 index 0000000..f0767e1 --- /dev/null +++ b/mvc/qtfaststart/exceptions.py @@ -0,0 +1,5 @@ +class FastStartException(Exception): + """ + Raised when something bad happens during processing. + """ + pass
\ No newline at end of file diff --git a/mvc/qtfaststart/exceptions.pyc b/mvc/qtfaststart/exceptions.pyc Binary files differnew file mode 100644 index 0000000..0e392e5 --- /dev/null +++ b/mvc/qtfaststart/exceptions.pyc diff --git a/mvc/qtfaststart/processor.py b/mvc/qtfaststart/processor.py new file mode 100755 index 0000000..df2a900 --- /dev/null +++ b/mvc/qtfaststart/processor.py @@ -0,0 +1,215 @@ +""" + The guts that actually do the work. This is available here for the + 'qtfaststart' script and for your application's direct use. +""" + +import logging +import os +import struct + +#from StringIO import StringIO +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +from mvc.qtfaststart.exceptions import FastStartException + +CHUNK_SIZE = 8192 + +log = logging.getLogger("qtfaststart") + +# Older versions of Python require this to be defined +if not hasattr(os, 'SEEK_CUR'): + os.SEEK_CUR = 1 + +def read_atom(datastream): + """ + Read an atom and return a tuple of (size, type) where size is the size + in bytes (including the 8 bytes already read) and type is a "fourcc" + like "ftyp" or "moov". + """ + return struct.unpack(">L4s", datastream.read(8)) + + +def get_index(datastream): + """ + Return an index of top level atoms, their absolute byte-position in the + file and their size in a list: + + index = [ + ("ftyp", 0, 24), + ("moov", 25, 2658), + ("free", 2683, 8), + ... + ] + + The tuple elements will be in the order that they appear in the file. + """ + index = [] + + log.debug("Getting index of top level atoms...") + + # Read atoms until we catch an error + while(datastream): + try: + skip = 8 + atom_size, atom_type = read_atom(datastream) + if atom_size == 1: + atom_size = struct.unpack(">Q", datastream.read(8))[0] + skip = 16 + log.debug("%s: %s" % (atom_type, atom_size)) + except: + break + + index.append((atom_type, datastream.tell() - skip, atom_size)) + + if atom_size == 0: + # Some files may end in mdat with no size set, which generally + # means to seek to the end of the file. We can just stop indexing + # as no more entries will be found! + break + + datastream.seek(atom_size - skip, os.SEEK_CUR) + + # Make sure the atoms we need exist + top_level_atoms = set([item[0] for item in index]) + for key in ["moov", "mdat"]: + if key not in top_level_atoms: + log.error("%s atom not found, is this a valid MOV/MP4 file?" % key) + raise FastStartException() + + return index + + +def find_atoms(size, datastream): + """ + This function is a generator that will yield either "stco" or "co64" + when either atom is found. datastream can be assumed to be 8 bytes + into the stco or co64 atom when the value is yielded. + + It is assumed that datastream will be at the end of the atom after + the value has been yielded and processed. + + size is the number of bytes to the end of the atom in the datastream. + """ + stop = datastream.tell() + size + + while datastream.tell() < stop: + try: + atom_size, atom_type = read_atom(datastream) + except: + log.exception("Error reading next atom!") + raise FastStartException() + + if atom_type in ["trak", "mdia", "minf", "stbl"]: + # Known ancestor atom of stco or co64, search within it! + for atype in find_atoms(atom_size - 8, datastream): + yield atype + elif atom_type in ["stco", "co64"]: + yield atom_type + else: + # Ignore this atom, seek to the end of it. + datastream.seek(atom_size - 8, os.SEEK_CUR) + + +def process(infilename, outfilename, limit=0): + """ + Convert a Quicktime/MP4 file for streaming by moving the metadata to + the front of the file. This method writes a new file. + + If limit is set to something other than zero it will be used as the + number of bytes to write of the atoms following the moov atom. This + is very useful to create a small sample of a file with full headers, + which can then be used in bug reports and such. + """ + datastream = open(infilename, "rb") + + # Get the top level atom index + index = get_index(datastream) + + mdat_pos = 999999 + free_size = 0 + + # Make sure moov occurs AFTER mdat, otherwise no need to run! + for atom, pos, size in index: + # The atoms are guaranteed to exist from get_index above! + if atom == "moov": + moov_pos = pos + moov_size = size + elif atom == "mdat": + mdat_pos = pos + elif atom == "free" and pos < mdat_pos: + # This free atom is before the mdat! + free_size += size + log.info("Removing free atom at %d (%d bytes)" % (pos, size)) + + # Offset to shift positions + offset = moov_size - free_size + + if moov_pos < mdat_pos: + # moov appears to be in the proper place, don't shift by moov size + offset -= moov_size + if not free_size: + # No free atoms and moov is correct, we are done! + log.error("This file appears to already be setup for streaming!") + raise FastStartException() + + # Read and fix moov + datastream.seek(moov_pos) + moov = StringIO(datastream.read(moov_size)) + + # Ignore moov identifier and size, start reading children + moov.seek(8) + + for atom_type in find_atoms(moov_size - 8, moov): + # Read either 32-bit or 64-bit offsets + ctype, csize = atom_type == "stco" and ("L", 4) or ("Q", 8) + + # Get number of entries + version, entry_count = struct.unpack(">2L", moov.read(8)) + + log.info("Patching %s with %d entries" % (atom_type, entry_count)) + + # Read entries + entries = struct.unpack(">" + ctype * entry_count, + moov.read(csize * entry_count)) + + # Patch and write entries + moov.seek(-csize * entry_count, os.SEEK_CUR) + moov.write(struct.pack(">" + ctype * entry_count, + *[entry + offset for entry in entries])) + + log.info("Writing output...") + outfile = open(outfilename, "wb") + + # Write ftype + for atom, pos, size in index: + if atom == "ftyp": + datastream.seek(pos) + outfile.write(datastream.read(size)) + + # Write moov + moov.seek(0) + outfile.write(moov.read()) + + # Write the rest + written = 0 + atoms = [item for item in index if item[0] not in ["ftyp", "moov", "free"]] + for atom, pos, size in atoms: + datastream.seek(pos) + + # Write in chunks to not use too much memory + for x in range(size / CHUNK_SIZE): + outfile.write(datastream.read(CHUNK_SIZE)) + written += CHUNK_SIZE + if limit and written >= limit: + # A limit was set and we've just passed it, stop writing! + break + + if size % CHUNK_SIZE: + outfile.write(datastream.read(size % CHUNK_SIZE)) + written += (size % CHUNK_SIZE) + if limit and written >= limit: + # A limit was set and we've just passed it, stop writing! + break diff --git a/mvc/qtfaststart/processor.pyc b/mvc/qtfaststart/processor.pyc Binary files differnew file mode 100644 index 0000000..73156b4 --- /dev/null +++ b/mvc/qtfaststart/processor.pyc diff --git a/mvc/resources/__init__.py b/mvc/resources/__init__.py new file mode 100644 index 0000000..005041d --- /dev/null +++ b/mvc/resources/__init__.py @@ -0,0 +1,21 @@ +import os.path +import glob +import sys + +def image_path(name): + return os.path.join(resources_dir(), 'images', name) + +def converter_scripts(): + return glob.glob(os.path.join(resources_dir(), 'converters', '*.py')) + + +def resources_dir(): + if in_py2exe(): + directory = os.path.join(os.path.dirname(sys.executable), "resources") + else: + directory = os.path.dirname(__file__) + return os.path.abspath(directory) + +def in_py2exe(): + return (hasattr(sys,"frozen") and + sys.frozen in ("windows_exe", "console_exe")) diff --git a/mvc/resources/__init__.pyc b/mvc/resources/__init__.pyc Binary files differnew file mode 100644 index 0000000..165624c --- /dev/null +++ b/mvc/resources/__init__.pyc diff --git a/mvc/resources/converters/android.py b/mvc/resources/converters/android.py new file mode 100644 index 0000000..ac2007d --- /dev/null +++ b/mvc/resources/converters/android.py @@ -0,0 +1,61 @@ +from mvc.converter import FFmpegConverterInfo +from mvc.basicconverters import MP4 + +class AndroidConversion(FFmpegConverterInfo): + media_type = 'android' + extension = 'mp4' + parameters = ('-acodec aac -ac 2 -ab 160k ' + '-vcodec libx264 -preset slow -profile:v baseline -level 30 ' + '-maxrate 10000000 -bufsize 10000000 -f mp4 -threads 0 ') + simple = MP4 + +y = AndroidConversion('Galaxy Y', 320, 240) +mini = AndroidConversion('Galaxy Mini', 320, 240) +ace = AndroidConversion('Galaxy Ace', 480, 320) +admire = AndroidConversion('Galaxy Admire', 480, 320) +charge = AndroidConversion('Galaxy Charge', 800, 480) +s = AndroidConversion('Galaxy S / SII / S Plus', 800, 480) +siii = AndroidConversion('Galaxy SIII', 1280, 720) +nexus = AndroidConversion('Galaxy Nexus', 1280, 720) +tab = AndroidConversion('Galaxy Tab', 1024, 600) +tab_10 = AndroidConversion('Galaxy Tab 10.1', 1280, 800) +note = AndroidConversion('Galaxy Note', 1280, 800) +note = AndroidConversion('Galaxy Note II', 1920, 1080) +infuse = AndroidConversion('Galaxy Infuse', 1280, 800) +epic = AndroidConversion('Galaxy Epic', 800, 480) + +samsung_devices = ('Samsung', [y, mini, ace, admire, charge, s, siii, nexus, + tab, tab_10, note, infuse, epic]) + +wildfire = AndroidConversion('Wildfire', 320, 240) +desire = AndroidConversion('Desire', 800, 480) +incredible = AndroidConversion('Droid Incredible', 800, 480) +thunderbolt = AndroidConversion('Thunderbolt', 800, 480) +evo = AndroidConversion('Evo 4G', 800, 480) +sensation = AndroidConversion('Sensation', 960, 540) +rezound = AndroidConversion('Rezound', 1280, 720) +onex = AndroidConversion('One X', 1280, 720) + +htc_devices = ('HTC', [wildfire, desire, incredible, thunderbolt, evo, + sensation, rezound, onex]) + +droid = AndroidConversion('Droid', 854, 480) +droid_x2 = AndroidConversion('Droid X2', 1280, 720) +razr = AndroidConversion('RAZR', 960, 540) +xoom = AndroidConversion('XOOM', 1280, 800) + +motorola_devices = ('Motorola', [droid, droid_x2, razr, xoom]) + +zio = AndroidConversion('Zio', 800, 480) + +sanyo_devices = ('Sanyo', [zio]) + +small = AndroidConversion('Small (480x320)', 480, 320) +normal = AndroidConversion('Normal (800x480)', 800, 480) +large720 = AndroidConversion('Large (720p)', 1280, 720) +large1080 = AndroidConversion('Large (1080p)', 1920, 1080) + +more_devices = ('More Devices', [small, normal, large720, large1080]) + +converters = [samsung_devices, htc_devices, motorola_devices, sanyo_devices, + more_devices] diff --git a/mvc/resources/converters/apple.py b/mvc/resources/converters/apple.py new file mode 100644 index 0000000..41fdd7a --- /dev/null +++ b/mvc/resources/converters/apple.py @@ -0,0 +1,28 @@ +from mvc.converter import FFmpegConverterInfo +from mvc.basicconverters import MP4 + +class AppleConversion(FFmpegConverterInfo): + media_type = 'apple' + extension = 'mp4' + parameters = ('-acodec aac -ac 2 -ab 160k ' + '-vcodec libx264 -preset slow -profile:v baseline -level 30 ' + '-maxrate 10000000 -bufsize 10000000 -vb 1200k -f mp4 ' + '-threads 0') + simple = MP4 + + +DEFAULT_SIZE = (480, 320) + +ipod = AppleConversion('iPod Nano/Classic', *DEFAULT_SIZE) +ipod_touch = AppleConversion('iPod Touch', 640, 480) +ipod_retina = AppleConversion('iPod Touch 4+', 960, 640) +iphone = AppleConversion('iPhone', 640, 480) +iphone_retina = AppleConversion('iPhone 4+', 960, 640) +iphone_5 = AppleConversion('iPhone 5', 1920, 1080) +ipad = AppleConversion('iPad', 1024, 768) +ipad_retina = AppleConversion('iPad 3', 1920, 1080) +apple_tv = AppleConversion('Apple TV', 1280, 720) +universal = AppleConversion('Apple Universal', 1280, 720) + +converters = [ipod, ipod_touch, ipod_retina, iphone, iphone_retina, iphone_5, + ipad, ipad_retina, apple_tv, universal] diff --git a/mvc/resources/converters/others.py b/mvc/resources/converters/others.py new file mode 100644 index 0000000..a05030f --- /dev/null +++ b/mvc/resources/converters/others.py @@ -0,0 +1,20 @@ +from mvc.converter import FFmpegConverterInfo + +class PlaystationPortable(FFmpegConverterInfo): + media_type = 'other' + extension = 'mp4' + parameters = ('-b 512000 -ar 24000 -ab 64000 ' + '-f psp -r 29.97').split() + + +class KindleFire(FFmpegConverterInfo): + media_type = 'other' + extension = 'mp4' + parameters = ('-acodec aac -ab 96k -vcodec libx264 ' + '-preset slow -f mp4 -crf 22').split() + + +psp = PlaystationPortable('Playstation Portable', 320, 240) +kindle_fire = KindleFire('Kindle Fire', 1224, 600) + +converters = [psp, kindle_fire] diff --git a/mvc/resources/images/android-icon-off.png b/mvc/resources/images/android-icon-off.png Binary files differnew file mode 100644 index 0000000..a20395c --- /dev/null +++ b/mvc/resources/images/android-icon-off.png diff --git a/mvc/resources/images/android-icon-on.png b/mvc/resources/images/android-icon-on.png Binary files differnew file mode 100644 index 0000000..69bb858 --- /dev/null +++ b/mvc/resources/images/android-icon-on.png diff --git a/mvc/resources/images/apple-icon-off.png b/mvc/resources/images/apple-icon-off.png Binary files differnew file mode 100644 index 0000000..04cb85a --- /dev/null +++ b/mvc/resources/images/apple-icon-off.png diff --git a/mvc/resources/images/apple-icon-on.png b/mvc/resources/images/apple-icon-on.png Binary files differnew file mode 100644 index 0000000..e151d89 --- /dev/null +++ b/mvc/resources/images/apple-icon-on.png diff --git a/mvc/resources/images/arrow-down-off.png b/mvc/resources/images/arrow-down-off.png Binary files differnew file mode 100644 index 0000000..f4c5f5d --- /dev/null +++ b/mvc/resources/images/arrow-down-off.png diff --git a/mvc/resources/images/arrow-down-on.png b/mvc/resources/images/arrow-down-on.png Binary files differnew file mode 100644 index 0000000..07696e0 --- /dev/null +++ b/mvc/resources/images/arrow-down-on.png diff --git a/mvc/resources/images/audio.png b/mvc/resources/images/audio.png Binary files differnew file mode 100644 index 0000000..6a21e49 --- /dev/null +++ b/mvc/resources/images/audio.png diff --git a/mvc/resources/images/clear-icon.png b/mvc/resources/images/clear-icon.png Binary files differnew file mode 100644 index 0000000..5fe6b51 --- /dev/null +++ b/mvc/resources/images/clear-icon.png diff --git a/mvc/resources/images/convert-button-off.png b/mvc/resources/images/convert-button-off.png Binary files differnew file mode 100644 index 0000000..50db34e --- /dev/null +++ b/mvc/resources/images/convert-button-off.png diff --git a/mvc/resources/images/convert-button-on.png b/mvc/resources/images/convert-button-on.png Binary files differnew file mode 100644 index 0000000..f6d492b --- /dev/null +++ b/mvc/resources/images/convert-button-on.png diff --git a/mvc/resources/images/convert-button-stop.png b/mvc/resources/images/convert-button-stop.png Binary files differnew file mode 100644 index 0000000..98396da --- /dev/null +++ b/mvc/resources/images/convert-button-stop.png diff --git a/mvc/resources/images/converted_to-icon.png b/mvc/resources/images/converted_to-icon.png Binary files differnew file mode 100644 index 0000000..47c7954 --- /dev/null +++ b/mvc/resources/images/converted_to-icon.png diff --git a/mvc/resources/images/dropoff-icon-off.png b/mvc/resources/images/dropoff-icon-off.png Binary files differnew file mode 100644 index 0000000..bb3d781 --- /dev/null +++ b/mvc/resources/images/dropoff-icon-off.png diff --git a/mvc/resources/images/dropoff-icon-on.png b/mvc/resources/images/dropoff-icon-on.png Binary files differnew file mode 100644 index 0000000..d1bf1e5 --- /dev/null +++ b/mvc/resources/images/dropoff-icon-on.png diff --git a/mvc/resources/images/dropoff-icon-small-off.png b/mvc/resources/images/dropoff-icon-small-off.png Binary files differnew file mode 100644 index 0000000..191b0df --- /dev/null +++ b/mvc/resources/images/dropoff-icon-small-off.png diff --git a/mvc/resources/images/dropoff-icon-small-on.png b/mvc/resources/images/dropoff-icon-small-on.png Binary files differnew file mode 100644 index 0000000..2d23caa --- /dev/null +++ b/mvc/resources/images/dropoff-icon-small-on.png diff --git a/mvc/resources/images/error-icon.png b/mvc/resources/images/error-icon.png Binary files differnew file mode 100644 index 0000000..1efcb57 --- /dev/null +++ b/mvc/resources/images/error-icon.png diff --git a/mvc/resources/images/item-completed.png b/mvc/resources/images/item-completed.png Binary files differnew file mode 100644 index 0000000..14dffc8 --- /dev/null +++ b/mvc/resources/images/item-completed.png diff --git a/mvc/resources/images/item-delete-button-off.png b/mvc/resources/images/item-delete-button-off.png Binary files differnew file mode 100644 index 0000000..b99dc7c --- /dev/null +++ b/mvc/resources/images/item-delete-button-off.png diff --git a/mvc/resources/images/item-delete-button-on.png b/mvc/resources/images/item-delete-button-on.png Binary files differnew file mode 100644 index 0000000..0b71149 --- /dev/null +++ b/mvc/resources/images/item-delete-button-on.png diff --git a/mvc/resources/images/item-error.png b/mvc/resources/images/item-error.png Binary files differnew file mode 100644 index 0000000..0e5ff9f --- /dev/null +++ b/mvc/resources/images/item-error.png diff --git a/mvc/resources/images/mvc-logo.png b/mvc/resources/images/mvc-logo.png Binary files differnew file mode 100644 index 0000000..be05fd3 --- /dev/null +++ b/mvc/resources/images/mvc-logo.png diff --git a/mvc/resources/images/other-icon-off.png b/mvc/resources/images/other-icon-off.png Binary files differnew file mode 100644 index 0000000..e2ed0f6 --- /dev/null +++ b/mvc/resources/images/other-icon-off.png diff --git a/mvc/resources/images/other-icon-on.png b/mvc/resources/images/other-icon-on.png Binary files differnew file mode 100644 index 0000000..e8748b6 --- /dev/null +++ b/mvc/resources/images/other-icon-on.png diff --git a/mvc/resources/images/progressbar-base.png b/mvc/resources/images/progressbar-base.png Binary files differnew file mode 100644 index 0000000..893bb8b --- /dev/null +++ b/mvc/resources/images/progressbar-base.png diff --git a/mvc/resources/images/queued-icon.png b/mvc/resources/images/queued-icon.png Binary files differnew file mode 100644 index 0000000..cf77676 --- /dev/null +++ b/mvc/resources/images/queued-icon.png diff --git a/mvc/resources/images/settings-base_center.png b/mvc/resources/images/settings-base_center.png Binary files differnew file mode 100644 index 0000000..65f4017 --- /dev/null +++ b/mvc/resources/images/settings-base_center.png diff --git a/mvc/resources/images/settings-base_left.png b/mvc/resources/images/settings-base_left.png Binary files differnew file mode 100644 index 0000000..43a228d --- /dev/null +++ b/mvc/resources/images/settings-base_left.png diff --git a/mvc/resources/images/settings-base_right.png b/mvc/resources/images/settings-base_right.png Binary files differnew file mode 100644 index 0000000..83de6a4 --- /dev/null +++ b/mvc/resources/images/settings-base_right.png diff --git a/mvc/resources/images/settings-depth_center.png b/mvc/resources/images/settings-depth_center.png Binary files differnew file mode 100644 index 0000000..04fe295 --- /dev/null +++ b/mvc/resources/images/settings-depth_center.png diff --git a/mvc/resources/images/settings-depth_left.png b/mvc/resources/images/settings-depth_left.png Binary files differnew file mode 100644 index 0000000..54390e0 --- /dev/null +++ b/mvc/resources/images/settings-depth_left.png diff --git a/mvc/resources/images/settings-depth_right.png b/mvc/resources/images/settings-depth_right.png Binary files differnew file mode 100644 index 0000000..a831090 --- /dev/null +++ b/mvc/resources/images/settings-depth_right.png diff --git a/mvc/resources/images/settings-dropdown-bottom-bg.png b/mvc/resources/images/settings-dropdown-bottom-bg.png Binary files differnew file mode 100644 index 0000000..3b062d0 --- /dev/null +++ b/mvc/resources/images/settings-dropdown-bottom-bg.png diff --git a/mvc/resources/images/settings-icon-off.png b/mvc/resources/images/settings-icon-off.png Binary files differnew file mode 100644 index 0000000..5fdb386 --- /dev/null +++ b/mvc/resources/images/settings-icon-off.png diff --git a/mvc/resources/images/settings-icon-on.png b/mvc/resources/images/settings-icon-on.png Binary files differnew file mode 100644 index 0000000..403f126 --- /dev/null +++ b/mvc/resources/images/settings-icon-on.png diff --git a/mvc/resources/images/showfile-icon.png b/mvc/resources/images/showfile-icon.png Binary files differnew file mode 100644 index 0000000..0fd2d6b --- /dev/null +++ b/mvc/resources/images/showfile-icon.png diff --git a/mvc/resources/nsis/modern-wizard.bmp b/mvc/resources/nsis/modern-wizard.bmp Binary files differnew file mode 100644 index 0000000..d8ea8d9 --- /dev/null +++ b/mvc/resources/nsis/modern-wizard.bmp diff --git a/mvc/resources/nsis/mvc-logo.ico b/mvc/resources/nsis/mvc-logo.ico Binary files differnew file mode 100644 index 0000000..007a929 --- /dev/null +++ b/mvc/resources/nsis/mvc-logo.ico diff --git a/mvc/resources/nsis/plugins/nsProcess.dll b/mvc/resources/nsis/plugins/nsProcess.dll Binary files differnew file mode 100644 index 0000000..4355d4a --- /dev/null +++ b/mvc/resources/nsis/plugins/nsProcess.dll diff --git a/mvc/resources/nsis/plugins/nsProcess.nsh b/mvc/resources/nsis/plugins/nsProcess.nsh new file mode 100644 index 0000000..76642e0 --- /dev/null +++ b/mvc/resources/nsis/plugins/nsProcess.nsh @@ -0,0 +1,21 @@ +!define nsProcess::FindProcess `!insertmacro nsProcess::FindProcess`
+
+!macro nsProcess::FindProcess _FILE _ERR
+ nsProcess::_FindProcess /NOUNLOAD `${_FILE}`
+ Pop ${_ERR}
+!macroend
+
+
+!define nsProcess::KillProcess `!insertmacro nsProcess::KillProcess`
+
+!macro nsProcess::KillProcess _FILE _ERR
+ nsProcess::_KillProcess /NOUNLOAD `${_FILE}`
+ Pop ${_ERR}
+!macroend
+
+
+!define nsProcess::Unload `!insertmacro nsProcess::Unload`
+
+!macro nsProcess::Unload
+ nsProcess::_Unload
+!macroend
diff --git a/mvc/resources/windows/README b/mvc/resources/windows/README new file mode 100644 index 0000000..bcc603e --- /dev/null +++ b/mvc/resources/windows/README @@ -0,0 +1,7 @@ +This directory contains resources files for the windows port. + +---- gtkrc --- + +Taken from +http://art.gnome.org/download/themes/gtk2/1203/GTK2-ClearlooksVisto.tar.bz2 +and modified for Libre Video Converter diff --git a/mvc/resources/windows/gtkrc b/mvc/resources/windows/gtkrc new file mode 100755 index 0000000..7add72c --- /dev/null +++ b/mvc/resources/windows/gtkrc @@ -0,0 +1,182 @@ +# Clearlooks-Visto by Marius M. M. < devilx at gdesklets dot org> +# This theme is GPLed :) + +gtk-icon-sizes = "panel-menu=16,16:panel=22,22" + +style "clearlooks-default" +{ + GtkButton::default_border = { 0, 0, 0, 0 } + GtkButton::default_outside_border = { 0, 0, 0, 0 } + GtkRange::trough_border = 0 + + GtkWidget::focus_padding = 1 + + GtkPaned::handle_size = 6 + + GtkRange::slider_width = 15 + GtkRange::stepper_size = 15 + GtkScrollbar::min_slider_length = 30 + GtkCheckButton::indicator_size = 12 + GtkMenuBar::internal-padding = 0 + + GtkTreeView::expander_size = 14 + GtkTreeView::odd_row_color = "#EBF5FF" + GtkExpander::expander_size = 16 + + xthickness = 1 + ythickness = 1 + + fg[NORMAL] = "#505050" + fg[ACTIVE] = "#505050" + fg[SELECTED] = "#ffffff" + fg[INSENSITIVE] = "#9B9B9B" + + bg[NORMAL] = "#F5F5F5" + bg[ACTIVE] = "#f9f9f9" + bg[PRELIGHT] = "#888888" + bg[SELECTED] = "#095fb2" + bg[INSENSITIVE] = "#888888" + + base[NORMAL] = "#ffffff" + base[ACTIVE] = "#095fb2" + base[PRELIGHT] = "#FFFFFF" + base[INSENSITIVE]= "#ffffff" + base[SELECTED] = "#095fb2" + + text[INSENSITIVE]= "#9B9B9B" + text[SELECTED] = "#ffffff" + text[ACTIVE] = "#ffffff" + + engine "clearlooks" + { + contrast = 1.1 + menubarstyle = 2 # 0 = flat, 1 = sunken, 2 = flat gradient + menuitemstyle = 1 # 0 = flat, 1 = 3d-ish (gradient), 2 = 3d-ish (button) + listviewitemstyle = 1 # 0 = flat, 1 = 3d-ish (gradient) + progressbarstyle = 1 # 0 = candy bar, 1 = flat + } +} + + +style "clearlooks-progressbar" = "clearlooks-default" +{ + fg[PRELIGHT] = "#ffffff" + xthickness = 1 + ythickness = 1 + +} + +style "clearlooks-wide" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 2 +} + +style "clearlooks-button" = "clearlooks-default" +{ + xthickness = 3 + ythickness = 3 +} + +style "clearlooks-notebook" = "clearlooks-wide" +{ + bg[NORMAL] = "#FAFAFA" +} + +style "clearlooks-tasklist" = "clearlooks-default" +{ + xthickness = 5 + ythickness = 3 +} + +style "clearlooks-menu" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 1 +} + +style "clearlooks-menubar" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 2 + base[PRELIGHT] = "#63E62E" + base[SELECTED] = "#4DB224" +} + +style "clearlooks-menu-item" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 3 + fg[PRELIGHT] = "#ffffff" + text[PRELIGHT] = "#ffffff" +} + +style "clearlooks-tree" = "clearlooks-default" +{ + xthickness = 2 + ythickness = 2 +} + +style "clearlooks-frame-title" = "clearlooks-default" +{ + fg[NORMAL] = "#505050" +} + +style "clearlooks-panel" = "clearlooks-default" +{ + xthickness = 3 + ythickness = 3 +} + +style "clearlooks-tooltips" = "clearlooks-default" +{ + xthickness = 4 + ythickness = 4 + bg[NORMAL] = { 1.0,1.0,0.75 } +} + +style "clearlooks-combo" = "clearlooks-default" +{ + xthickness = 1 + ythickness = 2 +} + +style "metacity-frame" +{ + bg[SELECTED] = "#095fb2" + fg[SELECTED] = "#ffffff" +} + +class "GtkWidget" style "clearlooks-default" +class "GtkButton" style "clearlooks-button" +class "GtkCombo" style "clearlooks-button" +class "GtkRange" style "clearlooks-wide" +class "GtkFrame" style "clearlooks-wide" +class "GtkMenu" style "clearlooks-menu" +class "GtkEntry" style "clearlooks-button" +class "GtkMenuItem" style "clearlooks-menu-item" +class "GtkStatusbar" style "clearlooks-wide" +class "GtkNotebook" style "clearlooks-notebook" +class "GtkProgressBar" style "clearlooks-progressbar" +class "*MenuBar*" style "clearlooks-menubar" +class "GtkMenuBar*" style "clearlooks-menubar" +class "MetaFrames" style "metacity-frame" + +widget_class "*MenuItem*" style "clearlooks-menu-item" + +widget_class "*.GtkComboBox.GtkButton" style "clearlooks-combo" +widget_class "*.GtkCombo.GtkButton" style "clearlooks-combo" + +widget_class "*.tooltips.*.GtkToggleButton" style "clearlooks-tasklist" +widget "gtk-tooltips" style "clearlooks-tooltips" + +widget_class "*.GtkTreeView.GtkButton" style "clearlooks-tree" +widget_class "*.GtkCTree.GtkButton" style "clearlooks-tree" +widget_class "*.GtkList.GtkButton" style "clearlooks-tree" +widget_class "*.GtkCList.GtkButton" style "clearlooks-tree" +widget_class "*.GtkFrame.GtkLabel" style "clearlooks-frame-title" + +widget_class "*.GtkNotebook.*.GtkEventBox" style "clearlooks-notebook" +widget_class "*.GtkNotebook.*.GtkViewport" style "clearlooks-notebook" + +widget_class "*MenuBar*" style "clearlooks-menubar" diff --git a/mvc/settings.py b/mvc/settings.py new file mode 100644 index 0000000..4d7255c --- /dev/null +++ b/mvc/settings.py @@ -0,0 +1,88 @@ +import logging +import os +import sys + +from mvc import execute + +ffmpeg_version = None + +_search_path_extra = [] +def add_to_search_path(directory): + """Add a path to the list of paths that which() searches.""" + _search_path_extra.append(directory) + +def which(name): + if sys.platform == 'win32': + name = name + '.exe' # we're looking for ffmpeg.exe in this case + if sys.platform == 'darwin' and 'Contents/Resources' in __file__: + # look for a bundled version + path = os.path.join(os.path.dirname(__file__), + '..', '..', '..', '..', 'Helpers', name) + if os.path.exists(path): + return path + dirs_to_search = os.environ['PATH'].split(os.pathsep) + dirs_to_search += _search_path_extra + for dirname in dirs_to_search: + fullpath = os.path.join(dirname, name) + # XXX check for +x bit + if os.path.exists(fullpath): + return fullpath + logging.warn("Can't find path to %s (searched in %s)", name, + dirs_to_search) + +def memoize(func): + cache = [] + def wrapper(): + if not cache: + cache.append(func()) + return cache[0] + return wrapper + +@memoize +def get_ffmpeg_executable_path(): + return which("ffmpeg") + avconv = which('avconv') + if avconv is not None: + return avconv + return which("ffmpeg") + +def get_ffmpeg_version(): + global ffmpeg_version + if ffmpeg_version is None: + commandline = [get_ffmpeg_executable_path(), '-version'] + p = execute.Popen(commandline, stderr=open(os.devnull, "wb")) + stdout, _ = p.communicate() + lines = stdout.split('\n') + version = lines[0].rsplit(' ', 1)[1].split('.') + def maybe_int(v): + try: + return int(v) + except ValueError: + return v + ffmpeg_version = tuple(maybe_int(v) for v in version) + return ffmpeg_version + +def customize_ffmpeg_parameters(params): + """Takes a list of parameters and modifies it based on + platform-specific issues. Returns the newly modified list of + parameters. + + :param params: list of parameters to modify + + :returns: list of modified parameters that will get passed to + ffmpeg + """ + if get_ffmpeg_version() < (0, 8): + # Fallback for older versions of FFmpeg (Ubuntu Natty, in particular). + # see also #18969 + params = ['-vpre' if i == '-preset' else i for i in params] + try: + profile_index = params.index('-profile:v') + except ValueError: + pass + else: + if params[profile_index + 1] == 'baseline': + params[profile_index:profile_index+2] = [ + '-coder', '0', '-bf', '0', '-refs', '1', + '-flags2', '-wpred-dct8x8'] + return params diff --git a/mvc/settings.pyc b/mvc/settings.pyc Binary files differnew file mode 100644 index 0000000..f97e675 --- /dev/null +++ b/mvc/settings.pyc diff --git a/mvc/signals.py b/mvc/signals.py new file mode 100644 index 0000000..6b09406 --- /dev/null +++ b/mvc/signals.py @@ -0,0 +1,301 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""signals.py + +GObject-like signal handling for Miro. +""" + +import itertools +import logging +import sys +import weakref + +class NestedSignalError(StandardError): + pass + +class WeakMethodReference: + """Used to handle weak references to a method. + + We can't simply keep a weak reference to method itself, because there + almost certainly aren't any other references to it. Instead we keep a + weak reference to the object, it's class and the unbound method. This + gives us enough info to recreate the bound method when we need it. + """ + + def __init__(self, method): + self.object = weakref.ref(method.im_self) + self.func = weakref.ref(method.im_func) + # don't create a weak reference to the class. That only works for + # new-style classes. It's highly unlikely the class will ever need to + # be garbage collected anyways. + self.cls = method.im_class + + def __call__(self): + func = self.func() + if func is None: return None + obj = self.object() + if obj is None: return None + return func.__get__(obj, self.cls) + +class Callback: + def __init__(self, func, extra_args): + self.func = func + self.extra_args = extra_args + + def invoke(self, obj, args): + return self.func(obj, *(args + self.extra_args)) + + def compare_function(self, func): + return self.func == func + + def is_dead(self): + return False + +class WeakCallback: + def __init__(self, method, extra_args): + self.ref = WeakMethodReference(method) + self.extra_args = extra_args + + def compare_function(self, func): + return self.ref() == func + + def invoke(self, obj, args): + callback = self.ref() + if callback is not None: + return callback(obj, *(args + self.extra_args)) + else: + return None + + def is_dead(self): + return self.ref() is None + +class SignalEmitter(object): + def __init__(self, *signal_names): + self.signal_callbacks = {} + self.id_generator = itertools.count() + self._currently_emitting = set() + self._frozen = False + for name in signal_names: + self.create_signal(name) + + def freeze_signals(self): + self._frozen = True + + def thaw_signals(self): + self._frozen = False + + def create_signal(self, name): + self.signal_callbacks[name] = {} + + def get_callbacks(self, signal_name): + try: + return self.signal_callbacks[signal_name] + except KeyError: + raise KeyError("Signal: %s doesn't exist" % signal_name) + + def _check_already_connected(self, name, func): + for callback in self.get_callbacks(name).values(): + if callback.compare_function(func): + raise ValueError("signal %s already connected to %s" % + (name, func)) + + def connect(self, name, func, *extra_args): + """Connect a callback to a signal. Returns an callback handle that + can be passed into disconnect(). + + If func is already connected to the signal, then a ValueError will be + raised. + """ + self._check_already_connected(name, func) + id_ = self.id_generator.next() + callbacks = self.get_callbacks(name) + callbacks[id_] = Callback(func, extra_args) + return (name, id_) + + def connect_weak(self, name, method, *extra_args): + """Connect a callback weakly. Callback must be a method of some + object. We create a weak reference to the method, so that the + connection doesn't keep the object from being garbage collected. + + If method is already connected to the signal, then a ValueError will be + raised. + """ + self._check_already_connected(name, method) + if not hasattr(method, 'im_self'): + raise TypeError("connect_weak must be called with object methods") + id_ = self.id_generator.next() + callbacks = self.get_callbacks(name) + callbacks[id_] = WeakCallback(method, extra_args) + return (name, id_) + + def disconnect(self, callback_handle): + """Disconnect a signal. callback_handle must be the return value from + connect() or connect_weak(). + """ + callbacks = self.get_callbacks(callback_handle[0]) + if callback_handle[1] in callbacks: + del callbacks[callback_handle[1]] + else: + logging.warning( + "disconnect called but callback_handle not in the callback") + + def disconnect_all(self): + for signal in self.signal_callbacks: + self.signal_callbacks[signal] = {} + + def emit(self, name, *args): + if self._frozen: + return + if name in self._currently_emitting: + raise NestedSignalError("Can't emit %s while handling %s" % + (name, name)) + self._currently_emitting.add(name) + try: + callback_returned_true = self._run_signal(name, args) + finally: + self._currently_emitting.discard(name) + self.clear_old_weak_references() + return callback_returned_true + + def _run_signal(self, name, args): + callback_returned_true = False + try: + self_callback = getattr(self, 'do_' + name.replace('-', '_')) + except AttributeError: + pass + else: + if self_callback(*args): + callback_returned_true = True + if not callback_returned_true: + for callback in self.get_callbacks(name).values(): + if callback.invoke(self, args): + callback_returned_true = True + break + return callback_returned_true + + def clear_old_weak_references(self): + for callback_map in self.signal_callbacks.values(): + for id_ in callback_map.keys(): + if callback_map[id_].is_dead(): + del callback_map[id_] + +class SystemSignals(SignalEmitter): + """System wide signals for Miro. These can be accessed from the singleton + object signals.system. Signals include: + + "error" - A problem occurred in Miro. The frontend should let the user + know this happened, hopefully with a nice dialog box or something that + lets the user report the error to bugzilla. + + Arguments: + - report -- string that can be submitted to the bug tracker + - exception -- Exception object (can be None) + + "startup-success" - The startup process is complete. The frontend should + wait for this signal to show the UI to the user. + + No arguments. + + "startup-failure" - The startup process fails. The frontend should inform + the user that this happened and quit. + + Arguments: + - summary -- Short, user-friendly, summary of the problem + - description -- Longer explanation of the problem + + "shutdown" - The backend has shutdown. The event loop is stopped at this + point. + + No arguments. + + "update-available" - A new version of LibreVideoConverter is available. + + Arguments: + - rssItem -- The RSS item for the latest version (in sparkle + appcast format). + + "new-dialog" - The backend wants to display a dialog to the user. + + Arguments: + - dialog -- The dialog to be displayed. + + "theme-first-run" - A theme was used for the first time + + Arguments: + - theme -- The name of the theme. + + "videos-added" -- Videos were added via the singleclick module. + Arguments: + - view -- A database view than contains the videos. + + "download-complete" -- A download was completed. + Arguments: + - item -- an Item of class Item. + + """ + def __init__(self): + SignalEmitter.__init__(self, 'error', 'startup-success', + 'startup-failure', 'shutdown', + 'update-available', 'new-dialog', + 'theme-first-run', 'videos-added', + 'download-complete') + + def shutdown(self): + self.emit('shutdown') + + def update_available(self, latest): + self.emit('update-available', latest) + + def new_dialog(self, dialog): + self.emit('new-dialog', dialog) + + def theme_first_run(self, theme): + self.emit('theme-first-run', theme) + + def videos_added(self, view): + self.emit('videos-added', view) + + def download_complete(self, item): + self.emit('download-complete', item) + + def failed_exn(self, when, details=None): + self.failed(when, with_exception=True, details=details) + + def failed(self, when, with_exception=False, details=None): + """Used to emit the error signal. Formats a nice crash report.""" + if with_exception: + exc_info = sys.exc_info() + else: + exc_info = None + logging.error('%s: %s' % (when, details), exc_info=exc_info) + + + +system = SystemSignals() diff --git a/mvc/signals.pyc b/mvc/signals.pyc Binary files differnew file mode 100644 index 0000000..3370676 --- /dev/null +++ b/mvc/signals.pyc diff --git a/mvc/ui/__init__.py b/mvc/ui/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/mvc/ui/__init__.py diff --git a/mvc/ui/__init__.pyc b/mvc/ui/__init__.pyc Binary files differnew file mode 100644 index 0000000..329825b --- /dev/null +++ b/mvc/ui/__init__.pyc diff --git a/mvc/ui/console.py b/mvc/ui/console.py new file mode 100644 index 0000000..a9751ac --- /dev/null +++ b/mvc/ui/console.py @@ -0,0 +1,120 @@ +import json +import operator +import optparse +import time +import sys + +import mvc +from mvc.widgets import app +from mvc.widgets import initialize + +parser = optparse.OptionParser( + usage='%prog [-l] [--list-converters] [-c <converter> <filenames..>]', + version='%prog ' + mvc.VERSION, + prog='python -m mvc.ui.console') +parser.add_option('-j', '--json', action='store_true', + dest='json', + help='Output JSON documents, rather than text.') +parser.add_option('-l', '--list-converters', action='store_true', + dest='list_converters', + help="Print a list of supported converter types.") +parser.add_option('-c', '--converter', dest='converter', + help="Specify the type of conversion to make.") + +class Application(mvc.Application): + + def run(self): + (options, args) = parser.parse_args() + + if options.list_converters: + for c in sorted(self.converter_manager.list_converters(), + key=operator.attrgetter('name')): + if options.json: + print json.dumps({'name': c.name, + 'identifier': c.identifier}) + else: + print '%s (-c %s)' % ( + c.name, + c.identifier) + return + + try: + self.converter_manager.get_by_id(options.converter) + except KeyError: + message = '%r is not a valid converter type.' % ( + options.converter,) + if options.json: + print json.dumps({'error': message}) + else: + print 'ERROR:', message + print 'Use "%s -l" to get a list of valid converters.' % ( + parser.prog,) + print + parser.print_help() + sys.exit(1) + + any_failed = False + + def changed(c): + if c.status == 'failed': + any_failed = True + if options.json: + output = { + 'filename': c.video.filename, + 'output': c.output, + 'status': c.status, + 'duration': c.duration, + 'progress': c.progress, + 'percent': (c.progress_percent * 100 if c.progress_percent + else 0), + } + if c.error is not None: + output['error'] = c.error + print json.dumps(output) + else: + if c.status == 'initialized': + line = 'starting (output: %s)' % (c.output,) + elif c.status == 'converting': + if c.progress_percent is not None: + line = 'converting (%i%% complete, %is remaining)' % ( + c.progress_percent * 100, c.eta) + else: + line = 'converting (0% complete, unknown remaining)' + elif c.status == 'staging': + line = 'staging' + elif c.status == 'failed': + line = 'failed (error: %r)' % (c.error,) + elif c.status == 'finished': + line = 'finished (output: %s)' % (c.output,) + else: + line = c.status + print '%s: %s' % (c.video.filename, line) + + for filename in args: + try: + c = app.start_conversion(filename, options.converter) + except ValueError: + message = 'could not parse %r' % filename + if options.json: + any_failed = True + print json.dumps({'status': 'failed', 'error': message, + 'filename': filename}) + else: + print 'ERROR:', message + continue + changed(c) + c.listen(changed) + + # XXX real mainloop + while self.conversion_manager.running: + self.conversion_manager.check_notifications() + time.sleep(1) + self.conversion_manager.check_notifications() # one last time + + sys.exit(0 if not any_failed else 1) + +if __name__ == "__main__": + initialize(None) + app.widgetapp = Application() + app.widgetapp.startup() + app.widgetapp.run() diff --git a/mvc/ui/widgets.py b/mvc/ui/widgets.py new file mode 100644 index 0000000..67dffc8 --- /dev/null +++ b/mvc/ui/widgets.py @@ -0,0 +1,1540 @@ +import logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +import os +import sys + +try: + import mvc +except ImportError: + mvc_path = os.path.join(os.path.dirname(__file__), '..', '..') + sys.path.append(mvc_path) + import mvc + +import copy +import tempfile +import urllib +import urlparse + +from mvc.widgets import (initialize, idle_add, mainloop_start, mainloop_stop, + attach_menubar, reveal_file, get_conversion_directory) +from mvc.widgets import menus +from mvc.widgets import widgetset +from mvc.widgets import cellpack +from mvc.widgets import widgetconst +from mvc.widgets import widgetutil +from mvc.widgets import app + +from mvc.converter import ConverterInfo +from mvc.video import VideoFile +from mvc.resources import image_path +from mvc.utils import size_string, round_even, convert_path_for_subprocess +from mvc import openfiles + +BUTTON_FONT = widgetutil.font_scale_from_osx_points(15.0) +LARGE_FONT = widgetutil.font_scale_from_osx_points(13.0) +SMALL_FONT = widgetutil.font_scale_from_osx_points(10.0) + +DEFAULT_FONT="Helvetica" + +CONVERT_TO_FONT = "Gill Sans Light" +CONVERT_TO_FONTSIZE = widgetutil.font_scale_from_osx_points(14.0) + +SETTINGS_FONT = "Gill Sans Light" +SETTINGS_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) + +CONVERT_NOW_FONT = "Gill Sans Light" +CONVERT_NOW_FONTSIZE = widgetutil.font_scale_from_osx_points(18.0) + +DND_FONT = "Gill Sans Light" +DND_LARGE_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) +DND_SMALL_FONTSIZE = widgetutil.font_scale_from_osx_points(12.0) + +ITEM_TITLE_FONT = "Futura Medium" +ITEM_TITLE_FONTSIZE = widgetutil.font_scale_from_osx_points(13.0) + +ITEM_ICONS_FONT= "Century Gothic" +ITEM_ICONS_FONTSIZE= widgetutil.font_scale_from_osx_points(10.0) + +GRADIENT_TOP = widgetutil.css_to_color('#585f63') +GRADIENT_BOTTOM = widgetutil.css_to_color('#383d40') + +DRAG_AREA = widgetutil.css_to_color('#2b2e31') + +TEXT_DISABLED = widgetutil.css_to_color('#333333') +TEXT_ACTIVE = widgetutil.css_to_color('#ffffff') +TEXT_CLICKED = widgetutil.css_to_color('#cccccc') +TEXT_INFO = widgetutil.css_to_color('#808080') +TEXT_COLOR = widgetutil.css_to_color('#ffffff') +TEXT_SHADOW = widgetutil.css_to_color('#000000') + +TABLE_WIDTH, TABLE_HEIGHT = 470, 87 + +class CustomLabel(widgetset.Background): + def __init__(self, text=''): + widgetset.Background.__init__(self) + self.text = text + self.font = DEFAULT_FONT + self.font_scale = LARGE_FONT + self.color = TEXT_COLOR + + def set_text(self, text): + self.text = text + self.invalidate_size_request() + + def set_color(self, color): + self.color = color + self.queue_redraw() + + def set_font(self, font, font_scale): + self.font = font + self.font_scale = font_scale + self.invalidate_size_request() + + def textbox(self, layout_manager): + layout_manager.set_text_color(self.color) + layout_manager.set_font(self.font_scale, family=self.font) + font = layout_manager.set_font(self.font_scale, family=self.font) + return layout_manager.textbox(self.text) + + def draw(self, context, layout_manager): + layout_manager.set_text_color(self.color) + layout_manager.set_font(LARGE_FONT, family=self.font) + textbox = self.textbox(layout_manager) + size = textbox.get_size() + textbox.draw(context, 0, (context.height - size[1]) // 2, + context.width, context.height) + + def size_request(self, layout_manager): + return self.textbox(layout_manager).get_size() + +class WebStyleButton(widgetset.CustomButton): + def __init__(self): + super(WebStyleButton, self).__init__() + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.text = '' + self.font = DEFAULT_FONT + self.font_scale = LARGE_FONT + + def set_text(self, text): + self.text = text + self.invalidate_size_request() + + def set_font(self, font, font_scale): + self.font = font + self.font_scale = font_scale + self.invalidate_size_request() + + def textbox(self, layout_manager): + return layout_manager.textbox(self.text, underline=True) + + def size_request(self, layout_manager): + textbox = self.textbox(layout_manager) + return textbox.get_size() + + def draw(self, context, layout_manager): + layout_manager.set_text_color(TEXT_COLOR) + layout_manager.set_font(self.font_scale, family=self.font) + textbox = self.textbox(layout_manager) + size = textbox.get_size() + textbox.draw(context, 0, (context.height - size[1]) // 2, + context.width, context.height) + +class FileDropTarget(widgetset.SolidBackground): + + dropoff_on = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-on.png"))) + dropoff_off = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-off.png"))) + dropoff_small_on = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-small-on.png"))) + dropoff_small_off = widgetset.ImageDisplay(widgetset.Image( + image_path("dropoff-icon-small-off.png"))) + + def __init__(self): + super(FileDropTarget, self).__init__() + self.set_background_color(DRAG_AREA) + self.alignment = widgetset.Alignment( + xscale=0.0, yscale=0.5, + xalign=0.5, yalign=0.5, + top_pad=10, right_pad=40, + bottom_pad=10, left_pad=40) + self.add(self.alignment) + + self.widgets = { + False: self.build_large_widgets(), + True: self.build_small_widgets() + } + + self.normal, self.drag = self.widgets[False] + self.alignment.add(self.normal) + + self.in_drag = False + self.small = False + + def build_large_widgets(self): + height = 40 # arbitrary, but the same for both + normal = widgetset.VBox(spacing=20) + normal.pack_start(widgetutil.align_center(self.dropoff_off, + top_pad=60)) + label = CustomLabel("Drag videos here or") + label.set_color(TEXT_COLOR) + label.set_font(DND_FONT, DND_LARGE_FONTSIZE) + hbox = widgetset.HBox(spacing=4) + hbox.pack_start(widgetutil.align_middle(label)) + + cfb = WebStyleButton() + cfb.set_font(DND_FONT, DND_LARGE_FONTSIZE) + cfb.set_text('Choose Files...') + + cfb.connect('clicked', self.choose_file) + hbox.pack_start(widgetutil.align_middle(cfb)) + hbox.set_size_request(-1, height) + normal.pack_start(hbox) + + drag = widgetset.VBox(spacing=20) + drag.pack_start(widgetutil.align_center(self.dropoff_on, + top_pad=60)) + hbox = widgetset.HBox(spacing=4) + hbox.pack_start(widgetutil.align_center( + widgetset.Label("Release button to drop off", + color=TEXT_COLOR))) + hbox.set_size_request(-1, height) + drag.pack_start(hbox) + return normal, drag + + def build_small_widgets(self): + height = 40 # arbitrary, but the same for both + normal = widgetset.HBox(spacing=4) + normal.pack_start(widgetutil.align_middle(self.dropoff_small_off, + right_pad=7)) + drag_label = CustomLabel('Drag more videos here or') + drag_label.set_font(DND_FONT, DND_SMALL_FONTSIZE) + drag_label.set_color(TEXT_COLOR) + normal.pack_start(widgetutil.align_middle(drag_label)) + cfb = WebStyleButton() + cfb.set_text('Choose Files...') + cfb.set_font(DND_FONT, DND_SMALL_FONTSIZE) + cfb.connect('clicked', self.choose_file) + normal.pack_start(cfb) + normal.set_size_request(-1, height) + + drop_label = CustomLabel('Release button to drop off') + drop_label.set_font(DND_FONT, DND_SMALL_FONTSIZE) + drop_label.set_color(TEXT_COLOR) + drag = widgetset.HBox(spacing=10) + drag.pack_start(widgetutil.align_middle(self.dropoff_small_on)) + drag.pack_start(widgetutil.align_middle(drop_label)) + drag.set_size_request(-1, height) + + return normal, drag + + def set_small(self, small): + if small != self.small: + self.small = small + self.normal, self.drag = self.widgets[small] + self.set_in_drag(self.in_drag, force=True) + + def set_in_drag(self, in_drag, force=False): + if force or in_drag != self.in_drag: + self.in_drag = in_drag + if in_drag: + self.alignment.set_child(self.drag) + else: + self.alignment.set_child(self.normal) + self.queue_redraw() + + def choose_file(self, widget): + app.widgetapp.choose_file() + +BUTTON_BACKGROUND = widgetutil.ThreeImageSurface('settings-base') + +class SettingsButton(widgetset.CustomButton): + + arrow_on = widgetset.ImageSurface(widgetset.Image( + image_path('arrow-down-on.png'))) + arrow_off = widgetset.ImageSurface(widgetset.Image( + image_path('arrow-down-off.png'))) + + def __init__(self, name): + super(SettingsButton, self).__init__() + if name != 'settings': + self.name = name.title() + else: + self.name = None + self.selected = False + if name != 'format': + self.surface_on = widgetset.ImageSurface(widgetset.Image( + image_path('%s-icon-on.png' % name))) + self.surface_off = widgetset.ImageSurface(widgetset.Image( + image_path('%s-icon-off.png' % name))) + if self.surface_on.height != self.surface_off.height: + raise ValueError('invalid surface: height mismatch') + self.image_padding = self.calc_image_padding(name) + else: + self.surface_on = self.surface_off = None + + def calc_image_padding(self, name): + """Add some padding to the bottom of our image icon. This can be used + to fine tune where it gets placed. + + :returns: padding in as a (top, right, bottom, left) tuple + """ + + # NOTE: we vertically center the images, so in order to move it X + # pickels up, we need X*2 pixels of bottom padding + if name == 'android': + return (0, 0, 2, 0) + elif name in ('apple', 'other'): + return (0, 0, 4, 0) + else: + return (0, 0, 0, 0) + + def textbox(self, layout_manager): + layout_manager.set_font(SETTINGS_FONTSIZE, family=SETTINGS_FONT) + return layout_manager.textbox(self.name) + + def size_request(self, layout_manager): + hbox = self.build_hbox(layout_manager) + size = hbox.get_size() + height = max(BUTTON_BACKGROUND.height, size[1]) + return int(size[0]) + 2, int(height) + 2 # padding + + def build_hbox(self, layout_manager): + hbox = cellpack.HBox(spacing=5) + if self.selected: + image = self.surface_on + arrow = self.arrow_on + layout_manager.set_text_color(TEXT_ACTIVE) + else: + image = self.surface_off + arrow = self.arrow_off + layout_manager.set_text_color(TEXT_DISABLED) + if image: + padding = cellpack.Padding(image, *self.image_padding) + hbox.pack(cellpack.Alignment(padding, xscale=0, yscale=0, + yalign=0.5)) + if self.name: + vbox = cellpack.VBox() + textbox = self.textbox(layout_manager) + vbox.pack(textbox) + vbox.pack_space(1) + hbox.pack(cellpack.Alignment(vbox, yscale=0, yalign=0.5), + expand=True) + a = cellpack.Alignment(arrow, xscale=0, yscale=0, yalign=0.5) + hbox.pack(cellpack.Padding(a, left=5, right=12)) + alignment = cellpack.Padding(hbox, left=5) + return alignment + + def draw(self, context, layout_manager): + BUTTON_BACKGROUND.draw(context, 1, 1, context.width - 2) + alignment = self.build_hbox(layout_manager) + padding = cellpack.Padding(alignment, top=1, right=3, bottom=1, left=3) + padding.render_layout(context) + + def set_selected(self, selected): + self.selected = selected + self.queue_redraw() + + +class OptionMenuBackground(widgetset.Background): + def __init__(self): + widgetset.Background.__init__(self) + self.surface = widgetutil.ThreeImageSurface('settings-depth') + + def set_child(self, child): + widgetset.Background.set_child(self, child) + # re-create the image surface and scale it as it needs to cover + # the whole of the height of the child + _, h = child.get_size_request() + self.surface = widgetutil.ThreeImageSurface('settings-depth', height=h) + self.invalidate_size_request() + + def size_request(self, layout_manager): + return -1, self.surface.height + + def draw(self, context, layout_manager): + child_width = self.child.get_size_request()[0] + self.surface.draw(context, 0, 0, child_width) + + +class BottomBackground(widgetset.Background): + + def draw(self, context, layout_manager): + gradient = widgetset.Gradient(0, 0, 0, context.height) + gradient.set_start_color(GRADIENT_TOP) + gradient.set_end_color(GRADIENT_BOTTOM) + context.rectangle(0, 0, context.width, context.height) + context.gradient_fill(gradient) + + +class LabeledNumberEntry(widgetset.HBox): + + def __init__(self, label): + super(LabeledNumberEntry, self).__init__(spacing=5) + self.label = widgetset.Label(label, color=TEXT_COLOR) + self.label.set_size(widgetconst.SIZE_SMALL) + self.entry = widgetset.NumberEntry() + self.entry.set_size_request(50, 20) + self.pack_start(self.label) + self.pack_start(self.entry) + self.entry.connect('focus-out', lambda x: self.emit('focus-out')) + + def get_text(self): + return self.entry.get_text() + + def set_text(self, text): + self.entry.set_text(text) + + def get_value(self): + try: + return int(self.entry.get_text()) + except ValueError: + return None + + +class CustomOptions(widgetset.Background): + + background = widgetset.ImageSurface(widgetset.Image( + image_path('settings-dropdown-bottom-bg.png'))) + + def __init__(self): + super(CustomOptions, self).__init__() + self.create_signal('setting-changed') + self.reset() + + def reset(self): + self.options = { + 'destination': None, + 'custom-size': False, + 'width': None, + 'height': None, + 'custom-aspect': False, + 'aspect-ratio': 4.0/3.0, + 'dont-upsize': True + } + + self.top = self.create_top() + self.top.set_size_request(390, 50) + self.left = self.create_left() + self.left.set_size_request(212, 70) + self.right = self.create_right() + self.right.set_size_request(178, 70) + vbox = widgetset.VBox() + vbox.pack_start(self.top) + hbox = widgetset.HBox() + hbox.pack_start(self.left) + hbox.pack_start(self.right) + vbox.pack_start(hbox) + + self.box = widgetutil.align_left(vbox) + + if self.child: + self.set_child(self.box) + + def create_top(self): + hbox = widgetset.HBox(spacing=0) + path_label = WebStyleButton() + path_label.set_text('Show output folder') + path_label.set_font(DEFAULT_FONT, widgetconst.SIZE_SMALL) + path_label.connect('clicked', self.on_path_label_clicked) + create_thumbnails = widgetset.Checkbox('Create Thumbnails', + color=TEXT_COLOR) + create_thumbnails.set_size(widgetconst.SIZE_SMALL) + create_thumbnails.connect('toggled', + self.on_create_thumbnails_changed) + + hbox.pack_start(widgetutil.align(path_label, xalign=0.5), expand=True) + hbox.pack_start(widgetutil.align(create_thumbnails, xalign=0.5), + expand=True) + # XXX: disabled until we can figure out how to do this properly. + #button = widgetset.Button('...') + #button.connect('clicked', self.on_destination_clicked) + #reset = widgetset.Button('Reset') + #reset.connect('clicked', self.on_destination_reset) + #hbox.pack_start(button) + #hbox.pack_start(reset) + return widgetutil.align(hbox, xscale=1.0, yalign=0.5) + + def _get_save_to_path(self): + if self.options['destination'] is None: + return get_conversion_directory() + else: + return self.options['destination'] + + def on_path_label_clicked(self, label): + save_path = self._get_save_to_path() + save_path = convert_path_for_subprocess(save_path) + openfiles.reveal_folder(save_path) + + def create_left(self): + self.custom_size = widgetset.Checkbox('Custom Size', color=TEXT_COLOR) + self.custom_size.set_size(widgetconst.SIZE_SMALL) + self.custom_size.connect('toggled', self.on_custom_size_changed) + + dont_upsize = widgetset.Checkbox('Don\'t Upsize', color=TEXT_COLOR) + dont_upsize.set_checked(self.options['dont-upsize']) + dont_upsize.set_size(widgetconst.SIZE_SMALL) + dont_upsize.connect('toggled', self.on_dont_upsize_changed) + + bottom = widgetset.HBox(spacing=5) + self.width_widget = LabeledNumberEntry('Width') + self.width_widget.connect('focus-out', self.on_width_changed) + self.width_widget.entry.connect('activate', + self.on_width_changed) + self.width_widget.disable() + self.height_widget = LabeledNumberEntry('Height') + self.height_widget.connect('focus-out', self.on_height_changed) + self.height_widget.entry.connect('activate', + self.on_height_changed) + self.height_widget.disable() + bottom.pack_start(self.width_widget) + bottom.pack_start(self.height_widget) + + hbox = widgetset.HBox(spacing=5) + hbox.pack_start(self.custom_size) + hbox.pack_start(dont_upsize) + + vbox = widgetset.VBox(spacing=5) + vbox.pack_start(widgetutil.align_left(hbox, left_pad=10)) + vbox.pack_start(widgetutil.align_center(bottom)) + return widgetutil.align_middle(vbox) + + def create_right(self): + aspect = widgetset.Checkbox('Custom Aspect Ratio', color=TEXT_COLOR) + aspect.set_size(widgetconst.SIZE_SMALL) + aspect.connect('toggled', self.on_aspect_changed) + self.aspect_widget = aspect + self.button_group = widgetset.RadioButtonGroup() + b1 = widgetset.RadioButton('4:3', self.button_group, color=TEXT_COLOR) + b2 = widgetset.RadioButton('3:2', self.button_group, color=TEXT_COLOR) + b3 = widgetset.RadioButton('16:9', self.button_group, color=TEXT_COLOR) + b1.set_selected() + b1.set_size(widgetconst.SIZE_SMALL) + b2.set_size(widgetconst.SIZE_SMALL) + b3.set_size(widgetconst.SIZE_SMALL) + self.aspect_map = dict() + self.aspect_map[b1] = (4, 3) + self.aspect_map[b2] = (3, 2) + self.aspect_map[b3] = (16, 9) + hbox = widgetset.HBox(spacing=5) + # Because the custom size starts off as disabled, so should aspect + # ratio as aspect ratio is dependent on a custom size set. + self.aspect_widget.disable() + for button in self.button_group.get_buttons(): + button.disable() + button.set_size(widgetconst.SIZE_SMALL) + hbox.pack_start(button) + button.connect('clicked', self.on_aspect_size_changed) + + vbox = widgetset.VBox() + vbox.pack_start(widgetutil.align_center(aspect)) + vbox.pack_start(widgetutil.align_center(hbox)) + return widgetutil.align_middle(vbox) + + def draw(self, context, layout_manager): + self.background.draw(context, 0, 0, self.background.width, + self.background.height) + + def enable_custom_size(self): + self.custom_size.enable() + + def disable_custom_size(self): + self.custom_size.disable() + self.custom_size.set_checked(False) + + def update_setting(self, setting, value): + self.options[setting] = value + if setting in ('width', 'height'): + if value is not None: + widget_text = str(value) + else: + widget_text = '' + if setting == 'width': + self.width_widget.set_text(widget_text) + elif setting == 'height': + self.height_widget.set_text(widget_text) + + def do_setting_changed(self, setting, value): + logging.info('setting-changed: %s -> %s', setting, value) + + def _change_setting(self, setting, value): + """Handles setting changes in response to widget changes.""" + + self.options[setting] = value + self.emit('setting-changed', setting, value) + + def force_width_to_aspect_ratio(self): + aspect_ratio = self.options['aspect-ratio'] + width = self.width_widget.get_text() + height = self.height_widget.get_text() + if not height: + return + new_width = round_even(float(height) * aspect_ratio) + if new_width != width: + self.update_setting('width', new_width) + self.emit('setting-changed', 'width', new_width) + + def force_height_to_aspect_ratio(self): + aspect_ratio = self.options['aspect-ratio'] + width = self.width_widget.get_text() + height = self.height_widget.get_text() + if not width: + return + new_height = round_even(float(width) / aspect_ratio) + if new_height != height: + self.update_setting('height', new_height) + self.emit('setting-changed', 'height', new_height) + + def show(self): + self.set_child(self.box) + self.set_size_request(self.background.width, + self.background.height + 28) + self.queue_redraw() + + def hide(self): + self.remove() + self.set_size_request(0, 0) + self.queue_redraw() + + def toggle(self): + if self.child: + self.hide() + else: + self.show() + + # signal handlers + def on_destination_clicked(self, widget): + dialog = widgetset.DirectorySelectDialog('Destination Directory') + r = dialog.run() + if r == 0: # picked a directory + self._change_setting('destination', directory) + + def on_destination_reset(self, widget): + self._change_setting('destination', None) + + def on_dont_upsize_changed(self, widget): + self._change_setting('dont-upsize', widget.get_checked()) + + def on_custom_size_changed(self, widget): + self._change_setting('custom-size', widget.get_checked()) + if widget.get_checked(): + self.width_widget.enable() + self.height_widget.enable() + self.aspect_widget.enable() + self.on_aspect_changed(self.aspect_widget) + else: + self.width_widget.disable() + self.height_widget.disable() + self.aspect_widget.disable() + self.on_aspect_changed(self.aspect_widget) + for button in self.button_group.get_buttons(): + button.disable() + + def on_create_thumbnails_changed(self, widget): + self._change_setting('create-thumbnails', widget.get_checked()) + + def on_width_changed(self, widget): + self._change_setting('width', self.width_widget.get_value()) + if self.options['custom-aspect']: + self.force_height_to_aspect_ratio() + + def on_height_changed(self, widget): + self._change_setting('height', self.height_widget.get_value()) + if self.options['custom-aspect']: + self.force_width_to_aspect_ratio() + + def on_aspect_changed(self, widget): + self._change_setting('custom-aspect', widget.get_checked()) + if widget.get_checked(): + self.force_height_to_aspect_ratio() + for button in self.button_group.get_buttons(): + button.enable() + else: + for button in self.button_group.get_buttons(): + button.disable() + + def on_aspect_size_changed(self, widget): + if self.options['custom-aspect']: + width_ratio, height_ratio = [float(v) for v in + self.aspect_map[widget]] + ratio = width_ratio / height_ratio + self._change_setting('aspect-ratio', ratio) + self.force_height_to_aspect_ratio() + +EMPTY_CONVERTER = ConverterInfo("") + + +class ConversionModel(widgetset.TableModel): + def __init__(self): + super(ConversionModel, self).__init__( + 'text', # filename + 'numeric', # output_size + 'text', # converter + 'text', # status + 'numeric', # duration + 'numeric', # progress + 'numeric', # eta, + 'object', # image + 'object', # the actual conversion + ) + self.conversion_to_iter = {} + self.thumbnail_to_image = {None: widgetset.Image( + image_path('audio.png'))} + + def conversions(self): + return iter(self.conversion_to_iter) + + def all_conversions_done(self): + has_conversions = any(self.conversions()) + all_done = ((set(c.status for c in self.conversions()) - + set(['canceled', 'finished', 'failed'])) == set()) + return all_done and has_conversions + + def get_image(self, path): + if path not in self.thumbnail_to_image: + try: + image = widgetset.Image(path) + except ValueError: + image = self.thumbnail_to_image[None] + self.thumbnail_to_image[path] = image + return self.thumbnail_to_image[path] + + def update_conversion(self, conversion): + try: + output_size = os.stat(conversion.output).st_size + except OSError: + output_size = 0 + + def complete(): + # needs to do it on the update_conversion() from app object + # which calls model_changed() and redraws for us + app.widgetapp.update_conversion(conversion) + + values = (conversion.video.filename, + output_size, + conversion.converter.name, + conversion.status, + conversion.duration or 0, + conversion.progress or 0, + conversion.eta or 0, + self.get_image(conversion.video.get_thumbnail(complete, 90, 70)), + conversion + ) + iter_ = self.conversion_to_iter.get(conversion) + if iter_ is None: + self.conversion_to_iter[conversion] = self.append(*values) + else: + self.update(iter_, *values) + + def remove(self, iter_): + conversion = self[iter_][-1] + del self.conversion_to_iter[conversion] + + # XXX If we add/remove too quickly, we could still be processing + # thumbnails and this may return null, and the self.thumbnail_to_image + # dictionary may get out of sync + def complete(path): + logging.info('calling completion handler for get_thumbnail on ' + 'removal') + + thumbnail_path = conversion.video.get_thumbnail(complete, 90, 70) + if thumbnail_path: + del self.thumbnail_to_image[thumbnail_path] + return super(ConversionModel, self).remove(iter_) + + +class IconWithText(cellpack.HBox): + + def __init__(self, icon, textbox): + super(IconWithText, self).__init__(spacing=5) + self.pack(cellpack.Alignment(icon, yalign=0.5, xscale=0, yscale=0)) + self.pack(textbox) + + +class ConversionCellRenderer(widgetset.CustomCellRenderer): + + IGNORE_PADDING = True + + clear = widgetset.ImageSurface(widgetset.Image( + image_path("clear-icon.png"))) + converted_to = widgetset.ImageSurface(widgetset.Image( + image_path("converted_to-icon.png"))) + queued = widgetset.ImageSurface(widgetset.Image( + image_path("queued-icon.png"))) + showfile = widgetset.ImageSurface(widgetset.Image( + image_path("showfile-icon.png"))) + show_ffmpeg = widgetset.ImageSurface(widgetset.Image( + image_path("error-icon.png"))) + progressbar_base = widgetset.ImageSurface(widgetset.Image( + image_path("progressbar-base.png"))) + delete_on = widgetset.ImageSurface(widgetset.Image( + image_path("item-delete-button-on.png"))) + delete_off = widgetset.ImageSurface(widgetset.Image( + image_path("item-delete-button-off.png"))) + error = widgetset.ImageSurface(widgetset.Image( + image_path("item-error.png"))) + completed = widgetset.ImageSurface(widgetset.Image( + image_path("item-completed.png"))) + + def __init__(self): + super(ConversionCellRenderer, self).__init__() + self.alignment = None + + def get_size(self, style, layout_manager): + return TABLE_WIDTH, TABLE_HEIGHT + + def render(self, context, layout_manager, selected, hotspot, hover): + left_right = cellpack.HBox() + top_bottom = cellpack.VBox() + left_right.pack(self.layout_left(layout_manager)) + left_right.pack(top_bottom, expand=True) + layout_manager.set_text_color(TEXT_COLOR) + layout_manager.set_font(ITEM_TITLE_FONTSIZE, bold=True, + family=ITEM_TITLE_FONT) + title = layout_manager.textbox(os.path.basename(self.input)) + title.set_wrap_style('truncated-char') + alignment = cellpack.Padding(cellpack.TruncatedTextLine(title), + top=25) + top_bottom.pack(alignment) + layout_manager.set_font(ITEM_ICONS_FONTSIZE, family=ITEM_ICONS_FONT) + + bottom = self.layout_bottom(layout_manager, hotspot) + if bottom is not None: + top_bottom.pack(bottom) + left_right.pack(self.layout_right(layout_manager, hotspot)) + + alignment = cellpack.Alignment(left_right, yscale=0, yalign=0.5) + self.alignment = alignment + + background = cellpack.Background(alignment) + background.set_callback(self.draw_background) + background.render_layout(context) + + @staticmethod + def draw_background(context, x, y, width, height): + # draw main background + gradient = widgetset.Gradient(x, y, x, height) + gradient.set_start_color(GRADIENT_TOP) + gradient.set_end_color(GRADIENT_BOTTOM) + context.rectangle(x, y, width, height) + context.gradient_fill(gradient) + # draw bottom line + context.set_line_width(1) + context.set_color((0, 0, 0)) + context.move_to(0, height-0.5) + context.line_to(context.width, height-0.5) + context.stroke() + + def draw_progressbar(self, context, x, y, _, height, width): + # We're only drawing a certain amount of width, not however much we're + # allocated. So, we ignore the passed-in width and just use what we + # set in layout_bottom. + widgetutil.circular_rect(context, x, y, width-1, height-1) + context.set_color((1, 1, 1)) + context.fill() + + def layout_left(self, layout_manager): + surface = widgetset.ImageSurface(self.thumbnail) + return cellpack.Padding(surface, 10, 10, 10, 10) + + def layout_right(self, layout_manager, hotspot): + alignment_kwargs = dict( + xalign=0.5, + xscale=0, + yalign=0.5, + yscale=0, + min_width=80) + if self.status == 'finished': + return cellpack.Alignment(self.completed, **alignment_kwargs) + elif self.status in ('canceled', 'failed'): + return cellpack.Alignment(self.error, **alignment_kwargs) + else: + if hotspot == 'cancel': + image = self.delete_on + else: + image = self.delete_off + return cellpack.Alignment(cellpack.Hotspot('cancel', + image), + **alignment_kwargs) + + def layout_bottom(self, layout_manager, hotspot): + layout_manager.set_text_color(TEXT_COLOR) + if self.status in ('converting', 'staging'): + box = cellpack.HBox(spacing=5) + stack = cellpack.Stack() + stack.pack(cellpack.Alignment(self.progressbar_base, + yalign=0.5, + xscale=0, yscale=0)) + percent = self.progress / self.duration + width = max(int(percent * self.progressbar_base.width), + 5) + stack.pack(cellpack.DrawingArea( + width, self.progressbar_base.height, + self.draw_progressbar, width)) + box.pack(cellpack.Alignment(stack, + yalign=0.5, + xscale=0, yscale=0)) + textbox = layout_manager.textbox("%d%%" % ( + 100 * percent)) + box.pack(textbox) + return box + elif self.status == 'initialized': # queued + vbox = cellpack.VBox() + vbox.pack_space(2) + vbox.pack(IconWithText(self.queued, + layout_manager.textbox("Queued"))) + return vbox + elif self.status in ('finished', 'failed', 'canceled'): + vbox = cellpack.VBox(spacing=5) + vbox.pack_space(4) + top = cellpack.HBox(spacing=5) + if self.status == 'finished': + if hotspot == 'show-file': + layout_manager.set_text_color(TEXT_CLICKED) + top.pack(cellpack.Hotspot('show-file', IconWithText( + self.showfile, + layout_manager.textbox('Show File', + underline=True)))) + elif self.status in ('failed', 'canceled'): + color = TEXT_CLICKED if hotspot == 'show-log' else TEXT_COLOR + layout_manager.set_text_color(color) + # XXX Missing grey error icon + if self.status == 'failed': + text = 'Error - Show FFmpeg Output' + else: + text = 'Canceled - Show FFmpeg Output' + top.pack(cellpack.Hotspot('show-log', IconWithText( + self.show_ffmpeg, + layout_manager.textbox(text, underline=True)))) + color = TEXT_CLICKED if hotspot == 'clear' else TEXT_COLOR + layout_manager.set_text_color(color) + top.pack(cellpack.Hotspot('clear', IconWithText( + self.showfile, + layout_manager.textbox('Clear', underline=True)))) + vbox.pack(top) + if self.status == 'finished': + layout_manager.set_text_color(TEXT_INFO) + vbox.pack(IconWithText( + self.converted_to, + layout_manager.textbox("Converted to %s" % ( + size_string(self.output_size))))) + return vbox + + def hotspot_test(self, style, layout_manager, x, y, width, height): + if self.alignment is None: + return + hotspot_info = self.alignment.find_hotspot(x, y, width, height) + if hotspot_info: + return hotspot_info[0] + +class ConvertButton(widgetset.CustomButton): + off = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-off.png"))) + clear = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-off.png"))) + on = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-on.png"))) + stop = widgetset.ImageSurface(widgetset.Image( + image_path("convert-button-stop.png"))) + + def __init__(self): + super(ConvertButton, self).__init__() + self.hidden = False + self.set_off() + + def set_on(self): + self.label = 'Convert to %s' % app.widgetapp.current_converter.name + self.image = self.on + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.queue_redraw() + + def set_clear(self): + self.label = 'Clear and Start Over' + self.image = self.clear + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.queue_redraw() + + def set_off(self): + self.label = 'Convert Now' + self.image = self.off + self.set_cursor(widgetconst.CURSOR_NORMAL) + self.queue_redraw() + + def set_stop(self): + self.label = 'Stop All Conversions' + self.image = self.stop + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + self.queue_redraw() + + def hide(self): + self.hidden = True + self.invalidate_size_request() + self.queue_redraw() + + def show(self): + self.hidden = False + self.invalidate_size_request() + self.queue_redraw() + + def size_request(self, layout_manager): + if self.hidden: + return 0, 0 + return self.off.width, self.off.height + + def draw(self, context, layout_manager): + if self.hidden: + return + self.image.draw(context, 0, 0, self.image.width, self.image.height) + layout_manager.set_font(CONVERT_NOW_FONTSIZE, family=CONVERT_NOW_FONT) + if self.image == self.off: + layout_manager.set_text_shadow(widgetutil.Shadow(TEXT_SHADOW, + 0.5, (-1, -1), 0)) + layout_manager.set_text_color(TEXT_DISABLED) + else: + layout_manager.set_text_shadow(widgetutil.Shadow(TEXT_SHADOW, + 0.5, (1, 1), 0)) + layout_manager.set_text_color(TEXT_ACTIVE) + textbox = layout_manager.textbox(self.label) + alignment = cellpack.Alignment(textbox, xalign=0.5, xscale=0.0, + yalign=0.5, yscale=0) + alignment.render_layout(context) + +# XXX do we want to export this for general purpose use? +class TextDialog(widgetset.Dialog): + def __init__(self, title, description, window): + widgetset.Dialog.__init__(self, title, description) + self.set_transient_for(window) + self.add_button('OK') + self.textbox = widgetset.MultilineTextEntry() + self.textbox.set_editable(False) + scroller = widgetset.Scroller(False, True) + scroller.set_has_borders(True) + scroller.add(self.textbox) + scroller.set_size_request(400, 500) + self.set_extra_widget(scroller) + + def set_text(self, text): + self.textbox.set_text(text) + +class Application(mvc.Application): + def __init__(self, simultaneous=None): + mvc.Application.__init__(self, simultaneous) + self.create_signal('window-shown') + self.sent_window_shown = False + + def startup(self): + if self.started: + return + + self.current_converter = EMPTY_CONVERTER + + mvc.Application.startup(self) + + self.menu_manager = menus.MenuManager() + self.menu_manager.setup_menubar(self.menubar) + + self.window = widgetset.Window("Libre Video Converter") + self.window.connect('on-shown', self.on_window_shown) + self.window.connect('will-close', self.destroy) + + # # table on top + self.model = ConversionModel() + self.table = widgetset.TableView(self.model) + self.table.draws_selection = False + self.table.set_row_spacing(0) + self.table.enable_album_view_focus_hack() + self.table.set_fixed_height(True) + self.table.set_grid_lines(False, False) + self.table.set_show_headers(False) + + c = widgetset.TableColumn("Data", ConversionCellRenderer(), + **dict((n, v) for (v, n) in enumerate(( + 'input', 'output_size', 'converter', 'status', + 'duration', 'progress', 'eta', 'thumbnail', + 'conversion')))) + c.set_min_width(TABLE_WIDTH) + self.table.add_column(c) + self.table.connect('hotspot-clicked', self.hotspot_clicked) + + # bottom buttons + converter_types = ('apple', 'android', 'other', 'format') + converters = {} + for c in self.converter_manager.list_converters(): + media_type = c.media_type + if media_type not in converter_types: + media_type = 'others' + brand = self.converter_manager.converter_to_brand(c) + # None = top level. Otherwise tack on the brand name. + if brand is None: + converters.setdefault(media_type, set()).add(c) + else: + converters.setdefault(media_type, set()).add(brand) + + self.menus = [] + + self.button_bar = widgetset.HBox() + buttons = widgetset.HBox() + + for type_ in converter_types: + options = [] + more_devices = None + for c in converters[type_]: + if isinstance(c, str): + rconverters = self.converter_manager.brand_to_converters(c) + values = [] + for r in rconverters: + values.append((r.name, r.identifier)) + # yuck + if c == 'More Devices': + more_devices = (c, values) + else: + options.append((c, values)) + else: + options.append((c.name, c.identifier)) + # Don't sort if formats.. + self.sort_converter_menu(type_, options) + if more_devices: + options.append(more_devices) + menu = SettingsButton(type_) + menu.connect('clicked', self.show_options_menu, options) + self.menus.append(menu) + buttons.pack_start(menu) + omb = OptionMenuBackground() + omb.set_child(widgetutil.pad(buttons, top=2, bottom=2, + left=2, right=2)) + self.button_bar.pack_start(omb) + + self.settings_button = SettingsButton('settings') + omb = OptionMenuBackground() + omb.set_child(widgetutil.pad(self.settings_button, top=2, + bottom=2, left=2, right=2)) + self.button_bar.pack_end(omb) + + self.drop_target = FileDropTarget() + self.drop_target.set_size_request(-1, 70) + + # # finish up + vbox = widgetset.VBox() + self.vbox = vbox + + # add menubars, if we're not on windows + if sys.platform != 'win32': + attach_menubar() + + self.scroller = widgetset.Scroller(False, True) + self.scroller.set_size_request(0, 0) + self.scroller.set_background_color(DRAG_AREA) + self.scroller.add(self.table) + vbox.pack_start(self.scroller) + vbox.pack_start(self.drop_target, expand=True) + + bottom = BottomBackground() + bottom_box = widgetset.VBox() + self.convert_label = CustomLabel('Convert to') + self.convert_label.set_font(CONVERT_TO_FONT, CONVERT_TO_FONTSIZE) + self.convert_label.set_color(TEXT_COLOR) + bottom_box.pack_start(widgetutil.align_left(self.convert_label, + top_pad=10, + bottom_pad=10)) + bottom_box.pack_start(self.button_bar) + + self.options = CustomOptions() + self.options.connect('setting-changed', self.on_setting_changed) + self.settings_button.connect('clicked', self.on_settings_toggle) + bottom_box.pack_start(widgetutil.align_right(self.options, + right_pad=5)) + + self.convert_button = ConvertButton() + self.convert_button.connect('clicked', self.convert) + + bottom_box.pack_start(widgetutil.align(self.convert_button, + xalign=0.5, yalign=0.5, + top_pad=50, bottom_pad=50)) + bottom.set_child(widgetutil.pad(bottom_box, left=20, right=20)) + vbox.pack_start(bottom) + self.window.set_content_widget(vbox) + + idle_add(self.conversion_manager.check_notifications, 1) + + self.window.connect('file-drag-motion', self.drag_motion) + self.window.connect('file-drag-received', self.drag_data_received) + self.window.connect('file-drag-leave', self.drag_finished) + self.window.accept_file_drag(True) + + self.window.center() + self.window.show() + self.update_table_size() + + def sort_converter_menu(self, menu_type, options): + """Sort a list of converter options for the menus + + :param menu_type: type of the menu + :param options: list of (name, menu) tuples, where menu is either a + ConverterInfo or list of ConverterInfos. + """ + if menu_type == 'format': + order = ['Audio', 'Video', 'Ingest Formats', 'Same Format'] + options.sort(key=lambda (name, menu): order.index(name)) + else: + options.sort() + + def drag_finished(self, widget): + self.drop_target.set_in_drag(False) + + def drag_motion(self, widget): + self.drop_target.set_in_drag(True) + + def drag_data_received(self, widget, values): + for uri in values: + parsed = urlparse.urlparse(uri) + if parsed.scheme == 'file': + pathname = urllib.url2pathname(parsed.path) + self.file_activated(widget, pathname) + + def on_window_shown(self, window): + # only emit window-shown once, even if our window gets shown, hidden, + # and shown again + if not self.sent_window_shown: + self.emit("window-shown") + self.sent_window_shown = True + + def destroy(self, widget): + for conversion in self.conversion_manager.in_progress.copy(): + conversion.stop() + mainloop_stop() + + def run(self): + mainloop_start() + + def choose_file(self): + dialog = widgetset.FileOpenDialog('Choose Files...') + dialog.set_select_multiple(True) + if dialog.run() == 0: # success + for filename in dialog.get_filenames(): + self.file_activated(None, filename) + dialog.destroy() + + def about(self): + dialog = widgetset.AboutDialog() + dialog.set_transient_for(self.window) + try: + dialog.run() + finally: + dialog.destroy() + + def quit(self): + self.window.close() + + def _generate_suboptions_menu(self, widget, options): + submenu = [] + for option, id_ in options: + callback = lambda x, i: self.on_select_converter(widget, + options[i][1]) + value = (option, callback) + submenu.append(value) + return submenu + + def show_options_menu(self, widget, options): + optionlist = [] + identifiers = dict() + for option, submenu in options: + if isinstance(submenu, list): + callback = self._generate_suboptions_menu(widget, submenu) + else: + callback = lambda x, i: self.on_select_converter(widget, + options[i][1]) + value = (option, callback) + optionlist.append(value) + menu = widgetset.ContextMenu(optionlist) + menu.popup() + + def update_convert_button(self): + can_cancel = False + can_start = False + has_conversions = any(self.model.conversions()) + all_done = self.model.all_conversions_done() + for c in self.model.conversions(): + if c.status == 'converting': + can_cancel = True + break + elif c.status == 'initialized': + can_start = True + # if there are no conversions ... these can't be set + if not has_conversions: + for m in self.menus: + m.set_selected(False) + self.settings_button.set_selected(False) + self.convert_label.set_color(TEXT_DISABLED) + # Set the colors - all are enabled if all conversions complete, or + # if we have conversions conversions but the converter has not yet + # been set. + # the converter has not been set. + if ((self.current_converter is EMPTY_CONVERTER and has_conversions) or + all_done): + for m in self.menus: + m.set_selected(True) + self.settings_button.set_selected(True) + if self.current_converter is EMPTY_CONVERTER: + self.convert_label.set_text('Convert to') + elif can_cancel: + target = self.current_converter.name + self.convert_label.set_text('Converting to %s' % target) + elif can_start: + target = self.current_converter.name + self.convert_label.set_text('Will convert to %s' % target) + self.convert_label.set_color(TEXT_ACTIVE) + if all_done: + self.convert_button.set_clear() + elif (self.current_converter is EMPTY_CONVERTER or not + (can_cancel or can_start)): + self.convert_button.set_off() + elif (self.current_converter is not EMPTY_CONVERTER and + self.options.options['custom-size'] and + (not self.options.options['width'] or + not self.options.options['height'])): + self.convert_button.set_off() + else: + self.convert_button.set_on() + if can_cancel: + self.convert_button.set_stop() + self.button_bar.disable() + else: + if has_conversions: + self.button_bar.enable() + else: + self.button_bar.disable() + + def file_activated(self, widget, filename): + filename = os.path.realpath(filename) + for c in self.model.conversions(): + if c.video.filename == filename: + logger.info('ignoring duplicate: %r', filename) + return + # XXX disabled - don't want to allow individualized file outputs + # since the workflow isn't entirely clear for now. + #if self.options.options['destination'] is None: + # try: + # tempfile.TemporaryFile(dir=os.path.dirname(filename)) + # except EnvironmentError: + # # can't write to the destination directory; ask for a new one + # self.options.on_destination_clicked(None) + try: + vf = VideoFile(filename) + except ValueError: + logging.info('invalid file %r, cannot parse', filename, + exc_info=True) + return + c = self.conversion_manager.get_conversion( + vf, + self.current_converter, + output_dir=self.options.options['destination']) + c.listen(self.update_conversion) + if self.conversion_manager.running: + # start running automatically if a conversion is already in + # progress + self.conversion_manager.run_conversion(c) + self.update_conversion(c) + self.update_table_size() + + def on_select_converter(self, widget, identifier): + self.current_converter = self.converter_manager.get_by_id(identifier) + self.options.reset() + + self.converter_changed(widget) + + def converter_changed(self, widget): + if hasattr(self, '_doing_conversion_change'): + return + self._doing_conversion_change = True + + # If all conversions are done, then change the status of them back + # to 'initialized'. + # + # XXX TODO: what happens if the state is 'failed'? Should we reset? + all_done = self.model.all_conversions_done() + if all_done: + for c in self.model.conversions(): + c.status = 'initialized' + + if self.current_converter is not EMPTY_CONVERTER: + self.convert_label.set_text( + 'Will convert to %s' % self.current_converter.name) + else: + self.convert_label.set_text('Convert to') + + if not self.current_converter.audio_only: + self.options.enable_custom_size() + self.options.update_setting('width', + self.current_converter.width) + self.options.update_setting('height', + self.current_converter.height) + else: + self.options.disable_custom_size() + + for c in self.model.conversions(): + if c.status == 'initialized': + c.set_converter(self.current_converter) + self.model.update_conversion(c) + + # We likely either reset the status or we've changed the conversion + # output so let's just reload the table model. + self.table.model_changed() + + self.update_convert_button() + + widget.set_selected(True) + for menu in self.menus: + if menu is not widget: + menu.set_selected(False) + + del self._doing_conversion_change + + def convert(self, widget): + self.convert_button.disable() + if not self.conversion_manager.running: + if self.current_converter is not EMPTY_CONVERTER: + valid_resolution = True + if (self.options.options['custom-size'] and + not (self.options.options['width'] and + self.options.options['height'])): + valid_resolution = False + if valid_resolution: + for conversion in self.model.conversions(): + if conversion.status == 'initialized': + self.conversion_manager.run_conversion(conversion) + self.button_bar.disable() + # all done: no conversion job should be running at this point + all_done = self.model.all_conversions_done() + if all_done: + # take stuff off one by one from the list until we have none! + # might not be very efficient. + iter_ = self.model.first_iter() + while iter_ is not None: + conversion = self.model[iter_][-1] + if conversion.status in ('finished', + 'failed', + 'canceled', + 'initialized'): + try: + self.conversion_manager.remove(conversion) + except ValueError: + pass + iter_ = self.model.remove(iter_) + self.update_table_size() + else: + for conversion in self.model.conversions(): + conversion.stop() + self.update_conversion(conversion) + self.conversion_manager.running = False + self.update_convert_button() + self.convert_button.enable() + + def update_conversion(self, conversion): + self.model.update_conversion(conversion) + self.update_table_size() + + def update_table_size(self): + conversions = len(self.model) + total_height = 380 + if not conversions: + self.scroller.set_size_request(-1, 0) + self.drop_target.set_small(False) + self.drop_target.set_size_request(-1, total_height) + else: + height = min(TABLE_HEIGHT * conversions, 320) + self.scroller.set_size_request(-1, height) + self.drop_target.set_small(True) + self.drop_target.set_size_request(-1, total_height - height) + self.update_convert_button() + self.table.model_changed() + + def hotspot_clicked(self, widget, name, iter_): + conversion = self.model[iter_][-1] + if name == 'show-file': + reveal_file(conversion.output) + elif name == 'clear': + self.model.remove(iter_) + self.update_table_size() + elif name == 'show-log': + lines = ''.join(conversion.lines) + d = TextDialog('Log', '', self.window) + d.set_text(lines) + try: + d.run() + finally: + d.destroy() + elif name == 'cancel': + if conversion.status == 'initialized': + self.model.remove(iter_) + try: + self.conversion_manager.remove(conversion) + except ValueError: + pass + self.update_table_size() + else: + conversion.stop() + self.update_conversion(conversion) + + def on_settings_toggle(self, widget): + if not self.options.child: + # hidden, going to show + self.convert_button.hide() + self.options.toggle() + if not self.options.child: + # was shown, not hidden + self.convert_button.show() + + def on_setting_changed(self, widget, setting, value): + if setting == 'destination': + for c in self.model.conversions(): + if c.status == 'initialized': + if value is None: + c.output_dir = os.path.dirname(c.video.filename) + else: + c.output_dir = value + # update final path + c.set_converter(self.current_converter) + return + elif setting == 'dont-upsize': + setattr(self.current_converter, 'dont_upsize', value) + return + + if (self.current_converter.identifier != 'custom' and + setting != 'create-thumbnails'): + if hasattr(self.current_converter, 'simple'): + self.current_converter = self.current_converter.simple( + self.current_converter.name) + else: + if self.current_converter is EMPTY_CONVERTER: + self.current_converter = copy.copy(self.converter_manager.get_by_id('sameformat')) + else: + self.current_converter = copy.copy(self.current_converter) + # If the current converter name is resize only, then we don't + # want to call it a custom conversion. + if self.current_converter.identifier != 'sameformat': + self.current_converter.name = 'Custom' + self.current_converter.width = self.options.options['width'] + self.current_converter.height = self.options.options['height'] + self.converter_changed(self.menus[-1]) # formats menu + if setting in ('width', 'height'): + setattr(self.current_converter, setting, value) + elif setting == 'custom-size': + if not value: + self.current_converter.old_size = ( + self.current_converter.width, + self.current_converter.height) + self.current_converter.width = None + self.current_converter.height = None + elif hasattr(self.current_converter, 'old_size'): + old_size = self.current_converter.old_size + (self.current_converter.width, + self.current_converter.height) = old_size + elif setting == 'create-thumbnails': + self.conversion_manager.create_thumbnails = bool(value) + +if __name__ == "__main__": + sys.dont_write_bytecode = True + app.widgetapp = Application() + initialize(app.widgetapp) diff --git a/mvc/utils.py b/mvc/utils.py new file mode 100644 index 0000000..e0a64f3 --- /dev/null +++ b/mvc/utils.py @@ -0,0 +1,230 @@ +import ctypes +import itertools +import logging +import os +import sys + +def hms_to_seconds(hours, minutes, seconds): + return (hours * 3600 + + minutes * 60 + + seconds) + + +def round_even(num): + """This takes a number, converts it to an integer, then makes + sure it's even. + + Additional rules: this helper always rounds down to avoid stray black + pixels (see bz18122). + + This function makes sure that the value returned is always >= 0. + """ + num = int(num) + val = num - (num % 2) + return val if val > 0 else 0 + + +def rescale_video((source_width, source_height), + (target_width, target_height), + dont_upsize=True): + """ + Rescale a video given a (width, height) target. This returns the largest + (width, height) which maintains the original aspect ratio while fitting + within the target size. + + If dont_upsize is set, then don't resize it such that the rescaled size + will be larger than the original size. + """ + if source_width is None or source_height is None: + return (round_even(target_width), round_even(target_height)) + + if target_width is None or target_height is None: + return (round_even(source_width), round_even(source_height)) + + if (dont_upsize and + (source_width <= target_width or source_height <= target_height)): + return (round_even(source_width), round_even(source_height)) + + width_ratio = float(source_width) / float(target_width) + height_ratio = float(source_height) / float(target_height) + ratio = max(width_ratio, height_ratio) + return round_even(source_width / ratio), round_even(source_height / ratio) + +def line_reader(handle): + """Builds a line reading generator for the given handle. This + generator breaks on empty strings, \\r and \\n. + + This a little weird, but it makes it really easy to test error + checking and progress monitoring. + """ + def _readlines(): + chars = [] + c = handle.read(1) + while True: + if c in ["", "\r", "\n"]: + if chars: + yield "".join(chars) + if not c: + break + chars = [] + else: + chars.append(c) + c = handle.read(1) + return _readlines() + + +class Matrix(object): + """2 Dimensional matrix. + + Matrix objects are accessed like a list, except tuples are used as + indices, for example: + + >>> m = Matrix(5, 5) + >>> m[3, 4] = 'foo' + >>> m + None, None, None, None, None + None, None, None, None, None + None, None, None, None, None + None, None, None, None, None + None, None, None, 'foo', None + """ + + def __init__(self, columns, rows, initial_value=None): + self.columns = columns + self.rows = rows + self.data = [ initial_value ] * (columns * rows) + + def __getitem__(self, key): + return self.data[(key[0] * self.rows) + key[1]] + + def __setitem__(self, key, value): + self.data[(key[0] * self.rows) + key[1]] = value + + def __iter__(self): + return iter(self.data) + + def __repr__(self): + return "\n".join([", ".join([repr(r) + for r in list(self.row(i))]) + for i in xrange(self.rows)]) + + def remove(self, value): + """This sets the value to None--it does NOT remove the cell + from the Matrix because that doesn't make any sense. + """ + i = self.data.index(value) + self.data[i] = None + + def row(self, row): + """Iterator that yields all the objects in a row.""" + for i in xrange(self.columns): + yield self[i, row] + + def column(self, column): + """Iterator that yields all the objects in a column.""" + for i in xrange(self.rows): + yield self[column, i] + + +class Cache(object): + def __init__(self, size): + self.size = size + self.dict = {} + self.counter = itertools.count() + self.access_times = {} + self.invalidators = {} + + def get(self, key, invalidator=None): + if key in self.dict: + existing_invalidator = self.invalidators[key] + if (existing_invalidator is None or + not existing_invalidator(key)): + self.access_times[key] = self.counter.next() + return self.dict[key] + + value = self.create_new_value(key, invalidator=invalidator) + self.set(key, value, invalidator=invalidator) + return value + + def set(self, key, value, invalidator=None): + if len(self.dict) == self.size: + self.shrink_size() + self.access_times[key] = self.counter.next() + self.dict[key] = value + self.invalidators[key] = invalidator + + def remove(self, key): + if key in self.dict: + del self.dict[key] + del self.access_times[key] + if key in self.invalidators: + del self.invalidators[key] + + def keys(self): + return self.dict.iterkeys() + + def shrink_size(self): + # shrink by LRU + to_sort = self.access_times.items() + to_sort.sort(key=lambda m: m[1]) + new_dict = {} + new_access_times = {} + new_invalidators = {} + latest_times = to_sort[len(self.dict) // 2:] + for (key, time) in latest_times: + new_dict[key] = self.dict[key] + new_invalidators[key] = self.invalidators[key] + new_access_times[key] = time + self.dict = new_dict + self.access_times = new_access_times + + def create_new_value(self, val, invalidator=None): + raise NotImplementedError() + + +def size_string(nbytes): + # when switching from the enclosure reported size to the + # downloader reported size, it takes a while to get the new size + # and the downloader returns -1. the user sees the size go to -1B + # which is weird.... better to return an empty string. + if nbytes == -1 or nbytes == 0: + return "" + + # FIXME this is a repeat of util.format_size_for_user ... should + # probably ditch one of them. + if nbytes >= (1 << 30): + value = "%.1f" % (nbytes / float(1 << 30)) + return "%(size)s GB" % {"size": value} + elif nbytes >= (1 << 20): + value = "%.1f" % (nbytes / float(1 << 20)) + return "%(size)s MB" % {"size": value} + elif nbytes >= (1 << 10): + value = "%.1f" % (nbytes / float(1 << 10)) + return "%(size)s KB" % {"size": value} + else: + return "%(size)s B" % {"size": nbytes} + +def convert_path_for_subprocess(path): + """Convert a path to a form suitable for passing to a subprocess. + + This method converts unicode paths to bytestrings according to the system + fileencoding. On windows, it converts the path to a short filename for + maximum compatibility + + This method should only be called on a path that exists on the filesystem. + """ + if not os.path.exists(path): + raise ValueError("path %r doesn't exist" % path) + if not isinstance(path, unicode): + # path already is a bytestring, just return it + return path + if sys.platform != 'win32': + return path.encode(sys.getfilesystemencoding()) + else: + buf_size = 1024 + short_path_buf = ctypes.create_unicode_buffer(buf_size) + ctypes.windll.kernel32.GetShortPathNameW(path, + short_path_buf, buf_size) + logging.info("convert_path_for_subprocess: got short path %r", + short_path_buf.value) + return short_path_buf.value.encode('ascii') diff --git a/mvc/utils.pyc b/mvc/utils.pyc Binary files differnew file mode 100644 index 0000000..1c81b99 --- /dev/null +++ b/mvc/utils.pyc diff --git a/mvc/video.py b/mvc/video.py new file mode 100644 index 0000000..fb46df9 --- /dev/null +++ b/mvc/video.py @@ -0,0 +1,287 @@ +import logging +import os +import re +import tempfile +import threading + +from mvc import execute +from mvc.widgets import idle_add +from mvc.settings import get_ffmpeg_executable_path +from mvc.utils import hms_to_seconds, convert_path_for_subprocess + +logger = logging.getLogger(__name__) + +class VideoFile(object): + def __init__(self, filename): + self.filename = filename + self.container = None + self.video_codec = None + self.audio_codec = None + self.width = None + self.height = None + self.duration = None + self.thumbnails = {} + self.parse() + + def parse(self): + self.__dict__.update( + get_media_info(self.filename)) + + @property + def audio_only(self): + return self.video_codec is None + + def get_thumbnail(self, completion, width=None, height=None, type_='.png'): + if self.audio_only: + # don't bother with thumbnails for audio files + return None + if width is None: + width = -1 + if height is None: + height = -1 + + if self.duration is None: + skip = 0 + else: + skip = min(int(self.duration / 3), 120) + + key = (width, height, type_) + + def complete(name): + self.thumbnails[key] = name + completion() + + if key not in self.thumbnails: + temp_path = tempfile.mktemp(suffix=type_) + get_thumbnail(self.filename, width, height, temp_path, complete, + skip=skip) + return None + + return self.thumbnails.get(key) + +class Node(object): + def __init__(self, line="", children=None): + self.line = line + if not children: + self.children = [] + else: + self.children = children + + if ": " in line: + self.key, self.value = line.split(": ", 1) + else: + self.key = "" + self.value = "" + + def add_node(self, node): + self.children.append(node) + + def pformat(self, indent=0): + s = (" " * indent) + ("Node: %s" % self.line) + "\n" + for mem in self.children: + s += mem.pformat(indent + 2) + return s + + def get_by_key(self, key): + if self.line.startswith(key): + return self + for mem in self.children: + ret = mem.get_by_key(key) + if ret: + return ret + return None + + def __repr__(self): + return "<Node %s: %s>" % (self.key, self.value) + + +def get_indent(line): + length = len(line) + line = line.lstrip() + return (length - len(line), line) + + +def parse_ffmpeg_output(output): + """Takes a list of strings and parses it into a loose AST-ish + thing. + + ffmpeg output uses indentation levels to indicate a hierarchy of + data. + + If there's a : in the line, then it's probably a key/value pair. + + :param output: the content to parse as a list of strings. + + :returns: a top level node of the ffmpeg output AST + """ + ast = Node() + node_stack = [ast] + indent_level = 0 + + for mem in output: + # skip blank lines + if len(mem.strip()) == 0: + continue + + indent, line = get_indent(mem) + node = Node(line) + + if indent == indent_level: + node_stack[-1].add_node(node) + elif indent > indent_level: + node_stack.append(node_stack[-1].children[-1]) + indent_level = indent + node_stack[-1].add_node(node) + else: + for dedent in range(indent, indent_level, 2): + # make sure we never pop everything off the stack. + # the root should always be on the stack. + if len(node_stack) <= 1: + break + node_stack.pop() + indent_level = indent + node_stack[-1].add_node(node) + + return ast + + +# there's always a space before the size and either a space or a comma +# afterwards. +SIZE_RE = re.compile(" (\\d+)x(\\d+)[ ,]") + + +def extract_info(ast): + info = {} + # logging.info("get_media_info: %s", ast.pformat()) + + input0 = ast.get_by_key("Input #0") + if not input0: + raise ValueError("no input #0") + + foo, info['container'], bar = input0.line.split(', ', 2) + if ',' in info['container']: + info['container'] = info['container'].split(',') + + metadata = input0.get_by_key("Metadata") + if metadata: + for key in ('title', 'artist', 'album', 'track', 'genre'): + node = metadata.get_by_key(key) + if node: + info[key] = node.line.split(':', 1)[1].strip() + major_brand_node = metadata.get_by_key("major_brand") + extra_container_types = [] + if major_brand_node: + major_brand = major_brand_node.line.split(':')[1].strip() + extra_container_types = [major_brand] + else: + major_brand = None + + compatible_brands_node = metadata.get_by_key("compatible_brands") + if compatible_brands_node: + line = compatible_brands_node.line.split(':')[1].strip() + extra_container_types.extend(line[i:i+4] for i in range(0, len(line), 4) + if line[i:i+4] != major_brand) + + if extra_container_types: + if not isinstance(info['container'], list): + info['container'] = [info['container']] + info['container'].extend(extra_container_types) + + duration = input0.get_by_key("Duration:") + if duration: + _, rest = duration.line.split(':', 1) + duration_string, _ = rest.split(', ', 1) + logging.info("duration: %r", duration_string) + try: + hours, minutes, seconds = [ + float(i) for i in duration_string.split(':')] + except ValueError: + if duration_string.strip() != "N/A": + logging.warn("Error parsing duration string: %r", + duration_string) + else: + info['duration'] = hms_to_seconds(hours, minutes, seconds) + for stream_node in duration.children: + stream = stream_node.line + if "Video:" in stream: + stream_number, video, data = stream.split(': ', 2) + video_codec = data.split(', ')[0] + if ' ' in video_codec: + video_codec, drmp = video_codec.split(' ', 1) + if 'drm' in drmp: + info.setdefault('has_drm', []).append('video') + info['video_codec'] = video_codec + match = SIZE_RE.search(data) + if match: + info["width"] = int(match.group(1)) + info["height"] = int(match.group(2)) + elif 'Audio:' in stream: + stream_number, video, data = stream.split(': ', 2) + audio_codec = data.split(', ')[0] + if ' ' in audio_codec: + audio_codec, drmp = audio_codec.split(' ', 1) + if 'drm' in drmp: + info.setdefault('has_drm', []).append('audio') + info['audio_codec'] = audio_codec + return info + +def get_ffmpeg_output(filepath): + + commandline = [get_ffmpeg_executable_path(), + "-i", convert_path_for_subprocess(filepath)] + logging.info("get_ffmpeg_output(): running %s", commandline) + try: + output = execute.check_output(commandline) + except execute.CalledProcessError, e: + if e.returncode != 1: + logger.exception("error calling %r\noutput:%s", commandline, + e.output) + # ffmpeg -i generally returns 1, so we ignore the exception and + # just get the output. + output = e.output + + return output + +def get_media_info(filepath): + """Takes a file path and returns a dict of information about + this media file that it extracted from ffmpeg -i. + + :param filepath: absolute path to the media file in question + + :returns: dict of media info possibly containing: height, width, + container, audio_codec, video_codec + """ + logger.info('get_media_info: %r', filepath) + output = get_ffmpeg_output(filepath) + ast = parse_ffmpeg_output(output.splitlines()) + info = extract_info(ast) + logger.info('get_media_info: %r', info) + return info + +def get_thumbnail(filename, width, height, output, completion, skip=0): + name = 'Thumbnail - %r @ %sx%s' % (filename, width, height) + def run(): + rv = get_thumbnail_synchronous(filename, width, height, output, skip) + idle_add(lambda: completion(rv)) + t = threading.Thread(target=run, name=name) + t.start() + +def get_thumbnail_synchronous(filename, width, height, output, skip=0): + executable = get_ffmpeg_executable_path() + filter_ = 'scale=%i:%i' % (width, height) + # bz19571: temporary disable: libav ffmpeg does not support this filter + #if 'ffmpeg' in executable: + # # supports the thumbnail filter, we hope + # filter_ = 'thumbnail,' + filter_ + commandline = [executable, + '-ss', str(skip), + '-i', convert_path_for_subprocess(filename), + '-vf', filter_, '-vframes', '1', output] + try: + execute.check_output(commandline) + except execute.CalledProcessError, e: + logger.exception('error calling %r\ncode:%s\noutput:%s', + commandline, e.returncode, e.output) + return None + else: + return output diff --git a/mvc/video.pyc b/mvc/video.pyc Binary files differnew file mode 100644 index 0000000..951dcbf --- /dev/null +++ b/mvc/video.pyc diff --git a/mvc/widgets/__init__.py b/mvc/widgets/__init__.py new file mode 100644 index 0000000..23a6edc --- /dev/null +++ b/mvc/widgets/__init__.py @@ -0,0 +1,30 @@ +import logging +import os +import sys + +if sys.platform == 'darwin': + import osx as plat + from .osx import widgetset +else: + import gtk as plat + from .gtk import widgetset + +attach_menubar = plat.attach_menubar +mainloop_start = plat.mainloop_start +mainloop_stop = plat.mainloop_stop +idle_add = plat.idle_add +idle_remove = plat.idle_remove +reveal_file = plat.reveal_file +get_conversion_directory = plat.get_conversion_directory + +def get_conversion_directory(): + return os.path.join(plat.get_conversion_directory(), 'Libre Video Converter') + """directorio donde se guardan los videos convertidos""" + +def initialize(app): + try: + os.makedirs(get_conversion_directory()) + except EnvironmentError, e: + logging.info('os.makedirs: %s', str(e)) + if app: + plat.initialize(app) diff --git a/mvc/widgets/__init__.pyc b/mvc/widgets/__init__.pyc Binary files differnew file mode 100644 index 0000000..d87eb5e --- /dev/null +++ b/mvc/widgets/__init__.pyc diff --git a/mvc/widgets/app.py b/mvc/widgets/app.py new file mode 100644 index 0000000..531b745 --- /dev/null +++ b/mvc/widgets/app.py @@ -0,0 +1,4 @@ +# app.py + +widgetapp = None + diff --git a/mvc/widgets/app.pyc b/mvc/widgets/app.pyc Binary files differnew file mode 100644 index 0000000..112624a --- /dev/null +++ b/mvc/widgets/app.pyc diff --git a/mvc/widgets/cellpack.py b/mvc/widgets/cellpack.py new file mode 100644 index 0000000..1347f56 --- /dev/null +++ b/mvc/widgets/cellpack.py @@ -0,0 +1,843 @@ +"""``miro.frontends.widgets.cellpack`` -- Code to layout +CustomTableCells. + +We use the hbox/vbox model to lay things out with a couple changes. +The main difference here is that layouts are one-shot. We don't keep +state around inside the cell renderers, so we just set up the objects +at the start, then use them to calculate info. +""" + +class Margin(object): + """Helper object used to calculate margins. + """ + def __init__(self , margin): + if margin is None: + margin = (0, 0, 0, 0) + self.margin_left = margin[3] + self.margin_top = margin[0] + self.margin_width = margin[1] + margin[3] + self.margin_height = margin[0] + margin[2] + + def inner_rect(self, x, y, width, height): + """Returns the x, y, width, height of the inner + box. + """ + return (x + self.margin_left, + y + self.margin_top, + width - self.margin_width, + height - self.margin_height) + + def outer_size(self, inner_size): + """Returns the width, height of the outer box. + """ + return (inner_size[0] + self.margin_width, + inner_size[1] + self.margin_height) + + def point_in_margin(self, x, y, width, height): + """Returns whether a given point is inside of the + margins. + """ + return ((0 <= x - self.margin_left < width - self.margin_width) and + (0 <= y - self.margin_top < height - self.margin_height)) + +class Packing(object): + """Helper object used to layout Boxes. + """ + def __init__(self, child, expand): + self.child = child + self.expand = expand + + def calc_size(self, translate_func): + return translate_func(*self.child.get_size()) + + def draw(self, context, x, y, width, height): + self.child.draw(context, x, y, width, height) + +class WhitespacePacking(object): + """Helper object used to layout Boxes. + """ + def __init__(self, size, expand): + self.size = size + self.expand = expand + + def calc_size(self, translate_func): + return self.size, 0 + + def draw(self, context, x, y, width, height): + pass + +class Packer(object): + """Base class packing objects. Packer objects work similarly to widgets, + but they only used in custom cell renderers so there's a couple + differences. The main difference is that cell renderers don't keep state + around. Therefore Packers just get set up, used, then discarded. + Also Packers can't receive events directly, so they have a different + system to figure out where mouse clicks happened (the Hotspot class). + """ + + def render_layout(self, context): + """position the child elements then call draw() on them.""" + self._layout(context, 0, 0, context.width, context.height) + + def draw(self, context, x, y, width, height): + """Included so that Packer objects have a draw() method that matches + ImageSurfaces, TextBoxes, etc. + """ + self._layout(context, x, y, width, height) + + def _find_child_at(self, x, y, width, height): + raise NotImplementedError() + + def get_size(self): + """Get the minimum size required to hold the Packer. """ + try: + return self._size + except AttributeError: + self._size = self._calc_size() + return self._size + + def get_current_size(self): + """Get the minimum size required to hold the Packer at this point + + Call this method if you are going to change the packer after the call, + for example if you have more children to pack into a box. get_size() + saves caches it's result which is can mess things up. + """ + return self._calc_size() + + def find_hotspot(self, x, y, width, height): + """Find the hotspot at (x, y). width and height are the size of the + cell this Packer is rendering. + + If a hotspot is found, return the tuple (name, x, y, width, height) + where name is the name of the hotspot, x, y is the position relative + to the top-left of the hotspot area and width, height are the + dimensions of the hotspot. + + If no Hotspot is found return None. + """ + child_pos = self._find_child_at(x, y, width, height) + if child_pos: + child, child_x, child_y, child_width, child_height = child_pos + try: + return child.find_hotspot(x - child_x, y - child_y, + child_width, child_height) + except AttributeError: + pass # child is a TextBox, Button or something like that + return None + + def _layout(self, context, x, y, width, height): + """Layout our children and call ``draw()`` on them. + """ + raise NotImplementedError() + + def _calc_size(self): + """Calculate the size needed to hold the box. The return value gets + cached and return in ``get_size()``. + """ + raise NotImplementedError() + +class Box(Packer): + """Box is the base class for VBox and HBox. Box objects lay out children + linearly either left to right or top to bottom. + """ + + def __init__(self, spacing=0): + """Create a new Box. spacing is the amount of space to place + in-between children. + """ + self.spacing = spacing + self.children = [] + self.children_end = [] + self.expand_count = 0 + + def pack(self, child, expand=False): + """Add a new child to the box. The child will be placed after all the + children packed before with pack_start. + + :param child: child to pack. It can be anything with a + ``get_size()`` method, including TextBoxes, + ImageSurfarces, Buttons, Boxes and Backgrounds. + :param expand: If True, then the child will enlarge if space + available is more than the space required. + """ + if not (hasattr(child, 'draw') and hasattr(child, 'get_size')): + raise TypeError("%s can't be drawn" % child) + self.children.append(Packing(child, expand)) + if expand: + self.expand_count += 1 + + def pack_end(self, child, expand=False): + """Add a new child to the end box. The child will be placed before + all the children packed before with pack_end. + + :param child: child to pack. It can be anything with a + ``get_size()`` method, including TextBoxes, + ImageSurfarces, Buttons, Boxes and Backgrounds. + :param expand: If True, then the child will enlarge if space + available is more than the space required. + """ + if not (hasattr(child, 'draw') and hasattr(child, 'get_size')): + raise TypeError("%s can't be drawn" % child) + self.children_end.append(Packing(child, expand)) + if expand: + self.expand_count += 1 + + def pack_space(self, size, expand=False): + """Pack whitespace into the box. + """ + self.children.append(WhitespacePacking(size, expand)) + if expand: + self.expand_count += 1 + + def pack_space_end(self, size, expand=False): + """Pack whitespace into the end of box. + """ + self.children_end.append(WhitespacePacking(size, expand)) + if expand: + self.expand_count += 1 + + def _calc_size(self): + length = 0 + breadth = 0 + for packing in self.children + self.children_end: + child_length, child_breadth = packing.calc_size(self._translate) + length += child_length + breadth = max(breadth, child_breadth) + total_children = len(self.children) + len(self.children_end) + length += self.spacing * (total_children - 1) + return self._translate(length, breadth) + + def _extra_space_iter(self, total_extra_space): + """Generate the amount of extra space for children with expand set.""" + if total_extra_space <= 0: + while True: + yield 0 + average_extra_space, leftover = \ + divmod(total_extra_space, self.expand_count) + while leftover > 1: + # expand_count doesn't divide equally into total_extra_space, + # yield average_extra_space+1 for each extra pixel + yield average_extra_space + 1 + leftover -= 1 + # if there's a fraction of a pixel leftover, add that in + yield average_extra_space + leftover + while True: + # no more leftover space + yield average_extra_space + + def _position_children(self, total_length): + my_length, my_breadth = self._translate(*self.get_size()) + extra_space_iter = self._extra_space_iter(total_length - my_length) + + pos = 0 + for packing in self.children: + child_length, child_breadth = packing.calc_size(self._translate) + if packing.expand: + child_length += extra_space_iter.next() + yield packing, pos, child_length + pos += child_length + self.spacing + + pos = total_length + for packing in self.children_end: + child_length, child_breadth = packing.calc_size(self._translate) + if packing.expand: + child_length += extra_space_iter.next() + pos -= child_length + yield packing, pos, child_length + pos -= self.spacing + + def _layout(self, context, x, y, width, height): + total_length, total_breadth = self._translate(width, height) + pos, offset = self._translate(x, y) + position_iter = self._position_children(total_length) + for packing, child_pos, child_length in position_iter: + x, y = self._translate(pos + child_pos, offset) + width, height = self._translate(child_length, total_breadth) + packing.draw(context, x, y, width, height) + + def _find_child_at(self, x, y, width, height): + total_length, total_breadth = self._translate(width, height) + pos, offset = self._translate(x, y) + position_iter = self._position_children(total_length) + for packing, child_pos, child_length in position_iter: + if child_pos <= pos < child_pos + child_length: + x, y = self._translate(child_pos, 0) + width, height = self._translate(child_length, total_breadth) + if isinstance(packing, WhitespacePacking): + return None + return packing.child, x, y, width, height + elif child_pos > pos: + break + return None + + def _translate(self, x, y): + """Translate (x, y) coordinates into (length, breadth) and + vice-versa. + """ + raise NotImplementedError() + +class HBox(Box): + def _translate(self, x, y): + return x, y + +class VBox(Box): + def _translate(self, x, y): + return y, x + +class Table(Packer): + def __init__(self, row_length=1, col_length=1, + row_spacing=0, col_spacing=0): + """Create a new Table. + + :param row_length: how many rows long this should be + :param col_length: how many rows wide this should be + :param row_spacing: amount of spacing (in pixels) between rows + :param col_spacing: amount of spacing (in pixels) between columns + """ + assert min(row_length, col_length) > 0 + assert isinstance(row_length, int) and isinstance(col_length, int) + self.row_length = row_length + self.col_length = col_length + self.row_spacing = row_spacing + self.col_spacing = col_spacing + self.table_multiarray = self._generate_table_multiarray() + + def _generate_table_multiarray(self): + table_multiarray = [] + table_multiarray = [ + [None for col in range(self.col_length)] + for row in range(self.row_length)] + return table_multiarray + + def pack(self, child, row, column, expand=False): + # TODO: flesh out "expand" ability, maybe? + # + # possibly throw a special exception if outside the range. + # For now, just allowing an IndexError to be thrown. + self.table_multiarray[row][column] = Packing(child, expand) + + def _get_grid_sizes(self): + """Get the width and eights for both rows and columns + """ + row_sizes = {} + col_sizes = {} + for row_count, row in enumerate(self.table_multiarray): + row_sizes.setdefault(row_count, 0) + for col_count, col_packing in enumerate(row): + col_sizes.setdefault(col_count, 0) + if col_packing: + x, y = col_packing.calc_size(self._translate) + if y > row_sizes[row_count]: + row_sizes[row_count] = y + if x > col_sizes[col_count]: + col_sizes[col_count] = x + return col_sizes, row_sizes + + def _find_child_at(self, x, y, width, height): + col_sizes, row_sizes = self._get_grid_sizes() + row_distance = 0 + for row_count, row in enumerate(self.table_multiarray): + col_distance = 0 + for col_count, packing in enumerate(row): + child_width, child_height = packing.calc_size(self._translate) + if packing.child: + if (col_distance <= x < col_distance + child_width + and row_distance <= y < row_distance + child_height): + return (packing.child, + col_distance, row_distance, + child_width, child_height) + col_distance += col_sizes[col_count] + self.col_spacing + row_distance += row_sizes[row_count] + self.row_spacing + + def _calc_size(self): + col_sizes, row_sizes = self._get_grid_sizes() + x = sum(col_sizes.values()) + ( + (self.col_length - 1) * self.col_spacing) + y = sum(row_sizes.values()) + ( + (self.row_length - 1) * self.row_spacing) + return x, y + + def _layout(self, context, x, y, width, height): + col_sizes, row_sizes = self._get_grid_sizes() + + row_distance = 0 + for row_count, row in enumerate(self.table_multiarray): + col_distance = 0 + for col_count, packing in enumerate(row): + if packing: + child_width, child_height = packing.calc_size( + self._translate) + packing.child.draw(context, + x + col_distance, y + row_distance, + child_width, child_height) + col_distance += col_sizes[col_count] + self.col_spacing + row_distance += row_sizes[row_count] + self.row_spacing + + def _translate(self, x, y): + return x, y + + +class Alignment(Packer): + """Positions a child inside a larger space. + """ + def __init__(self, child, xscale=1.0, yscale=1.0, xalign=0.0, yalign=0.0, + min_width=0, min_height=0): + self.child = child + self.xscale = xscale + self.yscale = yscale + self.xalign = xalign + self.yalign = yalign + self.min_width = min_width + self.min_height = min_height + + def _calc_size(self): + width, height = self.child.get_size() + return max(self.min_width, width), max(self.min_height, height) + + def _calc_child_position(self, width, height): + req_width, req_height = self.child.get_size() + child_width = req_width + self.xscale * (width-req_width) + child_height = req_height + self.yscale * (height-req_height) + child_x = round(self.xalign * (width - child_width)) + child_y = round(self.yalign * (height - child_height)) + return child_x, child_y, child_width, child_height + + def _layout(self, context, x, y, width, height): + child_x, child_y, child_width, child_height = \ + self._calc_child_position(width, height) + self.child.draw(context, x + child_x, y + child_y, child_width, + child_height) + + def _find_child_at(self, x, y, width, height): + child_x, child_y, child_width, child_height = \ + self._calc_child_position(width, height) + if ((child_x <= x < child_x + child_width) and + (child_y <= y < child_y + child_height)): + return self.child, child_x, child_y, child_width, child_height + else: + return None # (x, y) is in the empty space around child + +class DrawingArea(Packer): + """Area that uses custom drawing code. + """ + def __init__(self, width, height, callback, *args): + self.width = width + self.height = height + self.callback_info = (callback, args) + + def _calc_size(self): + return self.width, self.height + + def _layout(self, context, x, y, width, height): + callback, args = self.callback_info + callback(context, x, y, width, height, *args) + + def _find_child_at(self, x, y, width, height): + return None + +class Background(Packer): + """Draws a background behind a child element. + """ + def __init__(self, child, min_width=0, min_height=0, margin=None): + self.child = child + self.min_width = min_width + self.min_height = min_height + self.margin = Margin(margin) + self.callback_info = None + + def set_callback(self, callback, *args): + self.callback_info = (callback, args) + + def _calc_size(self): + width, height = self.child.get_size() + width = max(self.min_width, width) + height = max(self.min_height, height) + return self.margin.outer_size((width, height)) + + def _layout(self, context, x, y, width, height): + if self.callback_info: + callback, args = self.callback_info + callback(context, x, y, width, height, *args) + self.child.draw(context, *self.margin.inner_rect(x, y, width, height)) + + def _find_child_at(self, x, y, width, height): + if not self.margin.point_in_margin(x, y, width, height): + return None + return (self.child,) + self.margin.inner_rect(0, 0, width, height) + +class Padding(Packer): + """Adds padding to the edges of a packer. + """ + def __init__(self, child, top=0, right=0, bottom=0, left=0): + self.child = child + self.margin = Margin((top, right, bottom, left)) + + def _calc_size(self): + return self.margin.outer_size(self.child.get_size()) + + def _layout(self, context, x, y, width, height): + self.child.draw(context, *self.margin.inner_rect(x, y, width, height)) + + def _find_child_at(self, x, y, width, height): + if not self.margin.point_in_margin(x, y, width, height): + return None + return (self.child,) + self.margin.inner_rect(0, 0, width, height) + +class TextBoxPacker(Packer): + """Base class for ClippedTextLine and ClippedTextBox. + """ + def _layout(self, context, x, y, width, height): + self.textbox.draw(context, x, y, width, height) + + def _find_child_at(self, x, y, width, height): + # We could return the TextBox here, but we know it doesn't have a + # find_hotspot() method + return None + +class ClippedTextBox(TextBoxPacker): + """A TextBox that gets clipped if it's larger than it's allocated + width. + """ + def __init__(self, textbox, min_width=0, min_height=0): + self.textbox = textbox + self.min_width = min_width + self.min_height = min_height + + def _calc_size(self): + height = max(self.min_height, self.textbox.font.line_height()) + return self.min_width, height + +class ClippedTextLine(TextBoxPacker): + """A single line of text that gets clipped if it's larger than the + space allocated to it. By default the clipping will happen at character + boundaries. + """ + def __init__(self, textbox, min_width=0): + self.textbox = textbox + self.textbox.set_wrap_style('char') + self.min_width = min_width + + def _calc_size(self): + return self.min_width, self.textbox.font.line_height() + +class TruncatedTextLine(ClippedTextLine): + def __init__(self, textbox, min_width=0): + ClippedTextLine.__init__(self, textbox, min_width) + self.textbox.set_wrap_style('truncated-char') + +class Hotspot(Packer): + """A Hotspot handles mouse click tracking. It's only purpose is + to store a name to return from ``find_hotspot()``. In terms of + layout, it simply renders it's child in it's allocated space. + """ + def __init__(self, name, child): + self.name = name + self.child = child + + def _calc_size(self): + return self.child.get_size() + + def _layout(self, context, x, y, width, height): + self.child.draw(context, x, y, width, height) + + def find_hotspot(self, x, y, width, height): + return self.name, x, y, width, height + +class Stack(Packer): + """Packer that stacks other packers on top of each other. + """ + def __init__(self): + self.children = [] + + def pack(self, packer): + self.children.append(packer) + + def pack_below(self, packer): + self.children.insert(0, packer) + + def _layout(self, context, x, y, width, height): + for packer in self.children: + packer._layout(context, x, y, width, height) + + def _calc_size(self): + """Calculate the size needed to hold the box. The return value gets + cached and return in get_size(). + """ + width = height = 0 + for packer in self.children: + child_width, child_height = packer.get_size() + width = max(width, child_width) + height = max(height, child_height) + return width, height + + def _find_child_at(self, x, y, width, height): + # Return the topmost packer + try: + top = self.children[-1] + except IndexError: + return None + else: + return top._find_child_at(x, y, width, height) + +def align_left(packer): + """Align a packer to the left side of it's allocated space.""" + return Alignment(packer, xalign=0.0, xscale=0.0) + +def align_right(packer): + """Align a packer to the right side of it's allocated space.""" + return Alignment(packer, xalign=1.0, xscale=0.0) + +def align_top(packer): + """Align a packer to the top side of it's allocated space.""" + return Alignment(packer, yalign=0.0, yscale=0.0) + +def align_bottom(packer): + """Align a packer to the bottom side of it's allocated space.""" + return Alignment(packer, yalign=1.0, yscale=0.0) + +def align_middle(packer): + """Align a packer to the middle of it's allocated space.""" + return Alignment(packer, yalign=0.5, yscale=0.0) + +def align_center(packer): + """Align a packer to the center of it's allocated space.""" + return Alignment(packer, xalign=0.5, xscale=0.0) + +def pad(packer, top=0, left=0, bottom=0, right=0): + """Add padding to a packer.""" + return Padding(packer, top, right, bottom, left) + +class LayoutRect(object): + """Lightweight object use to track rectangles inside a layout + + :attribute x: top coordinate, read-write + :attribute y: left coordinate, read-write + :attribute width: width of the rect, read-write + :attribute height: height of the rect, read-write + """ + + def __init__(self, x, y, width, height): + self.x = x + self.y = y + self.width = width + self.height = height + + def __str__(self): + return "LayoutRect(%s, %s, %s, %s)" % (self.x, self.y, self.width, + self.height) + + def __eq__(self, other): + my_values = (self.x, self.y, self.width, self.height) + try: + other_values = (other.x, other.y, other.width, other.height) + except AttributeError: + return NotImplemented + return my_values == other_values + + def subsection(self, left, right, top, bottom): + """Create a new LayoutRect from inside this one.""" + return LayoutRect(self.x + left, self.y + top, + self.width - left - right, self.height - top - bottom) + + def right_side(self, width): + """Create a new LayoutRect from the right side of this one.""" + return LayoutRect(self.right - width, self.y, width, self.height) + + def left_side(self, width): + """Create a new LayoutRect from the left side of this one.""" + return LayoutRect(self.x, self.y, width, self.height) + + def top_side(self, height): + """Create a new LayoutRect from the top side of this one.""" + return LayoutRect(self.x, self.y, self.width, height) + + def bottom_side(self, height): + """Create a new LayoutRect from the bottom side of this one.""" + return LayoutRect(self.x, self.bottom - height, self.width, height) + + def past_right(self, width): + """Create a LayoutRect width pixels to the right of this one>""" + return LayoutRect(self.right, self.y, width, self.height) + + def past_left(self, width): + """Create a LayoutRect width pixels to the right of this one>""" + return LayoutRect(self.x-width, self.y, width, self.height) + + def past_top(self, height): + """Create a LayoutRect height pixels above this one>""" + return LayoutRect(self.x, self.y-height, self.width, height) + + def past_bottom(self, height): + """Create a LayoutRect height pixels below this one>""" + return LayoutRect(self.x, self.bottom, self.width, height) + + def is_point_inside(self, x, y): + return (self.x <= x < self.x + self.width + and self.y <= y < self.y + self.height) + + def get_right(self): + return self.x + self.width + def set_right(self, right): + self.width = right - self.x + right = property(get_right, set_right) + + def get_bottom(self): + return self.y + self.height + def set_bottom(self, bottom): + self.height = bottom - self.y + bottom = property(get_bottom, set_bottom) + +class Layout(object): + """Store the layout for a cell + + Layouts are lightweight objects that keep track of where stuff is inside a + cell. They can be used for both rendering and hotspot tracking. + + :attribute last_rect: the LayoutRect most recently added to the layout + """ + + def __init__(self): + self._rects = [] + self.last_rect = None + + def rect_count(self): + """Get the number of rects in this layout.""" + return len(self._rects) + + def add(self, x, y, width, height, drawing_function=None, + hotspot=None): + """Add a new element to this Layout + + :param x: x coordinate + :param y: y coordinate + :param width: width + :param height: height + :param drawing_function: if set, call this function to render the + element on a DrawingContext + :param hotspot: if set, the hotspot for this element + + :returns: LayoutRect of the added element + """ + return self.add_rect(LayoutRect(x, y, width, height), + drawing_function, hotspot) + + def add_rect(self, layout_rect, drawing_function=None, hotspot=None): + """Add a new element to this Layout using a LayoutRect + + :param layout_rect: LayoutRect object for positioning + :param drawing_function: if set, call this function to render the + element on a DrawingContext + :param hotspot: if set, the hotspot for this element + :returns: LayoutRect of the added element + """ + self.last_rect = layout_rect + value = (layout_rect, drawing_function, hotspot) + self._rects.append(value) + return layout_rect + + def add_text_line(self, textbox, x, y, width, hotspot=None): + """Add one line of text from a text box to the layout + + This is convenience method that's equivelent to: + self.add(x, y, width, textbox.font.line_height(), textbox.draw, + hotspot) + """ + return self.add(x, y, width, textbox.font.line_height(), textbox.draw, + hotspot) + + def add_image(self, image, x, y, hotspot=None): + """Add an ImageSurface to the layout + + This is convenience method that's equivelent to: + self.add(x, y, image.width, image.height, image.draw, hotspot) + """ + width, height = image.get_size() + return self.add(x, y, width, height, image.draw, hotspot) + + def merge(self, layout): + """Add another layout's elements with this one + """ + self._rects.extend(layout._rects) + self.last_rect = layout.last_rect + + def translate(self, delta_x, delta_y): + """Move each element inside this layout """ + for rect, _, _ in self._rects: + rect.x += delta_x + rect.y += delta_y + + def max_width(self): + """Get the max width of the elements in current group.""" + return max(rect.width for (rect, _, _) in self._rects) + + def max_height(self): + """Get the max height of the elements in current group.""" + return max(rect.height for (rect, _, _) in self._rects) + + def center_x(self, left=None, right=None): + """Center each rect inside this layout horizontally. + + The left and right arguments control the area to center the rects to. + If one is missing, it will be calculated using largest width of the + layout. If both are missing, a ValueError will be thrown. + + :param left: left-side of the area to center to + :param right: right-side of the area to center to + """ + if left is None: + if right is None: + raise ValueError("both left and right are None") + left = right - self.max_width() + elif right is None: + right = left + self.max_width() + area_width = right - left + for rect, _, _ in self._rects: + rect.x = left + (area_width - rect.width) // 2 + + def center_y(self, top=None, bottom=None): + """Center each rect inside this layout vertically. + + The top and bottom arguments control the area to center the rects to. + If one is missing, it will be calculated using largest height in the + layout. If both are missing, a ValueError will be thrown. + + :param top: top of the area to center to + :param bottom: bottom of the area to center to + """ + if top is None: + if bottom is None: + raise ValueError("both top and bottom are None") + top = bottom - self.max_height() + elif bottom is None: + bottom = top + self.max_height() + area_height = bottom - top + for rect, _, _ in self._rects: + rect.y = top + (area_height - rect.height) // 2 + + def find_hotspot(self, x, y): + """Find a hotspot inside our rects. + + If (x, y) is inside any of the rects for this layout and that rect has + a hotspot set, a 3-tuple containing the hotspot name, and the x, y + coordinates relative to the hotspot rect. If no rect is found, we + return None. + + :param x: x coordinate to check + :param y: y coordinate to check + """ + for rect, drawing_function, hotspot in self._rects: + if hotspot is not None and rect.is_point_inside(x, y): + return hotspot, x - rect.x, y - rect.y + return None + + def draw(self, context): + """Render each layout rect onto context + + :param context: a DrawingContext to draw on + """ + + for rect, drawing_function, hotspot in self._rects: + if drawing_function is not None: + drawing_function(context, rect.x, rect.y, rect.width, + rect.height) diff --git a/mvc/widgets/cellpack.pyc b/mvc/widgets/cellpack.pyc Binary files differnew file mode 100644 index 0000000..7e3fde1 --- /dev/null +++ b/mvc/widgets/cellpack.pyc diff --git a/mvc/widgets/dialogs.py b/mvc/widgets/dialogs.py new file mode 100644 index 0000000..3ccdcd7 --- /dev/null +++ b/mvc/widgets/dialogs.py @@ -0,0 +1,276 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""``miro.frontends.widgets.dialogs`` -- Dialog boxes for the Widget +frontend. + +The difference between this module and rundialog.py is that rundialog +handles dialog boxes that are coming from the backend code. This +model handles dialogs that we create from the frontend + +One big difference is that we don't have to be as general about +dialogs, so they can present a somewhat nicer API. One important +difference is that all of the dialogs run modally. +""" + +from mvc.widgets import widgetset +from mvc.widgets import widgetutil + +class DialogButton(object): + def __init__(self, text): + self._text = text + def __eq__(self, other): + return isinstance(other, DialogButton) and self.text == other.text + def __str__(self): + return "DialogButton(%r)" % self.text + @property + def text(self): + return unicode(self._text) + +BUTTON_OK = DialogButton("OK") +BUTTON_APPLY = DialogButton("Apply") +BUTTON_CLOSE = DialogButton("Close") +BUTTON_CANCEL = DialogButton("Cancel") +BUTTON_DONE = DialogButton("Done") +BUTTON_YES = DialogButton("Yes") +BUTTON_NO = DialogButton("No") +BUTTON_QUIT = DialogButton("Quit") +BUTTON_CONTINUE = DialogButton("Continue") +BUTTON_IGNORE = DialogButton("Ignore") +BUTTON_IMPORT_FILES = DialogButton("Import Files") +BUTTON_SUBMIT_REPORT = DialogButton("Submit Crash Report") +BUTTON_MIGRATE = DialogButton("Migrate") +BUTTON_DONT_MIGRATE = DialogButton("Don't Migrate") +BUTTON_DOWNLOAD = DialogButton("Download") +BUTTON_REMOVE_ENTRY = DialogButton("Remove Entry") +BUTTON_DELETE_FILE = DialogButton("Delete File") +BUTTON_DELETE_FILES = DialogButton("Delete Files") +BUTTON_KEEP_VIDEOS = DialogButton("Keep Videos") +BUTTON_DELETE_VIDEOS = DialogButton("Delete Videos") +BUTTON_CREATE = DialogButton("Create") +BUTTON_CREATE_FEED = DialogButton("Create Podcast") +BUTTON_CREATE_FOLDER = DialogButton("Create Folder") +BUTTON_CHOOSE_NEW_FOLDER = DialogButton("Choose New Folder") +BUTTON_ADD_FOLDER = DialogButton("Add Folder") +BUTTON_ADD = DialogButton("Add") +BUTTON_ADD_INTO_NEW_FOLDER = DialogButton("Add Into New Folder") +BUTTON_KEEP = DialogButton("Keep") +BUTTON_DELETE = DialogButton("Delete") +BUTTON_REMOVE = DialogButton("Remove") +BUTTON_NOT_NOW = DialogButton("Not Now") +BUTTON_CLOSE_TO_TRAY = DialogButton("Close to Tray") +BUTTON_LAUNCH_MIRO = DialogButton("Launch Miro") +BUTTON_DOWNLOAD_ANYWAY = DialogButton("Download Anyway") +BUTTON_OPEN_IN_EXTERNAL_BROWSER = DialogButton( + "Open in External Browser") +BUTTON_DONT_INSTALL = DialogButton("Don't Install") +BUTTON_SUBSCRIBE = DialogButton("Subscribe") +BUTTON_STOP_WATCHING = DialogButton("Stop Watching") +BUTTON_RETRY = DialogButton("Retry") +BUTTON_START_FRESH = DialogButton("Start Fresh") +BUTTON_INCLUDE_DATABASE = DialogButton("Include Database") +BUTTON_DONT_INCLUDE_DATABASE = DialogButton( + "Don't Include Database") + +WARNING_MESSAGE = 0 +INFO_MESSAGE = 1 +CRITICAL_MESSAGE = 2 + + +class ProgressDialog(widgetset.Dialog): + def __init__(self, title): + widgetset.Dialog.__init__(self, title, description='') + self.progress_bar = widgetset.ProgressBar() + self.label = widgetset.Label() + self.label.set_size(1.2) + self.vbox = widgetset.VBox(spacing=6) + self.vbox.pack_end(widgetutil.align_center(self.label)) + self.vbox.pack_end(self.progress_bar) + self.set_extra_widget(self.vbox) + + def update(self, description, progress): + self.label.set_text(description) + if progress >= 0: + self.progress_bar.set_progress(progress) + self.progress_bar.stop_pulsing() + else: + self.progress_bar.start_pulsing() + +class DBUpgradeProgressDialog(widgetset.Dialog): + def __init__(self, title, text): + widgetset.Dialog.__init__(self, title) + self.progress_bar = widgetset.ProgressBar() + self.top_label = widgetset.Label() + self.top_label.set_text(text) + self.top_label.set_wrap(True) + self.top_label.set_size_request(350, -1) + self.label = widgetset.Label() + self.vbox = widgetset.VBox(spacing=6) + self.vbox.pack_end(widgetutil.align_center(self.label)) + self.vbox.pack_end(self.progress_bar) + self.vbox.pack_end(widgetutil.pad(self.top_label, bottom=6)) + self.set_extra_widget(self.vbox) + + def update(self, stage, stage_progress, progress): + self.label.set_text(stage) + self.progress_bar.set_progress(progress) + +def show_about(): + window = widgetset.AboutDialog() + set_transient_for_main(window) + try: + window.run() + finally: + window.destroy() + +def show_message(title, description, alert_type=INFO_MESSAGE, + transient_for=None): + """Display a message to the user and wait for them to click OK""" + window = widgetset.AlertDialog(title, description, alert_type) + _set_transient_for(window, transient_for) + try: + window.add_button(BUTTON_OK.text) + window.run() + finally: + window.destroy() + +def show_choice_dialog(title, description, choices, transient_for=None): + """Display a message to the user and wait for them to choose an option. + Returns the button object chosen.""" + window = widgetset.Dialog(title, description) + try: + for mem in choices: + window.add_button(mem.text) + response = window.run() + return choices[response] + finally: + window.destroy() + +def ask_for_string(title, description, initial_text=None, transient_for=None): + """Ask the user to enter a string in a TextEntry box. + + description - textual description with newlines + initial_text - None, string or callable to pre-populate the entry box + + Returns the value entered, or None if the user clicked cancel + """ + window = widgetset.Dialog(title, description) + try: + window.add_button(BUTTON_OK.text) + window.add_button(BUTTON_CANCEL.text) + entry = widgetset.TextEntry() + entry.set_activates_default(True) + if initial_text: + if callable(initial_text): + initial_text = initial_text() + entry.set_text(initial_text) + window.set_extra_widget(entry) + response = window.run() + if response == 0: + return entry.get_text() + else: + return None + finally: + window.destroy() + +def ask_for_choice(title, description, choices): + """Ask the user to enter a string in a TextEntry box. + + :param title: title for the window + :param description: textual description with newlines + :param choices: list of labels for choices + Returns the index of the value chosen, or None if the user clicked cancel + """ + window = widgetset.Dialog(title, description) + try: + window.add_button(BUTTON_OK.text) + window.add_button(BUTTON_CANCEL.text) + menu = widgetset.OptionMenu(choices) + window.set_extra_widget(menu) + response = window.run() + if response == 0: + return menu.get_selected() + else: + return None + finally: + window.destroy() + +def ask_for_open_pathname(title, initial_filename=None, filters=[], + transient_for=None, select_multiple=False): + """Returns the file pathname or None. + """ + window = widgetset.FileOpenDialog(title) + _set_transient_for(window, transient_for) + try: + if initial_filename: + window.set_filename(initial_filename) + + if filters: + window.add_filters(filters) + + if select_multiple: + window.set_select_multiple(select_multiple) + + response = window.run() + if response == 0: + if select_multiple: + return window.get_filenames() + else: + return window.get_filename() + finally: + window.destroy() + +def ask_for_save_pathname(title, initial_filename=None, transient_for=None): + """Returns the file pathname or None. + """ + window = widgetset.FileSaveDialog(title) + _set_transient_for(window, transient_for) + try: + if initial_filename: + window.set_filename(initial_filename) + response = window.run() + if response == 0: + return window.get_filename() + finally: + window.destroy() + +def ask_for_directory(title, initial_directory=None, transient_for=None): + """Returns the directory pathname or None. + """ + window = widgetset.DirectorySelectDialog(title) + _set_transient_for(window, transient_for) + try: + if initial_directory: + window.set_directory(initial_directory) + + response = window.run() + if response == 0: + return window.get_directory() + finally: + window.destroy() diff --git a/mvc/widgets/gtk/__init__.py b/mvc/widgets/gtk/__init__.py new file mode 100644 index 0000000..4f42a43 --- /dev/null +++ b/mvc/widgets/gtk/__init__.py @@ -0,0 +1,65 @@ +import os +import sys +import gtk +import gobject + +def initialize(app): + from gtkmenus import MainWindowMenuBar + app.menubar = MainWindowMenuBar() + app.startup() + app.run() + +def attach_menubar(): + from mvc.widgets import app + app.widgetapp.vbox.pack_start(app.widgetapp.menubar) + +def mainloop_start(): + gobject.threads_init() + gtk.main() + +def mainloop_stop(): + gtk.main_quit() + +def idle_add(callback, periodic=None): + if periodic is not None and periodic < 0: + raise ValueError('periodic cannot be negative') + def wrapper(): + callback() + return periodic is not None + delay = periodic + if delay is not None: + delay *= 1000 # milliseconds + else: + delay = 0 + return gobject.timeout_add(delay, wrapper) + +def idle_remove(id_): + gobject.source_remove(id_) + +def check_kde(): + return os.environ.get("KDE_FULL_SESSION", None) != None + +def open_file_linux(filename): + if check_kde(): + os.spawnlp(os.P_NOWAIT, "kfmclient", "kfmclient", + "exec", "file://" + filename) + else: + os.spawnlp(os.P_NOWAIT, "gnome-open", "gnome-open", filename) + +def reveal_file(filename): + if hasattr(os, 'startfile'): # Windows + os.startfile(os.path.dirname(filename)) + else: + open_file_linux(filename) + +def get_conversion_directory_windows(): + from mvc.windows import specialfolders + return specialfolders.base_movies_directory + +def get_conversion_directory_linux(): + return os.path.expanduser('~') + +if sys.platform == 'win32': + get_conversion_directory = get_conversion_directory_windows +else: + get_conversion_directory = get_conversion_directory_linux diff --git a/mvc/widgets/gtk/__init__.pyc b/mvc/widgets/gtk/__init__.pyc Binary files differnew file mode 100644 index 0000000..629b52d --- /dev/null +++ b/mvc/widgets/gtk/__init__.pyc diff --git a/mvc/widgets/gtk/base.py b/mvc/widgets/gtk/base.py new file mode 100644 index 0000000..e02db3f --- /dev/null +++ b/mvc/widgets/gtk/base.py @@ -0,0 +1,300 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".base -- Base classes for GTK Widgets.""" + +import gtk + +from mvc import signals +import wrappermap +from .weakconnect import weak_connect +import keymap + +def make_gdk_color(miro_color): + def convert_value(value): + return int(round(value * 65535)) + + values = tuple(convert_value(c) for c in miro_color) + return gtk.gdk.Color(*values) + +class Widget(signals.SignalEmitter): + """Base class for GTK widgets. + + The actual GTK Widget is stored in '_widget'. + + signals: + + 'size-allocated' (widget, width, height): The widget had it's size + allocated. + """ + def __init__(self, *signal_names): + signals.SignalEmitter.__init__(self, *signal_names) + self.create_signal('size-allocated') + self.create_signal('key-press') + self.create_signal('focus-out') + self.style_mods = {} + self.use_custom_style = False + self._disabled = False + + def wrapped_widget_connect(self, signal, method, *user_args): + """Connect to a signal of the widget we're wrapping. + + We use a weak reference to ensures that we don't have circular + references between the wrapped widget and the wrapper widget. + """ + return weak_connect(self._widget, signal, method, *user_args) + + def set_widget(self, widget): + self._widget = widget + wrappermap.add(self._widget, self) + if self.should_connect_to_hierarchy_changed(): + self.wrapped_widget_connect('hierarchy_changed', + self.on_hierarchy_changed) + self.wrapped_widget_connect('size-allocate', self.on_size_allocate) + self.wrapped_widget_connect('key-press-event', self.on_key_press) + self.wrapped_widget_connect('focus-out-event', self.on_focus_out) + self.use_custom_style_callback = None + + def should_connect_to_hierarchy_changed(self): + # GTK creates windows to handle submenus, which messes with our + # on_hierarchy_changed callback. We don't care about custom styles + # for menus anyways, so just ignore the signal. + return not isinstance(self._widget, gtk.MenuItem) + + def set_can_focus(self, allow): + """Set if we allow the widget to hold keyboard focus. + """ + if allow: + self._widget.set_flags(gtk.CAN_FOCUS) + else: + self._widget.unset_flags(gtk.CAN_FOCUS) + + def on_hierarchy_changed(self, widget, previous_toplevel): + toplevel = widget.get_toplevel() + if not (toplevel.flags() & gtk.TOPLEVEL): + toplevel = None + if previous_toplevel != toplevel: + if self.use_custom_style_callback: + old_window = wrappermap.wrapper(previous_toplevel) + old_window.disconnect(self.use_custom_style_callback) + if toplevel is not None: + window = wrappermap.wrapper(toplevel) + callback_id = window.connect('use-custom-style-changed', + self.on_use_custom_style_changed) + self.use_custom_style_callback = callback_id + else: + self.use_custom_style_callback = None + if previous_toplevel is None: + # Setup our initial state + self.on_use_custom_style_changed(window) + + def on_size_allocate(self, widget, allocation): + self.emit('size-allocated', allocation.width, allocation.height) + + def on_key_press(self, widget, event): + key_modifiers = keymap.translate_gtk_event(event) + if key_modifiers: + key, modifiers = key_modifiers + return self.emit('key-press', key, modifiers) + + def on_focus_out(self, widget, event): + self.emit('focus-out') + + def on_use_custom_style_changed(self, window): + self.use_custom_style = window.use_custom_style + if not self.style_mods: + return # no need to do any work here + if self.use_custom_style: + for (what, state), color in self.style_mods.items(): + self.do_modify_style(what, state, color) + else: + # This should reset the style changes we've made + self._widget.modify_style(gtk.RcStyle()) + self.handle_custom_style_change() + + def handle_custom_style_change(self): + """Called when the user changes a from a theme where we don't want to + use our custom style to one where we do, or vice-versa. The Widget + class handles changes that used modify_style(), but subclasses might + want to do additional work. + """ + pass + + def modify_style(self, what, state, color): + """Change the style of our widget. This method checks to see if we + think the user's theme is compatible with our stylings, and doesn't + change things if not. what is either 'base', 'text', 'bg' or 'fg' + depending on which color is to be changed. + """ + if self.use_custom_style: + self.do_modify_style(what, state, color) + self.style_mods[(what, state)] = color + + def unmodify_style(self, what, state): + if (what, state) in self.style_mods: + del self.style_mods[(what, state)] + default_color = getattr(self.style, what)[state] + self.do_modify_style(what, state, default_color) + + def do_modify_style(self, what, state, color): + if what == 'base': + self._widget.modify_base(state, color) + elif what == 'text': + self._widget.modify_text(state, color) + elif what == 'bg': + self._widget.modify_bg(state, color) + elif what == 'fg': + self._widget.modify_fg(state, color) + else: + raise ValueError("Unknown what in do_modify_style: %s" % what) + + def get_window(self): + gtk_window = self._widget.get_toplevel() + return wrappermap.wrapper(gtk_window) + + def clear_size_request_cache(self): + # This is just an OS X hack + pass + + def get_size_request(self): + return self._widget.size_request() + + def invalidate_size_request(self): + self._widget.queue_resize() + + def set_size_request(self, width, height): + if not width >= -1 and height >= -1: + raise ValueError("invalid dimensions in set_size_request: %s" % + repr((width, height))) + self._widget.set_size_request(width, height) + + def relative_position(self, other_widget): + return other_widget._widget.translate_coordinates(self._widget, 0, 0) + + def convert_gtk_color(self, color): + return (color.red / 65535.0, color.green / 65535.0, + color.blue / 65535.0) + + def get_width(self): + try: + return self._widget.allocation.width + except AttributeError: + return -1 + width = property(get_width) + + def get_height(self): + try: + return self._widget.allocation.height + except AttributeError: + return -1 + height = property(get_height) + + def queue_redraw(self): + if self._widget: + self._widget.queue_draw() + + def redraw_now(self): + if self._widget: + self._widget.queue_draw() + self._widget.window.process_updates(True) + + def forward_signal(self, signal_name, forwarded_signal_name=None): + """Add a callback so that when the GTK widget emits a signal, we emit + signal from the wrapper widget. + """ + if forwarded_signal_name is None: + forwarded_signal_name = signal_name + self.wrapped_widget_connect(signal_name, self.do_forward_signal, + forwarded_signal_name) + + def do_forward_signal(self, widget, *args): + forwarded_signal_name = args[-1] + args = args[:-1] + self.emit(forwarded_signal_name, *args) + + def make_color(self, miro_color): + color = make_gdk_color(miro_color) + self._widget.get_colormap().alloc_color(color) + return color + + def enable(self): + self._disabled = False + self._widget.set_sensitive(True) + + def disable(self): + self._disabled = True + self._widget.set_sensitive(False) + + def set_disabled(self, disabled): + if disabled: + self.disable() + else: + self.enable() + + def get_disabled(self): + return self._disabled + +class Bin(Widget): + def __init__(self): + Widget.__init__(self) + self.child = None + + def add(self, child): + if self.child is not None: + raise ValueError("Already have a child: %s" % self.child) + if child._widget.parent is not None: + raise ValueError("%s already has a parent" % child) + self.child = child + self.add_child_to_widget() + child._widget.show() + + def add_child_to_widget(self): + self._widget.add(self.child._widget) + + def remove_child_from_widget(self): + if self._widget.get_child() is not None: + # otherwise gtkmozembed gets confused + self._widget.get_child().hide() + self._widget.remove(self._widget.get_child()) + + + def remove(self): + if self.child is not None: + self.child = None + self.remove_child_from_widget() + + def set_child(self, new_child): + self.remove() + self.add(new_child) + + def enable(self): + self.child.enable() + + def disable(self): + self.child.disable() diff --git a/mvc/widgets/gtk/base.pyc b/mvc/widgets/gtk/base.pyc Binary files differnew file mode 100644 index 0000000..df6c1f9 --- /dev/null +++ b/mvc/widgets/gtk/base.pyc diff --git a/mvc/widgets/gtk/const.py b/mvc/widgets/gtk/const.py new file mode 100644 index 0000000..5e9ec05 --- /dev/null +++ b/mvc/widgets/gtk/const.py @@ -0,0 +1,44 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".const -- Constants.""" + +import gtk + +DRAG_ACTION_NONE = 0 +DRAG_ACTION_COPY = gtk.gdk.ACTION_COPY +DRAG_ACTION_MOVE = gtk.gdk.ACTION_MOVE +DRAG_ACTION_LINK = gtk.gdk.ACTION_LINK +DRAG_ACTION_ALL = DRAG_ACTION_COPY | DRAG_ACTION_MOVE | DRAG_ACTION_LINK + +ITEM_TITLE_FONT = "Helvetica" +ITEM_DESC_FONT = "Helvetica" +ITEM_INFO_FONT = "Lucida Grande" + +TOOLBAR_GRAY = (0.2, 0.2, 0.2) diff --git a/mvc/widgets/gtk/const.pyc b/mvc/widgets/gtk/const.pyc Binary files differnew file mode 100644 index 0000000..fc565b6 --- /dev/null +++ b/mvc/widgets/gtk/const.pyc diff --git a/mvc/widgets/gtk/contextmenu.py b/mvc/widgets/gtk/contextmenu.py new file mode 100644 index 0000000..cd5b6ba --- /dev/null +++ b/mvc/widgets/gtk/contextmenu.py @@ -0,0 +1,31 @@ +import gtk + +from .base import Widget + +class ContextMenu(Widget): + + def __init__(self, options): + super(ContextMenu, self).__init__() + self.set_widget(gtk.Menu()) + for i, item_info in enumerate(options): + if item_info is None: + # separator + item = gtk.SeparatorMenuItem() + else: + label, callback = item_info + item = gtk.MenuItem(label) + if isinstance(callback, list): + submenu = ContextMenu(callback) + item.set_submenu(submenu._widget) + elif callback is not None: + item.connect('activate', self.on_activate, callback, i) + else: + item.set_sensitive(False) + self._widget.append(item) + item.show() + + def popup(self): + self._widget.popup(None, None, None, 0, 0) + + def on_activate(self, widget, callback, i): + callback(self, i) diff --git a/mvc/widgets/gtk/contextmenu.pyc b/mvc/widgets/gtk/contextmenu.pyc Binary files differnew file mode 100644 index 0000000..5fabfa0 --- /dev/null +++ b/mvc/widgets/gtk/contextmenu.pyc diff --git a/mvc/widgets/gtk/controls.py b/mvc/widgets/gtk/controls.py new file mode 100644 index 0000000..4367c1f --- /dev/null +++ b/mvc/widgets/gtk/controls.py @@ -0,0 +1,337 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".controls -- Control Widgets.""" + +import gtk +import pango + +from mvc.widgets import widgetconst +import layout +from .base import Widget +from .simple import Label + +class BinBaselineCalculator(object): + """Mixin class that defines the baseline method for gtk.Bin subclasses, + where the child is the label that we are trying to get the baseline for. + """ + + def baseline(self): + my_size = self._widget.size_request() + child_size = self._widget.child.size_request() + ypad = (my_size[1] - child_size[1]) / 2 + + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + +class TextEntry(Widget): + entry_class = gtk.Entry + def __init__(self, initial_text=None): + Widget.__init__(self) + self.create_signal('activate') + self.create_signal('changed') + self.create_signal('validate') + self.set_widget(self.entry_class()) + self.forward_signal('activate') + self.forward_signal('changed') + if initial_text is not None: + self.set_text(initial_text) + + def focus(self): + self._widget.grab_focus() + + def start_editing(self, text): + self.set_text(text) + self.focus() + self._widget.emit('move-cursor', gtk.MOVEMENT_BUFFER_ENDS, 1, False) + + def set_text(self, text): + self._widget.set_text(text) + + def get_text(self): + return self._widget.get_text().decode('utf-8') + + def set_max_length(self, chars): + self._widget.set_max_length(chars) + + def set_width(self, chars): + self._widget.set_width_chars(chars) + + def set_invisible(self, setting): + self._widget.props.visibility = not setting + + def set_activates_default(self, setting): + self._widget.set_activates_default(setting) + + def baseline(self): + layout_height = pango.PIXELS(self._widget.get_layout().get_size()[1]) + ypad = (self._widget.size_request()[1] - layout_height) / 2 + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + + +class NumberEntry(TextEntry): + def __init__(self, initial_text=None): + TextEntry.__init__(self, initial_text) + self._widget.connect('changed', self.validate) + self.previous_text = initial_text or "" + + def validate(self, entry): + text = self.get_text() + if text.isdigit() or not text: + self.previous_text = text + else: + self._widget.set_text(self.previous_text) + +class SecureTextEntry(TextEntry): + def __init__(self, initial_text=None): + TextEntry.__init__(self, initial_text) + self.set_invisible(True) + +class MultilineTextEntry(Widget): + entry_class = gtk.TextView + def __init__(self, initial_text=None, border=False): + Widget.__init__(self) + self.set_widget(self.entry_class()) + if initial_text is not None: + self.set_text(initial_text) + self._widget.set_wrap_mode(gtk.WRAP_WORD) + self._widget.set_accepts_tab(False) + self.border = border + + def focus(self): + self._widget.grab_focus() + + def set_text(self, text): + self._widget.get_buffer().set_text(text) + + def get_text(self): + buffer_ = self._widget.get_buffer() + return buffer_.get_text(*(buffer_.get_bounds())).decode('utf-8') + + def baseline(self): + # FIXME + layout_height = pango.PIXELS(self._widget.get_layout().get_size()[1]) + ypad = (self._widget.size_request()[1] - layout_height) / 2 + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + + def set_editable(self, editable): + self._widget.set_editable(editable) + +class Checkbox(Widget, BinBaselineCalculator): + """Widget that the user can toggle on or off.""" + + def __init__(self, text=None, bold=False, color=None): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + if text is None: + text = '' + self.set_widget(gtk.CheckButton()) + self.label = Label(text, color=color) + self._widget.add(self.label._widget) + self.label._widget.show() + self.create_signal('toggled') + self.forward_signal('toggled') + if bold: + self.label.set_bold(True) + + def get_checked(self): + return self._widget.get_active() + + def set_checked(self, value): + self._widget.set_active(value) + + def set_size(self, scale_factor): + self.label.set_size(scale_factor) + + def get_text_padding(self): + """ + Returns the amount of space the checkbox takes up before the label. + """ + indicator_size = self._widget.style_get_property('indicator-size') + indicator_spacing = self._widget.style_get_property( + 'indicator-spacing') + focus_width = self._widget.style_get_property('focus-line-width') + focus_padding = self._widget.style_get_property('focus-padding') + return (indicator_size + 3 * indicator_spacing + 2 * (focus_width + + focus_padding)) + +class RadioButtonGroup(Widget, BinBaselineCalculator): + """RadioButtonGroup. + + Create the group, then create a bunch of RadioButtons passing in the group. + + NB: GTK has built-in radio button grouping functionality, and we should + be using that but we need this widget for portable code. We create + a dummy GTK radio button and make this the "root" button which gets + inherited by all buttons in this radio button group. + """ + def __init__(self): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + self.set_widget(gtk.RadioButton(label="")) + self._widget.set_active(False) + self._buttons = [] + + def add_button(self, button): + self._buttons.append(button) + + def get_buttons(self): + return self._buttons + + def get_selected(self): + for mem in self._buttons: + if mem.get_selected(): + return mem + + def set_selected(self, button): + for mem in self._buttons: + if mem is button: + mem._widget.set_active(True) + else: + mem._widget.set_active(False) + +class RadioButton(Widget, BinBaselineCalculator): + """RadioButton.""" + def __init__(self, label, group=None, color=None): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + if group: + self.group = group + else: + self.group = RadioButtonGroup() + self.set_widget(gtk.RadioButton(group=self.group._widget)) + self.label = Label(label, color=color) + self._widget.add(self.label._widget) + self.label._widget.show() + self.create_signal('clicked') + self.forward_signal('clicked') + + group.add_button(self) + + def set_size(self, size): + self.label.set_size(size) + + def get_group(self): + return self.group + + def get_selected(self): + return self._widget.get_active() + + def set_selected(self): + self.group.set_selected(self) + +class Button(Widget, BinBaselineCalculator): + def __init__(self, text, style='normal', width=None): + Widget.__init__(self) + BinBaselineCalculator.__init__(self) + # We just ignore style here, GTK users expect their own buttons. + self.set_widget(gtk.Button()) + self.create_signal('clicked') + self.forward_signal('clicked') + self.label = Label(text) + # only honor width if its bigger than the width we need to display the + # label (#18994) + if width and width > self.label.get_width(): + alignment = layout.Alignment(0.5, 0.5, 0, 0) + alignment.set_size_request(width, -1) + alignment.add(self.label) + self._widget.add(alignment._widget) + else: + self._widget.add(self.label._widget) + self.label._widget.show() + + def set_text(self, title): + self.label.set_text(title) + + def set_bold(self, bold): + self.label.set_bold(bold) + + def set_size(self, scale_factor): + self.label.set_size(scale_factor) + + def set_color(self, color): + self.label.set_color(color) + +class OptionMenu(Widget): + def __init__(self, options): + Widget.__init__(self) + self.create_signal('changed') + + self.set_widget(gtk.ComboBox(gtk.ListStore(str, str))) + self.cell = gtk.CellRendererText() + self._widget.pack_start(self.cell, True) + self._widget.add_attribute(self.cell, 'text', 0) + if options: + for option, value in options: + self._widget.get_model().append((option, value)) + self._widget.set_active(0) + self.options = options + self.wrapped_widget_connect('changed', self.on_changed) + + def baseline(self): + my_size = self._widget.size_request() + child_size = self._widget.child.size_request() + ypad = self.cell.props.ypad + (my_size[1] - child_size[1]) / 2 + + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self._widget.style.font_desc) + return pango.PIXELS(metrics.get_descent()) + ypad + + def set_bold(self, bold): + if bold: + self.cell.props.weight = pango.WEIGHT_BOLD + else: + self.cell.props.weight = pango.WEIGHT_NORMAL + + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self.cell.props.scale = 1 + else: + self.cell.props.scale = 0.75 + + def set_color(self, color): + self.cell.props.foreground_gdk = self.make_color(color) + + def set_selected(self, index): + self._widget.set_active(index) + + def get_selected(self): + return self._widget.get_active() + + def on_changed(self, widget): + index = widget.get_active() + self.emit('changed', index) + + def set_width(self, width): + self._widget.set_property('width-request', width) diff --git a/mvc/widgets/gtk/controls.pyc b/mvc/widgets/gtk/controls.pyc Binary files differnew file mode 100644 index 0000000..a4a2179 --- /dev/null +++ b/mvc/widgets/gtk/controls.pyc diff --git a/mvc/widgets/gtk/customcontrols.py b/mvc/widgets/gtk/customcontrols.py new file mode 100644 index 0000000..070cebd --- /dev/null +++ b/mvc/widgets/gtk/customcontrols.py @@ -0,0 +1,517 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".controls -- Contains the ControlBox and +CustomControl classes. These handle the custom buttons/sliders used during +playback. +""" + +from __future__ import division +import math + +import gtk +import gobject + +import wrappermap +from .base import Widget +from .simple import Label, Image +from .drawing import (CustomDrawingMixin, Drawable, + ImageSurface) +from mvc.widgets import widgetconst + +class CustomControlMixin(CustomDrawingMixin): + def do_expose_event(self, event): + CustomDrawingMixin.do_expose_event(self, event) + if self.is_focus(): + style = self.get_style() + style.paint_focus(self.window, self.state, + event.area, self, None, self.allocation.x, + self.allocation.y, self.allocation.width, + self.allocation.height) + +class CustomButtonWidget(CustomControlMixin, gtk.Button): + def draw(self, wrapper, context): + if self.is_active(): + wrapper.state = 'pressed' + elif self.state == gtk.STATE_PRELIGHT: + wrapper.state = 'hover' + else: + wrapper.state = 'normal' + wrapper.draw(context, wrapper.layout_manager) + self.set_focus_on_click(False) + + def is_active(self): + return self.state == gtk.STATE_ACTIVE + +class ContinuousCustomButtonWidget(CustomButtonWidget): + def is_active(self): + return (self.state == gtk.STATE_ACTIVE or + wrappermap.wrapper(self).button_down) + +class DragableCustomButtonWidget(CustomButtonWidget): + def __init__(self): + CustomButtonWidget.__init__(self) + self.button_press_x = None + self.set_events(self.get_events() | gtk.gdk.POINTER_MOTION_MASK) + + def do_button_press_event(self, event): + self.button_press_x = event.x + self.last_drag_event = None + gtk.Button.do_button_press_event(self, event) + + def do_button_release_event(self, event): + self.button_press_x = None + gtk.Button.do_button_release_event(self, event) + + def do_motion_notify_event(self, event): + DRAG_THRESHOLD = 15 + if self.button_press_x is None: + # button not down + return + if (self.last_drag_event != 'right' and + event.x > self.button_press_x + DRAG_THRESHOLD): + wrappermap.wrapper(self).emit('dragged-right') + self.last_drag_event = 'right' + elif (self.last_drag_event != 'left' and + event.x < self.button_press_x - DRAG_THRESHOLD): + wrappermap.wrapper(self).emit('dragged-left') + self.last_drag_event = 'left' + + def do_clicked(self): + # only emit clicked if we didn't emit dragged-left or dragged-right + if self.last_drag_event is None: + wrappermap.wrapper(self).emit('clicked') + +class _DragInfo(object): + """Info about the start of a drag. + + Attributes: + + - button: button that started the drag + - start_pos: position of the slider + - click_pos: position of the click + + Note that start_pos and click_pos will be different if the user clicks + inside the slider. + """ + + def __init__(self, button, start_pos, click_pos): + self.button = button + self.start_pos = start_pos + self.click_pos = click_pos + +class CustomScaleMixin(CustomControlMixin): + def __init__(self): + CustomControlMixin.__init__(self) + self.drag_info = None + self.min = self.max = 0.0 + + def get_range(self): + return self.min, self.max + + def set_range(self, min, max): + self.min = float(min) + self.max = float(max) + gtk.Range.set_range(self, min, max) + + def is_continuous(self): + return wrappermap.wrapper(self).is_continuous() + + def is_horizontal(self): + # this comes from a mixin + pass + + def gtk_scale_class(self): + if self.is_horizontal(): + return gtk.HScale + else: + return gtk.VScale + + def get_slider_pos(self, value=None): + if value is None: + value = self.get_value() + if self.is_horizontal(): + size = self.allocation.width + else: + size = self.allocation.height + ratio = (float(value) - self.min) / (self.max - self.min) + start_pos = self.slider_size() / 2.0 + return start_pos + ratio * (size - self.slider_size()) + + def slider_size(self): + return wrappermap.wrapper(self).slider_size() + + def _event_pos(self, event): + """Get the position of an event. + + If we are horizontal, this will be the x coordinate. If we are + vertical, the y. + """ + if self.is_horizontal(): + return event.x + else: + return event.y + + def do_button_press_event(self, event): + if self.drag_info is not None: + return + current_pos = self.get_slider_pos() + event_pos = self._event_pos(event) + pos_difference = abs(current_pos - event_pos) + # only move the slider if the click was outside its boundaries + # (#18840) + if pos_difference > self.slider_size() / 2.0: + self.move_slider(event_pos) + current_pos = event_pos + self.drag_info = _DragInfo(event.button, current_pos, event_pos) + self.grab_focus() + wrappermap.wrapper(self).emit('pressed') + + def do_motion_notify_event(self, event): + if self.drag_info is not None: + event_pos = self._event_pos(event) + delta = event_pos - self.drag_info.click_pos + self.move_slider(self.drag_info.start_pos + delta) + + def move_slider(self, new_pos): + """Move the slider so that it's centered on new_pos.""" + if self.is_horizontal(): + size = self.allocation.width + else: + size = self.allocation.height + + slider_size = self.slider_size() + new_pos -= slider_size / 2 + size -= slider_size + ratio = max(0, min(1, float(new_pos) / size)) + self.set_value(ratio * (self.max - self.min)) + + wrappermap.wrapper(self).emit('moved', self.get_value()) + if self.is_continuous(): + wrappermap.wrapper(self).emit('changed', self.get_value()) + + def handle_drag_out_of_bounds(self): + if not self.is_continuous(): + self.set_value(self.start_value) + + def do_button_release_event(self, event): + if self.drag_info is None or event.button != self.drag_info.button: + return + self.drag_info = None + if (self.is_continuous and + (0 <= event.x < self.allocation.width) and + (0 <= event.y < self.allocation.height)): + wrappermap.wrapper(self).emit('changed', self.get_value()) + wrappermap.wrapper(self).emit('released') + + def do_scroll_event(self, event): + wrapper = wrappermap.wrapper(self) + if self.is_horizontal(): + if event.direction == gtk.gdk.SCROLL_UP: + event.direction = gtk.gdk.SCROLL_DOWN + elif event.direction == gtk.gdk.SCROLL_DOWN: + event.direction = gtk.gdk.SCROLL_UP + if (wrapper._scroll_step is not None and + event.direction in (gtk.gdk.SCROLL_UP, gtk.gdk.SCROLL_DOWN)): + # handle the scroll ourself + if event.direction == gtk.gdk.SCROLL_DOWN: + delta = wrapper._scroll_step + else: + delta = -wrapper._scroll_step + self.set_value(self.get_value() + delta) + else: + # let GTK handle the scroll + self.gtk_scale_class().do_scroll_event(self, event) + # Treat mouse scrolls as if the user clicked on the new position + wrapper.emit('pressed') + wrapper.emit('changed', self.get_value()) + wrapper.emit('released') + + def do_move_slider(self, scroll): + if self.is_horizontal(): + if scroll == gtk.SCROLL_STEP_UP: + scroll = gtk.SCROLL_STEP_DOWN + elif scroll == gtk.SCROLL_STEP_DOWN: + scroll = gtk.SCROLL_STEP_UP + elif scroll == gtk.SCROLL_PAGE_UP: + scroll = gtk.SCROLL_PAGE_DOWN + elif scroll == gtk.SCROLL_PAGE_DOWN: + scroll = gtk.SCROLL_PAGE_UP + elif scroll == gtk.SCROLL_START: + scroll = gtk.SCROLL_END + elif scroll == gtk.SCROLL_END: + scroll = gtk.SCROLL_START + return self.gtk_scale_class().do_move_slider(self, scroll) + +class CustomHScaleWidget(CustomScaleMixin, gtk.HScale): + def __init__(self): + CustomScaleMixin.__init__(self) + gtk.HScale.__init__(self) + + def is_horizontal(self): + return True + +class CustomVScaleWidget(CustomScaleMixin, gtk.VScale): + def __init__(self): + CustomScaleMixin.__init__(self) + gtk.VScale.__init__(self) + + def is_horizontal(self): + return False + +gobject.type_register(CustomButtonWidget) +gobject.type_register(ContinuousCustomButtonWidget) +gobject.type_register(DragableCustomButtonWidget) +gobject.type_register(CustomHScaleWidget) +gobject.type_register(CustomVScaleWidget) + +class CustomControlBase(Drawable, Widget): + def __init__(self): + Widget.__init__(self) + Drawable.__init__(self) + self._gtk_cursor = None + self._entry_handlers = None + + def _connect_enter_notify_handlers(self): + if self._entry_handlers is None: + self._entry_handlers = [ + self.wrapped_widget_connect('enter-notify-event', + self.on_enter_notify), + self.wrapped_widget_connect('leave-notify-event', + self.on_leave_notify), + self.wrapped_widget_connect('button-release-event', + self.on_click) + ] + + def _disconnect_enter_notify_handlers(self): + if self._entry_handlers is not None: + for handle in self._entry_handlers: + self._widget.disconnect(handle) + self._entry_handlers = None + + def set_cursor(self, cursor): + if cursor == widgetconst.CURSOR_NORMAL: + self._gtk_cursor = None + self._disconnect_enter_notify_handlers() + elif cursor == widgetconst.CURSOR_POINTING_HAND: + self._gtk_cursor = gtk.gdk.Cursor(gtk.gdk.HAND2) + self._connect_enter_notify_handlers() + else: + raise ValueError("Unknown cursor: %s" % cursor) + + def on_enter_notify(self, widget, event): + self._widget.window.set_cursor(self._gtk_cursor) + + def on_leave_notify(self, widget, event): + if self._widget.window: + self._widget.window.set_cursor(None) + + def on_click(self, widget, event): + self.emit('clicked') + return True + +class CustomButton(CustomControlBase): + def __init__(self): + """Create a new CustomButton. active_image will be displayed while + the button is pressed. The image must have the same size. + """ + CustomControlBase.__init__(self) + self.set_widget(CustomButtonWidget()) + self.create_signal('clicked') + self.forward_signal('clicked') + +class DragableCustomButton(CustomControlBase): + def __init__(self): + CustomControlBase.__init__(self) + self.set_widget(DragableCustomButtonWidget()) + self.create_signal('clicked') + self.create_signal('dragged-left') + self.create_signal('dragged-right') + +class CustomSlider(CustomControlBase): + def __init__(self): + CustomControlBase.__init__(self) + self.create_signal('pressed') + self.create_signal('released') + self.create_signal('changed') + self.create_signal('moved') + self._scroll_step = None + if self.is_horizontal(): + self.set_widget(CustomHScaleWidget()) + else: + self.set_widget(CustomVScaleWidget()) + self.wrapped_widget_connect('move-slider', self.on_slider_move) + + def on_slider_move(self, widget, scrolltype): + self.emit('changed', widget.get_value()) + self.emit('moved', widget.get_value()) + + def get_value(self): + return self._widget.get_value() + + def set_value(self, value): + self._widget.set_value(value) + + def get_range(self): + return self._widget.get_range() + + def get_slider_pos(self, value=None): + """Get the position for the slider for our current value. + + This will return position that the slider should be centered on to + display the value. It will be the x coordinate if is_horizontal() is + True and the y coordinate otherwise. + + This method takes into acount the size of the slider when calculating + the position. The slider position will start at (slider_size / 2) and + will end (slider_size / 2) px before the end of the widget. + + :param value: value to get the position for. Defaults to the current + value + """ + return self._widget.get_slider_pos(value) + + def set_range(self, min_value, max_value): + self._widget.set_range(min_value, max_value) + # set_digits controls the precision of the scale by limiting changes + # to a certain number of digits. If the range is [0, 1], this code + # will give us 4 digits of precision, which seems reasonable. + range = max_value - min_value + self._widget.set_digits(int(round(math.log10(10000.0 / range)))) + + def set_increments(self, small_step, big_step, scroll_step=None): + """Set the increments to scroll. + + :param small_step: scroll amount for up/down + :param big_step: scroll amount for page up/page down. + :param scroll_step: scroll amount for mouse wheel, or None to make + this 2 times the small step + """ + self._widget.set_increments(small_step, big_step) + self._scroll_step = scroll_step + +def to_miro_volume(value): + """Convert from 0 to 1.0 to 0.0 to MAX_VOLUME. + """ + if value == 0: + return 0.0 + return value * widgetconst.MAX_VOLUME + +def to_gtk_volume(value): + """Convert from 0.0 to MAX_VOLUME to 0 to 1.0. + """ + if value > 0.0: + value = (value / widgetconst.MAX_VOLUME) + return value + +if hasattr(gtk.VolumeButton, "get_popup"): + # FIXME - Miro on Windows has an old version of gtk (2.16) and + # doesn't have the get_popup method. Once we upgrade and + # fix that, we can take out the hasattr check. + + class VolumeMuter(Label): + """Empty space that has a clicked signal so it can be dropped + in place of the VolumeMuter. + """ + def __init__(self): + Label.__init__(self) + self.create_signal("clicked") + + class VolumeSlider(Widget): + """VolumeSlider that uses the gtk.VolumeButton(). + """ + def __init__(self): + Widget.__init__(self) + self.set_widget(gtk.VolumeButton()) + self.wrapped_widget_connect('value-changed', self.on_value_changed) + self._widget.get_popup().connect("hide", self.on_hide) + self.create_signal('changed') + self.create_signal('released') + + def on_value_changed(self, *args): + value = self.get_value() + self.emit('changed', value) + + def on_hide(self, *args): + self.emit('released') + + def get_value(self): + value = self._widget.get_property('value') + return to_miro_volume(value) + + def set_value(self, value): + value = to_gtk_volume(value) + self._widget.set_property('value', value) + +class ClickableImageButton(CustomButton): + """Image that can send clicked events. If max_width and/or max_height are + specified, resizes the image proportionally such that all constraints are + met. + """ + def __init__(self, image_path, max_width=None, max_height=None): + CustomButton.__init__(self) + self.max_width = max_width + self.max_height = max_height + self.image = None + self._width, self._height = None, None + if image_path: + self.set_path(image_path) + self.set_cursor(widgetconst.CURSOR_POINTING_HAND) + + def set_path(self, path): + image = Image(path) + if self.max_width: + image = image.resize_for_space(self.max_width, self.max_height) + self.image = ImageSurface(image) + self._width, self._height = image.width, image.height + + def size_request(self, layout): + w = self._width + h = self._height + if not w: + w = self.max_width + if not h: + h = self.max_height + return w, h + + def draw(self, context, layout): + if self.image: + self.image.draw(context, 0, 0, self._width, self._height) + w = self._width + h = self._height + if not w: + w = self.max_width + if not h: + h = self.max_height + w = min(context.width, w) + h = min(context.height, h) + context.rectangle(0, 0, w, h) + context.set_color((0, 0, 0)) # black + context.set_line_width(1) + context.stroke() diff --git a/mvc/widgets/gtk/customcontrols.pyc b/mvc/widgets/gtk/customcontrols.pyc Binary files differnew file mode 100644 index 0000000..ff42ada --- /dev/null +++ b/mvc/widgets/gtk/customcontrols.pyc diff --git a/mvc/widgets/gtk/drawing.py b/mvc/widgets/gtk/drawing.py new file mode 100644 index 0000000..5888851 --- /dev/null +++ b/mvc/widgets/gtk/drawing.py @@ -0,0 +1,268 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".drawing -- Contains classes used to draw on +widgets. +""" + +import cairo +import gobject +import gtk + +import wrappermap +from .base import Widget, Bin +from .layoutmanager import LayoutManager + +def css_to_color(css_string): + parts = (css_string[1:3], css_string[3:5], css_string[5:7]) + return tuple((int(value, 16) / 255.0) for value in parts) + +class ImageSurface: + def __init__(self, image): + format = cairo.FORMAT_RGB24 + if image.pixbuf.get_has_alpha(): + format = cairo.FORMAT_ARGB32 + self.image = cairo.ImageSurface( + format, int(image.width), int(image.height)) + context = cairo.Context(self.image) + gdkcontext = gtk.gdk.CairoContext(context) + gdkcontext.set_source_pixbuf(image.pixbuf, 0, 0) + gdkcontext.paint() + self.pattern = cairo.SurfacePattern(self.image) + self.pattern.set_extend(cairo.EXTEND_REPEAT) + self.width = image.width + self.height = image.height + + def get_size(self): + return self.width, self.height + + def _align_pattern(self, x, y): + """Line up our image pattern so that it's top-left corner is x, y.""" + m = cairo.Matrix() + m.translate(-x, -y) + self.pattern.set_matrix(m) + + def draw(self, context, x, y, width, height, fraction=1.0): + self._align_pattern(x, y) + cairo_context = context.context + cairo_context.save() + cairo_context.set_source(self.pattern) + cairo_context.new_path() + cairo_context.rectangle(x, y, width, height) + if fraction >= 1.0: + cairo_context.fill() + else: + cairo_context.clip() + cairo_context.paint_with_alpha(fraction) + cairo_context.restore() + + def draw_rect(self, context, dest_x, dest_y, source_x, source_y, + width, height, fraction=1.0): + + self._align_pattern(dest_x-source_x, dest_y-source_y) + cairo_context = context.context + cairo_context.save() + cairo_context.set_source(self.pattern) + cairo_context.new_path() + cairo_context.rectangle(dest_x, dest_y, width, height) + if fraction >= 1.0: + cairo_context.fill() + else: + cairo_context.clip() + cairo_context.paint_with_alpha(fraction) + cairo_context.restore() + +class DrawingStyle(object): + def __init__(self, widget, use_base_color=False, state=None): + if state is None: + state = widget._widget.state + self.use_custom_style = widget.use_custom_style + self.style = widget._widget.style + self.text_color = widget.convert_gtk_color(self.style.text[state]) + if use_base_color: + self.bg_color = widget.convert_gtk_color(self.style.base[state]) + else: + self.bg_color = widget.convert_gtk_color(self.style.bg[state]) + +class DrawingContext(object): + """DrawingContext. This basically just wraps a Cairo context and adds a + couple convenience methods. + """ + + def __init__(self, window, drawing_area, expose_area): + self.window = window + self.context = window.cairo_create() + self.context.rectangle(expose_area.x, expose_area.y, + expose_area.width, expose_area.height) + self.context.clip() + self.width = drawing_area.width + self.height = drawing_area.height + self.context.translate(drawing_area.x, drawing_area.y) + + def __getattr__(self, name): + return getattr(self.context, name) + + def set_color(self, (red, green, blue), alpha=1.0): + self.context.set_source_rgba(red, green, blue, alpha) + + def set_shadow(self, color, opacity, offset, blur_radius): + pass + + def gradient_fill(self, gradient): + old_source = self.context.get_source() + self.context.set_source(gradient.pattern) + self.context.fill() + self.context.set_source(old_source) + + def gradient_fill_preserve(self, gradient): + old_source = self.context.get_source() + self.context.set_source(gradient.pattern) + self.context.fill_preserve() + self.context.set_source(old_source) + +class Gradient(object): + def __init__(self, x1, y1, x2, y2): + self.pattern = cairo.LinearGradient(x1, y1, x2, y2) + + def set_start_color(self, (red, green, blue)): + self.pattern.add_color_stop_rgb(0, red, green, blue) + + def set_end_color(self, (red, green, blue)): + self.pattern.add_color_stop_rgb(1, red, green, blue) + +class CustomDrawingMixin(object): + def do_expose_event(self, event): + wrapper = wrappermap.wrapper(self) + if self.flags() & gtk.NO_WINDOW: + drawing_area = self.allocation + else: + drawing_area = gtk.gdk.Rectangle(0, 0, + self.allocation.width, self.allocation.height) + context = DrawingContext(event.window, drawing_area, event.area) + context.style = DrawingStyle(wrapper) + if self.flags() & gtk.CAN_FOCUS: + focus_space = (self.style_get_property('focus-padding') + + self.style_get_property('focus-line-width')) + if not wrapper.squish_width: + context.width -= focus_space * 2 + translate_x = focus_space + else: + translate_x = 0 + if not wrapper.squish_height: + context.height -= focus_space * 2 + translate_y = focus_space + else: + translate_y = 0 + context.translate(translate_x, translate_y) + wrapper.layout_manager.update_cairo_context(context.context) + self.draw(wrapper, context) + + def draw(self, wrapper, context): + wrapper.layout_manager.reset() + wrapper.draw(context, wrapper.layout_manager) + + def do_size_request(self, requesition): + wrapper = wrappermap.wrapper(self) + width, height = wrapper.size_request(wrapper.layout_manager) + requesition.width = width + requesition.height = height + if self.flags() & gtk.CAN_FOCUS: + focus_space = (self.style_get_property('focus-padding') + + self.style_get_property('focus-line-width')) + if not wrapper.squish_width: + requesition.width += focus_space * 2 + if not wrapper.squish_height: + requesition.height += focus_space * 2 + +class MiroDrawingArea(CustomDrawingMixin, gtk.Widget): + def __init__(self): + gtk.Widget.__init__(self) + CustomDrawingMixin.__init__(self) + self.set_flags(gtk.NO_WINDOW) + +class BackgroundWidget(CustomDrawingMixin, gtk.Bin): + def do_size_request(self, requesition): + CustomDrawingMixin.do_size_request(self, requesition) + if self.get_child(): + child_width, child_height = self.get_child().size_request() + requesition.width = max(child_width, requesition.width) + requesition.height = max(child_height, requesition.height) + + def do_expose_event(self, event): + CustomDrawingMixin.do_expose_event(self, event) + if self.get_child(): + self.propagate_expose(self.get_child(), event) + + def do_size_allocate(self, allocation): + gtk.Bin.do_size_allocate(self, allocation) + if self.get_child(): + self.get_child().size_allocate(allocation) + +gobject.type_register(MiroDrawingArea) +gobject.type_register(BackgroundWidget) + +class Drawable: + def __init__(self): + self.squish_width = self.squish_height = False + + def set_squish_width(self, setting): + self.squish_width = setting + + def set_squish_height(self, setting): + self.squish_height = setting + + def set_widget(self, drawing_widget): + if self.is_opaque() and 0: + box = gtk.EventBox() + box.add(drawing_widget) + Widget.set_widget(self, box) + else: + Widget.set_widget(self, drawing_widget) + self.layout_manager = LayoutManager(self._widget) + + def size_request(self, layout_manager): + return 0, 0 + + def draw(self, context, layout_manager): + pass + + def is_opaque(self): + return False + +class DrawingArea(Drawable, Widget): + def __init__(self): + Widget.__init__(self) + Drawable.__init__(self) + self.set_widget(MiroDrawingArea()) + +class Background(Drawable, Bin): + def __init__(self): + Bin.__init__(self) + Drawable.__init__(self) + self.set_widget(BackgroundWidget()) diff --git a/mvc/widgets/gtk/drawing.pyc b/mvc/widgets/gtk/drawing.pyc Binary files differnew file mode 100644 index 0000000..93075bd --- /dev/null +++ b/mvc/widgets/gtk/drawing.pyc diff --git a/mvc/widgets/gtk/gtkmenus.py b/mvc/widgets/gtk/gtkmenus.py new file mode 100644 index 0000000..926ba15 --- /dev/null +++ b/mvc/widgets/gtk/gtkmenus.py @@ -0,0 +1,404 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""gtkmenus.py -- Manage menu layout.""" + +import gtk + +from mvc.widgets import app + +import base +import keymap +import wrappermap + +def _setup_accel(widget, name, shortcut=None): + """Setup accelerators for a menu item. + + This method sets an accel path for the widget and optionally connects a + shortcut to that accel path. + """ + # The GTK docs say that we should set the path using this form: + # <Window-Name>/Menu/Submenu/MenuItem + # ...but this is hard to do because we don't yet know what window/menu + # this menu item is going to be added to. gtk.Action and gtk.ActionGroup + # don't follow the above suggestion, so we don't need to either. + path = "<MiroActions>/MenuBar/%s" % name + widget.set_accel_path(path) + if shortcut is not None: + accel_string = keymap.get_accel_string(shortcut) + key, mods = gtk.accelerator_parse(accel_string) + if gtk.accel_map_lookup_entry(path) is None: + gtk.accel_map_add_entry(path, key, mods) + else: + gtk.accel_map_change_entry(path, key, mods, True) + +# map menu names to GTK stock ids. +_STOCK_IDS = { + "SaveItem": gtk.STOCK_SAVE, + "CopyItemURL": gtk.STOCK_COPY, + "RemoveItems": gtk.STOCK_REMOVE, + "StopItem": gtk.STOCK_MEDIA_STOP, + "NextItem": gtk.STOCK_MEDIA_NEXT, + "PreviousItem": gtk.STOCK_MEDIA_PREVIOUS, + "PlayPauseItem": gtk.STOCK_MEDIA_PLAY, + "Open": gtk.STOCK_OPEN, + "EditPreferences": gtk.STOCK_PREFERENCES, + "Quit": gtk.STOCK_QUIT, + "Help": gtk.STOCK_HELP, + "About": gtk.STOCK_ABOUT, + "Translate": gtk.STOCK_EDIT +} +try: + _STOCK_IDS['Fullscreen'] = gtk.STOCK_FULLSCREEN +except AttributeError: + # fullscreen not available on all GTK versions + pass + +class MenuItemBase(base.Widget): + """Base class for MenuItem and Separator.""" + + def show(self): + """Show this menu item.""" + self._widget.show() + + def hide(self): + """Hide and disable this menu item.""" + self._widget.hide() + + def remove_from_parent(self): + """Remove this menu item from it's parent Menu.""" + parent_menu = self._widget.get_parent() + if parent_menu is None: + return + parent_menu_item = parent_menu.get_attach_widget() + if parent_menu_item is None: + return + parent_menu_item.remove(self._widget) + + def _set_accel_group(self, accel_group): + # menu items don't care about the accel group, their parent Menu + # handles it for them + pass + +class MenuItem(MenuItemBase): + """Single item in the menu that can be clicked + + :param label: The label it has (must be internationalized) + :param name: String identifier for this item + :param shortcut: Shortcut object to use + + Signals: + - activate: menu item was clicked + + Example: + + >>> MenuItem(_("Preferences"), "EditPreferences") + >>> MenuItem(_("Cu_t"), "ClipboardCut", Shortcut("x", MOD)) + >>> MenuItem(_("_Update Podcasts and Library"), "UpdatePodcasts", + ... (Shortcut("r", MOD), Shortcut(F5))) + >>> MenuItem(_("_Play"), "PlayPauseItem", + ... play=_("_Play"), pause=_("_Pause")) + """ + + def __init__(self, label, name, shortcut=None): + MenuItemBase.__init__(self) + self.name = name + self.set_widget(self.make_widget(label)) + self.activate_id = self.wrapped_widget_connect('activate', + self._on_activate) + self._widget.show() + self.create_signal('activate') + _setup_accel(self._widget, self.name, shortcut) + + def _on_activate(self, menu_item): + self.emit('activate') + gtk_menubar = self._find_menubar() + if gtk_menubar is not None: + try: + menubar = wrappermap.wrapper(gtk_menubar) + except KeyError: + logging.exception('menubar activate: ' + 'no wrapper for gtbbk.MenuBar') + else: + menubar.emit('activate', self.name) + + def _find_menubar(self): + """Find the MenuBar that this menu item is attached to.""" + menu_item = self._widget + while True: + parent_menu = menu_item.get_parent() + if isinstance(parent_menu, gtk.MenuBar): + return parent_menu + elif parent_menu is None: + return None + menu_item = parent_menu.get_attach_widget() + if menu_item is None: + return None + + def make_widget(self, label): + """Create the menu item to use for this widget. + + Subclasses will probably want to override this. + """ + if self.name in _STOCK_IDS: + mi = gtk.ImageMenuItem(stock_id=_STOCK_IDS[self.name]) + mi.set_label(label) + return mi + else: + return gtk.MenuItem(label) + + def set_label(self, new_label): + self._widget.set_label(new_label) + + def get_label(self): + self._widget.get_label() + +class CheckMenuItem(MenuItem): + """MenuItem that toggles on/off""" + + def make_widget(self, label): + return gtk.CheckMenuItem(label) + + def set_state(self, active): + # prevent the activate signal from fireing in response to us manually + # changing a value + self._widget.handler_block(self.activate_id) + if active is not None: + self._widget.set_inconsistent(False) + self._widget.set_active(active) + else: + self._widget.set_inconsistent(True) + self._widget.set_active(False) + self._widget.handler_unblock(self.activate_id) + + def get_state(self): + return self._widget.get_active() + +class RadioMenuItem(CheckMenuItem): + """MenuItem that toggles on/off and is grouped with other RadioMenuItems. + """ + + def make_widget(self, label): + widget = gtk.RadioMenuItem() + widget.set_label(label) + return widget + + def set_group(self, group_item): + self._widget.set_group(group_item._widget) + + def remove_from_group(self): + """Remove this RadioMenuItem from its current group.""" + self._widget.set_group(None) + + def _on_activate(self, menu_item): + # GTK sends the activate signal for both the radio button that's + # toggled on and the one that gets turned off. Just emit our signal + # for the active radio button. + if self.get_state(): + MenuItem._on_activate(self, menu_item) + +class Separator(MenuItemBase): + """Separator item for menus""" + + def __init__(self): + MenuItemBase.__init__(self) + self.set_widget(gtk.SeparatorMenuItem()) + self._widget.show() + # Set name to be None just so that it has a similar API to other menu + # items. + self.name = None + +class MenuShell(base.Widget): + """Common code shared between Menu and MenuBar. + + Subclasses must define a _menu attribute that's a gtk.MenuShell subclass. + """ + + def __init__(self): + base.Widget.__init__(self) + self._accel_group = None + self.children = [] + + def append(self, menu_item): + """Add a menu item to the end of this menu.""" + self.children.append(menu_item) + menu_item._set_accel_group(self._accel_group) + self._menu.append(menu_item._widget) + + def insert(self, index, menu_item): + """Insert a menu item in the middle of this menu.""" + self.children.insert(index, menu_item) + menu_item._set_accel_group(self._accel_group) + self._menu.insert(menu_item._widget, index) + + def remove(self, menu_item): + """Remove a child menu item. + + :raises ValueError: menu_item is not a child of this menu + """ + self.children.remove(menu_item) + self._menu.remove(menu_item._widget) + menu_item._set_accel_group(None) + + def index(self, name): + """Get the position of a menu item in this list. + + :param name: name of the menu + :returns: index of the menu item, or -1 if not found. + """ + for i, menu_item in enumerate(self.children): + if menu_item.name == name: + return i + return -1 + + def get_children(self): + """Get the child menu items in order.""" + return list(self.children) + + def find(self, name): + """Search for a menu or menu item + + This method recursively searches the entire menu structure for a Menu + or MenuItem object with a given name. + + :raises KeyError: name not found + """ + found = self._find(name) + if found is None: + raise KeyError(name) + else: + return found + + def _find(self, name): + """Low-level helper-method for find(). + + :returns: found menu item or None. + """ + for menu_item in self.get_children(): + if menu_item.name == name: + return menu_item + if isinstance(menu_item, MenuShell): + submenu_find = menu_item._find(name) + if submenu_find is not None: + return submenu_find + return None + +class Menu(MenuShell): + """A Menu holds a list of MenuItems and Menus. + + Example: + >>> Menu(_("P_layback"), "Playback", [ + ... MenuItem(_("_Foo"), "Foo"), + ... MenuItem(_("_Bar"), "Bar") + ... ]) + >>> Menu("", "toplevel", [ + ... Menu(_("_File"), "File", [ ... ]) + ... ]) + """ + + def __init__(self, label, name, child_items): + MenuShell.__init__(self) + self.set_widget(gtk.MenuItem(label)) + self._widget.show() + self.name = name + # set up _menu for the MenuShell code + self._menu = gtk.Menu() + _setup_accel(self._menu, self.name) + self._widget.set_submenu(self._menu) + for item in child_items: + self.append(item) + + def show(self): + """Show this menu.""" + self._widget.show() + + def hide(self): + """Hide this menu.""" + self._widget.hide() + + def _set_accel_group(self, accel_group): + """Set the accel group for this widget. + + Accel groups get created by the MenuBar. Whenever a menu or menu item + is added to that menu bar, the parent calls _set_accel_group() to give + the accel group to the child. + """ + if accel_group == self._accel_group: + return + self._menu.set_accel_group(accel_group) + self._accel_group = accel_group + for child in self.children: + child._set_accel_group(accel_group) + +class MenuBar(MenuShell): + """Displays a list of Menu items. + + Signals: + + - activate(menu_bar, name): a menu item was activated + """ + + def __init__(self): + """Create a new MenuBar + + :param name: string id to use for our action group + """ + MenuShell.__init__(self) + self.create_signal('activate') + self.set_widget(gtk.MenuBar()) + self._widget.show() + self._accel_group = gtk.AccelGroup() + # set up _menu for the MenuShell code + self._menu = self._widget + + def get_accel_group(self): + return self._accel_group + +class MainWindowMenuBar(MenuBar): + """MenuBar for the main window. + + This gets installed into app.widgetapp.menubar on GTK. + """ + def add_initial_menus(self, menus): + """Add the initial set of menus. + + We modify the menu structure slightly for GTK. + """ + for menu in menus: + self.append(menu) + self._modify_initial_menus() + + def _modify_initial_menus(self): + """Update the portable root menu with GTK-specific stuff.""" + # on linux, we don't have a CheckVersion option because + # we update with the package system. + #this_platform = app.config.get(prefs.APP_PLATFORM) + #if this_platform == 'linux': + # self.find("CheckVersion").remove_from_parent() + #app.video_renderer.setup_subtitle_encoding_menu() diff --git a/mvc/widgets/gtk/gtkmenus.pyc b/mvc/widgets/gtk/gtkmenus.pyc Binary files differnew file mode 100644 index 0000000..6641484 --- /dev/null +++ b/mvc/widgets/gtk/gtkmenus.pyc diff --git a/mvc/widgets/gtk/keymap.py b/mvc/widgets/gtk/keymap.py new file mode 100644 index 0000000..cf341ff --- /dev/null +++ b/mvc/widgets/gtk/keymap.py @@ -0,0 +1,94 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""keymap.py -- Map portable key values to GTK ones. +""" + +import gtk + +from mvc.widgets import keyboard + +menubar_mod_map = { + keyboard.MOD: '<Ctrl>', + keyboard.CTRL: '<Ctrl>', + keyboard.ALT: '<Alt>', + keyboard.SHIFT: '<Shift>', +} + +menubar_key_map = { + keyboard.RIGHT_ARROW: 'Right', + keyboard.LEFT_ARROW: 'Left', + keyboard.UP_ARROW: 'Up', + keyboard.DOWN_ARROW: 'Down', + keyboard.SPACE: 'space', + keyboard.ENTER: 'Return', + keyboard.DELETE: 'Delete', + keyboard.BKSPACE: 'BackSpace', + keyboard.ESCAPE: 'Escape', + '>': 'greater', + '<': 'less' +} +for i in range(1, 13): + name = 'F%d' % i + menubar_key_map[getattr(keyboard, name)] = name + +# These are reversed versions of menubar_key_map and menubar_mod_map +gtk_key_map = dict((i[1], i[0]) for i in menubar_key_map.items()) + +def get_accel_string(shortcut): + mod_str = ''.join(menubar_mod_map[mod] for mod in shortcut.modifiers) + key_str = menubar_key_map.get(shortcut.shortcut, shortcut.shortcut) + return mod_str + key_str + +def translate_gtk_modifiers(event): + """Convert a keypress event to a set of modifiers from the shortcut + module. + """ + modifiers = set() + if event.state & gtk.gdk.CONTROL_MASK: + modifiers.add(keyboard.CTRL) + if event.state & gtk.gdk.MOD1_MASK: + modifiers.add(keyboard.ALT) + if event.state & gtk.gdk.SHIFT_MASK: + modifiers.add(keyboard.SHIFT) + return modifiers + +def translate_gtk_event(event): + """Convert a GTK key event into the tuple (key, modifiers) where + key and modifiers are from the shortcut module. + """ + gtk_keyval = gtk.gdk.keyval_name(event.keyval) + if gtk_keyval == None: + return None + if len(gtk_keyval) == 1: + key = gtk_keyval + else: + key = gtk_key_map.get(gtk_keyval) + modifiers = translate_gtk_modifiers(event) + return key, modifiers diff --git a/mvc/widgets/gtk/keymap.pyc b/mvc/widgets/gtk/keymap.pyc Binary files differnew file mode 100644 index 0000000..435c36a --- /dev/null +++ b/mvc/widgets/gtk/keymap.pyc diff --git a/mvc/widgets/gtk/layout.py b/mvc/widgets/gtk/layout.py new file mode 100644 index 0000000..d887fcb --- /dev/null +++ b/mvc/widgets/gtk/layout.py @@ -0,0 +1,227 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".layout -- Layout widgets. """ + +import gtk + +from mvc.utils import Matrix +from .base import Widget, Bin + +class Box(Widget): + def __init__(self, spacing=0): + Widget.__init__(self) + self.children = set() + self.set_widget(self.WIDGET_CLASS(spacing=spacing)) + + def pack_start(self, widget, expand=False, padding=0): + self._widget.pack_start(widget._widget, expand, fill=True, + padding=padding) + widget._widget.show() + self.children.add(widget) + + def pack_end(self, widget, expand=False, padding=0): + self._widget.pack_end(widget._widget, expand, fill=True, + padding=padding) + widget._widget.show() + self.children.add(widget) + + def remove(self, widget): + widget._widget.hide() # otherwise gtkmozembed gets confused + self._widget.remove(widget._widget) + self.children.remove(widget) + + def enable(self): + for mem in self.children: + mem.enable() + + def disable(self): + for mem in self.children: + mem.disable() + +class HBox(Box): + WIDGET_CLASS = gtk.HBox + +class VBox(Box): + WIDGET_CLASS = gtk.VBox + +class Alignment(Bin): + def __init__(self, xalign=0, yalign=0, xscale=0, yscale=0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + Bin.__init__(self) + self.set_widget(gtk.Alignment(xalign, yalign, xscale, yscale)) + self.set_padding(top_pad, bottom_pad, left_pad, right_pad) + + def set(self, xalign=0, yalign=0, xscale=0, yscale=0): + self._widget.set(xalign, yalign, xscale, yscale) + + def set_padding(self, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + self._widget.set_padding(top_pad, bottom_pad, left_pad, right_pad) + +class DetachedWindowHolder(Alignment): + def __init__(self): + Alignment.__init__(self, xscale=1, yscale=1) + +class Splitter(Widget): + def __init__(self): + """Create a new splitter.""" + Widget.__init__(self) + self.set_widget(gtk.HPaned()) + + def set_left(self, widget): + """Set the left child widget.""" + self.left = widget + self._widget.pack1(widget._widget, resize=False, shrink=False) + widget._widget.show() + + def set_right(self, widget): + """Set the right child widget. """ + self.right = widget + self._widget.pack2(widget._widget, resize=True, shrink=False) + widget._widget.show() + + def remove_left(self): + """Remove the left child widget.""" + if self.left is not None: + self.left._widget.hide() # otherwise gtkmozembed gets confused + self._widget.remove(self.left._widget) + self.left = None + + def remove_right(self): + """Remove the right child widget.""" + if self.right is not None: + self.right._widget.hide() # otherwise gtkmozembed gets confused + self._widget.remove(self.right._widget) + self.right = None + + def set_left_width(self, width): + self._widget.set_position(width) + + def get_left_width(self): + return self._widget.get_position() + + def set_right_width(self, width): + self._widget.set_position(self.width - width) + # We should take into account the width of the bar, but this seems + # good enough. + +class Table(Widget): + """Lays out widgets in a table. It works very similar to the GTK Table + widget, or an HTML table. + """ + def __init__(self, columns, rows): + Widget.__init__(self) + self.set_widget(gtk.Table(rows, columns, homogeneous=False)) + self.children = Matrix(columns, rows) + + def pack(self, widget, column, row, column_span=1, row_span=1): + """Add a widget to the table. + """ + self.children[column, row] = widget + self._widget.attach(widget._widget, column, column + column_span, + row, row + row_span) + widget._widget.show() + + def remove(self, widget): + widget._widget.hide() # otherwise gtkmozembed gets confused + self.children.remove(widget) + self._widget.remove(widget._widget) + + def set_column_spacing(self, spacing): + self._widget.set_col_spacings(spacing) + + def set_row_spacing(self, spacing): + self._widget.set_row_spacings(spacing) + + def enable(self, row=None, column=None): + if row != None and column != None: + if self.children[column, row]: + self.children[column, row].enable() + elif row != None: + for mem in self.children.row(row): + if mem: mem.enable() + elif column != None: + for mem in self.children.column(column): + if mem: mem.enable() + else: + for mem in self.children: + if mem: mem.enable() + + def disable(self, row=None, column=None): + if row != None and column != None: + if self.children[column, row]: + self.children[column, row].disable() + elif row != None: + for mem in self.children.row(row): + if mem: mem.disable() + elif column != None: + for mem in self.children.column(column): + if mem: mem.disable() + else: + for mem in self.children: + if mem: mem.disable() + +class TabContainer(Widget): + def __init__(self, xalign=0, yalign=0, xscale=0, yscale=0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + Widget.__init__(self) + self.set_widget(gtk.Notebook()) + self._widget.set_tab_pos(gtk.POS_TOP) + self.children = [] + self._page_to_select = None + self.wrapped_widget_connect('realize', self._on_realize) + + def _on_realize(self, widget): + if self._page_to_select is not None: + self._widget.set_current_page(self._page_to_select) + self._page_to_select = None + + def append_tab(self, child_widget, text, image=None): + if image is not None: + label_widget = gtk.VBox(spacing=2) + image_widget = gtk.Image() + image_widget.set_from_pixbuf(image.pixbuf) + label_widget.pack_start(image_widget) + label_widget.pack_start(gtk.Label(text)) + label_widget.show_all() + else: + label_widget = gtk.Label(text) + + # switch from a center align to a top align + child_widget.set(0, 0, 1, 0) + child_widget.set_padding(10, 10, 10, 10) + + self._widget.append_page(child_widget._widget, label_widget) + self.children.append(child_widget) + + def select_tab(self, index): + if self._widget.flags() & gtk.REALIZED: + self._widget.set_current_page(index) + else: + self._page_to_select = index diff --git a/mvc/widgets/gtk/layout.pyc b/mvc/widgets/gtk/layout.pyc Binary files differnew file mode 100644 index 0000000..08e1105 --- /dev/null +++ b/mvc/widgets/gtk/layout.pyc diff --git a/mvc/widgets/gtk/layoutmanager.py b/mvc/widgets/gtk/layoutmanager.py new file mode 100644 index 0000000..fb60049 --- /dev/null +++ b/mvc/widgets/gtk/layoutmanager.py @@ -0,0 +1,550 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""drawing.py -- Contains the LayoutManager class. LayoutManager is +handles laying out complex objects for the custom drawing code like +text blocks and buttons. +""" + +import math + +import cairo +import gtk +import pango + +from mvc import utils + +use_native_buttons = False # not implemented in MVC + +class FontCache(utils.Cache): + def get(self, context, description, scale_factor, bold, italic): + key = (context, description, scale_factor, bold, italic) + return utils.Cache.get(self, key) + + def create_new_value(self, key, invalidator=None): + (context, description, scale_factor, bold, italic) = key + return Font(context, description, scale_factor, bold, italic) + +_font_cache = FontCache(512) + +class LayoutManager(object): + def __init__(self, widget): + self.pango_context = widget.get_pango_context() + self.update_style(widget.style) + self.update_direction(widget.get_direction()) + widget.connect('style-set', self.on_style_set) + widget.connect('direction-changed', self.on_direction_changed) + self.widget = widget + self.reset() + + def reset(self): + self.current_font = self.font(1.0) + self.text_color = (0, 0, 0) + self.text_shadow = None + + def on_style_set(self, widget, previous_style): + old_font_desc = self.style_font_desc + self.update_style(widget.style) + if self.style_font_desc != old_font_desc: + # bug #17423 font changed, so the widget's width might have changed + widget.queue_resize() + + def on_direction_changed(self, widget, previous_direction): + self.update_direction(widget.get_direction()) + + def update_style(self, style): + self.style_font_desc = style.font_desc + self.style = style + + def update_direction(self, direction): + if direction == gtk.TEXT_DIR_RTL: + self.pango_context.set_base_dir(pango.DIRECTION_RTL) + else: + self.pango_context.set_base_dir(pango.DIRECTION_LTR) + + def font(self, scale_factor, bold=False, italic=False, family=None): + return _font_cache.get(self.pango_context, self.style_font_desc, + scale_factor, bold, italic) + + def set_font(self, scale_factor, bold=False, italic=False, family=None): + self.current_font = self.font(scale_factor, bold, italic) + + def set_text_color(self, color): + self.text_color = color + + def set_text_shadow(self, shadow): + self.text_shadow = shadow + + def textbox(self, text, underline=False): + textbox = TextBox(self.pango_context, self.current_font, + self.text_color, self.text_shadow) + textbox.set_text(text, underline=underline) + return textbox + + def button(self, text, pressed=False, disabled=False, style='normal'): + if style == 'webby': + return StyledButton(text, self.pango_context, self.current_font, + pressed, disabled) + elif use_native_buttons: + return NativeButton(text, self.pango_context, self.current_font, + pressed, self.style, self.widget) + else: + return StyledButton(text, self.pango_context, self.current_font, + pressed) + + def update_cairo_context(self, cairo_context): + cairo_context.update_context(self.pango_context) + +class Font(object): + def __init__(self, context, style_font_desc, scale, bold, italic): + self.context = context + self.description = style_font_desc.copy() + self.description.set_size(int(scale * style_font_desc.get_size())) + if bold: + self.description.set_weight(pango.WEIGHT_BOLD) + if italic: + self.description.set_style(pango.STYLE_ITALIC) + self.font_metrics = None + + def get_font_metrics(self): + if self.font_metrics is None: + self.font_metrics = self.context.get_metrics(self.description) + return self.font_metrics + + def ascent(self): + return pango.PIXELS(self.get_font_metrics().get_ascent()) + + def descent(self): + return pango.PIXELS(self.get_font_metrics().get_descent()) + + def line_height(self): + metrics = self.get_font_metrics() + # the +1: some glyphs can be slightly taller than ascent+descent + # (#17329) + return (pango.PIXELS(metrics.get_ascent()) + + pango.PIXELS(metrics.get_descent()) + 1) + +class TextBox(object): + def __init__(self, context, font, color, shadow): + self.layout = pango.Layout(context) + self.layout.set_wrap(pango.WRAP_WORD_CHAR) + self.font = font + self.color = color + self.layout.set_font_description(font.description.copy()) + self.width = self.height = None + self.shadow = shadow + + def set_text(self, text, font=None, color=None, underline=False): + self.text_chunks = [] + self.attributes = [] + self.text_length = 0 + self.underlines = [] + self.append_text(text, font, color, underline) + + def append_text(self, text, font=None, color=None, underline=False): + if text == None: + text = u"" + startpos = self.text_length + self.text_chunks.append(text) + endpos = self.text_length = self.text_length + len(text) + if font is not None: + attr = pango.AttrFontDesc(font.description, startpos, endpos) + self.attributes.append(attr) + if underline: + self.underlines.append((startpos, endpos)) + if color: + def convert(value): + return int(round(value * 65535)) + attr = pango.AttrForeground(convert(color[0]), convert(color[1]), + convert(color[2]), startpos, endpos) + self.attributes.append(attr) + self.text_set = False + + def set_width(self, width): + if width is not None: + self.layout.set_width(int(width * pango.SCALE)) + else: + self.layout.set_width(-1) + self.width = width + + def set_height(self, height): + # if height is not None: + # # not sure why set_height isn't in the python bindings, but it + # # isn't + # pygtkhacks.set_pango_layout_height(self.layout, + # int(height * pango.SCALE)) + self.height = height + + def set_wrap_style(self, wrap): + if wrap == 'word': + self.layout.set_wrap(pango.WRAP_WORD_CHAR) + elif wrap == 'char' or wrap == 'truncated-char': + self.layout.set_wrap(pango.WRAP_CHAR) + else: + raise ValueError("Unknown wrap value: %s" % wrap) + if wrap == 'truncated-char': + self.layout.set_ellipsize(pango.ELLIPSIZE_END) + else: + self.layout.set_ellipsize(pango.ELLIPSIZE_NONE) + + def set_alignment(self, align): + if align == 'left': + self.layout.set_alignment(pango.ALIGN_LEFT) + elif align == 'right': + self.layout.set_alignment(pango.ALIGN_RIGHT) + elif align == 'center': + self.layout.set_alignment(pango.ALIGN_CENTER) + else: + raise ValueError("Unknown align value: %s" % align) + + def ensure_layout(self): + if not self.text_set: + text = ''.join(self.text_chunks) + if len(text) > 100: + text = text[:self._calc_text_cutoff()] + self.layout.set_text(text) + attr_list = pango.AttrList() + for attr in self.attributes: + attr_list.insert(attr) + self.layout.set_attributes(attr_list) + self.text_set = True + + def _calc_text_cutoff(self): + """This method is a bit of a hack... GTK slows down if we pass too + much text to the layout. Even text that falls below our height has a + performance penalty. Try not to have too much more than is necessary. + """ + if None in (self.width, self.height): + return -1 + + chars_per_line = (self.width * pango.SCALE // + self.font.get_font_metrics().get_approximate_char_width()) + lines_available = self.height // self.font.line_height() + # overestimate these because it's better to have too many characters + # than too little. + return int(chars_per_line * lines_available * 1.2) + + def line_count(self): + self.ensure_layout() + return self.layout.get_line_count() + + def get_size(self): + self.ensure_layout() + return self.layout.get_pixel_size() + + def char_at(self, x, y): + self.ensure_layout() + x *= pango.SCALE + y *= pango.SCALE + width, height = self.layout.get_size() + if 0 <= x < width and 0 <= y < height: + index, leading = self.layout.xy_to_index(x, y) + # xy_to_index returns the nearest character, but that + # doesn't mean the user actually clicked on it. Double + # check that (x, y) is actually inside that char's + # bounding box + char_x, char_y, char_w, char_h = self.layout.index_to_pos(index) + if char_w > 0: # the glyph is LTR + left = char_x + right = char_x + char_w + else: # the glyph is RTL + left = char_x + char_w + right = char_x + if left <= x < right: + return index + return None + + + def draw(self, context, x, y, width, height): + self.set_width(width) + self.set_height(height) + self.ensure_layout() + cairo_context = context.context + cairo_context.save() + underline_drawer = UnderlineDrawer(self.underlines) + if self.shadow: + # draw shadow first so that it's underneath the regular text + # FIXME: we don't use the blur_radius setting + cairo_context.set_source_rgba(self.shadow.color[0], + self.shadow.color[1], self.shadow.color[2], + self.shadow.opacity) + self._draw_layout(context, x + self.shadow.offset[0], + y + self.shadow.offset[1], width, height, + underline_drawer) + cairo_context.set_source_rgb(*self.color) + self._draw_layout(context, x, y, width, height, underline_drawer) + cairo_context.restore() + cairo_context.new_path() + + def _draw_layout(self, context, x, y, width, height, underline_drawer): + line_height = 0 + alignment = self.layout.get_alignment() + for i in xrange(self.layout.get_line_count()): + line = self.layout.get_line_readonly(i) + extents = line.get_pixel_extents()[1] + next_line_height = line_height + extents[3] + if next_line_height > height: + break + if alignment == pango.ALIGN_CENTER: + line_x = max(x, x + (width - extents[2]) / 2.0) + elif alignment == pango.ALIGN_RIGHT: + line_x = max(x, x + width - extents[2]) + else: + line_x = x + baseline = y + line_height + pango.ASCENT(extents) + context.move_to(line_x, baseline) + context.context.show_layout_line(line) + underline_drawer.draw(context, line_x, baseline, line) + line_height = next_line_height + +class UnderlineDrawer(object): + """Class to draw our own underlines because cairo's don't look + that great at small fonts. We make sure that the underline is + always drawn at a pixel boundary and that there always is space + between the text and the baseline. + + This class makes a couple assumptions that might not be that + great. It assumes that the correct underline size is 1 pixel and + that the text color doesn't change in the middle of an underline. + """ + def __init__(self, underlines): + self.underline_iter = iter(underlines) + self.finished = False + self.next_underline() + + def next_underline(self): + try: + self.startpos, self.endpos = self.underline_iter.next() + except StopIteration: + self.finished = True + else: + # endpos is the char to stop underlining at + self.endpos -= 1 + + def draw(self, context, x, baseline, line): + baseline = round(baseline) + 0.5 + context.set_line_width(1) + while not self.finished and line.start_index <= self.startpos: + startpos = max(line.start_index, self.startpos) + endpos = min(self.endpos, line.start_index + line.length) + x1 = x + pango.PIXELS(line.index_to_x(startpos, 0)) + x2 = x + pango.PIXELS(line.index_to_x(endpos, 1)) + context.move_to(x1, baseline + 1) + context.line_to(x2, baseline + 1) + context.stroke() + if endpos < self.endpos: + break + else: + self.next_underline() + +class NativeButton(object): + ICON_PAD = 4 + + def __init__(self, text, context, font, pressed, style, widget): + self.layout = pango.Layout(context) + self.font = font + self.pressed = pressed + self.layout.set_font_description(font.description.copy()) + self.layout.set_text(text) + self.pad_x = style.xthickness + 11 + self.pad_y = style.ythickness + 1 + self.style = style + self.widget = widget + # The above code assumes an "inner-border" style property of + # 1. PyGTK doesn't seem to support Border objects very well, + # so can't get it from the widget style. + self.min_width = 0 + self.icon = None + + def set_min_width(self, width): + self.min_width = width + + def set_icon(self, icon): + self.icon = icon + + def get_size(self): + width, height = self.layout.get_pixel_size() + if self.icon: + width += self.icon.width + self.ICON_PAD + height = max(height, self.icon.height) + width += self.pad_x * 2 + height += self.pad_y * 2 + return max(self.min_width, width), height + + def draw(self, context, x, y, width, height): + text_width, text_height = self.layout.get_pixel_size() + if self.icon: + inner_width = text_width + self.icon.width + self.ICON_PAD + # calculate the icon position x and y are still in cairo + # coordinates + icon_x = x + (width - inner_width) / 2.0 + icon_y = y + (height - self.icon.height) / 2.0 + text_x = icon_x + self.icon.width + self.ICON_PAD + else: + text_x = x + (width - text_width) / 2.0 + text_y = y + (height - text_height) / 2.0 + + x, y = context.context.user_to_device(x, y) + text_x, text_y = context.context.user_to_device(text_x, text_y) + # Hmm, maybe we should somehow support floating point numbers + # here, but I don't know how to. + x, y, width, height = (int(f) for f in (x, y, width, height)) + context.context.get_target().flush() + self.draw_box(context.window, x, y, width, height) + self.draw_text(context.window, text_x, text_y) + if self.icon: + self.icon.draw(context, icon_x, icon_y, self.icon.width, + self.icon.height) + + def draw_box(self, window, x, y, width, height): + if self.pressed: + shadow = gtk.SHADOW_IN + state = gtk.STATE_ACTIVE + else: + shadow = gtk.SHADOW_OUT + state = gtk.STATE_NORMAL + if 'QtCurveStyle' in str(self.style): + # This is a horrible hack for the libqtcurve library. See + # http://bugzilla.pculture.org/show_bug.cgi?id=10380 for + # details + widget = window.get_user_data() + else: + widget = self.widget + + self.style.paint_box(window, state, shadow, None, widget, "button", + int(x), int(y), int(width), int(height)) + + def draw_text(self, window, x, y): + if self.pressed: + state = gtk.STATE_ACTIVE + else: + state = gtk.STATE_NORMAL + self.style.paint_layout(window, state, True, None, None, None, + int(x), int(y), self.layout) + +class StyledButton(object): + PAD_HORIZONTAL = 4 + PAD_VERTICAL = 3 + TOP_COLOR = (1, 1, 1) + BOTTOM_COLOR = (0.86, 0.86, 0.86) + LINE_COLOR_TOP = (0.71, 0.71, 0.71) + LINE_COLOR_BOTTOM = (0.45, 0.45, 0.45) + TEXT_COLOR = (0.184, 0.184, 0.184) + DISABLED_COLOR = (0.86, 0.86, 0.86) + DISABLED_TEXT_COLOR = (0.5, 0.5, 0.5) + ICON_PAD = 8 + + def __init__(self, text, context, font, pressed, disabled=False): + self.layout = pango.Layout(context) + self.font = font + self.layout.set_font_description(font.description.copy()) + self.layout.set_text(text) + self.min_width = 0 + self.pressed = pressed + self.disabled = disabled + self.icon = None + + def set_icon(self, icon): + self.icon = icon + + def set_min_width(self, width): + self.min_width = width + + def get_size(self): + width, height = self.layout.get_pixel_size() + if self.icon: + width += self.icon.width + self.ICON_PAD + height = max(height, self.icon.height) + height += self.PAD_VERTICAL * 2 + if height % 2 == 1: + # make height even so that the radius of our circle is + # whole + height += 1 + width += self.PAD_HORIZONTAL * 2 + height + return max(self.min_width, width), height + + def draw_path(self, context, x, y, width, height, radius): + inner_width = width - radius * 2 + context.move_to(x + radius, y) + context.rel_line_to(inner_width, 0) + context.arc(x + width - radius, y+radius, radius, -math.pi/2, + math.pi/2) + context.rel_line_to(-inner_width, 0) + context.arc(x + radius, y+radius, radius, math.pi/2, -math.pi/2) + + def draw_button(self, context, x, y, width, height, radius): + context.context.save() + self.draw_path(context, x, y, width, height, radius) + if self.disabled: + end_color = self.DISABLED_COLOR + start_color = self.DISABLED_COLOR + elif self.pressed: + end_color = self.TOP_COLOR + start_color = self.BOTTOM_COLOR + else: + context.set_line_width(1) + start_color = self.TOP_COLOR + end_color = self.BOTTOM_COLOR + gradient = cairo.LinearGradient(x, y, x, y + height) + gradient.add_color_stop_rgb(0, *start_color) + gradient.add_color_stop_rgb(1, *end_color) + context.context.set_source(gradient) + context.fill() + context.set_line_width(1) + self.draw_path(context, x+0.5, y+0.5, width, height, radius) + gradient = cairo.LinearGradient(x, y, x, y + height) + gradient.add_color_stop_rgb(0, *self.LINE_COLOR_TOP) + gradient.add_color_stop_rgb(1, *self.LINE_COLOR_BOTTOM) + context.context.set_source(gradient) + context.stroke() + context.context.restore() + + def draw(self, context, x, y, width, height): + radius = height / 2 + self.draw_button(context, x, y, width, height, radius) + + text_width, text_height = self.layout.get_pixel_size() + # draw the text in the center of the button + text_x = x + (width - text_width) / 2 + text_y = y + (height - text_height) / 2 + if self.icon: + icon_x = text_x - (self.icon.width + self.ICON_PAD) / 2 + text_x += (self.icon.width + self.ICON_PAD) / 2 + icon_y = y + (height - self.icon.height) / 2 + self.icon.draw(context, icon_x, icon_y, self.icon.width, + self.icon.height) + self.draw_text(context, text_x, text_y, width, height, radius) + + def draw_text(self, context, x, y, width, height, radius): + if self.disabled: + context.set_color(self.DISABLED_TEXT_COLOR) + else: + context.set_color(self.TEXT_COLOR) + context.move_to(x, y) + context.context.show_layout(self.layout) diff --git a/mvc/widgets/gtk/layoutmanager.pyc b/mvc/widgets/gtk/layoutmanager.pyc Binary files differnew file mode 100644 index 0000000..cbd65b8 --- /dev/null +++ b/mvc/widgets/gtk/layoutmanager.pyc diff --git a/mvc/widgets/gtk/simple.py b/mvc/widgets/gtk/simple.py new file mode 100644 index 0000000..f0921e0 --- /dev/null +++ b/mvc/widgets/gtk/simple.py @@ -0,0 +1,313 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""simple.py -- Collection of simple widgets.""" + +import gtk +import gobject +import pango + +from mvc.widgets import widgetconst +from .base import Widget, Bin + +class Image(object): + def __init__(self, path): + try: + self._set_pixbuf(gtk.gdk.pixbuf_new_from_file(path)) + except gobject.GError, ge: + raise ValueError("%s" % ge) + self.width = self.pixbuf.get_width() + self.height = self.pixbuf.get_height() + + def _set_pixbuf(self, pixbuf): + self.pixbuf = pixbuf + self.width = self.pixbuf.get_width() + self.height = self.pixbuf.get_height() + + def resize(self, width, height): + width = int(round(width)) + height = int(round(height)) + resized_pixbuf = self.pixbuf.scale_simple(width, height, + gtk.gdk.INTERP_BILINEAR) + return TransformedImage(resized_pixbuf) + + def resize_for_space(self, width, height): + """Returns an image scaled to fit into the specified space at the + correct height/width ratio. + """ + ratio = min(1.0 * width / self.width, 1.0 * height / self.height) + return self.resize(ratio * self.width, ratio * self.height) + + def crop_and_scale(self, src_x, src_y, src_width, src_height, dest_width, + dest_height): + """Crop an image then scale it. + + The image will be cropped to the rectangle (src_x, src_y, src_width, + src_height), that rectangle will be scaled to a new Image with tisez + (dest_width, dest_height) + """ + dest = gtk.gdk.Pixbuf(self.pixbuf.get_colorspace(), + self.pixbuf.get_has_alpha(), + self.pixbuf.get_bits_per_sample(), dest_width, dest_height) + + scale_x = dest_width / float(src_width) + scale_y = dest_height / float(src_height) + + self.pixbuf.scale(dest, 0, 0, dest_width, dest_height, + -src_x * scale_x, -src_y * scale_y, scale_x, scale_y, + gtk.gdk.INTERP_BILINEAR) + return TransformedImage(dest) + +class TransformedImage(Image): + def __init__(self, pixbuf): + # XXX intentionally not calling direct super's __init__; we should do + # this differently + self._set_pixbuf(pixbuf) + +class ImageDisplay(Widget): + def __init__(self, image=None): + Widget.__init__(self) + self.set_widget(gtk.Image()) + self.set_image(image) + + def set_image(self, image): + self.image = image + if image is not None: + self._widget.set_from_pixbuf(image.pixbuf) + else: + self._widget.clear() + +class AnimatedImageDisplay(Widget): + def __init__(self, path): + Widget.__init__(self) + self.set_widget(gtk.Image()) + self._animation = gtk.gdk.PixbufAnimation(path) + # Set to animate before we are shown and stop animating after + # we disappear. + self._widget.connect('map', lambda w: self._set_animate(True)) + self._widget.connect('unmap-event', + lambda w, a: self._set_animate(False)) + + def _set_animate(self, enabled): + if enabled: + self._widget.set_from_animation(self._animation) + else: + self._widget.clear() + +class Label(Widget): + """Widget that displays simple text.""" + def __init__(self, text="", color=None): + Widget.__init__(self) + self.set_widget(gtk.Label()) + if text: + self.set_text(text) + self.attr_list = pango.AttrList() + self.font_description = self._widget.style.font_desc.copy() + self.scale_factor = 1.0 + if color is not None: + self.set_color(color) + self.wrapped_widget_connect('style-set', self.on_style_set) + + def set_bold(self, bold): + if bold: + weight = pango.WEIGHT_BOLD + else: + weight = pango.WEIGHT_NORMAL + self.font_description.set_weight(weight) + self.set_attr(pango.AttrFontDesc(self.font_description)) + + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self.scale_factor = 1 + elif size == widgetconst.SIZE_SMALL: + self.scale_factor = 0.75 + else: + self.scale_factor = size + baseline = self._widget.style.font_desc.get_size() + self.font_description.set_size(int(baseline * self.scale_factor)) + self.set_attr(pango.AttrFontDesc(self.font_description)) + + def get_preferred_width(self): + return self._widget.size_request()[0] + + def on_style_set(self, widget, old_style): + self.set_size(self.scale_factor) + + def set_wrap(self, wrap): + self._widget.set_line_wrap(wrap) + + def set_alignment(self, alignment): + # default to left. + gtkalignment = gtk.JUSTIFY_LEFT + if alignment == widgetconst.TEXT_JUSTIFY_LEFT: + gtkalignment = gtk.JUSTIFY_LEFT + elif alignment == widgetconst.TEXT_JUSTIFY_RIGHT: + gtkalignment = gtk.JUSTIFY_RIGHT + elif alignment == widgetconst.TEXT_JUSTIFY_CENTER: + gtkalignment = gtk.JUSTIFY_CENTER + self._widget.set_justify(gtkalignment) + + def get_alignment(self): + return self._widget.get_justify() + + def get_width(self): + return self._widget.get_layout().get_pixel_size()[0] + + def set_text(self, text): + self._widget.set_text(text) + + def get_text(self): + return self._widget.get_text().decode('utf-8') + + def set_selectable(self, val): + self._widget.set_selectable(val) + + def set_attr(self, attr): + attr.end_index = 65535 + self.attr_list.change(attr) + self._widget.set_attributes(self.attr_list) + + def set_color(self, color): + color_as_int = (int(65535 * c) for c in color) + self.set_attr(pango.AttrForeground(*color_as_int)) + + def baseline(self): + pango_context = self._widget.get_pango_context() + metrics = pango_context.get_metrics(self.font_description) + return pango.PIXELS(metrics.get_descent()) + + def hide(self): + self._widget.hide() + + def show(self): + self._widget.show() + +class Scroller(Bin): + def __init__(self, horizontal, vertical): + Bin.__init__(self) + self.set_widget(gtk.ScrolledWindow()) + if horizontal: + h_policy = gtk.POLICY_AUTOMATIC + else: + h_policy = gtk.POLICY_NEVER + if vertical: + v_policy = gtk.POLICY_AUTOMATIC + else: + v_policy = gtk.POLICY_NEVER + self._widget.set_policy(h_policy, v_policy) + + def set_has_borders(self, has_border): + pass + + def set_background_color(self, color): + pass + + def add_child_to_widget(self): + if (isinstance(self.child._widget, gtk.TreeView) or + isinstance(self.child._widget, gtk.TextView)): + # child has native scroller + self._widget.add(self.child._widget) + else: + self._widget.add_with_viewport(self.child._widget) + self._widget.get_child().set_shadow_type(gtk.SHADOW_NONE) + if isinstance(self.child._widget, gtk.TextView): + self._widget.set_shadow_type(gtk.SHADOW_IN) + else: + self._widget.set_shadow_type(gtk.SHADOW_NONE) + + def prepare_for_dark_content(self): + # this is just a hack for cocoa + pass + + +class SolidBackground(Bin): + def __init__(self, color=None): + Bin.__init__(self) + self.set_widget(gtk.EventBox()) + if color is not None: + self.set_background_color(color) + + def set_background_color(self, color): + self.modify_style('base', gtk.STATE_NORMAL, self.make_color(color)) + self.modify_style('bg', gtk.STATE_NORMAL, self.make_color(color)) + +class Expander(Bin): + def __init__(self, child=None): + Bin.__init__(self) + self.set_widget(gtk.Expander()) + if child is not None: + self.add(child) + self.label = None + # This is a complete hack. GTK expanders have a transparent + # background most of the time, except when they are prelighted. So we + # just set the background to white there because that's what should + # happen in the item list. + self.modify_style('bg', gtk.STATE_PRELIGHT, + gtk.gdk.color_parse('white')) + + def set_spacing(self, spacing): + self._widget.set_spacing(spacing) + + def set_label(self, widget): + self.label = widget + self._widget.set_label_widget(widget._widget) + widget._widget.show() + + def set_expanded(self, expanded): + self._widget.set_expanded(expanded) + +class ProgressBar(Widget): + def __init__(self): + Widget.__init__(self) + self.set_widget(gtk.ProgressBar()) + self._timer = None + + def set_progress(self, fraction): + self._widget.set_fraction(fraction) + + def start_pulsing(self): + if self._timer is None: + self._timer = gobject.timeout_add(100, self._do_pulse) + + def stop_pulsing(self): + if self._timer: + gobject.source_remove(self._timer) + self._timer = None + + def _do_pulse(self): + self._widget.pulse() + return True + +class HLine(Widget): + """A horizontal separator. Not to be confused with HSeparator, which is is + a DrawingArea, not a Widget. + """ + def __init__(self): + Widget.__init__(self) + self.set_widget(gtk.HSeparator()) diff --git a/mvc/widgets/gtk/simple.pyc b/mvc/widgets/gtk/simple.pyc Binary files differnew file mode 100644 index 0000000..ec7bc4c --- /dev/null +++ b/mvc/widgets/gtk/simple.pyc diff --git a/mvc/widgets/gtk/tableview.py b/mvc/widgets/gtk/tableview.py new file mode 100644 index 0000000..930270c --- /dev/null +++ b/mvc/widgets/gtk/tableview.py @@ -0,0 +1,1557 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tableview.py -- Wrapper for the GTKTreeView widget. It's used for the tab +list and the item list (AKA almost all of the miro). +""" + +import logging + +import itertools +import gobject +import gtk +from collections import namedtuple + +# These are probably wrong, and are placeholders for now, until custom headers +# are also implemented for GTK. +CUSTOM_HEADER_HEIGHT = 25 +HEADER_HEIGHT = 25 + +from mvc import signals +from mvc.errors import (WidgetActionError, WidgetDomainError, + WidgetRangeError, WidgetNotReadyError) +from mvc.widgets.tableselection import SelectionOwnerMixin +from mvc.widgets.tablescroll import ScrollbarOwnerMixin +import drawing +import wrappermap +from .base import Widget +from .simple import Image +from .layoutmanager import LayoutManager +from .weakconnect import weak_connect +from .tableviewcells import GTKCustomCellRenderer + + +PathInfo = namedtuple('PathInfo', 'path column x y') +Rect = namedtuple('Rect', 'x y width height') +_album_view_gtkrc_installed = False + +def _install_album_view_gtkrc(): + """Hack for styling GTKTreeView for the album view widget. + + We do a couple things: + - Remove the focus ring + - Remove any separator space. + + We do this so that we don't draw a box through the album view column for + selected rows. + """ + global _album_view_gtkrc_installed + if _album_view_gtkrc_installed: + return + rc_string = ('style "album-view-style"\n' + '{ \n' + ' GtkTreeView::vertical-separator = 0\n' + ' GtkTreeView::horizontal-separator = 0\n' + ' GtkWidget::focus-line-width = 0 \n' + '}\n' + 'widget "*.miro-album-view" style "album-view-style"\n') + gtk.rc_parse_string(rc_string) + _album_view_gtkrc_installed = True + +def rect_contains_point(rect, x, y): + return ((rect.x <= x < rect.x + rect.width) and + (rect.y <= y < rect.y + rect.height)) + +class TreeViewScrolling(object): + def __init__(self): + self.scrollbars = [] + self.scroll_positions = None, None + self.restoring_scroll = None + self.connect('parent-set', self.on_parent_set) + self.scroller = None + # hack necessary because of our weird widget hierarchy (GTK doesn't deal + # well with the Scroller's widget not being the direct parent of the + # TableView's widget.) + self._coords_working = False + + def scroll_range_changed(self): + """Faux-signal; this should all be integrated into + GTKScrollbarOwnerMixin, making this unnecessary. + """ + + @property + def manually_scrolled(self): + """Return whether the view has been scrolled explicitly by the user + since the last time it was set automatically. + """ + auto_pos = self.scroll_positions[1] + if auto_pos is None: + # if we don't have any position yet, user can't have manually + # scrolled + return False + real_pos = self.scrollbars[1].get_value() + return abs(auto_pos - real_pos) > 5 # allowing some fuzziness + + @property + def position_set(self): + """Return whether the scroll position has been set in any way.""" + return any(x is not None for x in self.scroll_positions) + + def on_parent_set(self, widget, old_parent): + """We have parent window now; we need to control its scrollbars.""" + self.set_scroller(widget.get_parent()) + + def set_scroller(self, window): + """Take control of the scrollbars of window.""" + if not isinstance(window, gtk.ScrolledWindow): + return + self.scroller = window + scrollbars = tuple(bar.get_adjustment() + for bar in (window.get_hscrollbar(), window.get_vscrollbar())) + self.scrollbars = scrollbars + for i, bar in enumerate(scrollbars): + weak_connect(bar, 'changed', self.on_scroll_range_changed, i) + if self.restoring_scroll: + self.set_scroll_position(self.restoring_scroll) + + def on_scroll_range_changed(self, adjustment, bar): + """The scrollbar might have a range now. Set its initial position if + we haven't already. + """ + self._coords_working = True + if self.restoring_scroll: + self.set_scroll_position(self.restoring_scroll) + # our wrapper handles the same thing for iters + self.scroll_range_changed() + + def set_scroll_position(self, scroll_position): + """Restore the scrollbars to a remembered state.""" + try: + self.scroll_positions = tuple(self._clip_pos(adj, x) + for adj, x in zip(self.scrollbars, scroll_position)) + except WidgetActionError, error: + logging.debug("can't scroll yet: %s", error.reason) + # try again later + self.restoring_scroll = scroll_position + else: + for adj, pos in zip(self.scrollbars, self.scroll_positions): + adj.set_value(pos) + self.restoring_scroll = None + + def _clip_pos(self, adj, pos): + lower = adj.get_lower() + upper = adj.get_upper() - adj.get_page_size() + # currently, StandardView gets an upper of 2.0 when it's not ready + # FIXME: don't count on that + if pos > upper and upper < 5: + raise WidgetRangeError("scrollable area", pos, lower, upper) + return min(max(pos, lower), upper) + + def get_path_rect(self, path): + """Return the Rect for the given item, in tree coords.""" + if not self._coords_working: + # part of solution to #17405; widget_to_tree_coords tends to return + # y=8 before the first scroll-range-changed signal. ugh. + raise WidgetNotReadyError('_coords_working') + rect = self.get_background_area(path, self.get_columns()[0]) + x, y = self.widget_to_tree_coords(rect.x, rect.y) + return Rect(x, y, rect.width, rect.height) + + @property + def _scrollbars(self): + if not self.scrollbars: + raise WidgetNotReadyError + return self.scrollbars + + def scroll_ancestor(self, newly_selected, down): + # Try to figure out what just became selected. If multiple things + # somehow became selected, select the outermost one + if len(newly_selected) == 0: + raise WidgetActionError("need at an item to scroll to") + if down: + path_to_show = max(newly_selected) + else: + path_to_show = min(newly_selected) + + if not self.scrollbars: + return + vadjustment = self.scrollbars[1] + + rect = self.get_background_area(path_to_show, self.get_columns()[0]) + _, top = self.translate_coordinates(self.scroller, 0, rect.y) + top += vadjustment.value + bottom = top + rect.height + if down: + if bottom > vadjustment.value + vadjustment.page_size: + bottom_value = min(bottom, vadjustment.upper) + vadjustment.set_value(bottom_value - vadjustment.page_size) + else: + if top < vadjustment.value: + vadjustment.set_value(max(vadjustment.lower, top)) + +class MiroTreeView(gtk.TreeView, TreeViewScrolling): + """Extends the GTK TreeView widget to help implement TableView + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + # Add a tiny bit of padding so that the user can drag feeds below + # the table, i.e. to the bottom row, as a top-level + PAD_BOTTOM = 3 + def __init__(self): + gtk.TreeView.__init__(self) + TreeViewScrolling.__init__(self) + self.height_without_pad_bottom = -1 + self.set_enable_search(False) + self.horizontal_separator = self.style_get_property("horizontal-separator") + self.expander_size = self.style_get_property("expander-size") + self.group_lines_enabled = False + self.group_line_color = (0, 0, 0) + self.group_line_width = 1 + + def do_size_request(self, req): + gtk.TreeView.do_size_request(self, req) + self.height_without_pad_bottom = req.height + req.height += self.PAD_BOTTOM + + def do_move_cursor(self, step, count): + if step == gtk.MOVEMENT_VISUAL_POSITIONS: + # GTK is asking us to move left/right. Since our TableViews don't + # support this, return False to let the key press propagate. See + # #15646 for more info. + return False + if isinstance(self.get_parent(), gtk.ScrolledWindow): + # If our parent is a ScrolledWindow, let GTK take care of this + handled = gtk.TreeView.do_move_cursor(self, step, count) + return handled + else: + # Otherwise, we have to search up the widget tree for a + # ScrolledWindow to take care of it + selection = self.get_selection() + model, start_selection = selection.get_selected_rows() + gtk.TreeView.do_move_cursor(self, step, count) + + model, end_selection = selection.get_selected_rows() + newly_selected = set(end_selection) - set(start_selection) + down = (count > 0) + + try: + self.scroll_ancestor(newly_selected, down) + except WidgetActionError: + # not possible + return False + return True + + def get_position_info(self, x, y): + """Wrapper for get_path_at_pos that converts the path_info to a named + tuple and handles rounding the coordinates. + """ + path_info = self.get_path_at_pos(int(round(x)), int(round(y))) + if path_info: + return PathInfo(*path_info) + +gobject.type_register(MiroTreeView) + +class HotspotTracker(object): + """Handles tracking hotspots. + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def __init__(self, treeview, event): + self.treeview = treeview + self.treeview_wrapper = wrappermap.wrapper(treeview) + self.hit = False + self.button = event.button + path_info = treeview.get_position_info(event.x, event.y) + if path_info is None: + return + self.path, self.column, background_x, background_y = path_info + # We always pack 1 renderer for each column + gtk_renderer = self.column.get_cell_renderers()[0] + if not isinstance(gtk_renderer, GTKCustomCellRenderer): + return + self.renderer = wrappermap.wrapper(gtk_renderer) + self.attr_map = self.treeview_wrapper.attr_map_for_column[self.column] + if not rect_contains_point(self.calc_cell_area(), event.x, event.y): + # Mouse is in the padding around the actual cell area + return + self.update_position(event) + self.iter = treeview.get_model().get_iter(self.path) + self.name = self.calc_hotspot() + if self.name is not None: + self.hit = True + + def is_for_context_menu(self): + return self.name == "#show-context-menu" + + def calc_cell_area(self): + cell_area = self.treeview.get_cell_area(self.path, self.column) + xpad = self.renderer._renderer.props.xpad + ypad = self.renderer._renderer.props.ypad + cell_area.x += xpad + cell_area.y += ypad + cell_area.width -= xpad * 2 + cell_area.height -= ypad * 2 + return cell_area + + def update_position(self, event): + self.x, self.y = int(event.x), int(event.y) + + def calc_cell_state(self): + if self.treeview.get_selection().path_is_selected(self.path): + if self.treeview.flags() & gtk.HAS_FOCUS: + return gtk.STATE_SELECTED + else: + return gtk.STATE_ACTIVE + else: + return gtk.STATE_NORMAL + + def calc_hotspot(self): + cell_area = self.calc_cell_area() + if rect_contains_point(cell_area, self.x, self.y): + model = self.treeview.get_model() + self.renderer.cell_data_func(self.column, self.renderer._renderer, + model, self.iter, self.attr_map) + style = drawing.DrawingStyle(self.treeview_wrapper, + use_base_color=True, state=self.calc_cell_state()) + x = self.x - cell_area.x + y = self.y - cell_area.y + return self.renderer.hotspot_test(style, + self.treeview_wrapper.layout_manager, + x, y, cell_area.width, cell_area.height) + else: + return None + + def update_hit(self): + if self.is_for_context_menu(): + return # we always keep hit = True for this one + old_hit = self.hit + self.hit = (self.calc_hotspot() == self.name) + if self.hit != old_hit: + self.redraw_cell() + + def redraw_cell(self): + # Check that the treeview is still around. We might have switched + # views in response to a hotspot being clicked. + if self.treeview.flags() & gtk.REALIZED: + cell_area = self.treeview.get_cell_area(self.path, self.column) + x, y = self.treeview.tree_to_widget_coords(cell_area.x, + cell_area.y) + self.treeview.queue_draw_area(x, y, + cell_area.width, cell_area.height) + +class TableColumn(signals.SignalEmitter): + """A single column of a TableView. + + Signals: + + clicked (table_column) -- The header for this column was clicked. + """ + # GTK hard-codes 4px of padding for each column + FIXED_PADDING = 4 + def __init__(self, title, renderer, header=None, **attrs): + # header widget not used yet in GTK (#15800) + signals.SignalEmitter.__init__(self) + self.create_signal('clicked') + self._column = gtk.TreeViewColumn(title, renderer._renderer) + self._column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) + self._column.set_clickable(True) + self.attrs = attrs + renderer.setup_attributes(self._column, attrs) + self.renderer = renderer + weak_connect(self._column, 'clicked', self._header_clicked) + self.do_horizontal_padding = True + + def set_right_aligned(self, right_aligned): + """Horizontal alignment of the header label.""" + if right_aligned: + self._column.set_alignment(1.0) + else: + self._column.set_alignment(0.0) + + def set_min_width(self, width): + self._column.props.min_width = width + TableColumn.FIXED_PADDING + + def set_max_width(self, width): + self._column.props.max_width = width + + def set_width(self, width): + self._column.set_fixed_width(width + TableColumn.FIXED_PADDING) + + def get_width(self): + return self._column.get_width() + + def _header_clicked(self, tablecolumn): + self.emit('clicked') + + def set_resizable(self, resizable): + """Set if the user can resize the column.""" + self._column.set_resizable(resizable) + + def set_do_horizontal_padding(self, horizontal_padding): + self.do_horizontal_padding = False + + def set_sort_indicator_visible(self, visible): + """Show/Hide the sort indicator for this column.""" + self._column.set_sort_indicator(visible) + + def get_sort_indicator_visible(self): + return self._column.get_sort_indicator() + + def set_sort_order(self, ascending): + """Display a sort indicator on the column header. Ascending can be + either True or False which affects the direction of the indicator. + """ + if ascending: + self._column.set_sort_order(gtk.SORT_ASCENDING) + else: + self._column.set_sort_order(gtk.SORT_DESCENDING) + + def get_sort_order_ascending(self): + """Returns if the sort indicator is displaying that the sort is + ascending. + """ + return self._column.get_sort_order() == gtk.SORT_ASCENDING + +class GTKSelectionOwnerMixin(SelectionOwnerMixin): + """GTK-specific methods for selection management. + + This subclass should not define any behavior. Methods that cannot be + completed in this widget state should raise WidgetActionError. + """ + def __init__(self): + SelectionOwnerMixin.__init__(self) + self.selection = self._widget.get_selection() + weak_connect(self.selection, 'changed', self.on_selection_changed) + + def _set_allow_multiple_select(self, allow): + if allow: + mode = gtk.SELECTION_MULTIPLE + else: + mode = gtk.SELECTION_SINGLE + self.selection.set_mode(mode) + + def _get_allow_multiple_select(self): + return self.selection.get_mode() == gtk.SELECTION_MULTIPLE + + def _get_selected_iters(self): + iters = [] + def collect(treemodel, path, iter_): + iters.append(iter_) + self.selection.selected_foreach(collect) + return iters + + def _get_selected_iter(self): + model, iter_ = self.selection.get_selected() + return iter_ + + @property + def num_rows_selected(self): + return self.selection.count_selected_rows() + + def _is_selected(self, iter_): + return self.selection.iter_is_selected(iter_) + + def _select(self, iter_): + self.selection.select_iter(iter_) + + def _unselect(self, iter_): + self.selection.unselect_iter(iter_) + + def _unselect_all(self): + self.selection.unselect_all() + + def _iter_to_string(self, iter_): + return self._model.get_string_from_iter(iter_) + + def _iter_from_string(self, string): + try: + return self._model.get_iter_from_string(string) + except ValueError: + raise WidgetDomainError( + "model iters", string, "%s other iters" % len(self.model)) + + def select_path(self, path): + self.selection.select_path(path) + + def _validate_iter(self, iter_): + if self.get_path(iter_) is None: + raise WidgetDomainError( + "model iters", iter_, "%s other iters" % len(self.model)) + real_model = self._widget.get_model() + if not real_model: + raise WidgetActionError("no model") + elif real_model != self._model: + raise WidgetActionError("wrong model?") + + def get_cursor(self): + """Return the path of the 'focused' item.""" + path, column = self._widget.get_cursor() + return path + + def set_cursor(self, path): + """Set the path of the 'focused' item.""" + if path is None: + # XXX: is there a way to clear the cursor? + return + path_as_string = ':'.join(str(component) for component in path) + with self.preserving_selection(): # set_cursor() messes up the selection + self._widget.set_cursor(path_as_string) + +class DNDHandlerMixin(object): + """TableView row DnD. + + Depends on arbitrary TableView methods; otherwise self-contained except: + on_button_press: may call start_drag + on_button_release: may unset drag_button_down + on_motion_notify: may call potential_drag_motion + """ + def __init__(self): + self.drag_button_down = False + self.drag_data = {} + self.drag_source = self.drag_dest = None + self.drag_start_x, self.drag_start_y = None, None + self.wrapped_widget_connect('drag-data-get', self.on_drag_data_get) + self.wrapped_widget_connect('drag-end', self.on_drag_end) + self.wrapped_widget_connect('drag-motion', self.on_drag_motion) + self.wrapped_widget_connect('drag-leave', self.on_drag_leave) + self.wrapped_widget_connect('drag-drop', self.on_drag_drop) + self.wrapped_widget_connect('drag-data-received', + self.on_drag_data_received) + self.wrapped_widget_connect('unrealize', self.on_drag_unrealize) + + def set_drag_source(self, drag_source): + self.drag_source = drag_source + # XXX: the following note no longer seems accurate: + # No need to call enable_model_drag_source() here, we handle it + # ourselves in on_motion_notify() + + def set_drag_dest(self, drag_dest): + """Set the drop handler.""" + self.drag_dest = drag_dest + if drag_dest is not None: + targets = self._gtk_target_list(drag_dest.allowed_types()) + self._widget.enable_model_drag_dest(targets, + drag_dest.allowed_actions()) + self._widget.drag_dest_set(0, targets, + drag_dest.allowed_actions()) + else: + self._widget.unset_rows_drag_dest() + self._widget.drag_dest_unset() + + def start_drag(self, treeview, event, path_info): + """Check whether the event is a drag event; return whether handled + here. + """ + if event.state & (gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK): + return False + model, row_paths = treeview.get_selection().get_selected_rows() + + if path_info.path not in row_paths: + # something outside the selection is being dragged. + # make it the new selection. + self.unselect_all(signal=False) + self.select_path(path_info.path) + row_paths = [path_info.path] + rows = self.model.get_rows(row_paths) + self.drag_data = rows and self.drag_source.begin_drag(self, rows) + self.drag_button_down = bool(self.drag_data) + if self.drag_button_down: + self.drag_start_x = int(event.x) + self.drag_start_y = int(event.y) + + if len(row_paths) > 1 and path_info.path in row_paths: + # handle multiple selection. If the current row is already + # selected, stop propagating the signal. We will only change + # the selection if the user doesn't start a DnD operation. + # This makes it more natural for the user to drag a block of + # selected items. + renderer = path_info.column.get_cell_renderers()[0] + if (not self._x_coord_in_expander(treeview, path_info) + and not isinstance(renderer, GTKCheckboxCellRenderer)): + self.delaying_press = True + # grab keyboard focus since we handled the event + self.focus() + return True + + def on_drag_data_get(self, treeview, context, selection, info, timestamp): + for typ, data in self.drag_data.items(): + selection.set(typ, 8, repr(data)) + + def on_drag_end(self, treeview, context): + self.drag_data = {} + + def find_type(self, drag_context): + return self._widget.drag_dest_find_target(drag_context, + self._widget.drag_dest_get_target_list()) + + def calc_positions(self, x, y): + """Given x and y coordinates, generate a list of drop positions to + try. The values are tuples in the form of (parent_path, position, + gtk_path, gtk_position), where parent_path and position is the + position to send to the Miro code, and gtk_path and gtk_position is an + equivalent position to send to the GTK code if the drag_dest validates + the drop. + """ + model = self._model + try: + gtk_path, gtk_position = self._widget.get_dest_row_at_pos(x, y) + except TypeError: + # Below the last row + yield (None, len(model), None, None) + return + + iter_ = model.get_iter(gtk_path) + if gtk_position in (gtk.TREE_VIEW_DROP_INTO_OR_BEFORE, + gtk.TREE_VIEW_DROP_INTO_OR_AFTER): + yield (iter_, -1, gtk_path, gtk_position) + + if hasattr(model, 'iter_is_valid'): + # tablist has this; item list does not + assert model.iter_is_valid(iter_) + parent_iter = model.iter_parent(iter_) + position = gtk_path[-1] + if gtk_position in (gtk.TREE_VIEW_DROP_BEFORE, + gtk.TREE_VIEW_DROP_INTO_OR_BEFORE): + # gtk gave us a "before" position, no need to change it + yield (parent_iter, position, gtk_path, gtk.TREE_VIEW_DROP_BEFORE) + else: + # gtk gave us an "after" position, translate that to before the + # next row for miro. + if (self._widget.row_expanded(gtk_path) and + model.iter_has_child(iter_)): + child_path = gtk_path + (0,) + yield (iter_, 0, child_path, gtk.TREE_VIEW_DROP_BEFORE) + else: + yield (parent_iter, position+1, gtk_path, + gtk.TREE_VIEW_DROP_AFTER) + + def on_drag_motion(self, treeview, drag_context, x, y, timestamp): + if not self.drag_dest: + return True + type = self.find_type(drag_context) + if type == "NONE": + drag_context.drag_status(0, timestamp) + return True + drop_action = 0 + for pos_info in self.calc_positions(x, y): + drop_action = self.drag_dest.validate_drop(self, self.model, type, + drag_context.actions, pos_info[0], pos_info[1]) + if isinstance(drop_action, (list, tuple)): + drop_action, iter = drop_action + path = self.model.get_path(iter) + pos = gtk.TREE_VIEW_DROP_INTO_OR_BEFORE + else: + path, pos = pos_info[2:4] + + if drop_action: + self.set_drag_dest_row(path, pos) + break + else: + self.unset_drag_dest_row() + drag_context.drag_status(drop_action, timestamp) + return True + + def set_drag_dest_row(self, path, position): + self._widget.set_drag_dest_row(path, position) + + def unset_drag_dest_row(self): + self._widget.unset_drag_dest_row() + + def on_drag_leave(self, treeview, drag_context, timestamp): + treeview.unset_drag_dest_row() + + def on_drag_drop(self, treeview, drag_context, x, y, timestamp): + # prevent the default handler + treeview.emit_stop_by_name('drag-drop') + target = self.find_type(drag_context) + if target == "NONE": + return False + treeview.drag_get_data(drag_context, target, timestamp) + treeview.unset_drag_dest_row() + + def on_drag_data_received(self, + treeview, drag_context, x, y, selection, info, timestamp): + # prevent the default handler + treeview.emit_stop_by_name('drag-data-received') + if not self.drag_dest: + return + type = self.find_type(drag_context) + if type == "NONE": + return + if selection.data is None: + return + drop_action = 0 + for pos_info in self.calc_positions(x, y): + drop_action = self.drag_dest.validate_drop(self, self.model, type, + drag_context.actions, pos_info[0], pos_info[1]) + if drop_action: + self.drag_dest.accept_drop(self, self.model, type, + drag_context.actions, pos_info[0], pos_info[1], + eval(selection.data)) + return True + return False + + def on_drag_unrealize(self, treeview): + self.drag_button_down = False + + def potential_drag_motion(self, treeview, event): + """A motion event has occurred and did not hit a hotspot; start a drag + if applicable. + """ + if (self.drag_data and self.drag_button_down and + treeview.drag_check_threshold(self.drag_start_x, + self.drag_start_y, int(event.x), int(event.y))): + self.delaying_press = False + treeview.drag_begin(self._gtk_target_list(self.drag_data.keys()), + self.drag_source.allowed_actions(), 1, event) + + @staticmethod + def _gtk_target_list(types): + count = itertools.count() + return [(type, gtk.TARGET_SAME_APP, count.next()) for type in types] + +class HotspotTrackingMixin(object): + def __init__(self): + self.hotspot_tracker = None + self.create_signal('hotspot-clicked') + self._hotspot_callback_handles = [] + self._connect_hotspot_signals() + self.wrapped_widget_connect('unrealize', self.on_hotspot_unrealize) + + def _connect_hotspot_signals(self): + SIGNALS = { + 'row-inserted': self.on_row_inserted, + 'row-deleted': self.on_row_deleted, + 'row-changed': self.on_row_changed, + } + self._hotspot_callback_handles.extend( + weak_connect(self._model, signal, handler) + for signal, handler in SIGNALS.iteritems()) + + def _disconnect_hotspot_signals(self): + for handle in self._hotspot_callback_handles: + self._model.disconnect(handle) + self._hotspot_callback_handles = [] + + def on_row_inserted(self, model, path, iter_): + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + + def on_row_deleted(self, model, path): + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + + def on_row_changed(self, model, path, iter_): + if self.hotspot_tracker: + self.hotspot_tracker.update_hit() + + def handle_hotspot_hit(self, treeview, event): + """Check whether the event is a hotspot event; return whether handled + here. + """ + if self.hotspot_tracker: + return + hotspot_tracker = HotspotTracker(treeview, event) + if hotspot_tracker.hit: + self.hotspot_tracker = hotspot_tracker + hotspot_tracker.redraw_cell() + if hotspot_tracker.is_for_context_menu(): + menu = self._popup_context_menu(self.hotspot_tracker.path, event) + if menu: + menu.connect('selection-done', + self._on_hotspot_context_menu_selection_done) + # grab keyboard focus since we handled the event + self.focus() + return True + + def _on_hotspot_context_menu_selection_done(self, menu): + # context menu is closed, we won't get the button-release-event in + # this case, but we can unset hotspot tracker here. + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + + def on_hotspot_unrealize(self, treeview): + self.hotspot_tracker = None + + def release_on_hotspot(self, event): + """A button_release occurred; return whether it has been handled as a + hotspot hit. + """ + hotspot_tracker = self.hotspot_tracker + if hotspot_tracker and event.button == hotspot_tracker.button: + hotspot_tracker.update_position(event) + hotspot_tracker.update_hit() + if (hotspot_tracker.hit and + not hotspot_tracker.is_for_context_menu()): + self.emit('hotspot-clicked', hotspot_tracker.name, + hotspot_tracker.iter) + hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + return True + + def hotspot_model_changed(self): + """A bulk change has ended; reconnect signals and update hotspots.""" + self._connect_hotspot_signals() + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker.update_hit() + +class ColumnOwnerMixin(object): + """Keeps track of the table's columns - including the list of columns, and + properties that we set for a table but need to apply to each column. + + This manages: + columns + attr_map_for_column + gtk_column_to_wrapper + for use throughout tableview. + """ + def __init__(self): + self._columns_draggable = False + self._renderer_xpad = self._renderer_ypad = 0 + self.columns = [] + self.attr_map_for_column = {} + self.gtk_column_to_wrapper = {} + self.create_signal('reallocate-columns') # not emitted on GTK + + def remove_column(self, index): + """Remove a column from the display and forget it from the column lists. + """ + column = self.columns.pop(index) + del self.attr_map_for_column[column._column] + del self.gtk_column_to_wrapper[column._column] + self._widget.remove_column(column._column) + + def get_columns(self): + """Returns the current columns, in order, by title.""" + # FIXME: this should probably return column objects, and really should + # not be keeping track of columns by title at all + titles = [column.get_title().decode('utf-8') + for column in self._widget.get_columns()] + return titles + + def add_column(self, column): + """Append a column to this table; setup all necessary mappings, and + setup the new column's properties to match the table's settings. + """ + self.model.check_new_column(column) + self._widget.append_column(column._column) + self.columns.append(column) + self.attr_map_for_column[column._column] = column.attrs + self.gtk_column_to_wrapper[column._column] = column + self.setup_new_column(column) + + def setup_new_column(self, column): + """Apply properties that we keep track of at the table level to a + newly-created column. + """ + if self.background_color: + column.renderer._renderer.set_property('cell-background-gdk', + self.background_color) + column._column.set_reorderable(self._columns_draggable) + if column.do_horizontal_padding: + column.renderer._renderer.set_property('xpad', self._renderer_xpad) + column.renderer._renderer.set_property('ypad', self._renderer_ypad) + + def set_column_spacing(self, space): + """Set the amount of space between columns.""" + self._renderer_xpad = space / 2 + for column in self.columns: + if column.do_horizontal_padding: + column.renderer._renderer.set_property('xpad', + self._renderer_xpad) + + def set_row_spacing(self, space): + """Set the amount of space between columns.""" + self._renderer_ypad = space / 2 + for column in self.columns: + column.renderer._renderer.set_property('ypad', self._renderer_ypad) + + def set_columns_draggable(self, setting): + """Set the draggability of existing and future columns.""" + self._columns_draggable = setting + for column in self.columns: + column._column.set_reorderable(setting) + + def set_column_background_color(self): + """Set the background color of existing columns to the table's + background_color. + """ + for column in self.columns: + column.renderer._renderer.set_property('cell-background-gdk', + self.background_color) + + def set_auto_resizes(self, setting): + # FIXME: to be implemented. + # At this point, GTK somehow does the right thing anyway in terms of + # auto-resizing. I'm not sure exactly what's happening, but I believe + # that if the column widths don't add up to the total width, + # gtk.TreeView allocates extra width for the last column. This works + # well enough for the tab list and item list, since there's only one + # column. + pass + +class HoverTrackingMixin(object): + """Handle mouse hover events - tooltips for some cells and hover events for + renderers which support them. + """ + def __init__(self): + self.hover_info = None + self.hover_pos = None + if hasattr(self, 'get_tooltip'): + # this should probably be something like self.set_tooltip_source + self._widget.set_property('has-tooltip', True) + self.wrapped_widget_connect('query-tooltip', self.on_tooltip) + self._last_tooltip_place = None + + def on_tooltip(self, treeview, x, y, keyboard_mode, tooltip): + # x, y are relative to the entire widget, but we want them to be + # relative to our bin window. The bin window doesn't include things + # like the column headers. + origin = treeview.window.get_origin() + bin_origin = treeview.get_bin_window().get_origin() + x += origin[0] - bin_origin[0] + y += origin[1] - bin_origin[1] + path_info = treeview.get_position_info(x, y) + if path_info is None: + self._last_tooltip_place = None + return False + if (self._last_tooltip_place is not None and + path_info[:2] != self._last_tooltip_place): + # the default GTK behavior is to keep the tooltip in the same + # position, but this is looks bad when we move to a different row. + # So return False once to stop this. + self._last_tooltip_place = None + return False + self._last_tooltip_place = path_info[:2] + iter_ = treeview.get_model().get_iter(path_info.path) + column = self.gtk_column_to_wrapper[path_info.column] + text = self.get_tooltip(iter_, column) + if text is None: + return False + pygtkhacks.set_tooltip_text(tooltip, text) + return True + + def _update_hover(self, treeview, event): + old_hover_info, old_hover_pos = self.hover_info, self.hover_pos + path_info = treeview.get_position_info(event.x, event.y) + if (path_info and + self.gtk_column_to_wrapper[path_info.column].renderer.want_hover): + self.hover_info = path_info.path, path_info.column + self.hover_pos = path_info.x, path_info.y + else: + self.hover_info = None + self.hover_pos = None + if (old_hover_info != self.hover_info or + old_hover_pos != self.hover_pos): + if (old_hover_info != self.hover_info and + old_hover_info is not None): + self._redraw_cell(treeview, *old_hover_info) + if self.hover_info is not None: + self._redraw_cell(treeview, *self.hover_info) + +class GTKScrollbarOwnerMixin(ScrollbarOwnerMixin): + # XXX this is half a wrapper for TreeViewScrolling. A lot of things will + # become much simpler when we integrate TVS into this + def __init__(self): + ScrollbarOwnerMixin.__init__(self) + # super uses this for postponed scroll_to_iter + # it's a faux-signal from our _widget; this hack is only necessary until + # we integrate TVS + self._widget.scroll_range_changed = (lambda *a: + self.emit('scroll-range-changed')) + + def set_scroller(self, scroller): + """Set the Scroller object for this widget, if its ScrolledWindow is + not a direct ancestor of the object. Standard View needs this. + """ + self._widget.set_scroller(scroller._widget) + + def _set_scroll_position(self, scroll_pos): + self._widget.set_scroll_position(scroll_pos) + + def _get_item_area(self, iter_): + return self._widget.get_path_rect(self.get_path(iter_)) + + @property + def _manually_scrolled(self): + return self._widget.manually_scrolled + + @property + def _position_set(self): + return self._widget.position_set + + def _get_visible_area(self): + """Return the Rect of the visible area, in tree coords. + + get_visible_rect gets this wrong for StandardView, always returning an + origin of (0, 0) - this is because our ScrolledWindow is not our direct + parent. + """ + bars = self._widget._scrollbars + x, y = (int(adj.get_value()) for adj in bars) + width, height = (int(adj.get_page_size()) for adj in bars) + if height == 0: + # this happens even after _widget._coords_working + raise WidgetNotReadyError('visible height') + return Rect(x, y, width, height) + + def _get_scroll_position(self): + """Get the current position of both scrollbars, to restore later.""" + try: + return tuple(int(bar.get_value()) for bar in self._widget._scrollbars) + except WidgetNotReadyError: + return None + +class TableView(Widget, GTKSelectionOwnerMixin, DNDHandlerMixin, + HotspotTrackingMixin, ColumnOwnerMixin, HoverTrackingMixin, + GTKScrollbarOwnerMixin): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + draws_selection = True + + def __init__(self, model, custom_headers=False): + Widget.__init__(self) + self.set_widget(MiroTreeView()) + self.model = model + self.model.add_to_tableview(self._widget) + self._model = self._widget.get_model() + wrappermap.add(self._model, model) + self._setup_colors() + self.background_color = None + self.context_menu_callback = None + self.in_bulk_change = False + self.delaying_press = False + self._use_custom_headers = False + self.layout_manager = LayoutManager(self._widget) + self.height_changed = None # 17178 hack + self._connect_signals() + # setting up mixins after general TableView init + GTKSelectionOwnerMixin.__init__(self) + DNDHandlerMixin.__init__(self) + HotspotTrackingMixin.__init__(self) + ColumnOwnerMixin.__init__(self) + HoverTrackingMixin.__init__(self) + GTKScrollbarOwnerMixin.__init__(self) + if custom_headers: + self._enable_custom_headers() + + # FIXME: should implement set_model() and make None a special case. + def unset_model(self): + """Disconnect our model from this table view. + + This should be called when you want to destroy a TableView and + there's a new TableView sharing its model. + """ + self._widget.set_model(None) + self.model = None + + def _connect_signals(self): + self.create_signal('row-expanded') + self.create_signal('row-collapsed') + self.create_signal('row-clicked') + self.create_signal('row-activated') + self.wrapped_widget_connect('row-activated', self.on_row_activated) + self.wrapped_widget_connect('row-expanded', self.on_row_expanded) + self.wrapped_widget_connect('row-collapsed', self.on_row_collapsed) + self.wrapped_widget_connect('button-press-event', self.on_button_press) + self.wrapped_widget_connect('button-release-event', + self.on_button_release) + self.wrapped_widget_connect('motion-notify-event', + self.on_motion_notify) + + def set_gradient_highlight(self, gradient): + # This is just an OS X thing. + pass + + def set_background_color(self, color): + self.background_color = self.make_color(color) + self.modify_style('base', gtk.STATE_NORMAL, self.background_color) + if not self.draws_selection: + self.modify_style('base', gtk.STATE_SELECTED, + self.background_color) + self.modify_style('base', gtk.STATE_ACTIVE, self.background_color) + if self.use_custom_style: + self.set_column_background_color() + + def set_group_lines_enabled(self, enabled): + """Enable/Disable group lines. + + This only has an effect if our model is an InfoListModel and it has a + grouping set. + + If group lines are enabled, we will draw a line below the last item in + the group. Use set_group_line_style() to change the look of the line. + """ + self._widget.group_lines_enabled = enabled + self.queue_redraw() + + def set_group_line_style(self, color, width): + self._widget.group_line_color = color + self._widget.group_line_width = width + self.queue_redraw() + + def handle_custom_style_change(self): + if self.background_color is not None: + if self.use_custom_style: + self.set_column_background_color() + else: + for column in self.columns: + column.renderer._renderer.set_property( + 'cell-background-set', False) + + def set_alternate_row_backgrounds(self, setting): + self._widget.set_rules_hint(setting) + + def set_grid_lines(self, horizontal, vertical): + if horizontal and vertical: + setting = gtk.TREE_VIEW_GRID_LINES_BOTH + elif horizontal: + setting = gtk.TREE_VIEW_GRID_LINES_HORIZONTAL + elif vertical: + setting = gtk.TREE_VIEW_GRID_LINES_VERTICAL + else: + setting = gtk.TREE_VIEW_GRID_LINES_NONE + self._widget.set_grid_lines(setting) + + def width_for_columns(self, total_width): + """Given the width allocated for the TableView, return how much of that + is available to column contents. Note that this depends on the number of + columns. + """ + column_spacing = TableColumn.FIXED_PADDING * len(self.columns) + return total_width - column_spacing + + def enable_album_view_focus_hack(self): + _install_album_view_gtkrc() + self._widget.set_name("miro-album-view") + + def focus(self): + self._widget.grab_focus() + + def _enable_custom_headers(self): + # NB: this is currently not used because the GTK tableview does not + # support custom headers. + self._use_custom_headers = True + + def set_show_headers(self, show): + self._widget.set_headers_visible(show) + self._widget.set_headers_clickable(show) + + def _setup_colors(self): + style = self._widget.style + if not self.draws_selection: + # if we don't want to draw selection, make the selected/active + # colors the same as the normal ones + self.modify_style('base', gtk.STATE_SELECTED, + style.base[gtk.STATE_NORMAL]) + self.modify_style('base', gtk.STATE_ACTIVE, + style.base[gtk.STATE_NORMAL]) + + def set_search_column(self, model_index): + self._widget.set_search_column(model_index) + + def set_fixed_height(self, fixed_height): + self._widget.set_fixed_height_mode(fixed_height) + + def set_row_expanded(self, iter_, expanded): + """Expand or collapse the row specified by iter_. Succeeds or raises + WidgetActionError. Causes row-expanded or row-collapsed to be emitted + when successful. + """ + path = self.get_path(iter_) + if expanded: + self._widget.expand_row(path, False) + else: + self._widget.collapse_row(path) + if bool(self._widget.row_expanded(path)) != bool(expanded): + raise WidgetActionError("cannot expand the given item - it " + "probably has no children.") + + def is_row_expanded(self, iter_): + path = self.get_path(iter_) + return self._widget.row_expanded(path) + + def set_context_menu_callback(self, callback): + self.context_menu_callback = callback + + # GTK is really good and it is safe to operate on table even when + # cells may be constantly changing in flux. + def set_volatile(self, volatile): + return + + def on_row_expanded(self, _widget, iter_, path): + self.emit('row-expanded', iter_, path) + + def on_row_collapsed(self, _widget, iter_, path): + self.emit('row-collapsed', iter_, path) + + def on_button_press(self, treeview, event): + """Handle a mouse button press""" + if event.type == gtk.gdk._2BUTTON_PRESS: + # already handled as row-activated + return False + + path_info = treeview.get_position_info(event.x, event.y) + if not path_info: + # no item was clicked, so it's not going to be a hotspot, drag, or + # context menu + return False + if event.type == gtk.gdk.BUTTON_PRESS: + # single click; emit the event but keep on running so we can handle + # stuff like drag and drop. + if not self._x_coord_in_expander(treeview, path_info): + iter_ = treeview.get_model().get_iter(path_info.path) + self.emit('row-clicked', iter_) + + if (event.button == 1 and self.handle_hotspot_hit(treeview, event)): + return True + if event.window != treeview.get_bin_window(): + # click is outside the content area, don't try to handle this. + # In particular, our DnD code messes up resizing table columns. + return False + if (event.button == 1 and self.drag_source and + not self._x_coord_in_expander(treeview, path_info)): + return self.start_drag(treeview, event, path_info) + elif event.button == 3 and self.context_menu_callback: + self.show_context_menu(treeview, event, path_info) + return True + + # FALLTHROUGH + return False + + def show_context_menu(self, treeview, event, path_info): + """Pop up a context menu for the given click event (which is a + right-click on a row). + """ + # hack for album view + if (treeview.group_lines_enabled and + path_info.column == treeview.get_columns()[0]): + self._select_all_rows_in_group(treeview, path_info.path) + self._popup_context_menu(path_info.path, event) + # grab keyboard focus since we handled the event + self.focus() + + def _select_all_rows_in_group(self, treeview, path): + """Select all items in the group """ + + # FIXME: this is very tightly coupled with the portable code. + + infolist = self.model + gtk_model = treeview.get_model() + if (not isinstance(infolist, InfoListModel) or + infolist.get_grouping() is None): + return + it = gtk_model.get_iter(path) + info, attrs, group_info = infolist.row_for_iter(it) + start_row = path[0] - group_info[0] + total_rows = group_info[1] + + with self._ignoring_changes(): + self.unselect_all() + for row in xrange(start_row, start_row + total_rows): + self.select_path((row,)) + self.emit('selection-changed') + + def _popup_context_menu(self, path, event): + if not self.selection.path_is_selected(path): + self.unselect_all(signal=False) + self.select_path(path) + menu = self.make_context_menu() + if menu: + menu.popup(None, None, None, event.button, event.time) + return menu + else: + return None + + # XXX treeview.get_cell_area handles what we're trying to use this for + def _x_coord_in_expander(self, treeview, path_info): + """Calculate if an x coordinate is over the expander triangle + + :param treeview: Gtk.TreeView + :param path_info: PathInfo( + tree path for the cell, + Gtk.TreeColumn, + x coordinate relative to column's cell area, + y coordinate relative to column's cell area (ignored), + ) + """ + if path_info.column != treeview.get_expander_column(): + return False + model = treeview.get_model() + if not model.iter_has_child(model.get_iter(path_info.path)): + return False + # GTK allocateds an extra 4px to the right of the expanders. This + # seems to be hardcoded as EXPANDER_EXTRA_PADDING in the source code. + total_exander_size = treeview.expander_size + 4 + # include horizontal_separator + # XXX: should this value be included in total_exander_size ? + offset = treeview.horizontal_separator / 2 + # allocate space for expanders for parent nodes + expander_start = total_exander_size * (len(path_info.path) - 1) + offset + expander_end = expander_start + total_exander_size + offset + return expander_start <= path_info.x < expander_end + + def on_row_activated(self, treeview, path, view_column): + iter_ = treeview.get_model().get_iter(path) + self.emit('row-activated', iter_) + + def make_context_menu(self): + def gen_menu(menu_items): + menu = gtk.Menu() + for menu_item_info in menu_items: + if menu_item_info is None: + item = gtk.SeparatorMenuItem() + else: + label, callback = menu_item_info + + if isinstance(label, tuple) and len(label) == 2: + text_label, icon_path = label + pixbuf = gtk.gdk.pixbuf_new_from_file(icon_path) + image = gtk.Image() + image.set_from_pixbuf(pixbuf) + item = gtk.ImageMenuItem(text_label) + item.set_image(image) + else: + item = gtk.MenuItem(label) + + if callback is None: + item.set_sensitive(False) + elif isinstance(callback, list): + item.set_submenu(gen_menu(callback)) + else: + item.connect('activate', self.on_context_menu_activate, + callback) + menu.append(item) + item.show() + return menu + + items = self.context_menu_callback(self) + if items: + return gen_menu(items) + else: + return None + + def on_context_menu_activate(self, item, callback): + callback() + + def on_button_release(self, treeview, event): + if self.release_on_hotspot(event): + return True + if event.button == 1: + self.drag_button_down = False + + if self.delaying_press: + # if dragging did not happen, unselect other rows and + # select current row + path_info = treeview.get_position_info(event.x, event.y) + if path_info is not None: + self.unselect_all(signal=False) + self.select_path(path_info.path) + self.delaying_press = False + + def _redraw_cell(self, treeview, path, column): + cell_area = treeview.get_cell_area(path, column) + x, y = treeview.convert_bin_window_to_widget_coords(cell_area.x, + cell_area.y) + treeview.queue_draw_area(x, y, cell_area.width, cell_area.height) + + def on_motion_notify(self, treeview, event): + self._update_hover(treeview, event) + + if self.hotspot_tracker: + self.hotspot_tracker.update_position(event) + self.hotspot_tracker.update_hit() + return True + + self.potential_drag_motion(treeview, event) + return None # XXX: used to fall through; not sure what retval does here + + def start_bulk_change(self): + self._widget.freeze_child_notify() + self._widget.set_model(None) + self._disconnect_hotspot_signals() + self.in_bulk_change = True + + def model_changed(self): + if self.in_bulk_change: + self._widget.set_model(self._model) + self._widget.thaw_child_notify() + self.hotspot_model_changed() + self.in_bulk_change = False + + def get_path(self, iter_): + """Always use this rather than the model's get_path directly - + if the iter isn't valid, a GTK assertion causes us to exit + without warning; this wrapper changes that to a much more useful + AssertionError. Example related bug: #17362. + """ + assert self.model.iter_is_valid(iter_) + return self._model.get_path(iter_) + +class TableModel(object): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + MODEL_CLASS = gtk.ListStore + + def __init__(self, *column_types): + self._model = self.MODEL_CLASS(*self.map_types(column_types)) + self._column_types = column_types + if 'image' in self._column_types: + self.convert_row_for_gtk = self.convert_row_for_gtk_slow + self.convert_value_for_gtk = self.convert_value_for_gtk_slow + else: + self.convert_row_for_gtk = self.convert_row_for_gtk_fast + self.convert_value_for_gtk = self.convert_value_for_gtk_fast + + def add_to_tableview(self, widget): + widget.set_model(self._model) + + def map_types(self, miro_column_types): + type_map = { + 'boolean': bool, + 'numeric': float, + 'integer': int, + 'text': str, + 'image': gtk.gdk.Pixbuf, + 'datetime': object, + 'object': object, + } + try: + return [type_map[type] for type in miro_column_types] + except KeyError, e: + raise ValueError("Unknown column type: %s" % e[0]) + + # If we store image data, we need to do some work to convert row data to + # send to GTK + def convert_value_for_gtk_slow(self, column_value): + if isinstance(column_value, Image): + return column_value.pixbuf + else: + return column_value + + def convert_row_for_gtk_slow(self, column_values): + return tuple(self.convert_value_for_gtk(c) for c in column_values) + + def check_new_column(self, column): + for value in column.attrs.values(): + if not isinstance(value, int): + msg = "Attribute values must be integers, not %r" % value + raise TypeError(msg) + if value < 0 or value >= len(self._column_types): + raise ValueError("Attribute index out of range: %s" % value) + + # If we don't store image data, we can don't need to do any work to + # convert row data to gtk + def convert_value_for_gtk_fast(self, value): + return value + + def convert_row_for_gtk_fast(self, column_values): + return column_values + + def append(self, *column_values): + return self._model.append(self.convert_row_for_gtk(column_values)) + + def update_value(self, iter_, index, value): + assert self._model.iter_is_valid(iter_) + self._model.set(iter_, index, self.convert_value_for_gtk(value)) + + def update(self, iter_, *column_values): + self._model[iter_] = self.convert_value_for_gtk(column_values) + + def remove(self, iter_): + if self._model.remove(iter_): + return iter_ + else: + return None + + def insert_before(self, iter_, *column_values): + row = self.convert_row_for_gtk(column_values) + return self._model.insert_before(iter_, row) + + def first_iter(self): + return self._model.get_iter_first() + + def next_iter(self, iter_): + return self._model.iter_next(iter_) + + def nth_iter(self, index): + assert index >= 0 + return self._model.iter_nth_child(None, index) + + def __iter__(self): + return iter(self._model) + + def __len__(self): + return len(self._model) + + def __getitem__(self, iter_): + return self._model[iter_] + + def get_rows(self, row_paths): + return [self._model[path] for path in row_paths] + + def get_path(self, iter_): + return self._model.get_path(iter_) + + def iter_is_valid(self, iter_): + return self._model.iter_is_valid(iter_) + +class TreeTableModel(TableModel): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + MODEL_CLASS = gtk.TreeStore + + def append(self, *column_values): + return self._model.append(None, self.convert_row_for_gtk( + column_values)) + + def insert_before(self, iter_, *column_values): + parent = self.parent_iter(iter_) + row = self.convert_row_for_gtk(column_values) + return self._model.insert_before(parent, iter_, row) + + def append_child(self, iter_, *column_values): + return self._model.append(iter_, self.convert_row_for_gtk( + column_values)) + + def child_iter(self, iter_): + return self._model.iter_children(iter_) + + def nth_child_iter(self, iter_, index): + assert index >= 0 + return self._model.iter_nth_child(iter_, index) + + def has_child(self, iter_): + return self._model.iter_has_child(iter_) + + def children_count(self, iter_): + return self._model.iter_n_children(iter_) + + def parent_iter(self, iter_): + assert self._model.iter_is_valid(iter_) + return self._model.iter_parent(iter_) diff --git a/mvc/widgets/gtk/tableview.pyc b/mvc/widgets/gtk/tableview.pyc Binary files differnew file mode 100644 index 0000000..a6e6afb --- /dev/null +++ b/mvc/widgets/gtk/tableview.pyc diff --git a/mvc/widgets/gtk/tableviewcells.py b/mvc/widgets/gtk/tableviewcells.py new file mode 100644 index 0000000..33ac6f8 --- /dev/null +++ b/mvc/widgets/gtk/tableviewcells.py @@ -0,0 +1,249 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tableviewcells.py - Cell renderers for TableView.""" + +import gobject +import gtk +import pango + +from mvc import signals +from mvc.widgets import widgetconst +import drawing +import wrappermap +from .base import make_gdk_color + +class CellRenderer(object): + """Simple Cell Renderer + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self): + self._renderer = gtk.CellRendererText() + self.want_hover = False + + def setup_attributes(self, column, attr_map): + column.add_attribute(self._renderer, 'text', attr_map['value']) + + def set_align(self, align): + if align == 'left': + self._renderer.props.xalign = 0.0 + elif align == 'center': + self._renderer.props.xalign = 0.5 + elif align == 'right': + self._renderer.props.xalign = 1.0 + else: + raise ValueError("unknown alignment: %s" % align) + + def set_color(self, color): + self._renderer.props.foreground_gdk = make_gdk_color(color) + + def set_bold(self, bold): + font_desc = self._renderer.props.font_desc + if bold: + font_desc.set_weight(pango.WEIGHT_BOLD) + else: + font_desc.set_weight(pango.WEIGHT_NORMAL) + self._renderer.props.font_desc = font_desc + + def set_text_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self._renderer.props.scale = 1.0 + elif size == widgetconst.SIZE_SMALL: + # FIXME: on 3.5 we just ignored the call. Always setting scale to + # 1.0 basically replicates that behavior, but should we actually + # try to implement the semantics of SIZE_SMALL? + self._renderer.props.scale = 1.0 + else: + raise ValueError("unknown size: %s" % size) + + def set_font_scale(self, scale_factor): + self._renderer.props.scale = scale_factor + +class ImageCellRenderer(object): + """Cell Renderer for images + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self): + self._renderer = gtk.CellRendererPixbuf() + self.want_hover = False + + def setup_attributes(self, column, attr_map): + column.add_attribute(self._renderer, 'pixbuf', attr_map['image']) + +class GTKCheckboxCellRenderer(gtk.CellRendererToggle): + def do_activate(self, event, treeview, path, background_area, cell_area, + flags): + iter = treeview.get_model().get_iter(path) + self.set_active(not self.get_active()) + wrappermap.wrapper(self).emit('clicked', iter) + +gobject.type_register(GTKCheckboxCellRenderer) + +class CheckboxCellRenderer(signals.SignalEmitter): + """Cell Renderer for booleans + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self): + signals.SignalEmitter.__init__(self) + self.create_signal("clicked") + self._renderer = GTKCheckboxCellRenderer() + wrappermap.add(self._renderer, self) + self.want_hover = False + + def set_control_size(self, size): + pass + + def setup_attributes(self, column, attr_map): + column.add_attribute(self._renderer, 'active', attr_map['value']) + +class GTKCustomCellRenderer(gtk.GenericCellRenderer): + """Handles the GTK hide of CustomCellRenderer + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def on_get_size(self, widget, cell_area=None): + wrapper = wrappermap.wrapper(self) + widget_wrapper = wrappermap.wrapper(widget) + style = drawing.DrawingStyle(widget_wrapper, use_base_color=True) + # NOTE: CustomCellRenderer.cell_data_func() sets up its attributes + # from the model itself, so we don't have to worry about setting them + # here. + width, height = wrapper.get_size(style, widget_wrapper.layout_manager) + x_offset = self.props.xpad + y_offset = self.props.ypad + width += self.props.xpad * 2 + height += self.props.ypad * 2 + if cell_area: + x_offset += cell_area.x + y_offset += cell_area.x + extra_width = max(0, cell_area.width - width) + extra_height = max(0, cell_area.height - height) + x_offset += int(round(self.props.xalign * extra_width)) + y_offset += int(round(self.props.yalign * extra_height)) + return x_offset, y_offset, width, height + + def on_render(self, window, widget, background_area, cell_area, expose_area, + flags): + widget_wrapper = wrappermap.wrapper(widget) + cell_wrapper = wrappermap.wrapper(self) + + selected = (flags & gtk.CELL_RENDERER_SELECTED) + if selected: + if widget.flags() & gtk.HAS_FOCUS: + state = gtk.STATE_SELECTED + else: + state = gtk.STATE_ACTIVE + else: + state = gtk.STATE_NORMAL + if cell_wrapper.IGNORE_PADDING: + area = background_area + else: + xpad = self.props.xpad + ypad = self.props.ypad + area = gtk.gdk.Rectangle(cell_area.x + xpad, cell_area.y + ypad, + cell_area.width - xpad * 2, cell_area.height - ypad * 2) + context = drawing.DrawingContext(window, area, expose_area) + if (selected and not widget_wrapper.draws_selection and + widget_wrapper.use_custom_style): + # Draw the base color as our background. This erases the gradient + # that GTK draws for selected items. + window.draw_rectangle(widget.style.base_gc[state], True, + background_area.x, background_area.y, + background_area.width, background_area.height) + context.style = drawing.DrawingStyle(widget_wrapper, + use_base_color=True, state=state) + widget_wrapper.layout_manager.update_cairo_context(context.context) + hotspot_tracker = widget_wrapper.hotspot_tracker + if (hotspot_tracker and hotspot_tracker.hit and + hotspot_tracker.column == self.column and + hotspot_tracker.path == self.path): + hotspot = hotspot_tracker.name + else: + hotspot = None + if (self.path, self.column) == widget_wrapper.hover_info: + hover = widget_wrapper.hover_pos + hover = (hover[0] - xpad, hover[1] - ypad) + else: + hover = None + # NOTE: CustomCellRenderer.cell_data_func() sets up its attributes + # from the model itself, so we don't have to worry about setting them + # here. + widget_wrapper.layout_manager.reset() + cell_wrapper.render(context, widget_wrapper.layout_manager, selected, + hotspot, hover) + + def on_activate(self, event, widget, path, background_area, cell_area, + flags): + pass + + def on_start_editing(self, event, widget, path, background_area, + cell_area, flags): + pass +gobject.type_register(GTKCustomCellRenderer) + +class CustomCellRenderer(object): + """Customizable Cell Renderer + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + IGNORE_PADDING = False + + def __init__(self): + self._renderer = GTKCustomCellRenderer() + self.want_hover = False + wrappermap.add(self._renderer, self) + + def setup_attributes(self, column, attr_map): + column.set_cell_data_func(self._renderer, self.cell_data_func, + attr_map) + + def cell_data_func(self, column, cell, model, iter, attr_map): + cell.column = column + cell.path = model.get_path(iter) + row = model[iter] + # Set attributes on self instead cell This works because cell is just + # going to turn around and call our methods to do the rendering. + for name, index in attr_map.items(): + setattr(self, name, row[index]) + + def hotspot_test(self, style, layout, x, y, width, height): + return None + +class InfoListRenderer(CustomCellRenderer): + """Custom Renderer for InfoListModels + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def cell_data_func(self, column, cell, model, iter, attr_map): + self.info, self.attrs, self.group_info = \ + wrappermap.wrapper(model).row_for_iter(iter) + cell.column = column + cell.path = model.get_path(iter) + +class InfoListRendererText(CellRenderer): + """Renderer for InfoListModels that only display text + https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + + def setup_attributes(self, column, attr_map): + infolist.gtk.setup_text_cell_data_func(column, self._renderer, + self.get_value) diff --git a/mvc/widgets/gtk/tableviewcells.pyc b/mvc/widgets/gtk/tableviewcells.pyc Binary files differnew file mode 100644 index 0000000..b9b7c51 --- /dev/null +++ b/mvc/widgets/gtk/tableviewcells.pyc diff --git a/mvc/widgets/gtk/weakconnect.py b/mvc/widgets/gtk/weakconnect.py new file mode 100644 index 0000000..b8b9526 --- /dev/null +++ b/mvc/widgets/gtk/weakconnect.py @@ -0,0 +1,56 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""weakconnect.py -- Connect to a signal of a GObject using a weak method +reference. This means that this connection will not keep the object alive. +This is a good thing because it prevents circular references between wrapper +widgets and the wrapped GTK widget. +""" + +from mvc import signals + +class WeakSignalHandler(object): + def __init__(self, method): + self.method = signals.WeakMethodReference(method) + + def connect(self, obj, signal, *user_args): + self.user_args = user_args + self.signal_handle = obj.connect(signal, self.handle_callback) + return self.signal_handle + + def handle_callback(self, obj, *args): + real_method = self.method() + if real_method is not None: + return real_method(obj, *(args + self.user_args)) + else: + obj.disconnect(self.signal_handle) + +def weak_connect(gobject, signal, method, *user_args): + handler = WeakSignalHandler(method) + return handler.connect(gobject, signal, *user_args) diff --git a/mvc/widgets/gtk/weakconnect.pyc b/mvc/widgets/gtk/weakconnect.pyc Binary files differnew file mode 100644 index 0000000..49133f8 --- /dev/null +++ b/mvc/widgets/gtk/weakconnect.pyc diff --git a/mvc/widgets/gtk/widgets.py b/mvc/widgets/gtk/widgets.py new file mode 100644 index 0000000..6c4280d --- /dev/null +++ b/mvc/widgets/gtk/widgets.py @@ -0,0 +1,47 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".widgets -- Contains portable implementations of +the GTK Widgets. These are shared between the windows port and the x11 port. +""" + +import gtk + +# Just use the GDK Rectangle class +class Rect(gtk.gdk.Rectangle): + @classmethod + def from_string(cls, rect_string): + x, y, width, height = [int(i) for i in rect_string.split(',')] + return Rect(x, y, width, height) + + def __str__(self): + return "%d,%d,%d,%d" % (self.x, self.y, self.width, self.height) + + def get_width(self): + return self.width diff --git a/mvc/widgets/gtk/widgets.pyc b/mvc/widgets/gtk/widgets.pyc Binary files differnew file mode 100644 index 0000000..e11de9e --- /dev/null +++ b/mvc/widgets/gtk/widgets.pyc diff --git a/mvc/widgets/gtk/widgetset.py b/mvc/widgets/gtk/widgetset.py new file mode 100644 index 0000000..c63855c --- /dev/null +++ b/mvc/widgets/gtk/widgetset.py @@ -0,0 +1,63 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +from .base import Widget, Bin +from .const import * +from .controls import TextEntry, NumberEntry, \ + SecureTextEntry, MultilineTextEntry, Checkbox, RadioButton, \ + RadioButtonGroup, OptionMenu, Button +from .customcontrols import ( + CustomButton, DragableCustomButton, CustomSlider, + ClickableImageButton) +# VolumeSlider and VolumeMuter aren't defined if gtk.VolumeButton +# doesn't have get_popup. +try: + from .customcontrols import ( + VolumeSlider, VolumeMuter) +except ImportError: + pass +from .contextmenu import ContextMenu +from .drawing import ImageSurface, DrawingContext, \ + DrawingArea, Background, Gradient +from .layout import HBox, VBox, Alignment, \ + Splitter, Table, TabContainer, DetachedWindowHolder +from .window import Window, MainWindow, Dialog, \ + FileOpenDialog, FileSaveDialog, DirectorySelectDialog, AboutDialog, \ + AlertDialog, DialogWindow +from .tableview import (TableView, TableModel, + TableColumn, TreeTableModel, CUSTOM_HEADER_HEIGHT) +from .tableviewcells import (CellRenderer, + ImageCellRenderer, CheckboxCellRenderer, CustomCellRenderer, + InfoListRenderer, InfoListRendererText) +from .simple import (Image, ImageDisplay, + AnimatedImageDisplay, Label, Scroller, Expander, SolidBackground, + ProgressBar, HLine) +from .widgets import Rect +from .gtkmenus import (MenuItem, RadioMenuItem, CheckMenuItem, Separator, + Menu, MenuBar, MainWindowMenuBar) diff --git a/mvc/widgets/gtk/widgetset.pyc b/mvc/widgets/gtk/widgetset.pyc Binary files differnew file mode 100644 index 0000000..677beb9 --- /dev/null +++ b/mvc/widgets/gtk/widgetset.pyc diff --git a/mvc/widgets/gtk/window.py b/mvc/widgets/gtk/window.py new file mode 100644 index 0000000..1f1cf04 --- /dev/null +++ b/mvc/widgets/gtk/window.py @@ -0,0 +1,708 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Jesus Eduardo (Heckyel) | 2017 +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".window -- GTK Window widget.""" + +import gobject +import gtk +import os + +from mvc import resources +from mvc import signals + +import keymap +import layout +import widgets +import wrappermap + +# keeps the objects alive until destroy() is called +alive_windows = set() +running_dialogs = set() + +class WrappedWindow(gtk.Window): + def do_map(self): + gtk.Window.do_map(self) + wrappermap.wrapper(self).emit('show') + + def do_unmap(self): + gtk.Window.do_unmap(self) + wrappermap.wrapper(self).emit('hide') + def do_focus_in_event(self, event): + gtk.Window.do_focus_in_event(self, event) + wrappermap.wrapper(self).emit('active-change') + def do_focus_out_event(self, event): + gtk.Window.do_focus_out_event(self, event) + wrappermap.wrapper(self).emit('active-change') + + def do_key_press_event(self, event): + if self.activate_key(event): # event activated a menu item + return + + if self.propagate_key_event(event): # event handled by widget + return + + ret = keymap.translate_gtk_event(event) + if ret is not None: + key, modifiers = ret + rv = wrappermap.wrapper(self).emit('key-press', key, modifiers) + if not rv: + gtk.Window.do_key_press_event(self, event) + + def _get_focused_wrapper(self): + """Get the wrapper of the widget with keyboard focus""" + focused = self.get_focus() + # some of our widgets created children for their use + # (GtkSearchTextEntry). If we don't find a wrapper for + # focused, try it's parents + while focused is not None: + try: + wrapper = wrappermap.wrapper(focused) + except KeyError: + focused = focused.get_parent() + else: + return wrapper + return None + + def change_focus_using_wrapper(self, direction): + my_wrapper = wrappermap.wrapper(self) + focused_wrapper = self._get_focused_wrapper() + if direction == gtk.DIR_TAB_FORWARD: + to_focus = my_wrapper.get_next_tab_focus(focused_wrapper, True) + elif direction == gtk.DIR_TAB_BACKWARD: + to_focus = my_wrapper.get_next_tab_focus(focused_wrapper, False) + else: + return False + if to_focus is not None: + to_focus.focus() + return True + return False + + def do_focus(self, direction): + if not self.change_focus_using_wrapper(direction): + gtk.Window.do_focus(self, direction) + +gobject.type_register(WrappedWindow) + +class WindowBase(signals.SignalEmitter): + def __init__(self): + signals.SignalEmitter.__init__(self) + self.create_signal('use-custom-style-changed') + self.create_signal('key-press') + self.create_signal('show') + self.create_signal('hide') + + def set_window(self, window): + self._window = window + window.connect('style-set', self.on_style_set) + wrappermap.add(window, self) + self.calc_use_custom_style() + + def on_style_set(self, widget, old_style): + old_use_custom_style = self.use_custom_style + self.calc_use_custom_style() + if old_use_custom_style != self.use_custom_style: + self.emit('use-custom-style-changed') + + def calc_use_custom_style(self): + if self._window is not None: + base = self._window.style.base[gtk.STATE_NORMAL] + # Decide if we should use a custom style. Right now the + # formula is the base color is a very light shade of + # gray/white (lighter than #f0f0f0). + self.use_custom_style = ((base.red == base.green == base.blue) and + base.red >= 61680) + + +class Window(WindowBase): + """The main Libre window. """ + + def __init__(self, title, rect=None): + """Create the Libre Main Window. Title is the name to give the + window, rect specifies the position it should have on screen. + """ + WindowBase.__init__(self) + self.set_window(self._make_gtk_window()) + self._window.set_title(title) + self.setup_icon() + if rect: + self._window.set_default_size(rect.width, rect.height) + self._window.set_default_size(rect.width, rect.height) + self._window.set_gravity(gtk.gdk.GRAVITY_CENTER) + self._window.move(rect.x, rect.y) + + self.create_signal('active-change') + self.create_signal('will-close') + self.create_signal('did-move') + self.create_signal('file-drag-motion') + self.create_signal('file-drag-received') + self.create_signal('file-drag-leave') + self.create_signal('on-shown') + self.drag_signals = [] + alive_windows.add(self) + + self._window.connect('delete-event', self.on_delete_window) + self._window.connect('map-event', lambda w, a: self.emit('on-shown')) + # XXX: Define MVCWindow/MiroWindow style not hard code this + self._window.set_resizable(False) + + def setup_icon(self): + icon_pixbuf = gtk.gdk.pixbuf_new_from_file( + resources.image_path("mvc-logo.png")) + self._window.set_icon(icon_pixbuf) + + + def accept_file_drag(self, val): + if not val: + self._window.drag_dest_set(0, [], 0) + for handle in self.drag_signals: + self.disconnect(handle) + self.drag_signals = [] + else: + self._window.drag_dest_set( + gtk.DEST_DEFAULT_MOTION | gtk.DEST_DEFAULT_DROP, + [('text/uri-list', 0, 0)], + gtk.gdk.ACTION_COPY) + for signal, callback in ( + ('drag-motion', self.on_drag_motion), + ('drag-data-received', self.on_drag_data_received), + ('drag-leave', self.on_drag_leave)): + self.drag_signals.append( + self._window.connect(signal, callback)) + + def on_drag_motion(self, widget, context, x, y, time): + self.emit('file-drag-motion') + + def on_drag_data_received(self, widget, context, x, y, selection_data, + info, time): + self.emit('file-drag-received', selection_data.get_uris()) + + def on_drag_leave(self, widget, context, time): + self.emit('file-drag-leave') + + def on_delete_window(self, widget, event): + # when the user clicks on the X in the corner of the window we + # want that to close the window, but also trigger our + # will-close signal and all that machinery unless the window + # is currently hidden--then we don't do anything. + if not self._window.window.is_visible(): + return + self.close() + return True + + def _make_gtk_window(self): + return WrappedWindow() + + def set_title(self, title): + self._window.set_title(title) + + def get_title(self): + self._window.get_title() + + def center(self): + self._window.set_position(gtk.WIN_POS_CENTER) + + def show(self): + if self not in alive_windows: + raise ValueError("Window destroyed") + self._window.show() + + def close(self): + if hasattr(self, "_closing"): + return + self._closing = True + # Keep a reference to the widget in case will-close signal handler + # calls destroy() + old_window = self._window + self.emit('will-close') + old_window.hide() + del self._closing + + def destroy(self): + self.close() + self._window = None + alive_windows.discard(self) + + def is_active(self): + return self._window.is_active() + + def is_visible(self): + return self._window.props.visible + + def get_next_tab_focus(self, current, is_forward): + return None + + def set_content_widget(self, widget): + """Set the widget that will be drawn in the content area for this + window. + + It will be allocated the entire area of the widget, except the + space needed for the titlebar, frame and other decorations. + When the window is resized, content should also be resized. + """ + self._add_content_widget(widget) + widget._widget.show() + self.content_widget = widget + + def _add_content_widget(self, widget): + self._window.add(widget._widget) + + def get_content_widget(self, widget): + """Get the current content widget.""" + return self.content_widget + + def get_frame(self): + pos = self._window.get_position() + size = self._window.get_size() + return widgets.Rect(pos[0], pos[1], size[0], size[1]) + + def set_frame(self, x=None, y=None, width=None, height=None): + if x is not None or y is not None: + pos = self._window.get_position() + x = x if x is not None else pos[0] + y = y if y is not None else pos[1] + self._window.move(x, y) + + if width is not None or height is not None: + size = self._window.get_size() + width = width if width is not None else size[0] + height = height if height is not None else size[1] + self._window.resize(width, height) + + def get_monitor_geometry(self): + """Returns a Rect of the geometry of the monitor that this + window is currently on. + + :returns: Rect + """ + gtkwindow = self._window + gdkwindow = gtkwindow.window + screen = gtkwindow.get_screen() + + monitor = screen.get_monitor_at_window(gdkwindow) + return screen.get_monitor_geometry(monitor) + + def check_position_and_fix(self): + """This pulls the geometry of the monitor of the screen this + window is on as well as the position of the window. + + It then makes sure that the position y is greater than the + monitor geometry y. This makes sure that the titlebar of + the window is showing. + """ + gtkwindow = self._window + gdkwindow = gtkwindow.window + monitor_geom = self.get_monitor_geometry() + + frame_extents = gdkwindow.get_frame_extents() + position = gtkwindow.get_position() + + # if the frame is not visible, then we move the window so that + # it is + if frame_extents.y < monitor_geom.y: + gtkwindow.move(position[0], + monitor_geom.y + (position[1] - frame_extents.y)) + + + +class DialogWindow(Window): + def __init__(self, title, rect=None): + Window.__init__(self, title, rect) + self._window.set_resizable(False) + +class MainWindow(Window): + def __init__(self, title, rect): + Window.__init__(self, title, rect) + self.vbox = gtk.VBox() + self._window.add(self.vbox) + self.vbox.show() + self._add_app_menubar() + self.create_signal('save-dimensions') + self.create_signal('save-maximized') + self._window.connect('key-release-event', self.on_key_release) + self._window.connect('window-state-event', self.on_window_state_event) + self._window.connect('configure-event', self.on_configure_event) + + def _make_gtk_window(self): + return WrappedWindow() + + def on_delete_window(self, widget, event): + return True + + def on_configure_event(self, widget, event): + (x, y) = self._window.get_position() + (width, height) = self._window.get_size() + self.emit('save-dimensions', x, y, width, height) + + def on_window_state_event(self, widget, event): + maximized = bool( + event.new_window_state & gtk.gdk.WINDOW_STATE_MAXIMIZED) + self.emit('save-maximized', maximized) + + def on_key_release(self, widget, event): + if app.playback_manager.is_playing: + if gtk.gdk.keyval_name(event.keyval) in ('Right', 'Left', + 'Up', 'Down'): + return True + + def _add_app_menubar(self): + self.menubar = app.widgetapp.menubar + self.vbox.pack_start(self.menubar._widget, expand=False) + self.connect_menu_keyboard_shortcuts() + + def _add_content_widget(self, widget): + self.vbox.pack_start(widget._widget, expand=True) + + +class DialogBase(WindowBase): + def set_transient_for(self, window): + self._window.set_transient_for(window._window) + + def run(self): + running_dialogs.add(self) + try: + return self._run() + finally: + running_dialogs.remove(self) + self._window = None + + def _run(self): + """Run the dialog. Must be implemented by subclasses.""" + raise NotImplementedError() + + def destroy(self): + if self._window is not None: + self._window.response(gtk.RESPONSE_NONE) + # don't set self._window to None yet. We will unset it when we + # return from the _run() method + +class Dialog(DialogBase): + def __init__(self, title, description=None): + """Create a dialog.""" + DialogBase.__init__(self) + self.create_signal('open') + self.create_signal('close') + self.set_window(gtk.Dialog(title)) + self._window.set_default_size(425, -1) + self.extra_widget = None + self.buttons_to_add = [] + wrappermap.add(self._window, self) + self.description = description + + def build_content(self): + packing_vbox = layout.VBox(spacing=20) + packing_vbox._widget.set_border_width(6) + if self.description is not None: + label = gtk.Label(self.description) + label.set_line_wrap(True) + label.set_size_request(390, -1) + label.set_selectable(True) + packing_vbox._widget.pack_start(label) + if self.extra_widget: + packing_vbox._widget.pack_start(self.extra_widget._widget) + return packing_vbox + + def add_button(self, text): + from mvc.widgets import dialogs + _stock = { + dialogs.BUTTON_OK.text: gtk.STOCK_OK, + dialogs.BUTTON_CANCEL.text: gtk.STOCK_CANCEL, + dialogs.BUTTON_YES.text: gtk.STOCK_YES, + dialogs.BUTTON_NO.text: gtk.STOCK_NO, + dialogs.BUTTON_QUIT.text: gtk.STOCK_QUIT, + dialogs.BUTTON_REMOVE.text: gtk.STOCK_REMOVE, + dialogs.BUTTON_DELETE.text: gtk.STOCK_DELETE, + } + if text in _stock: + # store both the text and the stock ID + text = _stock[text], text + self.buttons_to_add.append(text) + + def pack_buttons(self): + # There's a couple tricky things here: + # 1) We need to add them in the reversed order we got them, since GTK + # lays them out left-to-right + # + # 2) We can't use 0 as a response-id. GTK only reserves positive + # response_ids for the user. + response_id = len(self.buttons_to_add) + for text in reversed(self.buttons_to_add): + label = None + if isinstance(text, tuple): # stock ID, text + text, label = text + button = self._window.add_button(text, response_id) + if label is not None: + button.set_label(label) + response_id -= 1 + self.buttons_to_add = [] + self._window.set_default_response(1) + + def _run(self): + self.pack_buttons() + packing_vbox = self.build_content() + self._window.vbox.pack_start(packing_vbox._widget, True, True) + self._window.show_all() + response = self._window.run() + self._window.hide() + if response == gtk.RESPONSE_DELETE_EVENT: + return -1 + else: + return response - 1 # response IDs started at 1 + + def set_extra_widget(self, widget): + self.extra_widget = widget + + def get_extra_widget(self): + return self.extra_widget + +class FileDialogBase(DialogBase): + def _run(self): + ret = self._window.run() + self._window.hide() + if ret == gtk.RESPONSE_OK: + self._files = self._window.get_filenames() + return 0 + +class FileOpenDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._files = None + fcd = gtk.FileChooserDialog(title, + action=gtk.FILE_CHOOSER_ACTION_OPEN, + buttons=(gtk.STOCK_CANCEL, + gtk.RESPONSE_CANCEL, + gtk.STOCK_OPEN, + gtk.RESPONSE_OK)) + + self.set_window(fcd) + + def set_filename(self, text): + self._window.set_filename(text) + + def set_select_multiple(self, value): + self._window.set_select_multiple(value) + + def add_filters(self, filters): + for name, ext_list in filters: + f = gtk.FileFilter() + f.set_name(name) + for mem in ext_list: + f.add_pattern('*.%s' % mem) + self._window.add_filter(f) + + f = gtk.FileFilter() + f.set_name(_('All files')) + f.add_pattern('*') + self._window.add_filter(f) + + def get_filenames(self): + return [unicode(f) for f in self._files] + + def get_filename(self): + if self._files is None: + # clicked Cancel + return None + else: + return unicode(self._files[0]) + + # provide a common interface for file chooser dialogs + get_path = get_filename + def set_path(self, path): + # set_filename puts the whole path in the filename field + self._window.set_current_folder(os.path.dirname(path)) + self._window.set_current_name(os.path.basename(path)) + +class FileSaveDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._files = None + fcd = gtk.FileChooserDialog(title, + action=gtk.FILE_CHOOSER_ACTION_SAVE, + buttons=(gtk.STOCK_CANCEL, + gtk.RESPONSE_CANCEL, + gtk.STOCK_SAVE, + gtk.RESPONSE_OK)) + self.set_window(fcd) + + def set_filename(self, text): + self._window.set_current_name(text) + + def get_filename(self): + if self._files is None: + # clicked Cancel + return None + else: + return unicode(self._files[0]) + + # provide a common interface for file chooser dialogs + get_path = get_filename + def set_path(self, path): + # set_filename puts the whole path in the filename field + self._window.set_current_folder(os.path.dirname(path)) + self._window.set_current_name(os.path.basename(path)) + +class DirectorySelectDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._files = None + choose_str = 'Choose' + fcd = gtk.FileChooserDialog( + title, + action=gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER, + buttons=(gtk.STOCK_CANCEL, + gtk.RESPONSE_CANCEL, + choose_str, gtk.RESPONSE_OK)) + self.set_window(fcd) + + def set_directory(self, text): + self._window.set_filename(text) + + def get_directory(self): + if self._files is None: + # clicked Cancel + return None + else: + return unicode(self._files[0]) + + # provide a common interface for file chooser dialogs + get_path = get_directory + set_path = set_directory + +class AboutDialog(Dialog): + def __init__(self): + Dialog.__init__(self, "Libre Video Converter") +# _("About %(appname)s", +# {'appname': app.config.get(prefs.SHORT_APP_NAME)})) +# self.add_button(_("Close")) + self.add_button("Close") + self._window.set_has_separator(False) + + def build_content(self): + packing_vbox = layout.VBox(spacing=20) + #icon_pixbuf = gtk.gdk.pixbuf_new_from_file_at_size( + # resources.share_path('icons/hicolor/128x128/apps/miro.png'), + # 48, 48) + #packing_vbox._widget.pack_start(gtk.image_new_from_pixbuf(icon_pixbuf)) + #if app.config.get(prefs.APP_REVISION_NUM): + # version = "%s (%s)" % ( + # app.config.get(prefs.APP_VERSION), + # app.config.get(prefs.APP_REVISION_NUM)) + #else: + # version = "%s" % app.config.get(prefs.APP_VERSION) + version = '3.0' + #name_label = gtk.Label( + # '<span size="xx-large" weight="bold">%s %s</span>' % ( + # app.config.get(prefs.SHORT_APP_NAME), version)) + name_label = gtk.Label( + '<span size="xx-large" weight="bold">%s %s</span>' % ( + 'Libre Video Converter', version)) + name_label.set_use_markup(True) + packing_vbox._widget.pack_start(name_label) + copyright_text = 'Copyright (c) Jesus Eduardo (Heckyel) | 2017' + copyright_label = gtk.Label('<small>%s</small>' % copyright_text) + copyright_label.set_use_markup(True) + copyright_label.set_justify(gtk.JUSTIFY_CENTER) + packing_vbox._widget.pack_start(copyright_label) + + # FIXME - make the project url clickable + #packing_vbox._widget.pack_start( + # gtk.Label(app.config.get(prefs.PROJECT_URL))) + + #contributor_label = gtk.Label( + # _("Thank you to all the people who contributed to %(appname)s " + # "%(version)s:", + # {"appname": app.config.get(prefs.SHORT_APP_NAME), + # "version": app.config.get(prefs.APP_VERSION)})) + #contributor_label.set_justify(gtk.JUSTIFY_CENTER) + #packing_vbox._widget.pack_start(contributor_label) + + # get contributors, remove newlines and wrap it + #contributors = open(resources.path('CREDITS'), 'r').readlines() + #contributors = [c[2:].strip() + # for c in contributors if c.startswith("* ")] + #contributors = ", ".join(contributors) + + # show contributors + #contrib_buffer = gtk.TextBuffer() + #contrib_buffer.set_text(contributors) + + #contrib_view = gtk.TextView(contrib_buffer) + #contrib_view.set_editable(False) + #contrib_view.set_cursor_visible(False) + #contrib_view.set_wrap_mode(gtk.WRAP_WORD) + #contrib_window = gtk.ScrolledWindow() + #contrib_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) + #contrib_window.add(contrib_view) + #contrib_window.set_size_request(-1, 100) + #packing_vbox._widget.pack_start(contrib_window) + + # FIXME - make the project url clickable + #donate_label = gtk.Label( + # _("To help fund continued %(appname)s development, visit the " + # "donation page at:", + # {"appname": app.config.get(prefs.SHORT_APP_NAME)})) + #donate_label.set_justify(gtk.JUSTIFY_CENTER) + #packing_vbox._widget.pack_start(donate_label) + + #packing_vbox._widget.pack_start( + # gtk.Label(app.config.get(prefs.DONATE_URL))) + return packing_vbox + + def on_contrib_link_event(self, texttag, widget, event, iter_): + if event.type == gtk.gdk.BUTTON_PRESS: + resources.open_url('https://notabug.org/Heckyel/LibreVideoConverter') + +type_map = { + 0: gtk.MESSAGE_WARNING, + 1: gtk.MESSAGE_INFO, + 2: gtk.MESSAGE_ERROR +} + +class AlertDialog(DialogBase): + def __init__(self, title, description, alert_type): + DialogBase.__init__(self) + message_type = type_map.get(alert_type, gtk.MESSAGE_INFO) + self.set_window(gtk.MessageDialog(type=message_type, + message_format=description)) + self._window.set_title(title) + self.description = description + + def add_button(self, text): + self._window.add_button(_stock.get(text, text), 1) + self._window.set_default_response(1) + + def _run(self): + self._window.set_modal(False) + self._window.show_all() + response = self._window.run() + self._window.hide() + if response == gtk.RESPONSE_DELETE_EVENT: + return -1 + else: + # response IDs start at 1 + return response - 1 diff --git a/mvc/widgets/gtk/window.pyc b/mvc/widgets/gtk/window.pyc Binary files differnew file mode 100644 index 0000000..f8c7248 --- /dev/null +++ b/mvc/widgets/gtk/window.pyc diff --git a/mvc/widgets/gtk/wrappermap.py b/mvc/widgets/gtk/wrappermap.py new file mode 100644 index 0000000..c2b2aad --- /dev/null +++ b/mvc/widgets/gtk/wrappermap.py @@ -0,0 +1,50 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".wrappermap -- Map GTK Widgets to the Libre Widget +that wraps them. +""" + +import weakref + +# Maps gtk windows -> wrapper objects. We use a weak references to prevent +# circular references between the GTK widget and it's wrapper. (Keeping a +# reference to the GTK widget is fine, since if the wrapper is alive, the GTK +# widget should be). +widget_mapping = weakref.WeakValueDictionary() + +def wrapper(gtk_widget): + """Find the wrapper widget for a GTK widget.""" + try: + return widget_mapping[gtk_widget] + except KeyError: + raise KeyError("Widget wrapper no longer exists") + +def add(gtk_widget, wrapper): + widget_mapping[gtk_widget] = wrapper diff --git a/mvc/widgets/gtk/wrappermap.pyc b/mvc/widgets/gtk/wrappermap.pyc Binary files differnew file mode 100644 index 0000000..44a14a0 --- /dev/null +++ b/mvc/widgets/gtk/wrappermap.pyc diff --git a/mvc/widgets/keyboard.py b/mvc/widgets/keyboard.py new file mode 100644 index 0000000..6700de2 --- /dev/null +++ b/mvc/widgets/keyboard.py @@ -0,0 +1,69 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""Define keyboard input in a platform-independant way.""" + +(CTRL, ALT, SHIFT, CMD, MOD, RIGHT_ARROW, LEFT_ARROW, UP_ARROW, + DOWN_ARROW, SPACE, ENTER, DELETE, BKSPACE, ESCAPE, + F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12) = range(26) + +class Shortcut: + """Defines a shortcut key combination used to trigger this + menu item. + + The first argument is the shortcut key. Other arguments are + modifiers. + + Examples: + + >>> Shortcut("x", MOD) + >>> Shortcut(BKSPACE, MOD) + + This is wrong: + + >>> Shortcut(MOD, "x") + """ + def __init__(self, shortcut, *modifiers): + self.shortcut = shortcut + self.modifiers = modifiers + + def _get_key_symbol(self, value): + """Translate key values to their symbolic names.""" + if isinstance(self.shortcut, int): + shortcut_string = '<Unknown>' + for name, value in globals().iteritems(): + if value == self.shortcut: + return name + return repr(value) + + def __str__(self): + shortcut_string = self._get_key_symbol(self.shortcut) + mod_string = repr(set(self._get_key_symbol(k) for k in + self.modifiers)) + return "Shortcut(%s, %s)" % (shortcut_string, mod_string) diff --git a/mvc/widgets/keyboard.pyc b/mvc/widgets/keyboard.pyc Binary files differnew file mode 100644 index 0000000..35c715c --- /dev/null +++ b/mvc/widgets/keyboard.pyc diff --git a/mvc/widgets/menus.py b/mvc/widgets/menus.py new file mode 100644 index 0000000..62b0c68 --- /dev/null +++ b/mvc/widgets/menus.py @@ -0,0 +1,268 @@ +# menus.py +# +# Most of these are taken from libs/frontends/widgets/menus.py in the miro +# project. +# +# TODO: merge common bits! + +import collections + +from mvc import signals +from mvc.widgets import widgetutil +from mvc.widgets import widgetset +from mvc.widgets import app + +from mvc.widgets.keyboard import (Shortcut, CTRL, ALT, SHIFT, CMD, + MOD, RIGHT_ARROW, LEFT_ARROW, UP_ARROW, DOWN_ARROW, SPACE, ENTER, DELETE, + BKSPACE, ESCAPE, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12) + +# XXX hack: + +def _(text, *params): + if params: + return text % params[0] + return text + +class MenuItem(widgetset.MenuItem): + """Portable MenuItem class. + + This adds group handling to the platform menu items. + """ + # group_map is used for the legacy menu updater code + group_map = collections.defaultdict(set) + + def __init__(self, label, name, shortcut=None, groups=None, + **state_labels): + widgetset.MenuItem.__init__(self, label, name, shortcut) + # state_labels is used for the legacy menu updater code + self.state_labels = state_labels + if groups: + if len(groups) > 1: + raise ValueError("only support one group") + MenuItem.group_map[groups[0]].add(self) + +class MenuItemFetcher(object): + """Get MenuItems by their name quickly. """ + + def __init__(self): + self._cache = {} + + def __getitem__(self, name): + if name in self._cache: + return self._cache[name] + else: + menu_item = app.widgetapp.menubar.find(name) + self._cache[name] = menu_item + return menu_item + +def get_app_menu(): + """Returns the default menu structure.""" + + app_name = "Libre Video Converter" # XXX HACK + + file_menu = widgetset.Menu(_("_File"), "FileMenu", [ + MenuItem(_("_Open"), "Open", Shortcut("o", MOD), + groups=["NonPlaying"]), + MenuItem(_("_Quit"), "Quit", Shortcut("q", MOD)), + ]) + help_menu = widgetset.Menu(_("_Help"), "HelpMenu", [ + MenuItem(_("About %(name)s", + {'name': app_name}), + "About") + ]) + + all_menus = [file_menu, help_menu] + return all_menus + +action_handlers = {} +group_action_handlers = {} + +def on_menubar_activate(menubar, action_name): + callback = lookup_handler(action_name) + if callback is not None: + callback() + +def lookup_handler(action_name): + """For a given action name, get a callback to handle it. Return + None if no callback is found. + """ + + retval = _lookup_group_handler(action_name) + if retval is None: + retval = action_handlers.get(action_name) + return retval + +def _lookup_group_handler(action_name): + try: + group_name, callback_arg = action_name.split('-', 1) + except ValueError: + return None # split return tuple of length 1 + try: + group_handler = group_action_handlers[group_name] + except KeyError: + return None + else: + return lambda: group_handler(callback_arg) + +def action_handler(name): + """Decorator for functions that handle menu actions.""" + def decorator(func): + action_handlers[name] = func + return func + return decorator + +def group_action_handler(action_prefix): + def decorator(func): + group_action_handlers[action_prefix] = func + return func + return decorator + +# File menu +@action_handler("Open") +def on_open(): + app.widgetapp.choose_file() + +@action_handler("Quit") +def on_quit(): + app.widgetapp.quit() + +# Help menu +@action_handler("About") +def on_about(): + app.widgetapp.about() + +class MenuManager(signals.SignalEmitter): + """Updates the menu based on the current selection. + + This includes enabling/disabling menu items, changing menu text + for plural selection and enabling/disabling the play button. The + play button is obviously not a menu item, but it's pretty closely + related + + Whenever code makes a change that could possibly affect which menu + items should be enabled/disabled, it should call the + update_menus() method. + + Signals: + - menus-updated(reasons): Emitted whenever update_menus() is called + """ + def __init__(self): + signals.SignalEmitter.__init__(self, 'menus-updated') + self.menu_item_fetcher = MenuItemFetcher() + #self.subtitle_encoding_updater = SubtitleEncodingMenuUpdater() + self.subtitle_encoding_updater = None + + def setup_menubar(self, menubar): + """Setup the main miro menubar. + """ + menubar.add_initial_menus(get_app_menu()) + menubar.connect("activate", on_menubar_activate) + self.menu_updaters = [] + + def _set_play_pause(self): + if ((not app.playback_manager.is_playing + or app.playback_manager.is_paused)): + label = _('Play') + else: + label = _('Pause') + self.menu_item_fetcher['PlayPauseItem'].set_label(label) + + def add_subtitle_encoding_menu(self, category_label, *encodings): + """Set up a subtitles encoding menu. + + This method should be called for each category of subtitle encodings + (East Asian, Western European, Unicode, etc). Pass it the list of + encodings for that category. + + :param category_label: human-readable name for the category + :param encodings: list of (label, encoding) tuples. label is a + human-readable name, and encoding is a value that we can pass to + VideoDisplay.select_subtitle_encoding() + """ + self.subtitle_encoding_updater.add_menu(category_label, encodings) + + def select_subtitle_encoding(self, encoding): + if not self.subtitle_encoding_updater.has_encodings(): + # OSX never sets up the subtitle encoding menu + return + menu_item_name = self.subtitle_encoding_updater.action_name(encoding) + try: + self.menu_item_fetcher[menu_item_name].set_state(True) + except KeyError: + logging.warn("Error enabling subtitle encoding menu item: %s", + menu_item_name) + + def update_menus(self, *reasons): + """Call this when a change is made that could change the menus + + Use reasons to describe why the menus could change. Some MenuUpdater + objects will do some optimizations based on that + """ + reasons = set(reasons) + self._set_play_pause() + for menu_updater in self.menu_updaters: + menu_updater.update(reasons) + self.emit('menus-updated', reasons) + +class MenuUpdater(object): + """Base class for objects that dynamically update menus.""" + def __init__(self, menu_name): + self.menu_name = menu_name + self.first_update = False + + # we lazily access our menu item, since we are created before the menubar + # is fully setup. + def get_menu(self): + try: + return self._menu + except AttributeError: + self._menu = app.widgetapp.menubar.find(self.menu_name) + return self._menu + menu = property(get_menu) + + def update(self, reasons): + if not self.first_update and not self.should_process_update(reasons): + return + self.first_update = False + self.start_update() + if not self.should_show_menu(): + self.menu.hide() + return + + self.menu.show() + if self.should_rebuild_menu(): + self.clear_menu() + self.populate_menu() + self.update_items() + + def should_process_update(self, reasons): + """Test if we should ignore the update call. + + :param reasons: the reasons passed in to MenuManager.update_menus() + """ + return True + + def clear_menu(self): + """Remove items from our menu before rebuilding it.""" + for child in self.menu.get_children(): + self.menu.remove(child) + + def start_update(self): + """Called at the very start of the update method. """ + pass + + def should_show_menu(self): + """Should we display the menu? """ + return True + + def should_rebuild_menu(self): + """Should we rebuild the menu structure?""" + return False + + def populate_menu(self): + """Add MenuItems to our menu.""" + pass + + def update_items(self): + """Update our menu items.""" + pass diff --git a/mvc/widgets/menus.pyc b/mvc/widgets/menus.pyc Binary files differnew file mode 100644 index 0000000..0418ae9 --- /dev/null +++ b/mvc/widgets/menus.pyc diff --git a/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib b/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib new file mode 100644 index 0000000..b7fefd6 --- /dev/null +++ b/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/designable.nib @@ -0,0 +1,145 @@ +<?xml version="1.0" encoding="UTF-8"?> +<archive type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="8.00"> + <data> + <int key="IBDocument.SystemTarget">1060</int> + <string key="IBDocument.SystemVersion">12A269</string> + <string key="IBDocument.InterfaceBuilderVersion">2549</string> + <string key="IBDocument.AppKitVersion">1187</string> + <string key="IBDocument.HIToolboxVersion">624.00</string> + <object class="NSMutableDictionary" key="IBDocument.PluginVersions"> + <string key="NS.key.0">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="NS.object.0">2549</string> + </object> + <array key="IBDocument.IntegratedClassDependencies"> + <string>NSCustomObject</string> + <string>NSMenu</string> + <string>NSMenuItem</string> + </array> + <array key="IBDocument.PluginDependencies"> + <string>com.apple.InterfaceBuilder.CocoaPlugin</string> + </array> + <object class="NSMutableDictionary" key="IBDocument.Metadata"> + <string key="NS.key.0">PluginDependencyRecalculationVersion</string> + <integer value="1" key="NS.object.0"/> + </object> + <array class="NSMutableArray" key="IBDocument.RootObjects" id="864178278"> + <object class="NSCustomObject" id="422340081"> + <object class="NSMutableString" key="NSClassName"> + <characters key="NS.bytes">NSApplication</characters> + </object> + </object> + <object class="NSCustomObject" id="99063961"> + <string key="NSClassName">FirstResponder</string> + </object> + <object class="NSCustomObject" id="399126242"> + <string key="NSClassName">NSApplication</string> + </object> + <object class="NSMenu" id="603720448"> + <string key="NSTitle">MainMenu</string> + <array class="NSMutableArray" key="NSMenuItems"> + <object class="NSMenuItem" id="726726549"> + <reference key="NSMenu" ref="603720448"/> + <string key="NSTitle">Libre Video Converter</string> + <string key="NSKeyEquiv"/> + <int key="NSKeyEquivModMask">1048576</int> + <int key="NSMnemonicLoc">2147483647</int> + <object class="NSCustomResource" key="NSOnImage"> + <string key="NSClassName">NSImage</string> + <string key="NSResourceName">NSMenuCheckmark</string> + </object> + <object class="NSCustomResource" key="NSMixedImage"> + <string key="NSClassName">NSImage</string> + <string key="NSResourceName">NSMenuMixedState</string> + </object> + <string key="NSAction">submenuAction:</string> + <object class="NSMenu" key="NSSubmenu" id="530441688"> + <string key="NSTitle">Libre Video Converter</string> + <array class="NSMutableArray" key="NSMenuItems"/> + <string key="NSName">_NSAppleMenu</string> + </object> + </object> + </array> + <string key="NSName">_NSMainMenu</string> + </object> + </array> + <object class="IBObjectContainer" key="IBDocument.Objects"> + <array class="NSMutableArray" key="connectionRecords"/> + <object class="IBMutableOrderedSet" key="objectRecords"> + <array key="orderedObjects"> + <object class="IBObjectRecord"> + <int key="objectID">0</int> + <array key="object" id="0"/> + <reference key="children" ref="864178278"/> + <nil key="parent"/> + </object> + <object class="IBObjectRecord"> + <int key="objectID">-2</int> + <reference key="object" ref="422340081"/> + <reference key="parent" ref="0"/> + <string key="objectName">File's Owner</string> + </object> + <object class="IBObjectRecord"> + <int key="objectID">-1</int> + <reference key="object" ref="99063961"/> + <reference key="parent" ref="0"/> + <string key="objectName">First Responder</string> + </object> + <object class="IBObjectRecord"> + <int key="objectID">29</int> + <reference key="object" ref="603720448"/> + <array class="NSMutableArray" key="children"> + <reference ref="726726549"/> + </array> + <reference key="parent" ref="0"/> + <string key="objectName">MainMenu</string> + </object> + <object class="IBObjectRecord"> + <int key="objectID">56</int> + <reference key="object" ref="726726549"/> + <array class="NSMutableArray" key="children"> + <reference ref="530441688"/> + </array> + <reference key="parent" ref="603720448"/> + </object> + <object class="IBObjectRecord"> + <int key="objectID">57</int> + <reference key="object" ref="530441688"/> + <reference key="parent" ref="726726549"/> + </object> + <object class="IBObjectRecord"> + <int key="objectID">-3</int> + <reference key="object" ref="399126242"/> + <reference key="parent" ref="0"/> + <string key="objectName">Application</string> + </object> + </array> + </object> + <dictionary class="NSMutableDictionary" key="flattenedProperties"> + <string key="-1.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="-2.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="-3.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="29.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="56.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + <string key="57.IBPluginDependency">com.apple.InterfaceBuilder.CocoaPlugin</string> + </dictionary> + <dictionary class="NSMutableDictionary" key="unlocalizedProperties"/> + <nil key="activeLocalization"/> + <dictionary class="NSMutableDictionary" key="localizations"/> + <nil key="sourceID"/> + <int key="maxID">248</int> + </object> + <object class="IBClassDescriber" key="IBDocument.Classes"/> + <int key="IBDocument.localizationMode">0</int> + <string key="IBDocument.TargetRuntimeIdentifier">IBCocoaFramework</string> + <object class="NSMutableDictionary" key="IBDocument.PluginDeclaredDependencies"> + <string key="NS.key.0">com.apple.InterfaceBuilder.CocoaPlugin.macosx</string> + <real value="1060" key="NS.object.0"/> + </object> + <bool key="IBDocument.PluginDeclaredDependenciesTrackSystemTargetVersion">YES</bool> + <int key="IBDocument.defaultPropertyAccessControl">3</int> + <dictionary class="NSMutableDictionary" key="IBDocument.LastKnownImageSizes"> + <string key="NSMenuCheckmark">{11, 11}</string> + <string key="NSMenuMixedState">{10, 3}</string> + </dictionary> + </data> +</archive> diff --git a/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib b/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib Binary files differnew file mode 100644 index 0000000..963b444 --- /dev/null +++ b/mvc/widgets/osx/Resources-Widgets/MainMenu.nib/keyedobjects.nib diff --git a/mvc/widgets/osx/__init__.py b/mvc/widgets/osx/__init__.py new file mode 100644 index 0000000..f227b35 --- /dev/null +++ b/mvc/widgets/osx/__init__.py @@ -0,0 +1,74 @@ +import sys + +from objc import * +from Foundation import * +from AppKit import * + +from PyObjCTools import AppHelper + +size_request_manager = None + +class AppController(NSObject): + def applicationDidFinishLaunching_(self, notification): + from mvc.widgets.osx.osxmenus import MenuBar + self.portableApp.menubar = MenuBar() + self.portableApp.startup() + self.portableApp.run() + + def setPortableApp_(self, portableApp): + self.portableApp = portableApp + + def handleMenuActivate_(self, menu_item): + from mvc.widgets.osx import osxmenus + osxmenus.handle_menu_activate(menu_item) + +def initialize(app): + nsapp = NSApplication.sharedApplication() + delegate = AppController.alloc().init() + delegate.setPortableApp_(app) + nsapp.setDelegate_(delegate) + + global size_request_manager + from mvc.widgets.osx.widgetupdates import SizeRequestManager + size_request_manager = SizeRequestManager() + + NSApplicationMain(sys.argv) + +def attach_menubar(): + pass + +def mainloop_start(): + pass + +def mainloop_stop(): + NSApplication.sharedApplication().terminate_(nil) + +def idle_add(callback, periodic=None): + def wrapper(): + callback() + if periodic is not None: + AppHelper.callLater(periodic, wrapper) + if periodic is not None and periodic < 0: + raise ValueError('periodic cannot be negative') + # XXX: we have a lousy thread API that doesn't allocate pools for us... + pool = NSAutoreleasePool.alloc().init() + if periodic is not None: + AppHelper.callLater(periodic, wrapper) + else: + AppHelper.callAfter(wrapper) + del pool + +def idle_remove(id_): + pass + +def reveal_file(filename): + # XXX: dumb lousy type conversions ... + path = NSURL.fileURLWithPath_(filename.decode('utf-8')).path() + NSWorkspace.sharedWorkspace().selectFile_inFileViewerRootedAtPath_( + path, nil) + +def get_conversion_directory(): + url, error = NSFileManager.defaultManager().URLForDirectory_inDomain_appropriateForURL_create_error_(NSMoviesDirectory, NSUserDomainMask, nil, YES, None) + if error: + return None + return url.path().encode('utf-8') diff --git a/mvc/widgets/osx/base.py b/mvc/widgets/osx/base.py new file mode 100644 index 0000000..913b372 --- /dev/null +++ b/mvc/widgets/osx/base.py @@ -0,0 +1,367 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".base.py -- Widget base classes.""" + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from mvc import signals +import wrappermap +from .viewport import Viewport, BorrowedViewport + +class Widget(signals.SignalEmitter): + """Base class for Cocoa widgets. + + attributes: + + CREATES_VIEW -- Does the widget create a view for itself? If this is True + the widget must have an attribute named view, which is the view that the + widget uses. + + placement -- What portion of view the widget occupies. + """ + + CREATES_VIEW = True + + def __init__(self): + signals.SignalEmitter.__init__(self, 'size-request-changed', + 'size-allocated', 'key-press', 'focus-out') + self.create_signal('place-in-scroller') + self.viewport = None + self.parent_is_scroller = False + self.manual_size_request = None + self.cached_size_request = None + self._disabled = False + + def set_can_focus(self, allow): + assert isinstance(self.view, NSControl) + self.view.setRefusesFirstResponder_(not allow) + + def set_size_request(self, width, height): + self.manual_size_request = (width, height) + self.invalidate_size_request() + + def clear_size_request_cache(self): + from mvc.widgets.osx import size_request_manager + if size_request_manager is not None: + while size_request_manager.widgets_to_request: + size_request_manager._run_requests() + + def get_size_request(self): + if self.manual_size_request: + width, height = self.manual_size_request + if width == -1: + width = self.get_natural_size_request()[0] + if height == -1: + height = self.get_natural_size_request()[1] + return width, height + return self.get_natural_size_request() + + def get_natural_size_request(self): + if self.cached_size_request: + return self.cached_size_request + else: + self.cached_size_request = self.calc_size_request() + return self.cached_size_request + + def invalidate_size_request(self): + from mvc.widgets.osx import size_request_manager + if size_request_manager is not None: + size_request_manager.add_widget(self) + + def do_invalidate_size_request(self): + """Recalculate the size request for this widget.""" + old_size_request = self.cached_size_request + self.cached_size_request = None + self.emit('size-request-changed', old_size_request) + + def calc_size_request(self): + """Return the minimum size needed to display this widget. + Must be Implemented by subclasses. + """ + raise NotImplementedError() + + def _debug_size_request(self, nesting_level=0): + """Debug size request calculations. + + This method recursively prints out the size request for each widget. + """ + request = self.calc_size_request() + width = int(request[0]) + height = int(request[1]) + indent = ' ' * nesting_level + me = str(self.__class__).split('.')[-1] + print '%s%s: %sx%s' % (indent, me, width, height) + + def place(self, rect, containing_view): + """Place this widget on a view. """ + if self.viewport is None: + if self.CREATES_VIEW: + self.viewport = Viewport(self.view, rect) + containing_view.addSubview_(self.view) + wrappermap.add(self.view, self) + else: + self.viewport = BorrowedViewport(containing_view, rect) + self.viewport_created() + else: + if not self.viewport.at_position(rect): + self.viewport.reposition(rect) + self.viewport_repositioned() + self.emit('size-allocated', rect.size.width, rect.size.height) + + def remove_viewport(self): + if self.viewport is not None: + self.viewport.remove() + self.viewport = None + if self.CREATES_VIEW: + wrappermap.remove(self.view) + + def viewport_created(self): + """Called after we first create a viewport. Subclasses can override + this method if they want to handle this event. + """ + + def viewport_repositioned(self): + """Called when we reposition our viewport. Subclasses can override + this method if they want to handle this event. + """ + + def viewport_scrolled(self): + """Called by the Scroller widget on it's child widget when it is + scrolled. + """ + + def get_width(self): + return int(self.viewport.get_width()) + width = property(get_width) + + def get_height(self): + return int(self.viewport.get_height()) + height = property(get_height) + + def get_window(self): + if not self.viewport.view: + return None + return wrappermap.wrapper(self.viewport.view.window()) + + def queue_redraw(self): + if self.viewport: + self.viewport.queue_redraw() + + def redraw_now(self): + if self.viewport: + self.viewport.redraw_now() + + def relative_position(self, other_widget): + """Get the position of another widget, relative to this widget.""" + basePoint = self.viewport.view.convertPoint_fromView_( + other_widget.viewport.area().origin, + other_widget.viewport.view) + return (basePoint.x - self.viewport.area().origin.x, + basePoint.y - self.viewport.area().origin.y) + + def make_color(self, (red, green, blue)): + return NSColor.colorWithDeviceRed_green_blue_alpha_(red, green, blue, + 1.0) + + def enable(self): + self._disabled = False + + def disable(self): + self._disabled = True + + def set_disabled(self, disabled): + if disabled: + self.disable() + else: + self.enable() + + def get_disabled(self): + return self._disabled + +class Container(Widget): + """Widget that holds other widgets. """ + + def __init__(self): + Widget.__init__(self) + self.callback_handles = {} + + def on_child_size_request_changed(self, child, old_size): + self.invalidate_size_request() + + def connect_child_signals(self, child): + handle = child.connect_weak('size-request-changed', + self.on_child_size_request_changed) + self.callback_handles[child] = handle + + def disconnect_child_signals(self, child): + child.disconnect(self.callback_handles.pop(child)) + + def remove_viewport(self): + for child in self.children: + child.remove_viewport() + Widget.remove_viewport(self) + + def child_added(self, child): + """Must be called by subclasses when a child is added to the + Container.""" + self.connect_child_signals(child) + self.children_changed() + + def child_removed(self, child): + """Must be called by subclasses when a child is removed from the + Container.""" + self.disconnect_child_signals(child) + child.remove_viewport() + self.children_changed() + + def child_changed(self, old_child, new_child): + """Must be called by subclasses when a child is replaced by a new + child in the Container. To simplify things a bit for subclasses, + old_child can be None in which case this is the same as + child_added(new_child). + """ + if old_child is not None: + self.disconnect_child_signals(old_child) + old_child.remove_viewport() + self.connect_child_signals(new_child) + self.children_changed() + + def children_changed(self): + """Invoked when the set of children for this widget changes.""" + self.do_invalidate_size_request() + + def do_invalidate_size_request(self): + Widget.do_invalidate_size_request(self) + if self.viewport: + self.place_children() + + def viewport_created(self): + self.place_children() + + def viewport_repositioned(self): + self.place_children() + + def viewport_scrolled(self): + for child in self.children: + child.viewport_scrolled() + + def place_children(self): + """Layout our child widgets. Must be implemented by subclasses.""" + raise NotImplementedError() + + def _debug_size_request(self, nesting_level=0): + for child in self.children: + child._debug_size_request(nesting_level+1) + Widget._debug_size_request(self, nesting_level) + +class Bin(Container): + """Container that only has one child widget.""" + + def __init__(self, child=None): + Container.__init__(self) + self.child = None + if child is not None: + self.add(child) + + def get_children(self): + if self.child: + return [self.child] + else: + return [] + children = property(get_children) + + def add(self, child): + if self.child is not None: + raise ValueError("Already have a child: %s" % self.child) + self.child = child + self.child_added(self.child) + + def remove(self): + if self.child is not None: + old_child = self.child + self.child = None + self.child_removed(old_child) + + def set_child(self, new_child): + old_child = self.child + self.child = new_child + self.child_changed(old_child, new_child) + + def enable(self): + Container.enable(self) + self.child.enable() + + def disable(self): + Container.disable(self) + self.child.disable() + +class SimpleBin(Bin): + """Bin that whose child takes up it's entire space.""" + + def calc_size_request(self): + if self.child is None: + return (0, 0) + else: + return self.child.get_size_request() + + def place_children(self): + if self.child: + self.child.place(self.viewport.area(), self.viewport.view) + +class FlippedView(NSView): + """Flipped NSView. We use these internally to lessen the differences + between Cocoa and GTK. + """ + + def init(self): + self = super(FlippedView, self).init() + self.background = None + return self + + def initWithFrame_(self, rect): + self = super(FlippedView, self).initWithFrame_(rect) + self.background = None + return self + + def isFlipped(self): + return YES + + def isOpaque(self): + return self.background is not None + + def setBackgroundColor_(self, color): + self.background = color + + def drawRect_(self, rect): + if self.background: + self.background.set() + NSBezierPath.fillRect_(rect) diff --git a/mvc/widgets/osx/const.py b/mvc/widgets/osx/const.py new file mode 100644 index 0000000..ae0da40 --- /dev/null +++ b/mvc/widgets/osx/const.py @@ -0,0 +1,44 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +from AppKit import * + +"""const.py -- Constants""" + +DRAG_ACTION_NONE = NSDragOperationNone +DRAG_ACTION_COPY = NSDragOperationCopy +DRAG_ACTION_MOVE = NSDragOperationMove +DRAG_ACTION_LINK = NSDragOperationLink +DRAG_ACTION_ALL = (DRAG_ACTION_COPY | DRAG_ACTION_MOVE | DRAG_ACTION_LINK) + +ITEM_TITLE_FONT = "Helvetica" +ITEM_DESC_FONT = "Helvetica" +ITEM_INFO_FONT = "Lucida Grande" + +TOOLBAR_GRAY = (0.19, 0.19, 0.19) diff --git a/mvc/widgets/osx/contextmenu.py b/mvc/widgets/osx/contextmenu.py new file mode 100644 index 0000000..7a8fa55 --- /dev/null +++ b/mvc/widgets/osx/contextmenu.py @@ -0,0 +1,84 @@ +from AppKit import * +from objc import nil + +from .base import Widget + +class ContextMenuHandler(NSObject): + def initWithCallback_widget_i_(self, callback, widget, i): + self = super(ContextMenuHandler, self).init() + self.callback = callback + self.widget = widget + self.i = i + return self + + def handleMenuItem_(self, sender): + self.callback(self.widget, self.i) + + +class MiroContextMenu(NSMenu): + # Works exactly like NSMenu, except it keeps a reference to the menu + # handler objects. + def init(self): + self = super(MiroContextMenu, self).init() + self.handlers = set() + return self + + def addItem_(self, item): + if isinstance(item.target(), ContextMenuHandler): + self.handlers.add(item.target()) + return NSMenu.addItem_(self, item) + + +class ContextMenu(object): + + def __init__(self, options): + super(ContextMenu, self).__init__() + self.menu = MiroContextMenu.alloc().init() + for i, item_info in enumerate(options): + if item_info is None: + nsitem = NSMenuItem.separatorItem() + else: + label, callback = item_info + nsitem = NSMenuItem.alloc().init() + font_size = NSFont.systemFontSize() + font = NSFont.fontWithName_size_("Lucida Sans Italic", font_size) + if font is None: + font = NSFont.systemFontOfSize_(font_size) + attributes = {NSFontAttributeName: font} + attributed_label = NSAttributedString.alloc().initWithString_attributes_(label, attributes) + nsitem.setAttributedTitle_(attributed_label) + else: + nsitem.setTitle_(label) + if isinstance(callback, list): + submenu = ContextMenu(callback) + self.menu.setSubmenu_forItem_(submenu.menu, nsitem) + else: + handler = ContextMenuHandler.alloc().initWithCallback_widget_i_(callback, self, i) + nsitem.setTarget_(handler) + nsitem.setAction_('handleMenuItem:') + self.menu.addItem_(nsitem) + + def popup(self): + # support for non-window based popups thanks to + # http://stackoverflow.com/questions/9033534/how-can-i-pop-up-nsmenu-at-mouse-cursor-position + location = NSEvent.mouseLocation() + frame = NSMakeRect(location.x, location.y, 200, 200) + window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + frame, + NSBorderlessWindowMask, + NSBackingStoreBuffered, + NO) + window.setAlphaValue_(0) + window.makeKeyAndOrderFront_(NSApp) + location_in_window = window.convertScreenToBase_(location) + event = NSEvent.mouseEventWithType_location_modifierFlags_timestamp_windowNumber_context_eventNumber_clickCount_pressure_( + NSLeftMouseDown, + location_in_window, + 0, + 0, + window.windowNumber(), + nil, + 0, + 0, + 0) + NSMenu.popUpContextMenu_withEvent_forView_(self.menu, event, window.contentView()) diff --git a/mvc/widgets/osx/control.py b/mvc/widgets/osx/control.py new file mode 100644 index 0000000..ed6ea34 --- /dev/null +++ b/mvc/widgets/osx/control.py @@ -0,0 +1,530 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".control - Controls.""" + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from mvc.widgets import widgetconst +import wrappermap +from .base import Widget +from .helpers import NotificationForwarder + +class SizedControl(Widget): + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self.view.cell().setControlSize_(NSRegularControlSize) + font = NSFont.systemFontOfSize_(NSFont.systemFontSize()) + self.font_size = NSFont.systemFontSize() + elif size == widgetconst.SIZE_SMALL: + font = NSFont.systemFontOfSize_(NSFont.smallSystemFontSize()) + self.view.cell().setControlSize_(NSSmallControlSize) + self.font_size = NSFont.smallSystemFontSize() + else: + self.view.cell().setControlSize_(NSRegularControlSize) + font = NSFont.systemFontOfSize_(NSFont.systemFontSize() * size) + self.font_size = NSFont.systemFontSize() * size + self.view.setFont_(font) + +class BaseTextEntry(SizedControl): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, initial_text=None): + SizedControl.__init__(self) + self.view = self.make_view() + self.font = NSFont.systemFontOfSize_(NSFont.systemFontSize()) + self.view.setFont_(self.font) + self.view.setEditable_(YES) + self.view.cell().setScrollable_(YES) + self.view.cell().setLineBreakMode_(NSLineBreakByClipping) + self.sizer_cell = self.view.cell().copy() + if initial_text: + self.view.setStringValue_(initial_text) + self.set_width(len(initial_text)) + else: + self.set_width(10) + + self.notifications = NotificationForwarder.create(self.view) + + self.create_signal('activate') + self.create_signal('changed') + self.create_signal('validate') + + def focus(self): + if self.view.window() is not None: + self.view.window().makeFirstResponder_(self.view) + + def start_editing(self, initial_text): + self.set_text(initial_text) + self.focus() + # unselect the text and locate the cursor at the end of the entry + text_field = self.view.window().fieldEditor_forObject_(YES, self.view) + text_field.setSelectedRange_(NSMakeRange(len(self.get_text()), 0)) + + def viewport_created(self): + SizedControl.viewport_created(self) + self.notifications.connect(self.on_changed, 'NSControlTextDidChangeNotification') + self.notifications.connect(self.on_end_editing, + 'NSControlTextDidEndEditingNotification') + + def remove_viewport(self): + SizedControl.remove_viewport(self) + self.notifications.disconnect() + + def baseline(self): + return -self.view.font().descender() + 2 + + def on_changed(self, notification): + self.emit('changed') + + def on_end_editing(self, notification): + self.emit('focus-out') + + def calc_size_request(self): + size = self.sizer_cell.cellSize() + return size.width, size.height + + def set_text(self, text): + self.view.setStringValue_(text) + self.emit('changed') + + def get_text(self): + return self.view.stringValue() + + def set_width(self, chars): + self.sizer_cell.setStringValue_('X' * chars) + self.invalidate_size_request() + + def set_activates_default(self, setting): + pass + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + +class MiroTextField(NSTextField): + def textDidEndEditing_(self, notification): + wrappermap.wrapper(self).emit('activate') + return NSTextField.textDidEndEditing_(self, notification) + +class TextEntry(BaseTextEntry): + def make_view(self): + return MiroTextField.alloc().init() + +class NumberEntry(BaseTextEntry): + def make_view(self): + return MiroTextField.alloc().init() + + def set_max_length(self, length): + # TODO + pass + + def _filter_value(self): + """Discard any non-numeric characters""" + digits = ''.join(x for x in self.view.stringValue() if x.isdigit()) + self.view.setStringValue_(digits) + + def on_changed(self, notification): + # overriding on_changed rather than connecting to it ensures that we + # filter the value before anything else connected to the signal sees it + self._filter_value() + BaseTextEntry.on_changed(self, notification) + + def get_text(self): + # handles get_text between when text is entered and when on_changed + # filters it, in case that's possible + self._filter_value() + return BaseTextEntry.get_text(self) + +class MiroSecureTextField(NSSecureTextField): + def textDidEndEditing_(self, notification): + wrappermap.wrapper(self).emit('activate') + return NSSecureTextField.textDidEndEditing_(self, notification) + +class SecureTextEntry(BaseTextEntry): + def make_view(self): + return MiroSecureTextField.alloc().init() + +class MultilineTextEntry(Widget): + def __init__(self, initial_text=None): + Widget.__init__(self) + if initial_text is None: + initial_text = "" + self.view = NSTextView.alloc().initWithFrame_(NSRect((0,0),(50,50))) + self.view.setMaxSize_((1.0e7, 1.0e7)) + self.view.setHorizontallyResizable_(NO) + self.view.setVerticallyResizable_(YES) + self.notifications = NotificationForwarder.create(self.view) + self.create_signal('changed') + self.create_signal('focus-out') + if initial_text is not None: + self.set_text(initial_text) + self.set_size(widgetconst.SIZE_NORMAL) + + def set_size(self, size): + if size == widgetconst.SIZE_NORMAL: + font = NSFont.systemFontOfSize_(NSFont.systemFontSize()) + elif size == widgetconst.SIZE_SMALL: + self.view.cell().setControlSize_(NSSmallControlSize) + else: + raise ValueError("Unknown size: %s" % size) + self.view.setFont_(font) + + def viewport_created(self): + Widget.viewport_created(self) + self.notifications.connect(self.on_changed, 'NSTextDidChangeNotification') + self.notifications.connect(self.on_end_editing, + 'NSControlTextDidEndEditingNotification') + self.invalidate_size_request() + + def remove_viewport(self): + Widget.remove_viewport(self) + self.notifications.disconnect() + + def focus(self): + if self.view.window() is not None: + self.view.window().makeFirstResponder_(self.view) + + def set_text(self, text): + self.view.setString_(text) + self.invalidate_size_request() + + def get_text(self): + return self.view.string() + + def on_changed(self, notification): + self.invalidate_size_request() + self.emit("changed") + + def on_end_editing(self, notification): + self.emit("focus-out") + + def calc_size_request(self): + layout_manager = self.view.layoutManager() + text_container = self.view.textContainer() + # The next line is there just to force cocoa to layout the text + layout_manager.glyphRangeForTextContainer_(text_container) + rect = layout_manager.usedRectForTextContainer_(text_container) + return rect.size.width, rect.size.height + + def set_editable(self, editable): + if editable: + self.view.setEditable_(YES) + else: + self.view.setEditable_(NO) + + +class MiroButton(NSButton): + + def initWithSignal_(self, signal): + self = super(MiroButton, self).init() + self.signal = signal + return self + + def sendAction_to_(self, action, to): + # We override the Cocoa machinery here and just send it to our wrapper + # widget. + wrappermap.wrapper(self).emit(self.signal) + return YES + +class Checkbox(SizedControl): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, text="", bold=False, color=None): + SizedControl.__init__(self) + self.create_signal('toggled') + self.view = MiroButton.alloc().initWithSignal_('toggled') + self.view.setButtonType_(NSSwitchButton) + self.bold = bold + self.title = text + self.font_size = NSFont.systemFontSize() + self.color = self.make_color(color) + self._set_title() + + def set_size(self, size): + SizedControl.set_size(self, size) + self._set_title() + + def _set_title(self): + if self.color is None: + self.view.setTitle_(self.title) + else: + attributes = { + NSForegroundColorAttributeName: self.color, + NSFontAttributeName: NSFont.systemFontOfSize_(self.font_size) + } + string = NSAttributedString.alloc().initWithString_attributes_( + self.title, attributes) + self.view.setAttributedTitle_(string) + + def calc_size_request(self): + if self.manual_size_request: + width, height = self.manual_size_request + if width == -1: + width = 10000 + if height == -1: + height = 10000 + size = self.view.cell().cellSizeForBounds_( + NSRect((0, 0), (width, height))) + else: + size = self.view.cell().cellSize() + return (size.width, size.height) + + def baseline(self): + return -self.view.font().descender() + 1 + + def get_checked(self): + return self.view.state() == NSOnState + + def set_checked(self, value): + if value: + self.view.setState_(NSOnState) + else: + self.view.setState_(NSOffState) + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + + def get_text_padding(self): + """ + Returns the amount of space the checkbox takes up before the label. + """ + # XXX FIXME + return 18 + +class Button(SizedControl): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, label, style='normal', width=0): + SizedControl.__init__(self) + self.color = None + self.title = label + self.create_signal('clicked') + self.view = MiroButton.alloc().initWithSignal_('clicked') + self.view.setButtonType_(NSMomentaryPushInButton) + self._set_title() + self.setup_style(style) + self.min_width = width + + def set_text(self, label): + self.title = label + self._set_title() + + def set_color(self, color): + self.color = self.make_color(color) + self._set_title() + + def _set_title(self): + if self.color is None: + self.view.setTitle_(self.title) + else: + attributes = { + NSForegroundColorAttributeName: self.color, + NSFontAttributeName: self.view.font() + } + string = NSAttributedString.alloc().initWithString_attributes_( + self.title, attributes) + self.view.setAttributedTitle_(string) + + def setup_style(self, style): + if style == 'normal': + self.view.setBezelStyle_(NSRoundedBezelStyle) + self.pad_height = 0 + self.pad_width = 10 + self.min_width = 112 + elif style == 'smooth': + self.view.setBezelStyle_(NSRoundRectBezelStyle) + self.pad_width = 0 + self.pad_height = 4 + self.paragraph_style = NSMutableParagraphStyle.alloc().init() + self.paragraph_style.setAlignment_(NSCenterTextAlignment) + + def make_default(self): + self.view.setKeyEquivalent_("\r") + + def calc_size_request(self): + size = self.view.cell().cellSize() + width = max(self.min_width, size.width + self.pad_width) + height = size.height + self.pad_height + return width, height + + def baseline(self): + return -self.view.font().descender() + 10 + self.pad_height + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + +class MiroPopupButton(NSPopUpButton): + + def init(self): + self = super(MiroPopupButton, self).init() + self.setTarget_(self) + self.setAction_('handleChange:') + return self + + def handleChange_(self, sender): + wrappermap.wrapper(self).emit('changed', self.indexOfSelectedItem()) + +class OptionMenu(SizedControl): + def __init__(self, options): + SizedControl.__init__(self) + self.create_signal('changed') + self.view = MiroPopupButton.alloc().init() + self.options = options + for option, value in options: + self.view.addItemWithTitle_(option) + + def baseline(self): + if self.view.cell().controlSize() == NSRegularControlSize: + return -self.view.font().descender() + 6 + else: + return -self.view.font().descender() + 5 + + def calc_size_request(self): + return self.view.cell().cellSize() + + def set_selected(self, index): + self.view.selectItemAtIndex_(index) + + def get_selected(self): + return self.view.indexOfSelectedItem() + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) + + def set_width(self, width): + # TODO + pass + +class RadioButtonGroup: + def __init__(self): + self._buttons = [] + + def handle_click(self, widget): + self.set_selected(widget) + + def add_button(self, button): + self._buttons.append(button) + button.connect('clicked', self.handle_click) + if len(self._buttons) == 1: + button.view.setState_(NSOnState) + else: + button.view.setState_(NSOffState) + + def get_buttons(self): + return self._buttons + + def get_selected(self): + for mem in self._buttons: + if mem.get_selected(): + return mem + + def set_selected(self, button): + for mem in self._buttons: + if button is mem: + mem.view.setState_(NSOnState) + else: + mem.view.setState_(NSOffState) + +class RadioButton(SizedControl): + def __init__(self, label, group=None, bold=False, color=None): + SizedControl.__init__(self) + self.create_signal('clicked') + self.view = MiroButton.alloc().initWithSignal_('clicked') + self.view.setButtonType_(NSRadioButton) + self.color = self.make_color(color) + self.title = label + self.bold = bold + self.font_size = NSFont.systemFontSize() + self._set_title() + + if group is not None: + self.group = group + else: + self.group = RadioButtonGroup() + + self.group.add_button(self) + + def set_size(self, size): + SizedControl.set_size(self, size) + self._set_title() + + def _set_title(self): + if self.color is None: + self.view.setTitle_(self.title) + else: + attributes = { + NSForegroundColorAttributeName: self.color, + NSFontAttributeName: NSFont.systemFontOfSize_(self.font_size) + } + string = NSAttributedString.alloc().initWithString_attributes_( + self.title, attributes) + self.view.setAttributedTitle_(string) + + def calc_size_request(self): + size = self.view.cell().cellSize() + return (size.width, size.height) + + def baseline(self): + -self.view.font().descender() + 2 + + def get_group(self): + return self.group + + def get_selected(self): + return self.view.state() == NSOnState + + def set_selected(self): + self.group.set_selected(self) + + def enable(self): + SizedControl.enable(self) + self.view.setEnabled_(True) + + def disable(self): + SizedControl.disable(self) + self.view.setEnabled_(False) diff --git a/mvc/widgets/osx/customcontrol.py b/mvc/widgets/osx/customcontrol.py new file mode 100644 index 0000000..d100f33 --- /dev/null +++ b/mvc/widgets/osx/customcontrol.py @@ -0,0 +1,436 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".customcontrol -- CustomControl handlers. """ + +import collections + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from mvc.widgets import widgetconst +import wrappermap +from .base import Widget +import drawing +from .layoutmanager import LayoutManager + +class DrawableButtonCell(NSButtonCell): + def startTrackingAt_inView_(self, point, view): + view.setState_(NSOnState) + return YES + + def continueTracking_at_inView_(self, lastPoint, at, view): + view.setState_(NSOnState) + return YES + + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, view, mouseIsUp): + if not mouseIsUp: + view.mouse_inside = False + view.setState_(NSOffState) + +class DrawableButton(NSButton): + def init(self): + self = super(DrawableButton, self).init() + self.layout_manager = LayoutManager() + self.tracking_area = None + self.mouse_inside = False + self.custom_cursor = None + return self + + def resetCursorRects(self): + if self.custom_cursor is not None: + self.addCursorRect_cursor_(self.visibleRect(), self.custom_cursor) + self.custom_cursor.setOnMouseEntered_(YES) + + def updateTrackingAreas(self): + # remove existing tracking area if needed + if self.tracking_area: + self.removeTrackingArea_(self.tracking_area) + + # create a new tracking area for the entire view. This allows us to + # get mouseMoved events whenever the mouse is inside our view. + self.tracking_area = NSTrackingArea.alloc() + self.tracking_area.initWithRect_options_owner_userInfo_( + self.visibleRect(), + NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | + NSTrackingActiveInKeyWindow, + self, + nil) + self.addTrackingArea_(self.tracking_area) + + def mouseEntered_(self, event): + window = self.window() + if window is not nil and window.isMainWindow(): + self.mouse_inside = True + self.setNeedsDisplay_(YES) + + def mouseExited_(self, event): + window = self.window() + if window is not nil and window.isMainWindow(): + self.mouse_inside = False + self.setNeedsDisplay_(YES) + + def isOpaque(self): + return wrappermap.wrapper(self).is_opaque() + + def drawRect_(self, rect): + context = drawing.DrawingContext(self, self.bounds(), rect) + context.style = drawing.DrawingStyle() + wrapper = wrappermap.wrapper(self) + wrapper.state = 'normal' + disabled = wrapper.get_disabled() + if not disabled: + if self.state() == NSOnState: + wrapper.state = 'pressed' + elif self.mouse_inside: + wrapper.state = 'hover' + else: + wrapper.state = 'normal' + + wrapper.draw(context, self.layout_manager) + self.layout_manager.reset() + + def sendAction_to_(self, action, to): + # We override the Cocoa machinery here and just send it to our wrapper + # widget. + wrapper = wrappermap.wrapper(self) + disabled = wrapper.get_disabled() + if not disabled: + wrapper.emit('clicked') + # Tell Cocoa we handled it anyway, just not emit the actual clicked + # event. + return YES +DrawableButton.setCellClass_(DrawableButtonCell) + +class ContinousButtonCell(DrawableButtonCell): + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, view, mouseIsUp): + view.onStopTracking(at) + NSButtonCell.stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, + view, mouseIsUp) + +class ContinuousDrawableButton(DrawableButton): + def init(self): + self = super(ContinuousDrawableButton, self).init() + self.setContinuous_(YES) + return self + + def mouseDown_(self, event): + self.releaseInbounds = self.stopTracking = self.firedOnce = False + self.cell().trackMouse_inRect_ofView_untilMouseUp_(event, + self.bounds(), self, YES) + wrapper = wrappermap.wrapper(self) + if not wrapper.get_disabled(): + if self.firedOnce: + wrapper.emit('released') + elif self.releaseInbounds: + wrapper.emit('clicked') + + def sendAction_to_(self, action, to): + if self.stopTracking: + return NO + self.firedOnce = True + wrapper = wrappermap.wrapper(self) + if not wrapper.get_disabled(): + wrapper.emit('held-down') + return YES + + def onStopTracking(self, mouseLocation): + self.releaseInbounds = NSPointInRect(mouseLocation, self.bounds()) + self.stopTracking = True +ContinuousDrawableButton.setCellClass_(ContinousButtonCell) + +class DragableButtonCell(NSButtonCell): + def startTrackingAt_inView_(self, point, view): + self.start_x = point.x + return YES + + def continueTracking_at_inView_(self, lastPoint, at, view): + DRAG_THRESHOLD = 15 + wrapper = wrappermap.wrapper(view) + if not wrapper.get_disabled(): + if (view.last_drag_event != 'right' and + at.x > self.start_x + DRAG_THRESHOLD): + wrapper.emit("dragged-right") + view.last_drag_event = 'right' + elif (view.last_drag_event != 'left' and + at.x < self.start_x - DRAG_THRESHOLD): + view.last_drag_event = 'left' + wrapper.emit("dragged-left") + return YES + +class DragableDrawableButton(DrawableButton): + def mouseDown_(self, event): + self.last_drag_event = None + self.cell().trackMouse_inRect_ofView_untilMouseUp_(event, + self.bounds(), self, YES) + + def sendAction_to_(self, action, to): + # only send the click event if we didn't send a + # dragged-left/dragged-right event + wrapper = wrappermap.wrapper(self) + if self.last_drag_event is None and not wrapper.get_disabled(): + wrapper.emit('clicked') + return YES +DragableDrawableButton.setCellClass_(DragableButtonCell) + +MouseTrackingInfo = collections.namedtuple("MouseTrackingInfo", + "start_pos click_pos") + +class CustomSliderCell(NSSliderCell): + def calc_slider_amount(self, view, pos, size): + slider_size = wrappermap.wrapper(view).slider_size() + pos -= slider_size / 2 + size -= slider_size + return max(0, min(1, float(pos) / size)) + + def get_slider_pos(self, view, value=None): + if value is None: + value = view.floatValue() + if view.isVertical(): + size = view.bounds().size.height + else: + size = view.bounds().size.width + slider_size = view.knobThickness() + size -= slider_size + start_pos = slider_size / 2.0 + ratio = ((value - view.minValue()) / + view.maxValue() - view.minValue()) + return start_pos + (ratio * size) + + def startTrackingAt_inView_(self, at, view): + wrapper = wrappermap.wrapper(view) + start_pos = self.get_slider_pos(view) + if self.isVertical(): + click_pos = at.y + else: + click_pos = at.x + # only move the cursor if the click was outside the slider + if abs(click_pos - start_pos) > view.knobThickness() / 2: + self.moveSliderTo(view, click_pos) + start_pos = click_pos + view.mouse_tracking_info = MouseTrackingInfo(start_pos, click_pos) + if not wrapper.get_disabled(): + wrapper.emit('pressed') + return YES + + def moveSliderTo(self, view, pos): + if view.isVertical(): + size = view.bounds().size.height + else: + size = view.bounds().size.width + + slider_amount = self.calc_slider_amount(view, pos, size) + value = (self.maxValue() - self.minValue()) * slider_amount + self.setFloatValue_(value) + wrapper = wrappermap.wrapper(view) + if not wrapper.get_disabled(): + wrapper.emit('moved', value) + if self.isContinuous(): + wrapper.emit('changed', value) + + def continueTracking_at_inView_(self, lastPoint, at, view): + if view.isVertical(): + mouse_pos = at.y + else: + mouse_pos = at.x + + info = view.mouse_tracking_info + new_pos = info.start_pos + (mouse_pos - info.click_pos) + self.moveSliderTo(view, new_pos) + return YES + + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, view, mouseUp): + wrapper = wrappermap.wrapper(view) + if not wrapper.get_disabled(): + wrapper.emit('released') + view.mouse_tracking_info = None + +class CustomSliderView(NSSlider): + def init(self): + self = super(CustomSliderView, self).init() + self.layout_manager = LayoutManager() + self.custom_cursor = None + self.mouse_tracking_info = None + return self + + def get_slider_pos(self, value=None): + return self.cell().get_slider_pos(self, value) + + def resetCursorRects(self): + if self.custom_cursor is not None: + self.addCursorRect_cursor_(self.visibleRect(), self.custom_cursor) + self.custom_cursor.setOnMouseEntered_(YES) + + def isOpaque(self): + return wrappermap.wrapper(self).is_opaque() + + def knobThickness(self): + return wrappermap.wrapper(self).slider_size() + + def scrollWheel_(self, event): + wrapper = wrappermap.wrapper(self) + if wrapper.get_disabled(): + return + # NOTE: we ignore the scroll_step value passed into set_increments() + # and calculate the change using deltaY, which is in device + # coordinates. + slider_size = wrapper.slider_size() + if wrapper.is_horizontal(): + size = self.bounds().size.width + else: + size = self.bounds().size.height + size -= slider_size + + range = self.maxValue() - self.minValue() + value_change = (event.deltaY() / size) * range + self.setFloatValue_(self.floatValue() + value_change) + wrapper.emit('pressed') + wrapper.emit('changed', self.floatValue()) + wrapper.emit('released') + + def isVertical(self): + return not wrappermap.wrapper(self).is_horizontal() + + def drawRect_(self, rect): + context = drawing.DrawingContext(self, self.bounds(), rect) + context.style = drawing.DrawingStyle() + wrappermap.wrapper(self).draw(context, self.layout_manager) + self.layout_manager.reset() + + def sendAction_to_(self, action, to): + # We override the Cocoa machinery here and just send it to our wrapper + # widget. + wrapper = wrappermap.wrapper(self) + disabled = wrapper.get_disabled() + if not disabled: + wrapper.emit('changed', self.floatValue()) + # Total Cocoa we handled it anyway to prevent the event passed to + # upper layer. + return YES +CustomSliderView.setCellClass_(CustomSliderCell) + +class CustomControlBase(drawing.DrawingMixin, Widget): + def set_cursor(self, cursor): + if cursor == widgetconst.CURSOR_NORMAL: + self.view.custom_cursor = None + elif cursor == widgetconst.CURSOR_POINTING_HAND: + self.view.custom_cursor = NSCursor.pointingHandCursor() + else: + raise ValueError("Unknown cursor: %s" % cursor) + if self.view.window(): + self.view.window().invalidateCursorRectsForView_(self.view) + +class CustomButton(CustomControlBase): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomControlBase.__init__(self) + self.create_signal('clicked') + self.view = DrawableButton.alloc().init() + self.view.setRefusesFirstResponder_(NO) + self.view.setEnabled_(True) + + def enable(self): + Widget.enable(self) + self.view.setNeedsDisplay_(YES) + + def disable(self): + Widget.disable(self) + self.view.setNeedsDisplay_(YES) + +class ContinuousCustomButton(CustomButton): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomButton.__init__(self) + self.create_signal('held-down') + self.create_signal('released') + self.view = ContinuousDrawableButton.alloc().init() + self.view.setRefusesFirstResponder_(NO) + + def set_delays(self, initial, repeat): + self.view.cell().setPeriodicDelay_interval_(initial, repeat) + +class DragableCustomButton(CustomButton): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomButton.__init__(self) + self.create_signal('dragged-left') + self.create_signal('dragged-right') + self.view = DragableDrawableButton.alloc().init() + +class CustomSlider(CustomControlBase): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + CustomControlBase.__init__(self) + self.create_signal('pressed') + self.create_signal('released') + self.create_signal('changed') + self.create_signal('moved') + self.view = CustomSliderView.alloc().init() + self.view.setRefusesFirstResponder_(NO) + if self.is_continuous(): + self.view.setContinuous_(YES) + else: + self.view.setContinuous_(NO) + self.view.setEnabled_(True) + + def get_slider_pos(self, value=None): + return self.view.get_slider_pos(value) + + def viewport_created(self): + self.view.cell().setKnobThickness_(self.slider_size()) + + def get_value(self): + return self.view.floatValue() + + def set_value(self, value): + self.view.setFloatValue_(value) + + def get_range(self): + return self.view.minValue(), self.view.maxValue() + + def set_range(self, min_value, max_value): + self.view.setMinValue_(min_value) + self.view.setMaxValue_(max_value) + + def set_increments(self, small_step, big_step, scroll_step=None): + # NOTE: we ignore all of these parameters. + # + # Cocoa doesn't have a concept of changing the increments for + # NSScroller. scroll_step is isn't really compatible with + # the event object that's passed to scrollWheel_() + pass + + def enable(self): + Widget.enable(self) + self.view.setNeedsDisplay_(YES) + + def disable(self): + Widget.disable(self) + self.view.setNeedsDisplay_(YES) diff --git a/mvc/widgets/osx/drawing.py b/mvc/widgets/osx/drawing.py new file mode 100644 index 0000000..aaad1e9 --- /dev/null +++ b/mvc/widgets/osx/drawing.py @@ -0,0 +1,289 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""miro.plat.frontend.widgets.drawing -- Draw on Views.""" + +import math + +from Foundation import * +from AppKit import * +#from Quartz import * +from objc import YES, NO, nil + + +class ImageSurface: + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, image): + """Create a new ImageSurface.""" + self.image = image.nsimage.copy() + self.width = image.width + self.height = image.height + + def get_size(self): + return self.width, self.height + + def draw(self, context, x, y, width, height, fraction=1.0): + if self.width == 0 or self.height == 0: + return + current_context = NSGraphicsContext.currentContext() + current_context.setShouldAntialias_(YES) + current_context.setImageInterpolation_(NSImageInterpolationHigh) + current_context.saveGraphicsState() + flip_context(y + height) + dest_rect = NSMakeRect(x, 0, width, height) + if self.width >= width and self.height >= height: + # drawing to area smaller than our image + source_rect = NSMakeRect(0, 0, width, height) + self.image.drawInRect_fromRect_operation_fraction_( + dest_rect, source_rect, NSCompositeSourceOver, fraction) + else: + # drawing to area larger than our image. Need to tile it. + NSColor.colorWithPatternImage_(self.image).set() + current_context.setPatternPhase_( + self._calc_pattern_phase(context, x, y)) + NSBezierPath.fillRect_(dest_rect) + current_context.restoreGraphicsState() + + def draw_rect(self, context, dest_x, dest_y, source_x, source_y, width, + height, fraction=1.0): + if width == 0 or height == 0: + return + current_context = NSGraphicsContext.currentContext() + current_context.setShouldAntialias_(YES) + current_context.setImageInterpolation_(NSImageInterpolationHigh) + current_context.saveGraphicsState() + flip_context(dest_y + height) + dest_y = 0 + dest_rect = NSMakeRect(dest_x, dest_y, width, height) + source_rect = NSMakeRect(source_x, self.height-source_y-height, + width, height) + self.image.drawInRect_fromRect_operation_fraction_( + dest_rect, source_rect, NSCompositeSourceOver, fraction) + current_context.restoreGraphicsState() + + def _calc_pattern_phase(self, context, x, y): + """Calculate the pattern phase to draw tiled images. + + When we draw with a pattern, we want the image in the pattern to start + at the top-left of where we're drawing to. This function does the + dirty work necessary. + + :returns: NSPoint to send to setPatternPhase_ + """ + # convert to view coords + view_point = NSPoint(context.origin.x + x, context.origin.y + y) + # convert to window coords, which is setPatternPhase_ uses + return context.view.convertPoint_toView_(view_point, nil) + +def convert_cocoa_color(color): + rgb = color.colorUsingColorSpaceName_(NSDeviceRGBColorSpace) + return (rgb.redComponent(), rgb.greenComponent(), rgb.blueComponent()) + +def convert_widget_color(color, alpha=1.0): + return NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], + color[2], alpha) +def flip_context(height): + """Make the current context's coordinates flipped. + + This is useful for drawing images, since they use the normal cocoa + coordinates and we use flipped versions. + + :param height: height of the current area we are drawing to. + """ + xform = NSAffineTransform.transform() + xform.translateXBy_yBy_(0, height) + xform.scaleXBy_yBy_(1.0, -1.0) + xform.concat() + +class DrawingStyle(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, bg_color=None, text_color=None): + self.use_custom_style = True + if text_color is None: + self.text_color = self.default_text_color + else: + self.text_color = convert_cocoa_color(text_color) + if bg_color is None: + self.bg_color = self.default_bg_color + else: + self.bg_color = convert_cocoa_color(bg_color) + + default_text_color = convert_cocoa_color(NSColor.textColor()) + default_bg_color = convert_cocoa_color(NSColor.textBackgroundColor()) + +class DrawingContext: + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, view, drawing_area, rect): + self.view = view + self.path = NSBezierPath.bezierPath() + self.color = NSColor.blackColor() + self.width = drawing_area.size.width + self.height = drawing_area.size.height + self.origin = drawing_area.origin + if drawing_area.origin != NSZeroPoint: + xform = NSAffineTransform.transform() + xform.translateXBy_yBy_(drawing_area.origin.x, + drawing_area.origin.y) + xform.concat() + + def move_to(self, x, y): + self.path.moveToPoint_(NSPoint(x, y)) + + def rel_move_to(self, dx, dy): + self.path.relativeMoveToPoint_(NSPoint(dx, dy)) + + def line_to(self, x, y): + self.path.lineToPoint_(NSPoint(x, y)) + + def rel_line_to(self, dx, dy): + self.path.relativeLineToPoint_(NSPoint(dx, dy)) + + def curve_to(self, x1, y1, x2, y2, x3, y3): + self.path.curveToPoint_controlPoint1_controlPoint2_( + NSPoint(x3, y3), NSPoint(x1, y1), NSPoint(x2, y2)) + + def rel_curve_to(self, dx1, dy1, dx2, dy2, dx3, dy3): + self.path.relativeCurveToPoint_controlPoint1_controlPoint2_( + NSPoint(dx3, dy3), NSPoint(dx1, dy1), NSPoint(dx2, dy2)) + + def arc(self, x, y, radius, angle1, angle2): + angle1 = (angle1 * 360) / (2 * math.pi) + angle2 = (angle2 * 360) / (2 * math.pi) + center = NSPoint(x, y) + self.path.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(center, radius, angle1, angle2) + + def arc_negative(self, x, y, radius, angle1, angle2): + angle1 = (angle1 * 360) / (2 * math.pi) + angle2 = (angle2 * 360) / (2 * math.pi) + center = NSPoint(x, y) + self.path.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_clockwise_(center, radius, angle1, angle2, YES) + + def rectangle(self, x, y, width, height): + rect = NSMakeRect(x, y, width, height) + self.path.appendBezierPathWithRect_(rect) + + def set_color(self, color, alpha=1.0): + self.color = convert_widget_color(color, alpha) + self.color.set() + + def set_shadow(self, color, opacity, offset, blur_radius): + shadow = NSShadow.alloc().init() + # shadow offset is always in the cocoa coordinates, so we need to + # reverse the y part + shadow.setShadowOffset_(NSPoint(offset[0], -offset[1])) + shadow.setShadowBlurRadius_(blur_radius) + shadow.setShadowColor_(convert_widget_color(color, opacity)) + shadow.set() + + def set_line_width(self, width): + self.path.setLineWidth_(width) + + def stroke(self): + self.path.stroke() + self.path.removeAllPoints() + + def stroke_preserve(self): + self.path.stroke() + + def fill(self): + self.path.fill() + self.path.removeAllPoints() + + def fill_preserve(self): + self.path.fill() + + def clip(self): + self.path.addClip() + self.path.removeAllPoints() + + def save(self): + NSGraphicsContext.currentContext().saveGraphicsState() + + def restore(self): + NSGraphicsContext.currentContext().restoreGraphicsState() + + def gradient_fill(self, gradient): + self.gradient_fill_preserve(gradient) + self.path.removeAllPoints() + + def gradient_fill_preserve(self, gradient): + context = NSGraphicsContext.currentContext() + context.saveGraphicsState() + self.path.addClip() + gradient.draw() + context.restoreGraphicsState() + +class Gradient(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, x1, y1, x2, y2): + self.x1, self.y1, self.x2, self.y2 = x1, y1, x2, y2 + self.start_color = None + self.end_color = None + + def set_start_color(self, (red, green, blue)): + self.start_color = (red, green, blue) + + def set_end_color(self, (red, green, blue)): + self.end_color = (red, green, blue) + + def draw(self): + start_color = convert_widget_color(self.start_color) + end_color = convert_widget_color(self.end_color) + nsgradient = NSGradient.alloc().initWithStartingColor_endingColor_(start_color, end_color) + start_point = NSPoint(self.x1, self.y1) + end_point = NSPoint(self.x2, self.y2) + nsgradient.drawFromPoint_toPoint_options_(start_point, end_point, 0) + +class DrawingMixin(object): + def calc_size_request(self): + return self.size_request(self.view.layout_manager) + + # squish width / squish height only make sense on GTK + def set_squish_width(self, setting): + pass + + def set_squish_height(self, setting): + pass + + # Default implementations for methods that subclasses override. + + def is_opaque(self): + return False + + def size_request(self, layout_manager): + return 0, 0 + + def draw(self, context, layout_manager): + pass + + def viewport_repositioned(self): + # since this is a Mixin class, we want to make sure that our other + # classes see the viewport_repositioned() call. + super(DrawingMixin, self).viewport_repositioned() + self.queue_redraw() diff --git a/mvc/widgets/osx/drawingwidgets.py b/mvc/widgets/osx/drawingwidgets.py new file mode 100644 index 0000000..74e8232 --- /dev/null +++ b/mvc/widgets/osx/drawingwidgets.py @@ -0,0 +1,67 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""drawingviews.py -- views that support custom drawing.""" + +import wrappermap +import drawing +from .base import Widget, SimpleBin, FlippedView +from .layoutmanager import LayoutManager + +class DrawingView(FlippedView): + def init(self): + self = super(DrawingView, self).init() + self.layout_manager = LayoutManager() + return self + + def isOpaque(self): + return wrappermap.wrapper(self).is_opaque() + + def drawRect_(self, rect): + context = drawing.DrawingContext(self, self.bounds(), rect) + context.style = drawing.DrawingStyle() + wrappermap.wrapper(self).draw(context, self.layout_manager) + +class DrawingArea(drawing.DrawingMixin, Widget): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + Widget.__init__(self) + self.view = DrawingView.alloc().init() + +class Background(drawing.DrawingMixin, SimpleBin): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self): + SimpleBin.__init__(self) + self.view = DrawingView.alloc().init() + + def calc_size_request(self): + drawing_size = drawing.DrawingMixin.calc_size_request(self) + container_size = SimpleBin.calc_size_request(self) + return (max(container_size[0], drawing_size[0]), + max(container_size[1], drawing_size[1])) diff --git a/mvc/widgets/osx/fasttypes.c b/mvc/widgets/osx/fasttypes.c new file mode 100644 index 0000000..72d3b5b --- /dev/null +++ b/mvc/widgets/osx/fasttypes.c @@ -0,0 +1,540 @@ +/* +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + */ + +#include <Python.h> + +/* + * fasttypes.c + * + * Datastructures written in C to be fast. This used to be a big C++ file + * that depended on boost. Nowadays we only define LinkedList, which is easy + * enough to implement in pure C. + */ + +static int nodes_deleted = 0; // debugging only + +/* forward define python type objects */ + +static PyTypeObject LinkedListType; +static PyTypeObject LinkedListIterType; + +/* Structure definitions */ + +typedef struct LinkedListNode { + PyObject *obj; + struct LinkedListNode* next; + struct LinkedListNode* prev; + int deleted; // Has this node been removed? + int iter_count; // How many LinkedListIters point to this node? +} LinkedListNode; + +typedef struct { + PyObject_HEAD + int count; + LinkedListNode* sentinal; + // sentinal object to make list operations simpler/faster and equivalent + // to the boost API. It's prev node is the last element in the list and + // it's next node is the first +} LinkedListObject; + +typedef struct { + PyObject_HEAD + LinkedListNode* node; + LinkedListObject* list; +} LinkedListIterObject; + +/* LinkedListNode */ + +void check_node_deleted(LinkedListNode* node) +{ + if(node->iter_count <= 0 && node->deleted) { + free(node); + nodes_deleted += 1; + } +} + +static int remove_node(LinkedListObject* self, LinkedListNode* node) +{ + if(node->obj == NULL) { + PyErr_SetString(PyExc_IndexError, "can't remove lastIter()"); + return 0; + } + node->next->prev = node->prev; + node->prev->next = node->next; + node->deleted = 1; + self->count -= 1; + Py_DECREF(node->obj); + check_node_deleted(node); + return 1; +} + +/* LinkedListIter */ + +void switch_node(LinkedListIterObject* self, LinkedListNode* new_node) +{ + LinkedListNode* old_node; + + old_node = self->node; + self->node = new_node; + old_node->iter_count--; + self->node->iter_count++; + check_node_deleted(old_node); +} + +// Note that we don't expose the new method to python. We create +// LinkedListIters in the factory methods firstIter() and lastIter() +static LinkedListIterObject* LinkedListIterObject_new(LinkedListObject*list, + LinkedListNode* node) +{ + LinkedListIterObject* self; + + self = (LinkedListIterObject*)PyType_GenericAlloc(&LinkedListIterType, 0); + if(self != NULL) { + self->node = node; + self->list = list; + node->iter_count++; + } + return self; +} + +static void LinkedListIterObject_dealloc(LinkedListIterObject* self) +{ + self->node->iter_count--; + check_node_deleted(self->node); +} + +static PyObject *LinkedListIter_forward(LinkedListIterObject* self, PyObject *obj) +{ + switch_node(self, self->node->next); + Py_RETURN_NONE; +} + +static PyObject *LinkedListIter_back(LinkedListIterObject* self, PyObject *obj) +{ + switch_node(self, self->node->prev); + Py_RETURN_NONE; +} + +static PyObject *LinkedListIter_value(LinkedListIterObject* self, PyObject *obj) +{ + PyObject* retval; + + if(self->node->deleted) { + PyErr_SetString(PyExc_ValueError, "Node deleted"); + return NULL; + } + retval = self->node->obj; + if(retval == NULL) { + PyErr_SetString(PyExc_IndexError, "can't get value of lastIter()"); + return NULL; + } + Py_INCREF(retval); + return retval; +} + +static PyObject *LinkedListIter_copy(LinkedListIterObject* self, PyObject *obj) +{ + return (PyObject*)LinkedListIterObject_new(self->list, self->node); +} + +static PyObject *LinkedListIter_valid(LinkedListIterObject* self, PyObject *obj) +{ + return PyBool_FromLong(self->node->deleted == 0); +} + +PyObject* LinkedListIter_richcmp(LinkedListIterObject *o1, + LinkedListIterObject *o2, int opid) +{ + if(!PyObject_TypeCheck(o1, &LinkedListIterType) || + !PyObject_TypeCheck(o2, &LinkedListIterType)) { + return Py_NotImplemented; + } + switch(opid) { + case Py_EQ: + if(o1->node == o2->node) Py_RETURN_TRUE; + else Py_RETURN_FALSE; + case Py_NE: + if(o1->node != o2->node) Py_RETURN_TRUE; + else Py_RETURN_FALSE; + default: + return Py_NotImplemented; + } +} + +static PyMethodDef LinkedListIter_methods[] = { + {"forward", (PyCFunction)LinkedListIter_forward, METH_NOARGS, + "Move to the next element", + }, + {"back", (PyCFunction)LinkedListIter_back, METH_NOARGS, + "Move to the previous element", + }, + {"value", (PyCFunction)LinkedListIter_value, METH_NOARGS, + "Return the current element", + }, + {"copy", (PyCFunction)LinkedListIter_copy, METH_NOARGS, + "Duplicate iter", + }, + {"valid", (PyCFunction)LinkedListIter_valid, METH_NOARGS, + "Test if the iter is valid", + }, + {NULL}, +}; + +static PyTypeObject LinkedListIterType = { + PyObject_HEAD_INIT(NULL) + 0, /* ob_size */ + "fasttypes.LinkedListIter", /* tp_name */ + sizeof(LinkedListIterObject), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)LinkedListIterObject_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT|Py_TPFLAGS_HAVE_RICHCOMPARE, /* tp_flags */ + "fasttypes LinkedListIter", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + (richcmpfunc)LinkedListIter_richcmp, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + LinkedListIter_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + +/* LinkedList */ + +LinkedListNode* make_new_node(PyObject* obj, LinkedListNode* prev, + LinkedListNode* next) +{ + LinkedListNode* retval; + retval = malloc(sizeof(LinkedListNode)); + if(!retval) { + PyErr_SetString(PyExc_MemoryError, "can't create new node"); + return NULL; + } + Py_XINCREF(obj); + retval->obj = obj; + retval->prev = prev; + retval->next = next; + retval->iter_count = retval->deleted = 0; + return retval; +} + +void set_iter_type_error(PyObject* obj) +{ + // Set an exception when we expected a LinkedListIter and got something + // else + PyObject* args; + PyObject* fmt; + PyObject* err_str; + + args = Py_BuildValue("(O)", obj); + fmt = PyString_FromString("Expected LinkedListIter, got %r"); + err_str = PyString_Format(fmt, args); + PyErr_SetObject(PyExc_TypeError, err_str); + Py_DECREF(fmt); + Py_DECREF(err_str); + Py_DECREF(args); +} + +static PyObject* insert_before(LinkedListObject* self, LinkedListNode* node, + PyObject* obj) +{ + LinkedListNode* new_node; + PyObject* retval; + + new_node = make_new_node(obj, node->prev, node); + if(!new_node) return NULL; + node->prev->next = new_node; + node->prev = new_node; + self->count += 1; + retval = (PyObject*)LinkedListIterObject_new(self, new_node); + return retval; +} + +static PyObject* LinkedList_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + LinkedListObject *self; + LinkedListNode *sentinal; + + self = (LinkedListObject *)type->tp_alloc(type, 0); + if (self == NULL) return NULL; + + sentinal = make_new_node(NULL, NULL, NULL); + if(!sentinal) { + Py_DECREF(self); + return NULL; + } + self->sentinal = sentinal->next = sentinal->prev = sentinal; + sentinal->iter_count = 1; // prevent the sentinal from being deleted + self->count = 0; + + return (PyObject *)self; +} + +static void LinkedList_dealloc(LinkedListObject* self) +{ + LinkedListNode *node, *tmp; + + node = self->sentinal->next; + while(node != self->sentinal) { + node->deleted = 1; + tmp = node->next; + check_node_deleted(node); + node = tmp; + } + + self->sentinal->iter_count -= 1; + check_node_deleted(self->sentinal); + return; +} + +static int LinkedList_init(LinkedListObject *self) +{ + self->count = 0; + return 0; +} + +static Py_ssize_t LinkedList_len(LinkedListObject *self) +{ + return self->count; +} + +static PyObject* LinkedList_get(LinkedListObject *self, + LinkedListIterObject *iter) +{ + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error((PyObject*)iter); + return NULL; + } + return PyObject_CallMethod((PyObject*)iter, "value", "()"); +} +int LinkedList_set(LinkedListObject *self, LinkedListIterObject *iter, + PyObject *value) +{ + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error((PyObject*)iter); + return -1; + } + if(iter->node->deleted) { + PyErr_SetString(PyExc_ValueError, "Node deleted"); + return -1; + } + if(iter->node->obj == NULL) { + PyErr_SetString(PyExc_IndexError, "can't set value of lastIter()"); + return -1; + } + if(value == NULL) { + if(!remove_node(self, iter->node)) return -1; + return 0; + } + Py_INCREF(value); + Py_DECREF(iter->node->obj); + iter->node->obj = value; + return 0; +} + +static PyObject *LinkedList_insertBefore(LinkedListObject* self, PyObject *args) +{ + LinkedListIterObject *iter; + PyObject *obj; + + if(!PyArg_ParseTuple(args, "OO", &iter, &obj)) return NULL; + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error(obj); + return NULL; + } + + return insert_before(self, iter->node, obj); +} + +static PyObject *LinkedList_append(LinkedListObject* self, PyObject *obj) +{ + return insert_before(self, self->sentinal, obj); +} + +static PyObject *LinkedList_remove(LinkedListObject* self, + LinkedListIterObject *iter) +{ + LinkedListNode* next_node; + if(!PyObject_TypeCheck(iter, &LinkedListIterType)) { + set_iter_type_error((PyObject*)iter); + return NULL; + } + + next_node = iter->node->next; + if(!remove_node(self, iter->node)) return NULL; + return (PyObject*)LinkedListIterObject_new(self, next_node); +} + +static PyObject *LinkedList_firstIter(LinkedListObject* self, PyObject *obj) +{ + PyObject* retval; + retval = (PyObject*)LinkedListIterObject_new(self, self->sentinal->next); + return retval; +} + +static PyObject *LinkedList_lastIter(LinkedListObject* self, PyObject *obj) +{ + PyObject* retval; + retval = (PyObject*)LinkedListIterObject_new(self, self->sentinal); + return retval; +} + +static PyMappingMethods LinkedListMappingMethods = { + (lenfunc)LinkedList_len, + (binaryfunc)LinkedList_get, + (objobjargproc)LinkedList_set, +}; + +static PyMethodDef LinkedList_methods[] = { + {"insertBefore", (PyCFunction)LinkedList_insertBefore, METH_VARARGS, + "insert an element before iter", + }, + {"append", (PyCFunction)LinkedList_append, METH_O, + "append an element to the list", + }, + {"remove", (PyCFunction)LinkedList_remove, METH_O, + "remove an element to the list", + }, + {"firstIter", (PyCFunction)LinkedList_firstIter, METH_NOARGS, + "get an iter pointing to the first element in the list", + }, + {"lastIter", (PyCFunction)LinkedList_lastIter, METH_NOARGS, + "get an iter pointing to the last element in the list", + }, + {NULL}, +}; + +static PyTypeObject LinkedListType = { + PyObject_HEAD_INIT(NULL) + 0, /* ob_size */ + "fasttypes.LinkedList", /* tp_name */ + sizeof(LinkedListObject), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)LinkedList_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + &LinkedListMappingMethods, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT, /* tp_flags */ + "fasttypes LinkedList", /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + LinkedList_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)LinkedList_init, /* tp_init */ + 0, /* tp_alloc */ + LinkedList_new, /* tp_new */ +}; + +/* Module-level stuff */ + +static PyObject *count_nodes_deleted(PyObject *obj) +{ + return PyInt_FromLong(nodes_deleted); +} + +static PyObject *reset_nodes_deleted(PyObject *obj) +{ + nodes_deleted = 0; + Py_RETURN_NONE; +} + + +static PyMethodDef FasttypesMethods[] = +{ + {"_count_nodes_deleted", (PyCFunction)count_nodes_deleted, METH_NOARGS, + "get a count of how many nodes have been deleted (DEBUGGING ONLY)", + }, + {"_reset_nodes_deleted", (PyCFunction)reset_nodes_deleted, METH_NOARGS, + "reset the count of how many nodes have been deleted (DEBUGGING ONLY)", + }, + { NULL, NULL, 0, NULL } +}; + +PyMODINIT_FUNC initfasttypes(void) +{ + PyObject *m; + + if (PyType_Ready(&LinkedListType) < 0) + return; + + if (PyType_Ready(&LinkedListIterType) < 0) + return; + + m = Py_InitModule("fasttypes", FasttypesMethods); + + Py_INCREF(&LinkedListType); + Py_INCREF(&LinkedListIterType); + PyModule_AddObject(m, "LinkedList", (PyObject *)&LinkedListType); +} diff --git a/mvc/widgets/osx/helpers.py b/mvc/widgets/osx/helpers.py new file mode 100644 index 0000000..e4aa23a --- /dev/null +++ b/mvc/widgets/osx/helpers.py @@ -0,0 +1,95 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""helper classes.""" + +import logging +import traceback + +from Foundation import * +from objc import nil + +class NotificationForwarder(NSObject): + """Forward notifications from a Cocoa object to a python class. + """ + + def initWithNSObject_center_(self, nsobject, center): + """Initialize the NotificationForwarder nsobject is the NSObject to + forward notifications for. It can be nil in which case notifications + from all objects will be forwarded. + + center is the NSNotificationCenter to get notifications from. It can + be None, in which cas the default notification center is used. + """ + self.nsobject = nsobject + self.callback_map = {} + if center is None: + self.center = NSNotificationCenter.defaultCenter() + else: + self.center = center + return self + + @classmethod + def create(cls, object, center=None): + """Helper method to call aloc() then initWithNSObject_center_().""" + return cls.alloc().initWithNSObject_center_(object, center) + + def connect(self, callback, name): + """Register to listen for notifications. + Only one callback for each notification name can be connected. + """ + + if name in self.callback_map: + raise ValueError("%s already connected" % name) + + self.callback_map[name] = callback + self.center.addObserver_selector_name_object_(self, 'observe:', name, + self.nsobject) + + def disconnect(self, name=None): + if name is not None: + self.center.removeObserver_name_object_(self, name, self.nsobject) + self.callback_map.pop(name) + else: + self.center.removeObserver_(self) + self.callback_map.clear() + + def observe_(self, notification): + name = notification.name() + callback = self.callback_map[name] + if callback is None: + logging.warn("Callback for %s is dead", name) + self.center.removeObverser_name_object_(self, name, self.nsobject) + return + try: + callback(notification) + except: + logging.warn("Callback for %s raised exception:%s\n", + name.encode('utf-8'), + traceback.format_exc()) diff --git a/mvc/widgets/osx/layout.py b/mvc/widgets/osx/layout.py new file mode 100644 index 0000000..0238975 --- /dev/null +++ b/mvc/widgets/osx/layout.py @@ -0,0 +1,748 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".layout -- Widgets that handle laying out other +widgets. + +We basically follow GTK's packing model. Widgets are packed into vboxes, +hboxes or other container widgets. The child widgets request a minimum size, +and the container widgets allocate space for their children. Widgets may get +more size then they requested in which case they have to deal with it. In +rare cases, widgets may get less size then they requested in which case they +should just make sure they don't throw an exception or segfault. + +Check out the GTK tutorial for more info. +""" + +import itertools + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil, signature, loadBundle + +import tableview +import wrappermap +from .base import Container, Bin, FlippedView +from mvc.utils import Matrix + +# These don't seem to be in pyobjc's AppKit (yet) +NSScrollerKnobStyleDefault = 0 +NSScrollerKnobStyleDark = 1 +NSScrollerKnobStyleLight = 2 + +NSScrollerStyleLegacy = 0 +NSScrollerStyleOverlay = 1 + +def _extra_space_iter(extra_length, count): + """Utility function to allocate extra space left over in containers.""" + if count == 0: + return + extra_space, leftover = divmod(extra_length, count) + while leftover >= 1: + yield extra_space + 1 + leftover -= 1 + yield extra_space + leftover + while True: + yield extra_space + +class BoxPacking: + """Utility class to store how we are packing a single widget.""" + + def __init__(self, widget, expand, padding): + self.widget = widget + self.expand = expand + self.padding = padding + +class Box(Container): + """Base class for HBox and VBox. """ + CREATES_VIEW = False + + def __init__(self, spacing=0): + self.spacing = spacing + Container.__init__(self) + self.packing_start = [] + self.packing_end = [] + self.expand_count = 0 + + def packing_both(self): + return itertools.chain(self.packing_start, self.packing_end) + + def get_children(self): + for packing in self.packing_both(): + yield packing.widget + children = property(get_children) + + # Internally Boxes use a (length, breadth) coordinate system. length and + # breadth will be either x or y depending on which way the box is + # oriented. The subclasses must provide methods to translate between the + # 2 coordinate systems. + + def translate_size(self, size): + """Translate a (width, height) tulple to (length, breadth).""" + raise NotImplementedError() + + def untranslate_size(self, size): + """Reverse the work of translate_size.""" + raise NotImplementedError() + + def make_child_rect(self, position, length): + """Create a rect to position a child with.""" + raise NotImplementedError() + + def pack_start(self, child, expand=False, padding=0): + self.packing_start.append(BoxPacking(child, expand, padding)) + if expand: + self.expand_count += 1 + self.child_added(child) + + def pack_end(self, child, expand=False, padding=0): + self.packing_end.append(BoxPacking(child, expand, padding)) + if expand: + self.expand_count += 1 + self.child_added(child) + + def _remove_from_packing(self, child): + for i in xrange(len(self.packing_start)): + if self.packing_start[i].widget is child: + return self.packing_start.pop(i) + for i in xrange(len(self.packing_end)): + if self.packing_end[i].widget is child: + return self.packing_end.pop(i) + raise LookupError("%s not found" % child) + + def remove(self, child): + packing = self._remove_from_packing(child) + if packing.expand: + self.expand_count -= 1 + self.child_removed(child) + + def translate_widget_size(self, widget): + return self.translate_size(widget.get_size_request()) + + def calc_size_request(self): + length = breadth = 0 + for packing in self.packing_both(): + child_length, child_breadth = \ + self.translate_widget_size(packing.widget) + length += child_length + if packing.padding: + length += packing.padding * 2 # Need to pad on both sides + breadth = max(breadth, child_breadth) + spaces = max(0, len(self.packing_start) + len(self.packing_end) - 1) + length += spaces * self.spacing + return self.untranslate_size((length, breadth)) + + def place_children(self): + request_length, request_breadth = self.translate_widget_size(self) + ps = self.viewport.placement.size + total_length, dummy = self.translate_size((ps.width, ps.height)) + total_extra_space = total_length - request_length + extra_space_iter = _extra_space_iter(total_extra_space, + self.expand_count) + start_end = self._place_packing_list(self.packing_start, + extra_space_iter, 0) + if self.expand_count == 0 and total_extra_space > 0: + # account for empty space after the end of pack_start list and + # before the pack_end list. + self.draw_empty_space(start_end, total_extra_space) + start_end += total_extra_space + self._place_packing_list(reversed(self.packing_end), extra_space_iter, + start_end) + + def draw_empty_space(self, start, length): + empty_rect = self.make_child_rect(start, length) + my_view = self.viewport.view + opaque_view = my_view.opaqueAncestor() + if opaque_view is not None: + empty_rect2 = opaque_view.convertRect_fromView_(empty_rect, my_view) + opaque_view.setNeedsDisplayInRect_(empty_rect2) + + def _place_packing_list(self, packing_list, extra_space_iter, position): + for packing in packing_list: + child_length, child_breadth = \ + self.translate_widget_size(packing.widget) + if packing.expand: + child_length += extra_space_iter.next() + if packing.padding: # space before + self.draw_empty_space(position, packing.padding) + position += packing.padding + child_rect = self.make_child_rect(position, child_length) + if packing.padding: # space after + self.draw_empty_space(position, packing.padding) + position += packing.padding + packing.widget.place(child_rect, self.viewport.view) + position += child_length + if self.spacing > 0: + self.draw_empty_space(position, self.spacing) + position += self.spacing + return position + + def enable(self): + Container.enable(self) + for mem in self.children: + mem.enable() + + def disable(self): + Container.disable(self) + for mem in self.children: + mem.disable() + +class VBox(Box): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def translate_size(self, size): + return (size[1], size[0]) + + def untranslate_size(self, size): + return (size[1], size[0]) + + def make_child_rect(self, position, length): + placement = self.viewport.placement + return NSMakeRect(placement.origin.x, placement.origin.y + position, + placement.size.width, length) + +class HBox(Box): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def translate_size(self, size): + return (size[0], size[1]) + + def untranslate_size(self, size): + return (size[0], size[1]) + + def make_child_rect(self, position, length): + placement = self.viewport.placement + return NSMakeRect(placement.origin.x + position, placement.origin.y, + length, placement.size.height) + +class Alignment(Bin): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + CREATES_VIEW = False + + def __init__(self, xalign=0.0, yalign=0.0, xscale=0.0, yscale=0.0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + Bin.__init__(self) + self.xalign = xalign + self.yalign = yalign + self.xscale = xscale + self.yscale = yscale + self.top_pad = top_pad + self.bottom_pad = bottom_pad + self.left_pad = left_pad + self.right_pad = right_pad + if self.child is not None: + self.place_children() + + def set(self, xalign=0.0, yalign=0.0, xscale=0.0, yscale=0.0): + self.xalign = xalign + self.yalign = yalign + self.xscale = xscale + self.yscale = yscale + if self.child is not None: + self.place_children() + + def set_padding(self, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + self.top_pad = top_pad + self.bottom_pad = bottom_pad + self.left_pad = left_pad + self.right_pad = right_pad + if self.child is not None and self.viewport is not None: + self.place_children() + + def vertical_pad(self): + return self.top_pad + self.bottom_pad + + def horizontal_pad(self): + return self.left_pad + self.right_pad + + def calc_size_request(self): + if self.child: + child_width, child_height = self.child.get_size_request() + return (child_width + self.horizontal_pad(), + child_height + self.vertical_pad()) + else: + return (0, 0) + + def calc_size(self, requested, total, scale): + extra_width = max(0, total - requested) + return requested + int(round(extra_width * scale)) + + def calc_position(self, size, total, align): + return int(round((total - size) * align)) + + def place_children(self): + if self.child is None: + return + + total_width = self.viewport.placement.size.width + total_height = self.viewport.placement.size.height + total_width -= self.horizontal_pad() + total_height -= self.vertical_pad() + request_width, request_height = self.child.get_size_request() + + child_width = self.calc_size(request_width, total_width, self.xscale) + child_height = self.calc_size(request_height, total_height, self.yscale) + child_x = self.calc_position(child_width, total_width, self.xalign) + child_y = self.calc_position(child_height, total_height, self.yalign) + child_x += self.left_pad + child_y += self.top_pad + + my_origin = self.viewport.area().origin + child_rect = NSMakeRect(my_origin.x + child_x, my_origin.y + child_y, child_width, child_height) + self.child.place(child_rect, self.viewport.view) + # Make sure the space not taken up by our child is redrawn. + self.viewport.queue_redraw() + +class DetachedWindowHolder(Alignment): + def __init__(self): + Alignment.__init__(self, bottom_pad=16, xscale=1.0, yscale=1.0) + +class _TablePacking(object): + """Utility class to help with packing Table widgets.""" + def __init__(self, widget, column, row, column_span, row_span): + self.widget = widget + self.column = column + self.row = row + self.column_span = column_span + self.row_span = row_span + + def column_indexes(self): + return range(self.column, self.column + self.column_span) + + def row_indexes(self): + return range(self.row, self.row + self.row_span) + +class Table(Container): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + CREATES_VIEW = False + + def __init__(self, columns, rows): + Container.__init__(self) + self._cells = Matrix(columns, rows) + self._children = [] # List of _TablePacking objects + self._children_sorted = True + self.rows = rows + self.columns = columns + self.row_spacing = self.column_spacing = 0 + + def _ensure_children_sorted(self): + if not self._children_sorted: + def cell_area(table_packing): + return table_packing.column_span * table_packing.row_span + self._children.sort(key=cell_area) + self._children_sorted = True + + def get_children(self): + return [cell.widget for cell in self._children] + children = property(get_children) + + def calc_size_request(self): + self._ensure_children_sorted() + self._calc_dimensions() + return self.total_width, self.total_height + + def _calc_dimensions(self): + self.column_widths = [0] * self.columns + self.row_heights = [0] * self.rows + + for tp in self._children: + child_width, child_height = tp.widget.get_size_request() + # recalc the width of the child's columns + self._recalc_dimension(child_width, self.column_widths, + tp.column_indexes()) + # recalc the height of the child's rows + self._recalc_dimension(child_height, self.row_heights, + tp.row_indexes()) + + self.total_width = (self.column_spacing * (self.columns - 1) + + sum(self.column_widths)) + self.total_height = (self.row_spacing * (self.rows - 1) + + sum(self.row_heights)) + + def _recalc_dimension(self, child_size, size_array, positions): + current_size = sum(size_array[p] for p in positions) + child_size_needed = child_size - current_size + if child_size_needed > 0: + iter = _extra_space_iter(child_size_needed, len(positions)) + for p in positions: + size_array[p] += iter.next() + + def place_children(self): + # This method depepnds on us calling _calc_dimensions() in + # calc_size_request(). Ensure that this happens. + if self.cached_size_request is None: + self.get_size_request() + column_positions = [0] + for width in self.column_widths[:-1]: + column_positions.append(width + column_positions[-1] + self.column_spacing) + row_positions = [0] + for height in self.row_heights[:-1]: + row_positions.append(height + row_positions[-1] + self.row_spacing) + + my_x= self.viewport.placement.origin.x + my_y = self.viewport.placement.origin.y + for tp in self._children: + x = my_x + column_positions[tp.column] + y = my_y + row_positions[tp.row] + width = sum(self.column_widths[i] for i in tp.column_indexes()) + height = sum(self.row_heights[i] for i in tp.row_indexes()) + rect = NSMakeRect(x, y, width, height) + tp.widget.place(rect, self.viewport.view) + + def pack(self, widget, column, row, column_span=1, row_span=1): + tp = _TablePacking(widget, column, row, column_span, row_span) + for c in tp.column_indexes(): + for r in tp.row_indexes(): + if self._cells[c, r]: + raise ValueError("Cell %d x %d is already taken" % (c, r)) + self._cells[column, row] = widget + self._children.append(tp) + self._children_sorted = False + self.child_added(widget) + + def remove(self, child): + for i in xrange(len(self._children)): + if self._children[i].widget is child: + self._children.remove(i) + break + else: + raise ValueError("%s is not a child of this Table" % child) + self._cells.remove(child) + self.child_removed(widget) + + def set_column_spacing(self, spacing): + self.column_spacing = spacing + self.invalidate_size_request() + + def set_row_spacing(self, spacing): + self.row_spacing = spacing + self.invalidate_size_request() + + def enable(self, row=None, column=None): + Container.enable(self) + if row != None and column != None: + if self._cells[column, row]: + self._cells[column, row].enable() + elif row != None: + for mem in self._cells.row(row): + if mem: mem.enable() + elif column != None: + for mem in self._cells.column(column): + if mem: mem.enable() + else: + for mem in self._cells: + if mem: mem.enable() + + def disable(self, row=None, column=None): + Container.disable(self) + if row != None and column != None: + if self._cells[column, row]: + self._cells[column, row].disable() + elif row != None: + for mem in self._cells.row(row): + if mem: mem.disable() + elif column != None: + for mem in self._cells.column(column): + if mem: mem.disable() + else: + for mem in self._cells: + if mem: mem.disable() + +class MiroScrollView(NSScrollView): + def tile(self): + NSScrollView.tile(self) + # tile is called when we need to layout our child view and scrollers. + # This probably means that we've either hidden or shown a scrollbar so + # call invalidate_size_request to ensure that things get re-layed out + # correctly. (#see 13842) + wrapper = wrappermap.wrapper(self) + if wrapper is not None: + wrapper.invalidate_size_request() + +class Scroller(Bin): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, horizontal, vertical): + Bin.__init__(self) + self.view = MiroScrollView.alloc().init() + self.view.setAutohidesScrollers_(YES) + self.view.setHasHorizontalScroller_(horizontal) + self.view.setHasVerticalScroller_(vertical) + self.document_view = FlippedView.alloc().init() + self.view.setDocumentView_(self.document_view) + + def prepare_for_dark_content(self): + try: + self.view.setScrollerKnobStyle_(NSScrollerKnobStyleLight) + except AttributeError: + # This only works on 10.7 and abvoe + pass + + def set_has_borders(self, has_border): + self.view.setBorderType_(NSBezelBorder) + + def viewport_repositioned(self): + # If the window is resized, this translates to a + # viewport_repositioned() event. Instead of calling + # place_children() one, which is what our suporclass does, we need + # some extra logic here. place the chilren to work out if we need a + # scrollbar, then get the new size, then replace the children (which + # now takes into account of scrollbar size.) + super(Scroller, self).viewport_repositioned() + self.cached_size_request = self.calc_size_request() + self.place_children() + + def set_background_color(self, color): + self.view.setBackgroundColor_(self.make_color(color)) + + def add(self, child): + child.parent_is_scroller = True + Bin.add(self, child) + + def remove(self): + child.parent_is_scroller = False + Bin.remove(self) + + def children_changed(self): + # since our size isn't dependent on our children, don't call + # invalidate_size_request() here. Just call place_children() so that + # they get positioned correctly in the document view. + # + # XXX dodgy - why are we laying out the children twice? When the + # children change, the scroller could appear/disappear. But you have + # no idea if that's going to happen without knowing how big your + # children are. So we lay it out, get the size, then, place the + # children again. This makes sure that the right side of the children + # are redrawn. There's got to be a better way?? + self.place_children() + self.cached_size_request = self.calc_size_request() + self.place_children() + + def calc_size_request(self): + if self.child: + width = height = 0 + try: + legacy = self.view.scrollerStyle() == NSScrollerStyleLegacy + except AttributeError: + legacy = True + if not self.view.hasHorizontalScroller(): + width = self.child.get_size_request()[0] + if not self.view.hasVerticalScroller(): + height = self.child.get_size_request()[1] + # Add a little room for the scrollbars (if necessary) + if legacy and self.view.hasHorizontalScroller(): + height += NSScroller.scrollerWidth() + if legacy and self.view.hasVerticalScroller(): + width += NSScroller.scrollerWidth() + return width, height + else: + return 0, 0 + + def place_children(self): + if self.child is not None: + scroll_view_size = self.view.contentView().frame().size + child_width, child_height = self.child.get_size_request() + child_width = max(child_width, scroll_view_size.width) + child_height = max(child_height, scroll_view_size.height) + frame = NSRect(NSPoint(0,0), NSSize(child_width, child_height)) + if isinstance(self.child, tableview.TableView) and self.child.is_showing_headers(): + # Hack to allow the content of a table view to scroll, but not + # the headers + self.child.place(frame, self.document_view) + if self.view.documentView() is not self.child.tableview: + self.view.setDocumentView_(self.child.tableview) + else: + self.child.place(frame, self.document_view) + self.document_view.setFrame_(frame) + self.document_view.setNeedsDisplay_(YES) + self.view.setNeedsDisplay_(YES) + self.child.emit('place-in-scroller') + +class ExpanderView(FlippedView): + def init(self): + self = super(ExpanderView, self).init() + self.label_rect = None + self.content_view = None + self.button = NSButton.alloc().init() + self.button.setState_(NSOffState) + self.button.setTitle_("") + self.button.setBezelStyle_(NSDisclosureBezelStyle) + self.button.setButtonType_(NSPushOnPushOffButton) + self.button.sizeToFit() + self.addSubview_(self.button) + self.button.setTarget_(self) + self.button.setAction_('buttonChanged:') + self.content_view = FlippedView.alloc().init() + return self + + def buttonChanged_(self, button): + if button.state() == NSOnState: + self.addSubview_(self.content_view) + else: + self.content_view.removeFromSuperview() + if self.window(): + wrappermap.wrapper(self).invalidate_size_request() + + def mouseDown_(self, event): + pass # Just need to respond to the selector so we get mouseUp_ + + def mouseUp_(self, event): + position = event.locationInWindow() + window_label_rect = self.convertRect_toView_(self.label_rect, None) + if NSPointInRect(position, window_label_rect): + self.button.setNextState() + self.buttonChanged_(self.button) + +class Expander(Bin): + BUTTON_PAD_TOP = 2 + BUTTON_PAD_LEFT = 4 + LABEL_SPACING = 4 + + def __init__(self, child): + Bin.__init__(self) + if child: + self.add(child) + self.label = None + self.spacing = 0 + self.view = ExpanderView.alloc().init() + self.button = self.view.button + self.button.setFrameOrigin_(NSPoint(self.BUTTON_PAD_LEFT, + self.BUTTON_PAD_TOP)) + self.content_view = self.view.content_view + + def remove_viewport(self): + Bin.remove_viewport(self) + if self.label is not None: + self.label.remove_viewport() + + def set_spacing(self, spacing): + self.spacing = spacing + + def set_label(self, widget): + if self.label is not None: + self.label.remove_viewport() + self.label = widget + self.children_changed() + + def set_expanded(self, expanded): + if expanded: + self.button.setState_(NSOnState) + else: + self.button.setState_(NSOffState) + self.view.buttonChanged_(self.button) + + def calc_top_size(self): + width = self.button.bounds().size.width + height = self.button.bounds().size.height + if self.label is not None: + label_width, label_height = self.label.get_size_request() + width += self.LABEL_SPACING + label_width + height = max(height, label_height) + width += self.BUTTON_PAD_LEFT + height += self.BUTTON_PAD_TOP + return width, height + + def calc_size_request(self): + width, height = self.calc_top_size() + if self.child is not None and self.button.state() == NSOnState: + child_width, child_height = self.child.get_size_request() + width = max(width, child_width) + height += self.spacing + child_height + return width, height + + def place_children(self): + top_width, top_height = self.calc_top_size() + if self.label: + label_width, label_height = self.label.get_size_request() + button_width = self.button.bounds().size.width + label_x = self.BUTTON_PAD_LEFT + button_width + self.LABEL_SPACING + label_rect = NSMakeRect(label_x, self.BUTTON_PAD_TOP, + label_width, label_height) + self.label.place(label_rect, self.viewport.view) + self.view.label_rect = label_rect + if self.child: + size = self.viewport.area().size + child_rect = NSMakeRect(0, 0, size.width, size.height - + top_height) + self.content_view.setFrame_(NSMakeRect(0, top_height, size.width, + size.height - top_height)) + self.child.place(child_rect, self.content_view) + + +class TabViewDelegate(NSObject): + def tabView_willSelectTabViewItem_(self, tab_view, tab_view_item): + try: + wrapper = wrappermap.wrapper(tab_view) + except KeyError: + pass # The NSTabView hasn't been placed yet, don't worry about it. + else: + wrapper.place_child_with_item(tab_view_item) + +class TabContainer(Container): + def __init__(self): + Container.__init__(self) + self.children = [] + self.item_to_child = {} + self.view = NSTabView.alloc().init() + self.view.setAllowsTruncatedLabels_(NO) + self.delegate = TabViewDelegate.alloc().init() + self.view.setDelegate_(self.delegate) + + def append_tab(self, child_widget, label, image): + item = NSTabViewItem.alloc().init() + item.setLabel_(label) + item.setView_(FlippedView.alloc().init()) + self.view.addTabViewItem_(item) + self.children.append(child_widget) + self.child_added(child_widget) + self.item_to_child[item] = child_widget + + def select_tab(self, index): + self.view.selectTabViewItemAtIndex_(index) + + def place_children(self): + self.place_child_with_item(self.view.selectedTabViewItem()) + + def place_child_with_item(self, tab_view_item): + child = self.item_to_child[tab_view_item] + child_view = tab_view_item.view() + content_rect =self.view.contentRect() + child_view.setFrame_(content_rect) + child.place(child_view.bounds(), child_view) + + def calc_size_request(self): + tab_size = self.view.minimumSize() + # make sure there's enough room for the tabs, plus a little extra + # space to make things look good + max_width = tab_size.width + 60 + max_height = 0 + for child in self.children: + width, height = child.get_size_request() + max_width = max(width, max_width) + max_height = max(height, max_height) + max_height += tab_size.height + + return max_width, max_height diff --git a/mvc/widgets/osx/layoutmanager.py b/mvc/widgets/osx/layoutmanager.py new file mode 100644 index 0000000..de4301b --- /dev/null +++ b/mvc/widgets/osx/layoutmanager.py @@ -0,0 +1,445 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""textlayout.py -- Contains the LayoutManager class. It handles laying text, +buttons, getting font metrics and other tasks that are required to size +things. +""" +import logging +import math + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +import drawing + +INFINITE = 1000000 # size of an "infinite" dimension + +class MiroLayoutManager(NSLayoutManager): + """Overide NSLayoutManager to draw better underlines.""" + + def drawUnderlineForGlyphRange_underlineType_baselineOffset_lineFragmentRect_lineFragmentGlyphRange_containerOrigin_(self, glyph_range, typ, offset, line_rect, line_glyph_range, container_origin): + container, _ = self.textContainerForGlyphAtIndex_effectiveRange_(glyph_range.location, None) + rect = self.boundingRectForGlyphRange_inTextContainer_(glyph_range, container) + x = container_origin.x + rect.origin.x + y = (container_origin.y + rect.origin.y + rect.size.height - offset) + underline_height, offset = self.calc_underline_extents(glyph_range) + y = math.ceil(y + offset) + underline_height / 2.0 + path = NSBezierPath.bezierPath() + path.setLineWidth_(underline_height) + path.moveToPoint_(NSPoint(x, y)) + path.relativeLineToPoint_(NSPoint(rect.size.width, 0)) + path.stroke() + + def calc_underline_extents(self, line_glyph_range): + index = self.characterIndexForGlyphAtIndex_(line_glyph_range.location) + font, _ = self.textStorage().attribute_atIndex_effectiveRange_(NSFontAttributeName, index, None) + # we use a couple of magic numbers that seems to work okay. I (BDK) + # got it from some old mozilla code. + height = font.ascender() - font.descender() + height = max(1.0, round(0.05 * height)) + offset = max(1.0, round(0.1 * height)) + return height, offset + +class TextBoxPool(object): + """Handles a pool of TextBox objects. We monitor the TextBox objects and + when those objects die, we reclaim them for the pool. + + Creating TextBoxes is fairly expensive and NSLayoutManager do a lot of + caching, so it's useful to keep them around rather than destroying them. + """ + + def __init__(self): + self.used_text_boxes = [] + self.available_text_boxes = [] + + def get(self): + """Get a NSLayoutManager, either from the pool or by creating a new + one. + """ + try: + rv = self.available_text_boxes.pop() + except IndexError: + rv = TextBox() + self.used_text_boxes.append(rv) + return rv + + def reclaim_textboxes(self): + """Move used TextBoxes back to the available pool. This should be + called after the code using text boxes is done using all of them. + """ + self.available_text_boxes.extend(self.used_text_boxes) + self.used_text_boxes[:] = [] + +text_box_pool = TextBoxPool() + +class Font(object): + line_height_sizer = NSLayoutManager.alloc().init() + + def __init__(self, nsfont): + self.nsfont = nsfont + + def ascent(self): + return self.nsfont.ascender() + + def descent(self): + return -self.nsfont.descender() + + def line_height(self): + return Font.line_height_sizer.defaultLineHeightForFont_(self.nsfont) + +class FontPool(object): + def __init__(self): + self._cached_fonts = {} + + def get(self, scale_factor, bold, italic, family): + cache_key = (scale_factor, bold, italic, family) + try: + return self._cached_fonts[cache_key] + except KeyError: + font = self._create(scale_factor, bold, italic, family) + self._cached_fonts[cache_key] = font + return font + + def _create(self, scale_factor, bold, italic, family): + size = round(scale_factor * NSFont.systemFontSize()) + nsfont = None + if family is not None: + if bold: + nsfont = NSFont.fontWithName_size_(family + " Bold", size) + else: + nsfont = NSFont.fontWithName_size_(family, size) + if nsfont is None: + logging.error('FontPool: family %s scale %s bold %s ' + 'italic %s not found', + family, scale_factor, bold, italic) + # Att his point either we have requested a custom font that failed + # to load or the system font was requested. + if nsfont is None: + if bold: + nsfont = NSFont.boldSystemFontOfSize_(size) + else: + nsfont = NSFont.systemFontOfSize_(size) + return Font(nsfont) + +class LayoutManager(object): + font_pool = FontPool() + default_font = font_pool.get(1.0, False, False, None) + + def __init__(self): + self.current_font = self.default_font + self.set_text_color((0, 0, 0)) + self.set_text_shadow(None) + + def font(self, scale_factor, bold=False, italic=False, family=None): + return self.font_pool.get(scale_factor, bold, italic, family) + + def set_font(self, scale_factor, bold=False, italic=False, family=None): + self.current_font = self.font(scale_factor, bold, italic, family) + + def set_text_color(self, color): + self.text_color = color + + def set_text_shadow(self, shadow): + self.shadow = shadow + + def textbox(self, text, underline=False): + text_box = text_box_pool.get() + color = NSColor.colorWithDeviceRed_green_blue_alpha_(self.text_color[0], self.text_color[1], self.text_color[2], 1.0) + text_box.reset(text, self.current_font, color, self.shadow, underline) + return text_box + + def button(self, text, pressed=False, disabled=False, style='normal'): + if style == 'webby': + return StyledButton(text, self.current_font, pressed, disabled) + else: + return NativeButton(text, self.current_font, pressed, disabled) + + def reset(self): + text_box_pool.reclaim_textboxes() + self.current_font = self.default_font + self.text_color = (0, 0, 0) + self.shadow = None + +class TextBox(object): + def __init__(self): + self.layout_manager = MiroLayoutManager.alloc().init() + container = NSTextContainer.alloc().init() + container.setLineFragmentPadding_(0) + self.layout_manager.addTextContainer_(container) + self.layout_manager.setUsesFontLeading_(NO) + self.text_storage = NSTextStorage.alloc().init() + self.text_storage.addLayoutManager_(self.layout_manager) + self.text_container = self.layout_manager.textContainers()[0] + + def reset(self, text, font, color, shadow, underline): + """Reset the text box so it's ready to be used by a new owner.""" + self.text_storage.deleteCharactersInRange_(NSRange(0, + self.text_storage.length())) + self.text_container.setContainerSize_(NSSize(INFINITE, INFINITE)) + self.paragraph_style = NSMutableParagraphStyle.alloc().init() + self.font = font + self.color = color + self.shadow = shadow + self.width = None + self.set_text(text, underline=underline) + + def make_attr_string(self, text, color, font, underline): + attributes = NSMutableDictionary.alloc().init() + if color is not None: + nscolor = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], color[2], 1.0) + attributes.setObject_forKey_(nscolor, NSForegroundColorAttributeName) + else: + attributes.setObject_forKey_(self.color, NSForegroundColorAttributeName) + if font is not None: + attributes.setObject_forKey_(font.nsfont, NSFontAttributeName) + else: + attributes.setObject_forKey_(self.font.nsfont, NSFontAttributeName) + if underline: + attributes.setObject_forKey_(NSUnderlineStyleSingle, NSUnderlineStyleAttributeName) + attributes.setObject_forKey_(self.paragraph_style.copy(), NSParagraphStyleAttributeName) + if text is None: + text = "" + return NSAttributedString.alloc().initWithString_attributes_(text, attributes) + + def set_text(self, text, color=None, font=None, underline=False): + string = self.make_attr_string(text, color, font, underline) + self.text_storage.setAttributedString_(string) + + def append_text(self, text, color=None, font=None, underline=False): + string = self.make_attr_string(text, color, font, underline) + self.text_storage.appendAttributedString_(string) + + def set_width(self, width): + if width is not None: + self.text_container.setContainerSize_(NSSize(width, INFINITE)) + else: + self.text_container.setContainerSize_(NSSize(INFINITE, INFINITE)) + self.width = width + + def update_paragraph_style(self): + attr = NSParagraphStyleAttributeName + value = self.paragraph_style.copy() + rnge = NSMakeRange(0, self.text_storage.length()) + self.text_storage.addAttribute_value_range_(attr, value, rnge) + + def set_wrap_style(self, wrap): + if wrap == 'word': + self.paragraph_style.setLineBreakMode_(NSLineBreakByWordWrapping) + elif wrap == 'char': + self.paragraph_style.setLineBreakMode_(NSLineBreakByCharWrapping) + elif wrap == 'truncated-char': + self.paragraph_style.setLineBreakMode_(NSLineBreakByTruncatingTail) + else: + raise ValueError("Unknown wrap value: %s" % wrap) + self.update_paragraph_style() + + def set_alignment(self, align): + if align == 'left': + self.paragraph_style.setAlignment_(NSLeftTextAlignment) + elif align == 'right': + self.paragraph_style.setAlignment_(NSRightTextAlignment) + elif align == 'center': + self.paragraph_style.setAlignment_(NSCenterTextAlignment) + else: + raise ValueError("Unknown align value: %s" % align) + self.update_paragraph_style() + + def get_size(self): + # The next line is there just to force cocoa to layout the text + self.layout_manager.glyphRangeForTextContainer_(self.text_container) + rect = self.layout_manager.usedRectForTextContainer_(self.text_container) + return rect.size.width, rect.size.height + + def char_at(self, x, y): + width, height = self.get_size() + if 0 <= x < width and 0 <= y < height: + index, _ = self.layout_manager.glyphIndexForPoint_inTextContainer_fractionOfDistanceThroughGlyph_(NSPoint(x, y), self.text_container, None) + return index + else: + return None + + def draw(self, context, x, y, width, height): + if self.shadow is not None: + context.save() + context.set_shadow(self.shadow.color, self.shadow.opacity, self.shadow.offset, self.shadow.blur_radius) + self.width = width + self.text_container.setContainerSize_(NSSize(width, height)) + glyph_range = self.layout_manager.glyphRangeForTextContainer_(self.text_container) + self.layout_manager.drawGlyphsForGlyphRange_atPoint_(glyph_range, NSPoint(x, y)) + if self.shadow is not None: + context.restore() + context.path.removeAllPoints() + +class NativeButton(object): + + def __init__(self, text, font, pressed, disabled=False): + self.min_width = 0 + self.cell = NSButtonCell.alloc().init() + self.cell.setBezelStyle_(NSRoundRectBezelStyle) + self.cell.setButtonType_(NSMomentaryPushInButton) + self.cell.setFont_(font.nsfont) + self.cell.setEnabled_(not disabled) + self.cell.setTitle_(text) + if pressed: + self.cell.setState_(NSOnState) + else: + self.cell.setState_(NSOffState) + self.cell.setImagePosition_(NSImageLeft) + + def set_icon(self, icon): + image = icon.image.copy() + image.setFlipped_(NO) + self.cell.setImage_(image) + + def get_size(self): + size = self.cell.cellSize() + return size.width, size.height + + def draw(self, context, x, y, width, height): + rect = NSMakeRect(x, y, width, height) + NSGraphicsContext.currentContext().saveGraphicsState() + self.cell.drawWithFrame_inView_(rect, context.view) + NSGraphicsContext.currentContext().restoreGraphicsState() + context.path.removeAllPoints() + +class StyledButton(object): + PAD_HORIZONTAL = 11 + BIG_PAD_VERTICAL = 4 + SMALL_PAD_VERTICAL = 2 + TOP_COLOR = (1, 1, 1) + BOTTOM_COLOR = (0.86, 0.86, 0.86) + LINE_COLOR_TOP = (0.71, 0.71, 0.71) + LINE_COLOR_BOTTOM = (0.45, 0.45, 0.45) + TEXT_COLOR = (0.19, 0.19, 0.19) + DISABLED_COLOR = (0.86, 0.86, 0.86) + DISABLED_TEXT_COLOR = (0.43, 0.43, 0.43) + ICON_PAD = 8 + + def __init__(self, text, font, pressed, disabled=False): + self.pressed = pressed + self.disabled = disabled + attributes = NSMutableDictionary.alloc().init() + attributes.setObject_forKey_(font.nsfont, NSFontAttributeName) + if self.disabled: + color = self.DISABLED_TEXT_COLOR + else: + color = self.TEXT_COLOR + nscolor = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], color[2], 1.0) + attributes.setObject_forKey_(nscolor, NSForegroundColorAttributeName) + self.title = NSAttributedString.alloc().initWithString_attributes_(text, attributes) + self.image = None + + def set_icon(self, icon): + self.image = icon.image.copy() + self.image.setFlipped_(YES) + + def get_size(self): + width, height = self.get_text_size() + if self.image is not None: + width += self.image.size().width + self.ICON_PAD + height = max(height, self.image.size().height) + height += self.BIG_PAD_VERTICAL * 2 + else: + height += self.SMALL_PAD_VERTICAL * 2 + if height % 2 == 1: + # make height even so that the radius of our circle is whole + height += 1 + width += self.PAD_HORIZONTAL * 2 + return width, height + + def get_text_size(self): + size = self.title.size() + return size.width, size.height + + def draw(self, context, x, y, width, height): + self._draw_button(context, x, y, width, height) + self._draw_title(context, x, y) + context.path.removeAllPoints() + + def _draw_button(self, context, x, y, width, height): + radius = height / 2 + self._draw_path(context, x, y, width, height, radius) + if self.disabled: + end_color = self.DISABLED_COLOR + start_color = self.DISABLED_COLOR + elif self.pressed: + end_color = self.TOP_COLOR + start_color = self.BOTTOM_COLOR + else: + context.set_line_width(1) + start_color = self.TOP_COLOR + end_color = self.BOTTOM_COLOR + gradient = drawing.Gradient(x, y, x, y+height) + gradient.set_start_color(start_color) + gradient.set_end_color(end_color) + context.gradient_fill(gradient) + self._draw_border(context, x, y, width, height, radius) + + def _draw_path(self, context, x, y, width, height, radius): + inner_width = width - radius * 2 + context.move_to(x + radius, y) + context.rel_line_to(inner_width, 0) + context.arc(x + width - radius, y+radius, radius, -math.pi/2, math.pi/2) + context.rel_line_to(-inner_width, 0) + context.arc(x + radius, y+radius, radius, math.pi/2, -math.pi/2) + + def _draw_path_reverse(self, context, x, y, width, height, radius): + inner_width = width - radius * 2 + context.move_to(x + radius, y) + context.arc_negative(x + radius, y+radius, radius, -math.pi/2, math.pi/2) + context.rel_line_to(inner_width, 0) + context.arc_negative(x + width - radius, y+radius, radius, math.pi/2, -math.pi/2) + context.rel_line_to(-inner_width, 0) + + def _draw_border(self, context, x, y, width, height, radius): + self._draw_path(context, x, y, width, height, radius) + self._draw_path_reverse(context, x+1, y+1, width-2, height-2, radius-1) + gradient = drawing.Gradient(x, y, x, y+height) + gradient.set_start_color(self.LINE_COLOR_TOP) + gradient.set_end_color(self.LINE_COLOR_BOTTOM) + context.save() + context.clip() + context.rectangle(x, y, width, height) + context.gradient_fill(gradient) + context.restore() + + def _draw_title(self, context, x, y): + c_width, c_height = self.get_size() + t_width, t_height = self.get_text_size() + x = x + self.PAD_HORIZONTAL + y = y + (c_height - t_height) / 2 + if self.image is not None: + self.image.drawAtPoint_fromRect_operation_fraction_( + NSPoint(x, y+3), NSZeroRect, NSCompositeSourceOver, 1.0) + x += self.image.size().width + self.ICON_PAD + else: + y += 0.5 + self.title.drawAtPoint_(NSPoint(x, y)) diff --git a/mvc/widgets/osx/osxmenus.py b/mvc/widgets/osx/osxmenus.py new file mode 100644 index 0000000..32ca469 --- /dev/null +++ b/mvc/widgets/osx/osxmenus.py @@ -0,0 +1,571 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""menus.py -- Menu handling code.""" + +import logging +import struct + +from objc import nil, NO, YES +import AppKit +from AppKit import * +from Foundation import * + +from mvc import signals +from mvc.widgets import keyboard +# import these names directly into our namespace for easy access +from mvc.widgets.keyboard import Shortcut, MOD + +# XXX hacks +def _(text, *params): + if params: + return text % params[0] + return text + +MODIFIERS_MAP = { + keyboard.MOD: NSCommandKeyMask, + keyboard.CMD: NSCommandKeyMask, + keyboard.SHIFT: NSShiftKeyMask, + keyboard.CTRL: NSControlKeyMask, + keyboard.ALT: NSAlternateKeyMask +} + +if isinstance(NSBackspaceCharacter, int): + backspace = NSBackspaceCharacter +else: + backspace = ord(NSBackspaceCharacter) + +KEYS_MAP = { + keyboard.SPACE: " ", + keyboard.ENTER: "\r", + keyboard.BKSPACE: struct.pack("H", backspace), + keyboard.DELETE: NSDeleteFunctionKey, + keyboard.RIGHT_ARROW: NSRightArrowFunctionKey, + keyboard.LEFT_ARROW: NSLeftArrowFunctionKey, + keyboard.UP_ARROW: NSUpArrowFunctionKey, + keyboard.DOWN_ARROW: NSDownArrowFunctionKey, + '.': '.', + ',': ',' +} +# add function keys +for i in range(1, 13): + portable_key = getattr(keyboard, "F%s" % i) + osx_key = getattr(AppKit, "NSF%sFunctionKey" % i) + KEYS_MAP[portable_key] = osx_key + +REVERSE_MODIFIERS_MAP = dict((i[1], i[0]) for i in MODIFIERS_MAP.items()) +REVERSE_KEYS_MAP = dict((i[1], i[0]) for i in KEYS_MAP.items() + if i[0] != keyboard.BKSPACE) +REVERSE_KEYS_MAP[u'\x7f'] = keyboard.BKSPACE +REVERSE_KEYS_MAP[u'\x1b'] = keyboard.ESCAPE + +def make_modifier_mask(shortcut): + mask = 0 + for modifier in shortcut.modifiers: + mask |= MODIFIERS_MAP[modifier] + return mask + +VIEW_ITEM_MAP = {} + +def _remove_mnemonic(label): + """Remove the underscore used by GTK for mnemonics. + + We totally ignore them on OSX, since they are now deprecated. + """ + return label.replace("_", "") + +def handle_menu_activate(ns_menu_item): + """Handle a menu item being activated. + + This gets called by our application delegate. + """ + + menu_item = ns_menu_item.representedObject() + menu_item.emit("activate") + menubar = menu_item._find_menubar() + if menubar is not None: + menubar.emit("activate", menu_item.name) + +class MenuItemBase(signals.SignalEmitter): + """Base class for MenuItem and Separator""" + def __init__(self): + signals.SignalEmitter.__init__(self) + self.name = None + self.parent = None + + def show(self): + self._menu_item.setHidden_(False) + + def hide(self): + self._menu_item.setHidden_(True) + + def enable(self): + self._menu_item.setEnabled_(True) + + def disable(self): + self._menu_item.setEnabled_(False) + + def remove_from_parent(self): + """Remove this menu item from it's parent Menu.""" + if self.parent is not None: + self.parent.remove(self) + +class MenuItem(MenuItemBase): + """See the GTK version of this method for the current docstring.""" + + # map Miro action names to standard OSX actions. + _STD_ACTION_MAP = { + "HideMiro": (NSApp(), 'hide:'), + "HideOthers": (NSApp(), 'hideOtherApplications:'), + "ShowAll": (NSApp(), 'unhideAllApplications:'), + "Cut": (nil, 'cut:'), + "Copy": (nil, 'copy:'), + "Paste": (nil, 'paste:'), + "Delete": (nil, 'delete:'), + "SelectAll": (nil, 'selectAll:'), + "Zoom": (nil, 'performZoom:'), + "Minimize": (nil, 'performMiniaturize:'), + "BringAllToFront": (nil, 'arrangeInFront:'), + "CloseWindow": (nil, 'performClose:'), + } + + def __init__(self, label, name, shortcut=None): + MenuItemBase.__init__(self) + self.name = name + self._menu_item = self._make_menu_item(label) + self.create_signal('activate') + self._setup_shortcut(shortcut) + + def _make_menu_item(self, label): + menu_item = NSMenuItem.alloc().init() + menu_item.setTitle_(_remove_mnemonic(label)) + # we set ourselves as the represented object for the menu item so we + # can easily translate one to the other + menu_item.setRepresentedObject_(self) + if self.name in self._STD_ACTION_MAP: + menu_item.setTarget_(self._STD_ACTION_MAP[self.name][0]) + menu_item.setAction_(self._STD_ACTION_MAP[self.name][1]) + else: + menu_item.setTarget_(NSApp().delegate()) + menu_item.setAction_('handleMenuActivate:') + return menu_item + + def _setup_shortcut(self, shortcut): + if shortcut is None: + key = '' + modifier_mask = 0 + elif isinstance(shortcut.shortcut, str): + key = shortcut.shortcut + modifier_mask = make_modifier_mask(shortcut) + elif shortcut.shortcut in KEYS_MAP: + key = KEYS_MAP[shortcut.shortcut] + modifier_mask = make_modifier_mask(shortcut) + else: + logging.warn("Don't know how to handle shortcut: %s", shortcut) + return + self._menu_item.setKeyEquivalent_(key) + self._menu_item.setKeyEquivalentModifierMask_(modifier_mask) + + def _change_shortcut(self, shortcut): + self._setup_shortcut(shortcut) + + def set_label(self, new_label): + self._menu_item.setTitle_(new_label) + + def get_label(self): + self._menu_item.title() + + def _find_menubar(self): + """Remove this menu item from it's parent Menu.""" + menu_item = self + while menu_item.parent is not None: + menu_item = menu_item.parent + if isinstance(menu_item, MenuBar): + return menu_item + else: + return None + +class CheckMenuItem(MenuItem): + """See the GTK version of this method for the current docstring.""" + def set_state(self, active): + if active is None: + state = NSMixedState + elif active: + state = NSOnState + else: + state = NSOffState + self._menu_item.setState_(state) + + def get_state(self): + return self._menu_item.state() == NSOnState + + def do_activate(self): + if self._menu_item.state() == NSOffState: + self._menu_item.setState_(NSOnState) + else: + self._menu_item.setState_(NSOffState) + +class RadioMenuItem(CheckMenuItem): + """See the GTK version of this method for the current docstring.""" + def __init__(self, label, name, shortcut=None): + CheckMenuItem.__init__(self, label, name, shortcut) + # The leader of a radio group stores the list of all items in the + # group + self.group_leader = None + self.others_in_group = set() + + def set_group(self, group_item): + if self.group_leader is not None: + raise ValueError("%s is already in a group" % self) + if group_item.group_leader is None: + group_leader = group_item + else: + group_leader = group_item.group_leader + if group_leader.group_leader is not None: + raise AssertionError("group_leader structure is wrong") + self.group_leader = group_leader + group_leader.others_in_group.add(self) + + def remove_from_group(self): + """Remove this RadioMenuItem from its current group.""" + if self.group_leader is not None: + # we have a group leader, remove ourself from their list. + # Note that this code will work even if we're the last item in + # others_in_group. + self.group_leader.others_in_group.remove(self) + self.group_leader = None + elif len(self.others_in_group) > 1: + # we're the group leader, hand off the leader to a different item + first_item = iter(self.others_in_group).next() + for other in self.others_in_group: + if other is first_item: + other.others_in_group = self.others_in_group + other.others_in_group.remove(first_item) + other.group_leader = None + else: + other.group_leader = first_item + self.others_in_group = set() + elif len(self.others_in_group) == 1: + # we're the group leader, but there's only 1 other item. unset + # everything. + for other in self.others_in_group: + other.group_leader = None + self.others_in_group = set() + + def _items_in_group(self): + if self.group_leader is not None: # we have a group leader + yield self.group_leader + for other in self.group_leader.others_in_group: + yield other + elif self.others_in_group: # we're the group leader + yield self + for other in self.others_in_group: + yield other + else: # we don't have a group set + yield self + + def do_activate(self): + for item in self._items_in_group(): + if item is not self: + item.set_state(False) + CheckMenuItem.do_activate(self) + +class Separator(MenuItemBase): + """See the GTK version of this method for the current docstring.""" + def __init__(self): + MenuItemBase.__init__(self) + self._menu_item = NSMenuItem.separatorItem() + +class MenuShell(signals.SignalEmitter): + def __init__(self, nsmenu): + signals.SignalEmitter.__init__(self) + self._menu = nsmenu + self.children = [] + self.parent = None + + def append(self, menu_item): + """Add a menu item to the end of this menu.""" + self.children.append(menu_item) + self._menu.addItem_(menu_item._menu_item) + menu_item.parent = self + + def insert(self, index, menu_item): + """Insert a menu item in the middle of this menu.""" + self.children.insert(index, menu_item) + self._menu.insertItem_atIndex_(menu_item._menu_item, index) + menu_item.parent = self + + def index(self, name): + """Find the position of a child menu item.""" + for i, menu_item in enumerate(self.children): + if menu_item.name == name: + return i + return -1 + + def remove(self, menu_item): + """Remove a child menu item. + + :raises ValueError: menu_item is not a child of this menu + """ + self.children.remove(menu_item) + self._menu.removeItem_(menu_item._menu_item) + menu_item.parent = None + + def get_children(self): + """Get the child menu items in order.""" + return list(self.children) + + def find(self, name): + """Search for a menu or menu item + + This method recursively searches the entire menu structure for a Menu + or MenuItem object with a given name. + + :raises KeyError: name not found + """ + found = self._find(name) + if found is None: + raise KeyError(name) + else: + return found + + def _find(self, name): + """Low-level helper-method for find(). + + :returns: found menu item or None. + """ + for menu_item in self.get_children(): + if menu_item.name == name: + return menu_item + if isinstance(menu_item, Menu): + submenu_find = menu_item._find(name) + if submenu_find is not None: + return submenu_find + return None + +class Menu(MenuShell): + """See the GTK version of this method for the current docstring.""" + def __init__(self, label, name, child_items=None): + MenuShell.__init__(self, NSMenu.alloc().init()) + self._menu.setTitle_(_remove_mnemonic(label)) + # we will enable/disable menu items manually + self._menu.setAutoenablesItems_(False) + self.name = name + if child_items is not None: + for item in child_items: + self.append(item) + self._menu_item = NSMenuItem.alloc().init() + self._menu_item.setTitle_(_remove_mnemonic(label)) + self._menu_item.setSubmenu_(self._menu) + # Hack to set the services menu + if name == "ServicesMenu": + NSApp().setServicesMenu_(self._menu_item) + + def show(self): + self._menu_item.setHidden_(False) + + def hide(self): + self._menu_item.setHidden_(True) + + def enable(self): + self._menu_item.setEnabled_(True) + + def disable(self): + self._menu_item.setEnabled_(False) + +class AppMenu(MenuShell): + """Wrapper for the application menu (AKA the Miro menu) + + We need to special case this because OSX automatically creates the menu + item. + """ + def __init__(self): + MenuShell.__init__(self, NSApp().mainMenu().itemAtIndex_(0).submenu()) + self.name = "Libre Video Converter" + +class MenuBar(MenuShell): + """See the GTK version of this method for the current docstring.""" + def __init__(self): + MenuShell.__init__(self, NSApp().mainMenu()) + self.create_signal('activate') + self._add_app_menu() + + def _add_app_menu(self): + """Add the app menu to this menu bar. + + We need to special case this because OSX automatically adds the + NSMenuItem for the app menu, we just need to set up our wrappers. + """ + self._app_menu = AppMenu() + self.children.append(self._app_menu) + self._app_menu.parent = self + + def add_initial_menus(self, menus): + for menu in menus: + self.append(menu) + self._modify_initial_menus() + + def _extract_menu_item(self, name): + """Helper method for changing the portable menu structure.""" + menu_item = self.find(name) + menu_item.remove_from_parent() + return menu_item + + def _modify_initial_menus(self): + short_appname = "Libre Video Converter" # XXX + + # Application menu + miroMenuItems = [ + self._extract_menu_item("About"), + Separator(), + self._extract_menu_item("Quit") + ] + + for item in miroMenuItems: + self._app_menu.append(item) + + self._app_menu.find("Quit").set_label(_("Quit %(appname)s", + {"appname": short_appname})) + + # Help Menu + #helpItem = self.find("Help") + #helpItem.set_label(_("%(appname)s Help", {"appname": short_appname})) + #helpItem._change_shortcut(Shortcut("?", MOD)) + + self._update_present_menu() + self._connect_to_signals() + + def do_activate(self, name): + # We handle a couple OSX-specific actions here + if name == "PresentActualSize": + NSApp().delegate().present_movie('natural-size') + elif name == "PresentDoubleSize": + NSApp().delegate().present_movie('double-size') + elif name == "PresentHalfSize": + NSApp().delegate().present_movie('half-size') + elif name == "ShowMain": + app.widgetapp.window.nswindow.makeKeyAndOrderFront_(self) + + def _connect_to_signals(self): + return + app.playback_manager.connect("will-play", self._on_playback_change) + app.playback_manager.connect("will-stop", self._on_playback_change) + + def _on_playback_change(self, playback_manager, *args): + self._update_present_menu() + + def _update_present_menu(self): + return + if self._should_enable_present_menu(): + for menu_item in self.present_menu.get_children(): + menu_item.enable() + else: + for menu_item in self.present_menu.get_children(): + menu_item.disable() + + def _should_enable_present_menu(self): + return False + if (app.playback_manager.is_playing and + not app.playback_manager.is_playing_audio): + # we're currently playing video, allow the user to fullscreen + return True + selection_info = app.item_list_controller_manager.get_selection_info() + if (selection_info.has_download and + selection_info.has_file_type('video')): + # A downloaded video is selected, allow the user to start playback + # in fullscreen + return True + return False + +#class ContextMenuHandler(NSObject): +# def initWithCallback_(self, callback): +# self = super(ContextMenuHandler, self).init() +# self.callback = callback +# return self +# +# def handleMenuItem_(self, sender): +# self.callback() +# +#class MiroContextMenu(NSMenu): +# # Works exactly like NSMenu, except it keeps a reference to the menu +# # handler objects. +# def init(self): +# self = super(MiroContextMenu, self).init() +# self.handlers = set() +# return self +# +# def addItem_(self, item): +# if isinstance(item.target(), ContextMenuHandler): +# self.handlers.add(item.target()) +# return NSMenu.addItem_(self, item) +# +def make_context_menu(menu_items): + nsmenu = MiroContextMenu.alloc().init() + for item in menu_items: + if item is None: + nsitem = NSMenuItem.separatorItem() + else: + label, callback = item + nsitem = NSMenuItem.alloc().init() + if isinstance(label, tuple) and len(label) == 2: + label, icon_path = label + image = NSImage.alloc().initWithContentsOfFile_(icon_path) + nsitem.setImage_(image) + if callback is None: + font_size = NSFont.systemFontSize() + font = NSFont.fontWithName_size_("Lucida Sans Italic", font_size) + if font is None: + font = NSFont.systemFontOfSize_(font_size) + attributes = {NSFontAttributeName: font} + attributed_label = NSAttributedString.alloc().initWithString_attributes_(label, attributes) + nsitem.setAttributedTitle_(attributed_label) + else: + nsitem.setTitle_(label) + if isinstance(callback, list): + submenu = make_context_menu(callback) + nsmenu.setSubmenu_forItem_(submenu, nsitem) + else: + handler = ContextMenuHandler.alloc().initWithCallback_(callback) + nsitem.setTarget_(handler) + nsitem.setAction_('handleMenuItem:') + nsmenu.addItem_(nsitem) + return nsmenu + +def translate_event_modifiers(event): + mods = set() + flags = event.modifierFlags() + if flags & NSCommandKeyMask: + mods.add(keyboard.CMD) + if flags & NSControlKeyMask: + mods.add(keyboard.CTRL) + if flags & NSAlternateKeyMask: + mods.add(keyboard.ALT) + if flags & NSShiftKeyMask: + mods.add(keyboard.SHIFT) + return mods diff --git a/mvc/widgets/osx/rect.py b/mvc/widgets/osx/rect.py new file mode 100644 index 0000000..3c8d448 --- /dev/null +++ b/mvc/widgets/osx/rect.py @@ -0,0 +1,78 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".rect -- Simple Rectangle class.""" + +from Foundation import NSMakeRect, NSRectFromString + +class Rect(object): + @classmethod + def from_string(cls, rect_string): + if rect_string.startswith('{{'): + return NSRectWrapper(NSRectFromString(rect_string)) + else: + try: + items = [int(i) for i in rect_string.split(',')] + return Rect(*items) + except: + return None + + def __init__(self, x, y, width, height): + self.nsrect = NSMakeRect(x, y, width, height) + + def get_x(self): + return self.nsrect.origin.x + def set_x(self, x): + self.nsrect.origin.x = x + x = property(get_x, set_x) + + def get_y(self): + return self.nsrect.origin.y + def set_y(self, y): + self.nsrect.origin.x = y + y = property(get_y, set_y) + + def get_width(self): + return self.nsrect.size.width + def set_width(self, width): + self.nsrect.size.width = width + width = property(get_width, set_width) + + def get_height(self): + return self.nsrect.size.height + def set_height(self, height): + self.nsrect.size.height = height + height = property(get_height, set_height) + + def __str__(self): + return "%d,%d,%d,%d" % (self.nsrect.origin.x, self.nsrect.origin.y, self.nsrect.size.width, self.nsrect.size.height) + +class NSRectWrapper(Rect): + def __init__(self, nsrect): + self.nsrect = nsrect diff --git a/mvc/widgets/osx/simple.py b/mvc/widgets/osx/simple.py new file mode 100644 index 0000000..1c12b06 --- /dev/null +++ b/mvc/widgets/osx/simple.py @@ -0,0 +1,376 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +from __future__ import division +import logging +import math + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from mvc.widgets import widgetconst +from .base import Widget, SimpleBin, FlippedView +from .utils import filename_to_unicode +import drawing +import wrappermap + +"""A collection of various simple widgets.""" + +class Image(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, path): + self._set_image(NSImage.alloc().initByReferencingFile_( + filename_to_unicode(path))) + + def _set_image(self, nsimage): + self.nsimage = nsimage + self.width = self.nsimage.size().width + self.height = self.nsimage.size().height + if self.width * self.height == 0: + raise ValueError('Image has invalid size: (%d, %d)' % ( + self.width, self.height)) + + def resize(self, width, height): + return ResizedImage(self, width, height) + + def crop_and_scale(self, src_x, src_y, src_width, src_height, dest_width, + dest_height): + if dest_width <= 0 or dest_height <= 0: + logging.stacktrace("invalid dest sizes: %s %s" % (dest_width, + dest_height)) + return TransformedImage(self.nsimage) + + source_rect = NSMakeRect(src_x, src_y, src_width, src_height) + dest_rect = NSMakeRect(0, 0, dest_width, dest_height) + + dest = NSImage.alloc().initWithSize_(NSSize(dest_width, dest_height)) + dest.lockFocus() + try: + NSGraphicsContext.currentContext().setImageInterpolation_( + NSImageInterpolationHigh) + self.nsimage.drawInRect_fromRect_operation_fraction_(dest_rect, + source_rect, NSCompositeCopy, 1.0) + finally: + dest.unlockFocus() + return TransformedImage(dest) + + def resize_for_space(self, width, height): + """Returns an image scaled to fit into the specified space at the + correct height/width ratio. + """ + # this prevents division by 0. + if self.width == 0 and self.height == 0: + return self + elif self.width == 0: + ratio = height / self.height + return self.resize(self.width, ratio * self.height) + elif self.height == 0: + ratio = width / self.width + return self.resize(ratio * self.width, self.height) + + ratio = min(width / self.width, height / self.height) + return self.resize(ratio * self.width, ratio * self.height) + +class ResizedImage(Image): + def __init__(self, image, width, height): + nsimage = image.nsimage.copy() + nsimage.setCacheMode_(NSImageCacheNever) + nsimage.setScalesWhenResized_(YES) + nsimage.setSize_(NSSize(width, height)) + self._set_image(nsimage) + +class TransformedImage(Image): + def __init__(self, nsimage): + self._set_image(nsimage) + +class NSImageDisplay(NSView): + def init(self): + self = super(NSImageDisplay, self).init() + self.border = False + self.image = None + return self + + def isFlipped(self): + return YES + + def set_border(self, border): + self.border = border + + def set_image(self, image): + self.image = image + + def drawRect_(self, dest_rect): + if self.image is not None: + source_rect = self.calculateSourceRectFromDestRect_(dest_rect) + context = NSGraphicsContext.currentContext() + context.setShouldAntialias_(YES) + context.setImageInterpolation_(NSImageInterpolationHigh) + context.saveGraphicsState() + drawing.flip_context(self.bounds().size.height) + self.image.nsimage.drawInRect_fromRect_operation_fraction_( + dest_rect, source_rect, NSCompositeSourceOver, 1.0) + context.restoreGraphicsState() + if self.border: + context = drawing.DrawingContext(self, self.bounds(), dest_rect) + context.style = drawing.DrawingStyle() + context.set_line_width(1) + context.set_color((0, 0, 0)) # black + context.rectangle(0, 0, context.width, context.height) + context.stroke() + + def calculateSourceRectFromDestRect_(self, dest_rect): + """Calulate where dest_rect maps to on our image. + + This is tricky because our image might be scaled up, in which case + the rect from our image will be smaller than dest_rect. + """ + view_size = self.frame().size + x_scale = float(self.image.width) / view_size.width + y_scale = float(self.image.height) / view_size.height + + return NSMakeRect(dest_rect.origin.x * x_scale, + dest_rect.origin.y * y_scale, + dest_rect.size.width * x_scale, + dest_rect.size.height * y_scale) + + # XXX FIXME: should track mouse movement - mouseDown is not the correct + # event. + def mouseDown_(self, event): + wrappermap.wrapper(self).emit('clicked') + +class ImageDisplay(Widget): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, image=None): + Widget.__init__(self) + self.create_signal('clicked') + self.view = NSImageDisplay.alloc().init() + self.set_image(image) + + def set_image(self, image): + self.image = image + if image: + image.nsimage.setCacheMode_(NSImageCacheNever) + self.view.set_image(image) + self.invalidate_size_request() + + def set_border(self, border): + self.view.set_border(border) + + def calc_size_request(self): + if self.image is not None: + return self.image.width, self.image.height + else: + return 0, 0 + +class ClickableImageButton(ImageDisplay): + def __init__(self, image_path, max_width=None, max_height=None): + ImageDisplay.__init__(self) + self.set_border(True) + self.max_width = max_width + self.max_height = max_height + self.image = None + self._width, self._height = None, None + if image_path: + self.set_path(image_path) + + def set_path(self, path): + image = Image(path) + if self.max_width: + image = image.resize_for_space(self.max_width, self.max_height) + super(ClickableImageButton, self).set_image(image) + + def calc_size_request(self): + if self.max_width: + return self.max_width, self.max_height + else: + return ImageDisplay.calc_size_request(self) + +class MiroImageView(NSImageView): + def viewWillMoveToWindow_(self, aWindow): + self.setAnimates_(not aWindow == nil) + +class AnimatedImageDisplay(Widget): + def __init__(self, path): + Widget.__init__(self) + self.nsimage = NSImage.alloc().initByReferencingFile_( + filename_to_unicode(path)) + self.view = MiroImageView.alloc().init() + self.view.setImage_(self.nsimage) + # enabled when viewWillMoveToWindow:aWindow invoked + self.view.setAnimates_(NO) + + def calc_size_request(self): + return self.nsimage.size().width, self.nsimage.size().height + +class Label(Widget): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, text="", wrap=False, color=None): + Widget.__init__(self) + self.view = NSTextField.alloc().init() + self.view.setEditable_(NO) + self.view.setBezeled_(NO) + self.view.setBordered_(NO) + self.view.setDrawsBackground_(NO) + self.wrap = wrap + self.bold = False + self.size = NSFont.systemFontSize() + self.sizer_cell = self.view.cell().copy() + self.set_font() + self.set_text(text) + self.__color = self.view.textColor() + if color is not None: + self.set_color(color) + + def get_width(self): + return self.calc_size_request()[0] + + def set_bold(self, bold): + self.bold = bold + self.set_font() + + def set_size(self, size): + if size > 0: + self.size = NSFont.systemFontSize() * size + elif size == widgetconst.SIZE_SMALL: + self.size = NSFont.smallSystemFontSize() + elif size == widgetconst.SIZE_NORMAL: + self.size = NSFont.systemFontSize() + else: + raise ValueError("Unknown size constant: %s" % size) + + self.set_font() + + def set_color(self, color): + self.__color = self.make_color(color) + + if self.view.isEnabled(): + self.view.setTextColor_(self.__color) + else: + self.view.setTextColor_(self.__color.colorWithAlphaComponent_(0.5)) + + def set_background_color(self, color): + self.view.setBackgroundColor_(self.make_color(color)) + self.view.setDrawsBackground_(YES) + + def set_font(self): + if self.bold: + font = NSFont.boldSystemFontOfSize_(self.size) + else: + font= NSFont.systemFontOfSize_(self.size) + self.view.setFont_(font) + self.sizer_cell.setFont_(font) + self.invalidate_size_request() + + def calc_size_request(self): + if (self.wrap and self.manual_size_request is not None and + self.manual_size_request[0] > 0): + wrap_width = self.manual_size_request[0] + size = self.sizer_cell.cellSizeForBounds_(NSMakeRect(0, 0, + wrap_width, 10000)) + else: + size = self.sizer_cell.cellSize() + return math.ceil(size.width), math.ceil(size.height) + + def baseline(self): + return -self.view.font().descender() + + def set_text(self, text): + self.view.setStringValue_(text) + self.sizer_cell.setStringValue_(text) + self.invalidate_size_request() + + def get_text(self): + val = self.view.stringValue() + if not val: + val = u'' + return val + + def set_selectable(self, val): + self.view.setSelectable_(val) + + def set_alignment(self, alignment): + self.view.setAlignment_(alignment) + + def get_alignment(self, alignment): + return self.view.alignment() + + def set_wrap(self, wrap): + self.wrap = True + self.invalidate_size_request() + + def enable(self): + Widget.enable(self) + self.view.setTextColor_(self.__color) + self.view.setEnabled_(True) + + def disable(self): + Widget.disable(self) + self.view.setTextColor_(self.__color.colorWithAlphaComponent_(0.5)) + self.view.setEnabled_(False) + +class SolidBackground(SimpleBin): + def __init__(self, color=None): + SimpleBin.__init__(self) + self.view = FlippedView.alloc().init() + if color is not None: + self.set_background_color(color) + + def set_background_color(self, color): + self.view.setBackgroundColor_(self.make_color(color)) + +class ProgressBar(Widget): + def __init__(self): + Widget.__init__(self) + self.view = NSProgressIndicator.alloc().init() + self.view.setMaxValue_(1.0) + self.view.setIndeterminate_(False) + + def calc_size_request(self): + return 20, 20 + + def set_progress(self, fraction): + self.view.setIndeterminate_(False) + self.view.setDoubleValue_(fraction) + + def start_pulsing(self): + self.view.setIndeterminate_(True) + self.view.startAnimation_(nil) + + def stop_pulsing(self): + self.view.stopAnimation_(nil) + +class HLine(Widget): + def __init__(self): + Widget.__init__(self) + self.view = NSBox.alloc().init() + self.view.setBoxType_(NSBoxSeparator) + + def calc_size_request(self): + return self.view.frame().size.width, self.view.frame().size.height diff --git a/mvc/widgets/osx/tablemodel.py b/mvc/widgets/osx/tablemodel.py new file mode 100644 index 0000000..980b60b --- /dev/null +++ b/mvc/widgets/osx/tablemodel.py @@ -0,0 +1,532 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tablemodel.py -- Model classes for TableView. """ + +import logging + +from AppKit import (NSDragOperationNone, NSDragOperationAll, NSTableViewDropOn, + NSOutlineViewDropOnItemIndex, protocols) +from Foundation import NSObject, NSNotFound, NSMutableIndexSet +from objc import YES, NO, nil + +from mvc import signals +from mvc.errors import WidgetActionError +import fasttypes +import wrappermap + +MIRO_DND_ITEM_LOCAL = 'miro-local-item' + +# XXX need unsigned but value comes out as signed. +NSDragOperationEvery = NSDragOperationAll + +def list_from_nsindexset(index_set): + rows = list() + index = index_set.firstIndex() + while (index != NSNotFound): + rows.append(index) + index = index_set.indexGreaterThanIndex_(index) + return rows + +class RowList(object): + """RowList is a Linked list that has some optimizations for looking up + rows by index number. + """ + def __init__(self): + self.list = fasttypes.LinkedList() + self.iter_cache = [] + + def firstIter(self): + return self.list.firstIter() + + def lastIter(self): + return self.list.lastIter() + + def insertBefore(self, iter, value): + self.iter_cache = [] + if iter is None: + return self.list.append(value) + else: + return self.list.insertBefore(iter, value) + + def append(self, value): + return self.list.append(value) + + def __len__(self): + return len(self.list) + + def __getitem__(self, iter): + return self.list[iter] + + def __iter__(self): + iter = self.firstIter() + while iter != self.lastIter(): + yield iter.value() + iter.forward() + + def remove(self, iter): + self.iter_cache = [] + return self.list.remove(iter) + + def nth_iter(self, index): + if index < 0: + raise IndexError(index) + elif index >= len(self): + raise LookupError() + if len(self.iter_cache) == 0: + self.iter_cache.append(self.firstIter()) + try: + return self.iter_cache[index].copy() + except IndexError: + pass + iter = self.iter_cache[-1].copy() + index -= len(self.iter_cache) - 1 + for x in xrange(index): + iter.forward() + self.iter_cache.append(iter.copy()) + return iter + +class TableModelBase(signals.SignalEmitter): + """Base class for TableModel and TreeTableModel.""" + def __init__(self, *column_types): + signals.SignalEmitter.__init__(self) + self.row_list = RowList() + self.column_types = column_types + self.create_signal('row-changed') + self.create_signal('structure-will-change') + + def check_column_values(self, column_values): + if len(self.column_types) != len(column_values): + raise ValueError("Wrong number of columns") + # We might want to do more typechecking here + + def get_column_data(self, row, column): + attr_map = column.attrs() + return dict((name, row[index]) for name, index in attr_map.items()) + + def update_value(self, iter, index, value): + iter.value().values[index] = value + self.emit('row-changed', iter) + + def update(self, iter, *column_values): + iter.value().update_values(column_values) + self.emit('row-changed', iter) + + def remove(self, iter): + self.emit('structure-will-change') + row_list = self.containing_list(iter) + rv = row_list.remove(iter) + if rv == row_list.lastIter(): + rv = None + return rv + + def nth_iter(self, index): + return self.row_list.nth_iter(index) + + def next_iter(self, iter): + row_list = self.containing_list(iter) + retval = iter.copy() + retval.forward() + if retval == row_list.lastIter(): + return None + else: + return retval + + def first_iter(self): + if len(self.row_list) > 0: + return self.row_list.firstIter() + else: + return None + + def __len__(self): + return len(self.row_list) + + def __getitem__(self, iter): + return iter.value() + + def __iter__(self): + return iter(self.row_list) + +class TableRow(object): + """See https://develop.participatoryculture.org/index.php/WidgetAPITableView for a description of the API for this class.""" + def __init__(self, column_values): + self.update_values(column_values) + + def update_values(self, column_values): + self.values = list(column_values) + + def __getitem__(self, index): + return self.values[index] + + def __len__(self): + return len(self.values) + + def __iter__(self): + return iter(self.values) + +class TableModel(TableModelBase): + """See https://develop.participatoryculture.org/index.php/WidgetAPITableView for a description of the API for this class.""" + def __init__(self, *column_types): + TableModelBase.__init__(self, column_types) + self.row_indexes = {} + + def remember_row_at_index(self, row, index): + if row not in self.row_indexes: + self.row_indexes[row] = index + + def row_of_iter(self, tableview, iter): + row = iter.value() + try: + return self.row_indexes[row] + except KeyError: + iter = self.row_list.firstIter() + index = 0 + while iter != self.row_list.lastIter(): + current_row = iter.value() + self.row_indexes[current_row] = index + if current_row is row: + return index + index += 1 + iter.forward() + raise LookupError("%s is not in this table" % row) + + def containing_list(self, iter): + return self.row_list + + def append(self, *column_values): + self.emit('structure-will-change') + self.row_indexes = {} + retval = self.row_list.append(TableRow(column_values)) + return retval + + def remove(self, iter): + self.row_indexes = {} + return TableModelBase.remove(self, iter) + + def insert_before(self, iter, *column_values): + self.emit('structure-will-change') + self.row_indexes = {} + row = TableRow(column_values) + retval = self.row_list.insertBefore(iter, row) + return retval + + def iter_for_row(self, tableview, row): + return self.row_list.nth_iter(row) + + +class TreeNode(NSObject, TableRow): + """A row in a TreeTableModel""" + + # Implementation note: these need to be NSObjects because we return them + # to the NSOutlineView. + + def initWithValues_parent_(self, column_values, parent): + self.children = RowList() + self.update_values(column_values) + self.parent = parent + return self + + @staticmethod + def create_(values, parent): + return TreeNode.alloc().initWithValues_parent_(values, parent) + + def iterchildren(self): + return iter(self.children) + +class TreeTableModel(TableModelBase): + """https://develop.participatoryculture.org/index.php/WidgetAPITableView""" + def __init__(self, *column_values): + TableModelBase.__init__(self, *column_values) + self.iter_for_item = {} + + def containing_list(self, iter): + return self.row_list_for_iter(iter.value().parent) + + def row_list_for_iter(self, iter): + """Return the rows of all direct children of iter.""" + if iter is None: + return self.row_list + else: + return iter.value().children + + def remember_iter(self, iter): + self.iter_for_item[iter.value()] = iter + return iter + + def append(self, *column_values): + self.emit('structure-will-change') + retval = self.row_list.append(TreeNode.create_(column_values, None)) + return self.remember_iter(retval) + + def forget_iter_for_item(self, item): + del self.iter_for_item[item] + for child in item.children: + self.forget_iter_for_item(child) + + def remove(self, iter): + item = iter.value() + rv = TableModelBase.remove(self, iter) + self.forget_iter_for_item(item) + return rv + + def insert_before(self, iter, *column_values): + self.emit('structure-will-change') + row = TreeNode.create_(column_values, self.parent_iter(iter)) + retval = self.containing_list(iter).insertBefore(iter, row) + return self.remember_iter(retval) + + def append_child(self, iter, *column_values): + self.emit('structure-will-change') + row_list = self.row_list_for_iter(iter) + retval = row_list.append(TreeNode.create_(column_values, iter)) + return self.remember_iter(retval) + + def child_iter(self, iter): + row_list = iter.value().children + if len(row_list) == 0: + return None + else: + return row_list.firstIter() + + def nth_child_iter(self, iter, index): + row_list = self.row_list_for_iter(iter) + return row_list.nth_iter(index) + + def has_child(self, iter): + return len(iter.value().children) > 0 + + def children_count(self, iter): + if iter is not None: + return len(iter.value().children) + else: + return len(self.row_list) + + def children_iters(self, iter): + return self.iters_in_rowlist(self.row_list_for_iter(iter)) + + def parent_iter(self, iter): + return iter.value().parent + + def iter_for_row(self, tableview, row): + item = tableview.itemAtRow_(row) + if item in self.iter_for_item: + return self.iter_for_item[item] + elif item == -1: + raise WidgetActionError("no item at row %s" % row) + else: + raise WidgetActionError("no iter for item %s at row %s" % + (repr(item), row)) + + def row_of_iter(self, tableview, iter): + item = iter.value() + row = tableview.rowForItem_(item) + if row == -1: + raise LookupError("%s is not in this table" % repr(item)) + return row + + def get_path(self, iter_): + """Not implemented (yet?) for Cocoa. Currently the only place this is + needed is tablistmanager, where the situation that uses paths results + from GTK peculiarities. + """ + return NotImplemented + +class DataSourceBase(NSObject): + def initWithModel_(self, model): + self.model = model + self.drag_source = None + self.drag_dest = None + return self + + def setDragSource_(self, drag_source): + self.drag_source = drag_source + + def setDragDest_(self, drag_dest): + self.drag_dest = drag_dest + + def view_writeColumnData_ToPasteboard_(self, view, data, pasteboard): + if not self.drag_source: + return NO + wrapper = wrappermap.wrapper(view) + drag_data = self.drag_source.begin_drag(wrapper, data) + if not drag_data: + return NO + pasteboard.declareTypes_owner_((MIRO_DND_ITEM_LOCAL,), self) + for typ, value in drag_data.items(): + stringval = repr((repr(value), typ)) + pasteboard.setString_forType_(stringval, MIRO_DND_ITEM_LOCAL) + return YES + + def calcType_(self, drag_info): + source_actions = drag_info.draggingSourceOperationMask() + if not (self.drag_dest and + (self.drag_dest.allowed_actions() | source_actions)): + return None + types = self.drag_dest.allowed_types() + available = drag_info.draggingPasteboard().availableTypeFromArray_( + (MIRO_DND_ITEM_LOCAL,)) + if available: + # XXX using eval() sucks. + data = eval(drag_info.draggingPasteboard().stringForType_( + MIRO_DND_ITEM_LOCAL)) + if data: + _, typ = data + return typ + return None + + def validateDrop_dragInfo_parentIter_position_(self, view, drag_info, + parent, position): + typ = self.calcType_(drag_info) + if typ: + wrapper = wrappermap.wrapper(view) + drop_action = self.drag_dest.validate_drop( + wrapper, self.model, typ, + drag_info.draggingSourceOperationMask(), parent, + position) + if not drop_action: + return NSDragOperationNone + if isinstance(drop_action, (tuple, list)): + drop_action, iter = drop_action + view.setDropRow_dropOperation_( + self.model.row_of_iter(view, iter), + NSTableViewDropOn) + return drop_action + else: + return NSDragOperationNone + + def acceptDrop_dragInfo_parentIter_position_(self, view, drag_info, + parent, position): + typ = self.calcType_(drag_info) + if typ: + # XXX using eval sucks. + data = eval(drag_info.draggingPasteboard().stringForType_(MIRO_DND_ITEM_LOCAL)) + ids, _ = data + ids = eval(ids) + wrapper = wrappermap.wrapper(view) + self.drag_dest.accept_drop(wrapper, self.model, typ, + drag_info.draggingSourceOperationMask(), parent, + position, ids) + return YES + else: + return NO + +class MiroTableViewDataSource(DataSourceBase, protocols.NSTableDataSource): + def numberOfRowsInTableView_(self, table_view): + return len(self.model) + + def tableView_objectValueForTableColumn_row_(self, table_view, column, row): + node = self.model.nth_iter(row).value() + self.model.remember_row_at_index(node, row) + return self.model.get_column_data(node.values, column) + + def tableView_writeRowsWithIndexes_toPasteboard_(self, tableview, rowIndexes, + pasteboard): + indexes = list_from_nsindexset(rowIndexes) + data = [self.model[self.model.nth_iter(i)] for i in indexes] + return self.view_writeColumnData_ToPasteboard_(tableview, data, + pasteboard) + + def translateRow_operation_(self, row, operation): + if operation == NSTableViewDropOn: + return self.model.nth_iter(row), -1 + else: + return None, row + + def tableView_validateDrop_proposedRow_proposedDropOperation_(self, + tableview, drag_info, row, operation): + parent, position = self.translateRow_operation_(row, operation) + drop_action = self.validateDrop_dragInfo_parentIter_position_(tableview, + drag_info, parent, position) + if isinstance(drop_action, (list, tuple)): + # XXX nothing uses this yet + drop_action, iter = drop_action + tableview.setDropRow_dropOperation_( + self.model.row_of_iter(tableview, iter), + NSTableViewDropOn) + return drop_action + + def tableView_acceptDrop_row_dropOperation_(self, + tableview, drag_info, row, operation): + parent, position = self.translateRow_operation_(row, operation) + return self.acceptDrop_dragInfo_parentIter_position_(tableview, + drag_info, parent, position) + + +class MiroOutlineViewDataSource(DataSourceBase, protocols.NSOutlineViewDataSource): + def outlineView_child_ofItem_(self, view, child, item): + if item is nil: + row_list = self.model.row_list + else: + row_list = item.children + return row_list.nth_iter(child).value() + + def outlineView_isItemExpandable_(self, view, item): + if item is not nil and hasattr(item, 'children'): + return len(item.children) > 0 + else: + return len(self.model) > 0 + + def outlineView_numberOfChildrenOfItem_(self, view, item): + if item is not nil and hasattr(item, 'children'): + return len(item.children) + else: + return len(self.model) + + def outlineView_objectValueForTableColumn_byItem_(self, view, column, + item): + return self.model.get_column_data(item.values, column) + + def outlineView_writeItems_toPasteboard_(self, outline_view, items, + pasteboard): + data = [i.values for i in items] + return self.view_writeColumnData_ToPasteboard_(outline_view, data, + pasteboard) + + def outlineView_validateDrop_proposedItem_proposedChildIndex_(self, + outlineview, drag_info, item, child_index): + if item is None: + iter = None + else: + iter = self.model.iter_for_item[item] + drop_action = self.validateDrop_dragInfo_parentIter_position_( + outlineview, drag_info, iter, child_index) + if isinstance(drop_action, (tuple, list)): + drop_action, iter = drop_action + outlineview.setDropItem_dropChildIndex_( + iter.value(), NSOutlineViewDropOnItemIndex) + return drop_action + + def outlineView_acceptDrop_item_childIndex_(self, outlineview, drag_info, + item, child_index): + if item is None: + iter = None + else: + iter = self.model.iter_for_item[item] + return self.acceptDrop_dragInfo_parentIter_position_(outlineview, + drag_info, iter, child_index) diff --git a/mvc/widgets/osx/tableview.py b/mvc/widgets/osx/tableview.py new file mode 100644 index 0000000..2d2256f --- /dev/null +++ b/mvc/widgets/osx/tableview.py @@ -0,0 +1,1629 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".tableview -- TableView widget and it's +associated classes. +""" + +import math +import logging +from contextlib import contextmanager +from collections import namedtuple + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +from mvc import signals +from mvc import errors +from mvc.widgets import widgetconst +from mvc.widgets.tableselection import SelectionOwnerMixin +from mvc.widgets.tablescroll import ScrollbarOwnerMixin +from .utils import filename_to_unicode +import wrappermap +import tablemodel +import osxmenus +from .base import Widget +from .simple import Image +from .drawing import DrawingContext, DrawingStyle, Gradient, ImageSurface +from .helpers import NotificationForwarder +from .layoutmanager import LayoutManager + +EXPANDER_PADDING = 6 +HEADER_HEIGHT = 17 +CUSTOM_HEADER_HEIGHT = 25 + +def iter_range(ns_range): + """Iterate over an NSRange object""" + return xrange(ns_range.location, ns_range.location + ns_range.length) + +Rect = namedtuple('Rect', 'x y width height') +def NSRectToRect(nsrect): + origin, size = nsrect.origin, nsrect.size + return Rect(origin.x, origin.y, size.width, size.height) + +Point = namedtuple('Point', 'x y') +def NSPointToPoint(nspoint): + return Point(int(nspoint.x), int(nspoint.y)) + +class HotspotTracker(object): + """Contains the info on the currently tracked hotspot. See: + https://develop.participatoryculture.org/index.php/WidgetAPITableView + """ + def __init__(self, tableview, point): + self.tableview = tableview + self.row = tableview.rowAtPoint_(point) + self.column = tableview.columnAtPoint_(point) + if self.row == -1 or self.column == -1: + self.hit = False + return + model = tableview.dataSource().model + self.iter = model.iter_for_row(tableview, self.row) + self.table_column = tableview.tableColumns()[self.column] + self.cell = self.table_column.dataCell() + self.update_position(point) + if isinstance(self.cell, CustomTableCell): + self.name = self.calc_hotspot() + else: + self.name = None + self.hit = (self.name is not None) + + def is_for_context_menu(self): + return self.name == '#show-context-menu' + + def calc_cell_hotspot(self, column, row): + if (self.hit and self.column == column and self.row == row): + return self.name + else: + return None + + def update_position(self, point): + cell_frame = self.tableview.frameOfCellAtColumn_row_(self.column, + self.row) + self.pos = NSPoint(point.x - cell_frame.origin.x, + point.y - cell_frame.origin.y) + + def update_hit(self): + old_hit = self.hit + self.hit = (self.calc_hotspot() == self.name) + if old_hit != self.hit: + self.redraw_cell() + + def set_cell_data(self): + model = self.tableview.dataSource().model + row = model[self.iter] + value_dict = model.get_column_data(row, self.table_column) + self.cell.setObjectValue_(value_dict) + self.cell.set_wrapper_data() + + def calc_hotspot(self): + self.set_cell_data() + cell_frame = self.tableview.frameOfCellAtColumn_row_(self.column, + self.row) + style = self.cell.make_drawing_style(cell_frame, self.tableview) + layout_manager = self.cell.layout_manager + layout_manager.reset() + return self.cell.wrapper.hotspot_test(style, layout_manager, + self.pos.x, self.pos.y, cell_frame.size.width, + cell_frame.size.height) + + def redraw_cell(self): + # Check to see if we removed the table in response to a hotspot click. + if self.tableview.superview() is not nil: + cell_frame = self.tableview.frameOfCellAtColumn_row_(self.column, + self.row) + self.tableview.setNeedsDisplayInRect_(cell_frame) + +def _calc_interior_frame(total_frame, tableview): + """Calculate the inner cell area for a table cell. + + We tell cocoa that the intercell spacing is (0, 0) and instead handle the + spacing ourselves. This method calculates the area that a cell should + render to, given the total spacing. + """ + return NSMakeRect(total_frame.origin.x + tableview.column_spacing // 2, + total_frame.origin.y + tableview.row_spacing // 2, + total_frame.size.width - tableview.column_spacing, + total_frame.size.height - tableview.row_spacing) + +class MiroTableCell(NSTextFieldCell): + def init(self): + return super(MiroTableCell, self).initTextCell_('') + + def calcHeight_(self, view): + font = self.font() + return math.ceil(font.ascender() + abs(font.descender()) + + font.leading()) + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def setObjectValue_(self, value_dict): + if isinstance(value_dict, dict): + NSCell.setObjectValue_(self, value_dict['value']) + else: + # OS X calls setObjectValue_('') on intialization + NSCell.setObjectValue_(self, value_dict) + + def drawInteriorWithFrame_inView_(self, frame, view): + return NSTextFieldCell.drawInteriorWithFrame_inView_(self, + _calc_interior_frame(frame, view), view) + +class MiroTableInfoListTextCell(MiroTableCell): + def initWithAttrGetter_(self, attr_getter): + self = self.init() + self.setWraps_(NO) + self.attr_getter = attr_getter + self._textColor = self.textColor() + return self + + def drawWithFrame_inView_(self, frame, view): + # adjust frame based on the cell spacing + frame = _calc_interior_frame(frame, view) + if (self.isHighlighted() and frame is not None and + (view.isDescendantOf_(view.window().firstResponder()) or + view.gradientHighlight) and view.window().isMainWindow()): + self.setTextColor_(NSColor.whiteColor()) + else: + self.setTextColor_(self._textColor) + return MiroTableCell.drawWithFrame_inView_(self, frame, view) + + def titleRectForBounds_(self, rect): + frame = MiroTableCell.titleRectForBounds_(self, rect) + text_size = self.attributedStringValue().size() + frame.origin.y = rect.origin.y + (rect.size.height - text_size.height) / 2.0 + return frame + + def drawInteriorWithFrame_inView_(self, frame, view): + rect = self.titleRectForBounds_(frame) + self.attributedStringValue().drawInRect_(rect) + + def setObjectValue_(self, value): + if isinstance(value, tuple): + info, attrs, group_info = value + cell_text = self.attr_getter(info) + NSCell.setObjectValue_(self, cell_text) + else: + # Getting set to a something other than a model row, usually this + # happens in initialization + NSCell.setObjectValue_(self, '') + +class MiroTableImageCell(NSImageCell): + def calcHeight_(self, view): + return self.value_dict['image'].size().height + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def setObjectValue_(self, value_dict): + NSImageCell.setObjectValue_(self, value_dict['image']) + + def drawInteriorWithFrame_inView_(self, frame, view): + return NSImageCell.drawInteriorWithFrame_inView_(self, + _calc_interior_frame(frame, view), view) + +class MiroCheckboxCell(NSButtonCell): + def init(self): + self = super(MiroCheckboxCell, self).init() + self.setButtonType_(NSSwitchButton) + self.setTitle_('') + return self + + def calcHeight_(self, view): + return self.cellSize().height + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def setObjectValue_(self, value_dict): + if isinstance(value_dict, dict): + NSButtonCell.setObjectValue_(self, value_dict['value']) + else: + # OS X calls setObjectValue_('') on intialization + NSCell.setObjectValue_(self, value_dict) + + def startTrackingAt_inView_(self, point, view): + return YES + + def continueTracking_at_inView_(self, lastPoint, at, view): + return YES + + def stopTracking_at_inView_mouseIsUp_(self, lastPoint, at, tableview, mouseIsUp): + if mouseIsUp: + column = tableview.columnAtPoint_(at) + row = tableview.rowAtPoint_(at) + if column != -1 and row != -1: + wrapper = wrappermap.wrapper(tableview) + column = wrapper.columns[column] + itr = wrapper.model.iter_for_row(tableview, row) + column.renderer.emit('clicked', itr) + return NSButtonCell.stopTracking_at_inView_mouseIsUp_(self, lastPoint, + at, tableview, mouseIsUp) + + def drawInteriorWithFrame_inView_(self, frame, view): + return NSButtonCell.drawInteriorWithFrame_inView_(self, + _calc_interior_frame(frame, view), view) + +class CellRendererBase(object): + DRAW_BACKGROUND = True + + def set_index(self, index): + self.index = index + + def get_index(self): + return self.index + +class CellRenderer(CellRendererBase): + def __init__(self): + self.cell = self.build_cell() + self._font_scale_factor = 1.0 + self._font_bold = False + self.set_align('left') + + def build_cell(self): + return MiroTableCell.alloc().init() + + def setDataCell_(self, column): + column.setDataCell_(self.cell) + + def set_text_size(self, size): + if size == widgetconst.SIZE_NORMAL: + self._font_scale_factor = 1.0 + elif size == widgetconst.SIZE_SMALL: + # make the scale factor such so that the font size is 11.0 + self._font_scale_factor = 11.0 / NSFont.systemFontSize() + else: + raise ValueError("Unknown size: %s" % size) + self._set_font() + + def set_font_scale(self, scale_factor): + self._font_scale_factor = scale_factor + self._set_font() + + def set_bold(self, bold): + self._font_bold = bold + self._set_font() + + def _set_font(self): + size = NSFont.systemFontSize() * self._font_scale_factor + if self._font_bold: + font = NSFont.boldSystemFontOfSize_(size) + else: + font = NSFont.systemFontOfSize_(size) + self.cell.setFont_(font) + + def set_color(self, color): + color = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], + color[1], color[2], 1.0) + self.cell._textColor = color + self.cell.setTextColor_(color) + + def set_align(self, align): + if align == 'left': + ns_alignment = NSLeftTextAlignment + elif align == 'center': + ns_alignment = NSCenterTextAlignment + elif align == 'right': + ns_alignment = NSRightTextAlignment + else: + raise ValueError("unknown alignment: %s", align) + self.cell.setAlignment_(ns_alignment) + +class ImageCellRenderer(CellRendererBase): + def setDataCell_(self, column): + column.setDataCell_(MiroTableImageCell.alloc().init()) + +class CheckboxCellRenderer(CellRendererBase, signals.SignalEmitter): + def __init__(self): + signals.SignalEmitter.__init__(self, 'clicked') + self.size = widgetconst.SIZE_NORMAL + + def set_control_size(self, size): + self.size = size + + def setDataCell_(self, column): + cell = MiroCheckboxCell.alloc().init() + if self.size == widgetconst.SIZE_SMALL: + cell.setControlSize_(NSSmallControlSize) + column.setDataCell_(cell) + +class CustomTableCell(NSCell): + def init(self): + self = super(CustomTableCell, self).init() + self.layout_manager = LayoutManager() + self.hotspot = None + self.default_drawing_style = DrawingStyle() + return self + + def highlightColorWithFrame_inView_(self, frame, view): + return nil + + def calcHeight_(self, view): + self.layout_manager.reset() + self.set_wrapper_data() + cell_size = self.wrapper.get_size(self.default_drawing_style, + self.layout_manager) + return cell_size[1] + + def make_drawing_style(self, frame, view): + text_color = None + if (self.isHighlighted() and frame is not None and + (view.isDescendantOf_(view.window().firstResponder()) or + view.gradientHighlight) and view.window().isMainWindow()): + text_color = NSColor.whiteColor() + return DrawingStyle(text_color=text_color) + + def drawInteriorWithFrame_inView_(self, frame, view): + NSGraphicsContext.currentContext().saveGraphicsState() + if not self.wrapper.IGNORE_PADDING: + # adjust frame based on the cell spacing. We also have to adjust + # the hover position to account for the new frame + original_frame = frame + frame = _calc_interior_frame(frame, view) + hover_adjustment = (frame.origin.x - original_frame.origin.x, + frame.origin.y - original_frame.origin.y) + else: + hover_adjustment = (0, 0) + if self.wrapper.outline_column: + pad_left = EXPANDER_PADDING + else: + pad_left = 0 + drawing_rect = NSMakeRect(frame.origin.x + pad_left, frame.origin.y, + frame.size.width - pad_left, frame.size.height) + context = DrawingContext(view, drawing_rect, drawing_rect) + context.style = self.make_drawing_style(frame, view) + self.layout_manager.reset() + self.set_wrapper_data() + column = self.wrapper.get_index() + hover_pos = view.get_hover(self.row, column) + if hover_pos is not None: + hover_pos = [hover_pos[0] - hover_adjustment[0], + hover_pos[1] - hover_adjustment[1]] + self.wrapper.render(context, self.layout_manager, self.isHighlighted(), + self.hotspot, hover_pos) + NSGraphicsContext.currentContext().restoreGraphicsState() + + def setObjectValue_(self, value): + self.object_value = value + + def set_wrapper_data(self): + self.wrapper.__dict__.update(self.object_value) + +class CustomCellRenderer(CellRendererBase): + CellClass = CustomTableCell + + IGNORE_PADDING = False + + def __init__(self): + self.outline_column = False + self.index = None + + def setDataCell_(self, column): + # Note that the ownership is the opposite of what happens in widgets. + # The NSObject owns it's wrapper widget. This happens for a couple + # reasons: + # 1) The data cell gets copied a bunch of times, so wrappermap won't + # work with it. + # 2) The Wrapper should only needs to stay around as long as the + # NSCell that it's wrapping is around. Once the column gets removed + # from the table, the wrapper can be deleted. + nscell = self.CellClass.alloc().init() + nscell.wrapper = self + column.setDataCell_(nscell) + + def hotspot_test(self, style, layout, x, y, width, height): + return None + +class InfoListTableCell(CustomTableCell): + def set_wrapper_data(self): + self.wrapper.info, self.wrapper.attrs, self.wrapper.group_info = \ + self.object_value + +class InfoListRenderer(CustomCellRenderer): + CellClass = InfoListTableCell + + def hotspot_test(self, style, layout, x, y, width, height): + return None + +class InfoListRendererText(CellRenderer): + def build_cell(self): + cell = MiroTableInfoListTextCell.alloc() + return cell.initWithAttrGetter_(self.get_value) + +def calc_row_height(view, model_row): + max_height = 0 + model = view.dataSource().model + for column in view.tableColumns(): + cell = column.dataCell() + data = model.get_column_data(model_row, column) + cell.setObjectValue_(data) + cell_height = cell.calcHeight_(view) + max_height = max(max_height, cell_height) + if max_height == 0: + max_height = 12 + return max_height + view.row_spacing + +class TableViewDelegate(NSObject): + def tableView_willDisplayCell_forTableColumn_row_(self, view, cell, + column, row): + column = view.column_index_map[column] + cell.column = column + cell.row = row + if view.hotspot_tracker: + cell.hotspot = view.hotspot_tracker.calc_cell_hotspot(column, row) + else: + cell.hotspot = None + + def tableView_didClickTableColumn_(self, tableview, column): + wrapper = wrappermap.wrapper(tableview) + for column_wrapper in wrapper.columns: + if column_wrapper._column is column: + column_wrapper.emit('clicked') + + def tableView_toolTipForCell_rect_tableColumn_row_mouseLocation_(self, tableview, cell, rect, column, row, location): + wrapper = wrappermap.wrapper(tableview) + iter = tableview.dataSource().model.iter_for_row(tableview, row) + for wrapper_column in wrapper.columns: + if wrapper_column._column is column: + break + return (wrapper.get_tooltip(iter, wrapper_column), rect) + +class VariableHeightTableViewDelegate(TableViewDelegate): + def tableView_heightOfRow_(self, table_view, row): + model = table_view.dataSource().model + iter = model.iter_for_row(table_view, row) + if iter is None: + return 12 + return calc_row_height(table_view, model[iter]) + + +# TableViewCommon is a hack to do a Mixin class. We want the same behaviour +# for our table views and our outline views. Normally we would use a Mixin, +# but that doesn't work with pyobjc. Instead we define the common code in +# TableViewCommon, then copy it into MiroTableView and MiroOutlineView + +class TableViewCommon(object): + def init(self): + self = super(self.__class__, self).init() + self.hotspot_tracker = None + self._tracking_rects = [] + self.hover_info = None + self.column_index_map = {} + self.setFocusRingType_(NSFocusRingTypeNone) + self.handled_last_mouse_down = False + self.gradientHighlight = False + self.tracking_area = None + self.group_lines_enabled = False + self.group_line_width = 1 + self.group_line_color = (0, 0, 0, 1.0) + # we handle cell spacing manually + self.setIntercellSpacing_(NSSize(0, 0)) + self.column_spacing = 3 + self.row_spacing = 2 + return self + + def updateTrackingAreas(self): + # remove existing tracking area if needed + if self.tracking_area: + self.removeTrackingArea_(self.tracking_area) + + # create a new tracking area for the entire view. This allows us to + # get mouseMoved events whenever the mouse is inside our view. + self.tracking_area = NSTrackingArea.alloc() + self.tracking_area.initWithRect_options_owner_userInfo_( + self.visibleRect(), + NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | + NSTrackingActiveInKeyWindow, + self, + nil) + self.addTrackingArea_(self.tracking_area) + + def addTableColumn_(self, column): + index = len(self.tableColumns()) + column.set_index(index) + self.column_index_map[column._column] = index + self.SuperClass.addTableColumn_(self, column._column) + + def removeTableColumn_(self, column): + self.SuperClass.removeTableColumn_(self, column) + removed = self.column_index_map.pop(column) + for key, index in self.column_index_map.items(): + if index > removed: + self.column_index_map[key] -= 1 + + def moveColumn_toColumn_(self, src, dest): + # Need to switch the TableColumn objects too + columns = wrappermap.wrapper(self).columns + columns[src], columns[dest] = columns[dest], columns[src] + for index, column in enumerate(columns): + column.set_index(index) + self.SuperClass.moveColumn_toColumn_(self, src, dest) + + def highlightSelectionInClipRect_(self, rect): + if wrappermap.wrapper(self).draws_selection: + if not self.gradientHighlight: + return self.SuperClass.highlightSelectionInClipRect_(self, + rect) + context = NSGraphicsContext.currentContext() + focused = self.isDescendantOf_(self.window().firstResponder()) + for row in tablemodel.list_from_nsindexset(self.selectedRowIndexes()): + self.drawBackgroundGradient(context, focused, row) + + def setFrameSize_(self, size): + if size.height == 0: + size.height = 4 + self.SuperClass.setFrameSize_(self, size) + + def drawBackgroundGradient(self, context, focused, row): + widget = wrappermap.wrapper(self) + window = widget.get_window() + if window and window.is_active(): + if focused: + start_color = (0.588, 0.717, 0.843) + end_color = (0.416, 0.568, 0.713) + top_line_color = (0.416, 0.569, 0.714, 1.0) + bottom_line_color = (0.416, 0.569, 0.714, 1.0) + else: + start_color = (168 / 255.0, 188 / 255.0, 208 / 255.0) + end_color = (129 / 255.0, 152 / 255.0, 176 / 255.0) + top_line_color = (129 / 255.0, 152 / 255.0, 175 / 255.0, 1.0) + bottom_line_color = (0.416, 0.569, 0.714, 1.0) + else: + start_color = (0.675, 0.722, 0.765) + end_color = (0.592, 0.659, 0.710) + top_line_color = (0.596, 0.635, 0.671, 1.0) + bottom_line_color = (0.522, 0.576, 0.620, 1.0) + + rect = self.rectOfRow_(row) + top = NSMakeRect(rect.origin.x, rect.origin.y, rect.size.width, 1) + context.saveGraphicsState() + # draw the top line + NSColor.colorWithDeviceRed_green_blue_alpha_(*top_line_color).set() + NSRectFill(top) + bottom = NSMakeRect(rect.origin.x, rect.origin.y + rect.size.height - 2, + rect.size.width, 1) + NSColor.colorWithDeviceRed_green_blue_alpha_(*bottom_line_color).set() + NSRectFill(bottom) + highlight = NSMakeRect(rect.origin.x, rect.origin.y + rect.size.height - 1, + rect.size.width, 1) + NSColor.colorWithDeviceRed_green_blue_alpha_(0.918, 0.925, 0.941, 1.0).set() + NSRectFill(highlight) + + # draw the gradient + rect.origin.y += 1 + rect.size.height -= 3 + NSRectClip(rect) + gradient = Gradient(rect.origin.x, rect.origin.y, + rect.origin.x, rect.origin.y + rect.size.height) + gradient.set_start_color(start_color) + gradient.set_end_color(end_color) + gradient.draw() + context.restoreGraphicsState() + + def drawBackgroundInClipRect_(self, clip_rect): + # save our graphics state, since we are about to modify the clip path + graphics_context = NSGraphicsContext.currentContext() + graphics_context.saveGraphicsState() + # create a NSBezierPath that contains the rects of the columns with + # DRAW_BACKGROUND True. + clip_path = NSBezierPath.bezierPath() + number_of_columns = len(self.tableColumns()) + for col_index in iter_range(self.columnsInRect_(clip_rect)): + column = wrappermap.wrapper(self.tableColumns()[col_index]) + column_rect = None + if column.renderer.DRAW_BACKGROUND: + # We should draw the background for this column, add it's rect + # to our clip rect. + column_rect = self.rectOfColumn_(col_index) + clip_path.appendBezierPathWithRect_(column_rect) + else: + # We shouldn't draw the background for this column. Don't add + # anything to the clip rect, but do draw the area before the + # first row and after the last row. + self.drawBackgroundOutsideContent_clipRect_(col_index, + clip_rect) + if col_index == number_of_columns - 1: # last column + if not column_rect: + column_rect = self.rectOfColumn_(col_index) + column_right = column_rect.origin.x + column_rect.size.width + clip_right = clip_rect.origin.x + clip_rect.size.width + if column_right < clip_right: + # there's space to the right, so add that to the clip_rect + remaining = clip_right - column_right + left_rect = NSMakeRect(column_right, clip_rect.origin.y, + remaining, clip_rect.size.height) + clip_path.appendBezierPathWithRect_(left_rect) + # clip to that path + clip_path.addClip() + # do the default drawing + self.SuperClass.drawBackgroundInClipRect_(self, clip_rect) + # restore graphics state + graphics_context.restoreGraphicsState() + + def drawBackgroundOutsideContent_clipRect_(self, index, clip_rect): + """Draw our background outside the rows with content + + We call this for cells with DRAW_BACKGROUND set to False. For those, + we let the cell draw their own background, but we still need to draw + the background before the first cell and after the last cell. + """ + + self.backgroundColor().set() + + total_rect = NSIntersectionRect(self.rectOfColumn_(index), clip_rect) + + if self.numberOfRows() == 0: + # if no rows are selected, draw the background over everything + NSRectFill(total_rect) + return + + # fill the area above the first row + first_row_rect = self.rectOfRow_(0) + if first_row_rect.origin.y > total_rect.origin.y: + height = first_row_rect.origin.y - total_rect.origin.y + NSRectFill(NSMakeRect(total_rect.origin.x, total_rect.origin.y, + total_rect.size.width, height)) + + # fill the area below the last row + last_row_rect = self.rectOfRow_(self.numberOfRows()-1) + if NSMaxY(last_row_rect) < NSMaxY(total_rect): + y = NSMaxY(last_row_rect) + 1 + height = NSMaxY(total_rect) - NSMaxY(last_row_rect) + NSRectFill(NSMakeRect(total_rect.origin.x, y, + total_rect.size.width, height)) + + def drawRow_clipRect_(self, row, clip_rect): + self.SuperClass.drawRow_clipRect_(self, row, clip_rect) + if self.group_lines_enabled: + self.drawGroupLine_(row) + + def drawGroupLine_(self, row): + infolist = wrappermap.wrapper(self).model + if (not isinstance(infolist, tablemodel.InfoListModel) or + infolist.get_grouping() is None): + return + + info, attrs, group_info = infolist[row] + if group_info[0] == group_info[1] - 1: + rect = self.rectOfRow_(row) + rect.origin.y = NSMaxY(rect) - self.group_line_width + rect.size.height = self.group_line_width + NSColor.colorWithDeviceRed_green_blue_alpha_( + *self.group_line_color).set() + NSRectFill(rect) + + def canDragRowsWithIndexes_atPoint_(self, indexes, point): + return YES + + def draggingSourceOperationMaskForLocal_(self, local): + drag_source = wrappermap.wrapper(self).drag_source + if drag_source and local: + return drag_source.allowed_actions() + return NSDragOperationNone + + def mouseMoved_(self, event): + location = self.convertPoint_fromView_(event.locationInWindow(), nil) + row = self.rowAtPoint_(location) + column = self.columnAtPoint_(location) + if (self.hover_info is not None and self.hover_info != (row, column)): + # left a cell, redraw it the old one + rect = self.frameOfCellAtColumn_row_(self.hover_info[1], + self.hover_info[0]) + self.setNeedsDisplayInRect_(rect) + if row == -1 or column == -1: + # corner case: we got a mouseMoved_ event, but the pointer is + # outside the view + self.hover_pos = self.hover_info = None + return + # queue a redraw on the cell currently hovered over + rect = self.frameOfCellAtColumn_row_(column, row) + self.setNeedsDisplayInRect_(rect) + # recalculate hover_pos and hover_info + self.hover_pos = (location[0] - rect[0][0], + location[0] - rect[0][1]) + self.hover_info = (row, column) + + def mouseExited_(self, event): + if self.hover_info: + # mouse left our window, unset hover and redraw the cell that the + # mouse was in + rect = self.frameOfCellAtColumn_row_(self.hover_info[1], + self.hover_info[0]) + self.setNeedsDisplayInRect_(rect) + self.hover_pos = self.hover_info = None + + def get_hover(self, row, column): + if self.hover_info == (row, column): + return self.hover_pos + else: + return None + + def mouseDown_(self, event): + if event.modifierFlags() & NSControlKeyMask: + self.handleContextMenu_(event) + self.handled_last_mouse_down = True + return + + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + + if event.clickCount() == 2: + if self.handled_last_mouse_down: + return + wrapper = wrappermap.wrapper(self) + row = self.rowAtPoint_(point) + if (row != -1 and self.point_should_click(point, row)): + iter = wrapper.model.iter_for_row(self, row) + wrapper.emit('row-activated', iter) + return + + # Like clickCount() == 2 but keep running so we can get to run the + # hotspot tracker et al. + if event.clickCount() == 1: + wrapper = wrappermap.wrapper(self) + row = self.rowAtPoint_(point) + if (row != -1 and self.point_should_click(point, row)): + + iter = wrapper.model.iter_for_row(self, row) + wrapper.emit('row-clicked', iter) + + hotspot_tracker = HotspotTracker(self, point) + if hotspot_tracker.hit: + self.hotspot_tracker = hotspot_tracker + self.hotspot_tracker.redraw_cell() + self.handled_last_mouse_down = True + if hotspot_tracker.is_for_context_menu(): + self.popup_context_menu(self.hotspot_tracker.row, event) + # once we're out of that call, we know the context menu is + # gone + hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + else: + self.handled_last_mouse_down = False + self.SuperClass.mouseDown_(self, event) + + def point_should_click(self, point, row): + """Should a click on a point result in a row-clicked signal? + + Subclasses can override if not every point should result in a click. + """ + return True + + def rightMouseDown_(self, event): + self.handleContextMenu_(event) + + def handleContextMenu_(self, event): + self.window().makeFirstResponder_(self) + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + column = self.columnAtPoint_(point) + row = self.rowAtPoint_(point) + if self.group_lines_enabled and column == 0: + self.selectAllItemsInGroupForRow_(row) + self.popup_context_menu(row, event) + + def selectAllItemsInGroupForRow_(self, row): + wrapper = wrappermap.wrapper(self) + infolist = wrapper.model + if (not isinstance(infolist, tablemodel.InfoListModel) or + infolist.get_grouping() is None): + return + + info, attrs, group_info = infolist[row] + select_range = NSMakeRange(row - group_info[0], group_info[1]) + index_set = NSIndexSet.indexSetWithIndexesInRange_(select_range) + self.selectRowIndexes_byExtendingSelection_(index_set, NO) + + def popup_context_menu(self, row, event): + selection = self.selectedRowIndexes() + if row != -1 and not selection.containsIndex_(row): + index_set = NSIndexSet.alloc().initWithIndex_(row) + self.selectRowIndexes_byExtendingSelection_(index_set, NO) + wrapper = wrappermap.wrapper(self) + if wrapper.context_menu_callback is not None: + menu_items = wrapper.context_menu_callback(wrapper) + menu = osxmenus.make_context_menu(menu_items) + NSMenu.popUpContextMenu_withEvent_forView_(menu, event, self) + + def mouseDragged_(self, event): + if self.hotspot_tracker is not None: + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + self.hotspot_tracker.update_position(point) + self.hotspot_tracker.update_hit() + else: + self.SuperClass.mouseDragged_(self, event) + + def mouseUp_(self, event): + if self.hotspot_tracker is not None: + point = self.convertPoint_fromView_(event.locationInWindow(), nil) + self.hotspot_tracker.update_position(point) + self.hotspot_tracker.update_hit() + if self.hotspot_tracker.hit: + wrappermap.wrapper(self).send_hotspot_clicked() + if self.hotspot_tracker: + self.hotspot_tracker.redraw_cell() + self.hotspot_tracker = None + else: + self.SuperClass.mouseUp_(self, event) + + def keyDown_(self, event): + mods = osxmenus.translate_event_modifiers(event) + if event.charactersIgnoringModifiers() == ' ' and len(mods) == 0: + # handle spacebar with no modifiers by sending the row-activated + # signal + wrapper = wrappermap.wrapper(self) + row = self.selectedRow() + if row >= 0: + iter = wrapper.model.iter_for_row(self, row) + wrapper.emit('row-activated', iter) + else: + self.SuperClass.keyDown_(self, event) + +class TableColumn(signals.SignalEmitter): + def __init__(self, title, renderer, header=None, **attrs): + signals.SignalEmitter.__init__(self) + self.create_signal('clicked') + self._column = MiroTableColumn.alloc().initWithIdentifier_(title) + self._column.set_attrs(attrs) + wrappermap.add(self._column, self) + header_cell = MiroTableHeaderCell.alloc().init() + self.custom_header = False + if header: + header_cell.set_widget(header) + self.custom_header = True + self._column.setHeaderCell_(header_cell) + self._column.headerCell().setStringValue_(title) + self._column.setEditable_(NO) + self._column.setResizingMask_(NSTableColumnNoResizing) + self.renderer = renderer + self.sort_order_ascending = True + self.sort_indicator_visible = False + self.do_horizontal_padding = True + self.min_width = self.max_width = None + renderer.setDataCell_(self._column) + + def set_do_horizontal_padding(self, horizontal_padding): + self.do_horizontal_padding = horizontal_padding + + def set_right_aligned(self, right_aligned): + if right_aligned: + self._column.headerCell().setAlignment_(NSRightTextAlignment) + else: + self._column.headerCell().setAlignment_(NSLeftTextAlignment) + + def set_min_width(self, width): + self.min_width = width + + def set_max_width(self, width): + self.max_width = width + + def set_width(self, width): + self._column.setWidth_(width) + + def get_width(self): + return self._column.width() + + def set_resizable(self, resizable): + mask = 0 + if resizable: + mask |= NSTableColumnUserResizingMask + self._column.setResizingMask_(mask) + + def set_sort_indicator_visible(self, visible): + self.sort_indicator_visible = visible + self._column.tableView().headerView().setNeedsDisplay_(True) + + def get_sort_indicator_visible(self): + return self.sort_indicator_visible + + def set_sort_order(self, ascending): + self.sort_order_ascending = ascending + self._column.tableView().headerView().setNeedsDisplay_(True) + + def get_sort_order_ascending(self): + return self.sort_order_ascending + + def set_index(self, index): + self.index = index + self.renderer.set_index(index) + +class MiroTableColumn(NSTableColumn): + def set_attrs(self, attrs): + self._attrs = attrs + + def attrs(self): + return self._attrs + +class MiroTableView(NSTableView): + SuperClass = NSTableView + for name, value in TableViewCommon.__dict__.items(): + locals()[name] = value + +class MiroTableHeaderView(NSTableHeaderView): + def initWithFrame_(self, frame): + # frame is not used + self = super(MiroTableHeaderView, self).initWithFrame_(frame) + self.selected = None + self.custom_header = False + return self + + def drawRect_(self, rect): + wrapper = wrappermap.wrapper(self.tableView()) + if self.selected: + self.selected.set_selected(False) + for column in wrapper.columns: + if column.sort_indicator_visible: + self.selected = column._column.headerCell() + self.selected.set_selected(True) + self.selected.set_ascending(column.sort_order_ascending) + break + NSTableHeaderView.drawRect_(self, rect) + if self.custom_header: + NSGraphicsContext.currentContext().saveGraphicsState() + # Draw the separator between the header and the contents. + context = DrawingContext(self, rect, rect) + context.set_line_width(1) + context.set_color((2 / 255.0, 2 / 255.0, 2 / 255.0)) + context.move_to(0, context.height - 0.5) + context.rel_line_to(context.width, 0) + context.stroke() + NSGraphicsContext.currentContext().restoreGraphicsState() + +class MiroTableHeaderCell(NSTableHeaderCell): + def init(self): + self = super(MiroTableHeaderCell, self).init() + self.layout_manager = LayoutManager() + self.button = None + return self + + def set_selected(self, selected): + self.button._enabled = selected + + def set_ascending(self, ascending): + self.button._ascending = ascending + + def set_widget(self, widget): + self.button = widget + + def drawWithFrame_inView_(self, frame, view): + if self.button is None: + # use the default behavior when set_widget hasn't been called + return NSTableHeaderCell.drawWithFrame_inView_(self, frame, view) + + NSGraphicsContext.currentContext().saveGraphicsState() + drawing_rect = NSMakeRect(frame.origin.x, frame.origin.y, + frame.size.width, frame.size.height) + context = DrawingContext(view, drawing_rect, drawing_rect) + context.style = self.make_drawing_style(frame, view) + self.layout_manager.reset() + columns = wrappermap.wrapper(view.tableView()).columns + header_cells = [c._column.headerCell() for c in columns] + background_only = not self in header_cells + self.button.draw(context, self.layout_manager, background_only) + NSGraphicsContext.currentContext().restoreGraphicsState() + + def make_drawing_style(self, frame, view): + text_color = None + if (self.isHighlighted() and frame is not None and + (view.isDescendantOf_(view.window().firstResponder()) or + view.gradientHighlight)): + text_color = NSColor.whiteColor() + return DrawingStyle(text_color=text_color) + +class CocoaSelectionOwnerMixin(SelectionOwnerMixin): + """Cocoa-specific methods for selection management. + + This subclass should not define any behavior. Methods that cannot be + completed in this widget state should raise WidgetActionError. + """ + + def _set_allow_multiple_select(self, allow): + self.tableview.setAllowsMultipleSelection_(allow) + + def _get_allow_multiple_select(self): + return self.tableview.allowsMultipleSelection() + + def _get_selected_iters(self): + selection = self.tableview.selectedRowIndexes() + selrows = tablemodel.list_from_nsindexset(selection) + return [self.model.iter_for_row(self.tableview, row) for row in selrows] + + def _get_selected_iter(self): + row = self.tableview.selectedRow() + if row == -1: + return None + return self.model.iter_for_row(self.tableview, row) + + def _get_selected_rows(self): + return [self.model[i] for i in self._get_selected_iters()] + + @property + def num_rows_selected(self): + return self.tableview.numberOfSelectedRows() + + def _is_selected(self, iter_): + row = self.row_of_iter(iter_) + return self.tableview.isRowSelected_(row) + + def _select(self, iter_): + row = self.row_of_iter(iter_) + index_set = NSIndexSet.alloc().initWithIndex_(row) + self.tableview.selectRowIndexes_byExtendingSelection_(index_set, YES) + + def _unselect(self, iter_): + self.tableview.deselectRow_(self.row_of_iter(iter_)) + + def _unselect_all(self): + self.tableview.deselectAll_(nil) + + def _iter_to_string(self, iter_): + return unicode(self.model.row_of_iter(self.tableview, iter_)) + + def _iter_from_string(self, row): + return self.model.iter_for_row(self.tableview, int(row)) + +class CocoaScrollbarOwnerMixin(ScrollbarOwnerMixin): + """Manages a TableView's scroll position.""" + def __init__(self): + ScrollbarOwnerMixin.__init__(self, _work_around_17153=True) + self.connect('place-in-scroller', self.on_place_in_scroller) + self.scroll_position = (0, 0) + self.clipview_notifications = None + self._position_set = False + + def _set_scroll_position(self, scroll_to): + """Restore a saved scroll position.""" + self.scroll_position = scroll_to + try: + scroller = self._scroller + except errors.WidgetNotReadyError: + return + self._position_set = True + clipview = scroller.contentView() + if not self.clipview_notifications: + self.clipview_notifications = NotificationForwarder.create(clipview) + # NOTE: intentional changes are BoundsChanged; bad changes are + # FrameChanged + clipview.setPostsFrameChangedNotifications_(YES) + self.clipview_notifications.connect(self.on_scroll_changed, + 'NSViewFrameDidChangeNotification') + # NOTE: scrollPoint_ just scrolls the point into view; we want to + # scroll the view so that the point becomes the origin + size = self.tableview.visibleRect().size + size = (size.width, size.height) + rect = NSMakeRect(scroll_to[0], scroll_to[1], size[0], size[1]) + self.tableview.scrollRectToVisible_(rect) + + def on_place_in_scroller(self, scroller): + # workaround for 17153.1 + if not self._position_set: + self._set_scroll_position(self.scroll_position) + + @property + def _manually_scrolled(self): + """Return whether the view has been scrolled explicitly by the user + since the last time it was set automatically. Ignores X coords. + """ + auto_y = self.scroll_position[1] + real_y = self.get_scroll_position()[1] + return abs(auto_y - real_y) > 5 + + def _get_item_area(self, iter_): + rect = self.tableview.rectOfRow_(self.row_of_iter(iter_)) + return NSRectToRect(rect) + + def _get_visible_area(self): + return NSRectToRect(self._scroller.contentView().documentVisibleRect()) + + def _get_scroll_position(self): + point = self._scroller.contentView().documentVisibleRect().origin + return NSPointToPoint(point) + + def on_scroll_changed(self, notification): + # we get this notification when the scroll position has been reset (when + # it should not have been); put it back + self.set_scroll_position(self.scroll_position) + # this notification also serves as the Cocoa equivalent to + # on_scroll_range_changed, which tells super when we may be ready to + # scroll to an iter + self.emit('scroll-range-changed') + + def set_scroller(self, scroller): + """For GTK; Cocoa tableview knows its enclosingScrollView""" + + @property + def _scroller(self): + """Return an NSScrollView or raise WidgetNotReadyError""" + scroller = self.tableview.enclosingScrollView() + if not scroller: + raise errors.WidgetNotReadyError('enclosingScrollView') + return scroller + +class SorterPadding(NSView): + # Why is this a Mac only widget? Because the wrappermap mechanism requires + # us to layout the widgets (so that we may call back to the portable API + # hooks of the widget. Since we only set the view component, this fake + # widget is never placed so the wrappermap mechanism fails to work. + # + # So far, this is okay because only the Mac uses custom headers. + def init(self): + self = super(SorterPadding, self).init() + image = Image(resources.path('images/headertoolbar.png')) + self.image = ImageSurface(image) + return self + + def isFlipped(self): + return YES + + def drawRect_(self, rect): + context = DrawingContext(self, self.bounds(), rect) + context.style = DrawingStyle() + self.image.draw(context, 0, 0, context.width, context.height) + # XXX this color doesn't take into account enable/disabled state + # of the sorting widgets. + edge = 72.0 / 255 + context.set_color((edge, edge, edge)) + context.set_line_width(1) + context.move_to(0.5, 0) + context.rel_line_to(0, context.height) + context.stroke() + +class TableView(CocoaSelectionOwnerMixin, CocoaScrollbarOwnerMixin, Widget): + """Displays data as a tabular list. TableView follows the GTK TreeView + widget fairly closely. + """ + + CREATES_VIEW = False + # Bit of a hack. We create several views. By setting CREATES_VIEW to + # False, we get to position the views manually. + + draws_selection = True + + def __init__(self, model, custom_headers=False): + Widget.__init__(self) + CocoaSelectionOwnerMixin.__init__(self) + CocoaScrollbarOwnerMixin.__init__(self) + self.create_signal('hotspot-clicked') + self.create_signal('row-clicked') + self.create_signal('row-activated') + self.create_signal('reallocate-columns') + self.model = model + self.columns = [] + self.drag_source = self.drag_dest = None + self.context_menu_callback = None + self.tableview = MiroTableView.alloc().init() + self.data_source = tablemodel.MiroTableViewDataSource.alloc() + types = (tablemodel.MIRO_DND_ITEM_LOCAL,) + self.tableview.registerForDraggedTypes_(types) + self.view = self.tableview + self.data_source.initWithModel_(self.model) + self.tableview.setDataSource_(self.data_source) + self.tableview.setVerticalMotionCanBeginDrag_(YES) + self.set_columns_draggable(False) + self.set_auto_resizes(False) + self.row_height_set = False + self.set_fixed_height(False) + self.auto_resizing = False + self.header_view = MiroTableHeaderView.alloc().initWithFrame_( + NSMakeRect(0, 0, 0, 0)) + self.tableview.setCornerView_(None) + self.custom_header = False + self.header_height = HEADER_HEIGHT + self.set_show_headers(True) + self.notifications = NotificationForwarder.create(self.tableview) + self.model_signal_ids = [ + self.model.connect_weak('row-changed', self.on_row_change), + self.model.connect_weak('structure-will-change', + self.on_model_structure_change), + ] + self.iters_to_update = [] + self.height_changed = self.reload_needed = False + self.old_selection = None + self._resizing = False + if custom_headers: + self._enable_custom_headers() + + def unset_model(self): + for signal_id in self.model_signal_ids: + self.model.disconnect(signal_id) + self.model = None + self.tableview.setDataSource_(None) + self.data_source = None + + def _enable_custom_headers(self): + self.custom_header = True + self.header_height = CUSTOM_HEADER_HEIGHT + self.header_view.custom_header = True + self.tableview.setCornerView_(SorterPadding.alloc().init()) + + def enable_album_view_focus_hack(self): + # this only matters on GTK + pass + + def focus(self): + if self.tableview.window() is not None: + self.tableview.window().makeFirstResponder_(self.tableview) + + def send_hotspot_clicked(self): + tracker = self.tableview.hotspot_tracker + self.emit('hotspot-clicked', tracker.name, tracker.iter) + + def on_row_change(self, model, iter): + self.iters_to_update.append(iter) + if not self.fixed_height: + self.height_changed = True + if self.tableview.hotspot_tracker is not None: + self.tableview.hotspot_tracker.update_hit() + + def on_model_structure_change(self, model): + self.will_need_reload() + self.cancel_hotspot_track() + + def will_need_reload(self): + if not self.reload_needed: + self.reload_needed = True + self.old_selection = self._get_selected_rows() + + def cancel_hotspot_track(self): + if self.tableview.hotspot_tracker is not None: + self.tableview.hotspot_tracker.redraw_cell() + self.tableview.hotspot_tracker = None + + def on_expanded(self, notification): + self.invalidate_size_request() + item = notification.userInfo()['NSObject'] + iter_ = self.model.iter_for_item[item] + self.emit('row-expanded', iter_, self.model.get_path(iter_)) + + def on_collapsed(self, notification): + self.invalidate_size_request() + item = notification.userInfo()['NSObject'] + iter_ = self.model.iter_for_item[item] + self.emit('row-collapsed', iter_, self.model.get_path(iter_)) + + def on_column_resize(self, notification): + if self.auto_resizing or self._resizing: + return + self._resizing = True + try: + column = notification.userInfo()['NSTableColumn'] + label = column.headerCell().stringValue() + self.emit('reallocate-columns', {label: column.width()}) + finally: + self._resizing = False + + def is_tree(self): + return isinstance(self.model, tablemodel.TreeTableModel) + + def set_row_expanded(self, iter, expanded): + """Expand or collapse the specified row. Succeeds or raises + WidgetActionError. + """ + item = iter.value() + if expanded: + self.tableview.expandItem_(item) + else: + self.tableview.collapseItem_(item) + if self.tableview.isItemExpanded_(item) != expanded: + raise errors.WidgetActionError( + "cannot expand iter. expandable: %r" % ( + self.tableview.isExpandable_(item),)) + self.invalidate_size_request() + + def is_row_expanded(self, iter): + return self.tableview.isItemExpanded_(iter.value()) + + def calc_size_request(self): + self.tableview.tile() + height = self.tableview.frame().size.height + if self._show_headers: + height += self.header_height + return self.calc_width(), height + + def viewport_repositioned(self): + self._do_layout() + + def viewport_created(self): + wrappermap.add(self.tableview, self) + self._do_layout() + self._add_views() + self.notifications.connect(self.on_selection_changed, + 'NSTableViewSelectionDidChangeNotification') + self.notifications.connect(self.on_column_resize, + 'NSTableViewColumnDidResizeNotification') + # scroll has been unset + self._position_set = False + + def remove_viewport(self): + if self.viewport is not None: + self._remove_views() + wrappermap.remove(self.tableview) + self.notifications.disconnect() + self.viewport = None + if self.clipview_notifications: + self.clipview_notifications.disconnect() + self.clipview_notifications = None + + def _should_place_header_view(self): + return self._show_headers and not self.parent_is_scroller + + def _add_views(self): + self.viewport.view.addSubview_(self.tableview) + if self._should_place_header_view(): + self.viewport.view.addSubview_(self.header_view) + + def _remove_views(self): + self.tableview.removeFromSuperview() + self.header_view.removeFromSuperview() + + def _do_layout(self): + x = self.viewport.placement.origin.x + y = self.viewport.placement.origin.y + width = self.viewport.get_width() + height = self.viewport.get_height() + if self._should_place_header_view(): + self.header_view.setFrame_(NSMakeRect(x, y, + width, self.header_height)) + self.tableview.setFrame_(NSMakeRect(x, y + self.header_height, + width, height - self.header_height)) + else: + self.header_view.setFrame_(NSMakeRect(x, y, + width, self.header_height)) + self.tableview.setFrame_(NSMakeRect(x, y, width, height)) + + if self.auto_resize: + self.auto_resizing = True + # ListView sizes itself in do_size_allocated; + # this is necessary for tablist and StandardView + columns = self.tableview.tableColumns() + if len(columns) == 1: + columns[0].setWidth_(self.viewport.area().size.width) + self.auto_resizing = False + self.queue_redraw() + + def calc_width(self): + if self.column_count() == 0: + return 0 + width = 0 + columns = self.tableview.tableColumns() + if self.auto_resize: + # Table auto-resizes, we can shrink to min-width for each column + width = sum(column.minWidth() for column in columns) + width += self.tableview.column_spacing * self.column_count() + else: + # Table doesn't auto-resize, the columns can't get smaller than + # their current width + width = sum(column.width() for column in columns) + return width + + def start_bulk_change(self): + # stop our model from emitting signals, which is slow if we're + # adding/removing/changing a bunch of rows. Instead, just reload the + # model afterwards. + self.will_need_reload() + self.cancel_hotspot_track() + self.model.freeze_signals() + + def model_changed(self): + if not self.row_height_set and self.fixed_height: + self.try_to_set_row_height() + self.model.thaw_signals() + size_changed = False + if self.reload_needed: + self.tableview.reloadData() + new_selection = self._get_selected_rows() + if new_selection != self.old_selection: + self.on_selection_changed(self.tableview) + self.old_selection = None + size_changed = True + elif self.iters_to_update: + if self.fixed_height or not self.height_changed: + # our rows don't change height, just update cell areas + if self.is_tree(): + for iter in self.iters_to_update: + self.tableview.reloadItem_(iter.value()) + else: + for iter in self.iters_to_update: + row = self.row_of_iter(iter) + rect = self.tableview.rectOfRow_(row) + self.tableview.setNeedsDisplayInRect_(rect) + else: + # our rows can change height inform Cocoa that their heights + # might have changed (this will redraw them) + index_set = NSMutableIndexSet.alloc().init() + for iter in self.iters_to_update: + try: + index_set.addIndex_(self.row_of_iter(iter)) + except LookupError: + # This happens when the iter's parent is unexpanded, + # just ignore. + pass + self.tableview.noteHeightOfRowsWithIndexesChanged_(index_set) + size_changed = True + else: + return + if size_changed: + self.invalidate_size_request() + self.height_changed = self.reload_needed = False + self.iters_to_update = [] + + def width_for_columns(self, width): + """If the table is width pixels big, how much width is available for + the table's columns. + """ + # XXX this used to do some calculation with the spacing of each column, + # but it doesn't appear like we need it to be that complicated anymore + # (see #18273) + return width - 2 + + def set_column_spacing(self, column_spacing): + self.tableview.column_spacing = column_spacing + + def set_row_spacing(self, row_spacing): + self.tableview.row_spacing = row_spacing + + def set_alternate_row_backgrounds(self, setting): + self.tableview.setUsesAlternatingRowBackgroundColors_(setting) + + def set_grid_lines(self, horizontal, vertical): + mask = 0 + if horizontal: + mask |= NSTableViewSolidHorizontalGridLineMask + if vertical: + mask |= NSTableViewSolidVerticalGridLineMask + self.tableview.setGridStyleMask_(mask) + + def set_gradient_highlight(self, setting): + self.tableview.gradientHighlight = setting + + def set_group_lines_enabled(self, enabled): + self.tableview.group_lines_enabled = enabled + self.queue_redraw() + + def set_group_line_style(self, color, width): + self.tableview.group_line_color = color + (1.0,) + self.tableview.group_line_width = width + self.queue_redraw() + + def get_tooltip(self, iter, column): + return None + + def add_column(self, column): + if not self.custom_header == column.custom_header: + raise ValueError('Column header does not match type ' + 'required by TableView') + self.columns.append(column) + self.tableview.addTableColumn_(column) + self._set_min_max_column_widths(column) + # Adding a column means that each row could have a different height. + # call noteNumberOfRowsChanged() to have OS X recalculate the heights + self.tableview.noteNumberOfRowsChanged() + self.invalidate_size_request() + self.try_to_set_row_height() + + def _set_min_max_column_widths(self, column): + if column.do_horizontal_padding: + spacing = self.tableview.column_spacing + else: + spacing = 0 + if column.min_width > 0: + column._column.setMinWidth_(column.min_width + spacing) + if column.max_width > 0: + column._column.setMaxWidth_(column.max_width + spacing) + + def column_count(self): + return len(self.tableview.tableColumns()) + + def remove_column(self, index): + column = self.columns.pop(index) + self.tableview.removeTableColumn_(column._column) + self.invalidate_size_request() + + def get_columns(self): + titles = [] + columns = self.tableview.tableColumns() + for column in columns: + titles.append(column.headerCell().stringValue()) + return titles + + def set_background_color(self, (red, green, blue)): + color = NSColor.colorWithDeviceRed_green_blue_alpha_(red, green, blue, + 1.0) + self.tableview.setBackgroundColor_(color) + + def set_show_headers(self, show): + self._show_headers = show + if show: + self.tableview.setHeaderView_(self.header_view) + else: + self.tableview.setHeaderView_(None) + if self.viewport is not None: + self._remove_views() + self._do_layout() + self._add_views() + self.invalidate_size_request() + self.queue_redraw() + + def is_showing_headers(self): + return self._show_headers + + def set_search_column(self, model_index): + pass + + def try_to_set_row_height(self): + if len(self.model) > 0: + first_iter = self.model.first_iter() + height = calc_row_height(self.tableview, self.model[first_iter]) + self.tableview.setRowHeight_(height) + self.row_height_set = True + + def set_auto_resizes(self, setting): + self.auto_resize = setting + + def set_columns_draggable(self, dragable): + self.tableview.setAllowsColumnReordering_(dragable) + + def set_fixed_height(self, fixed): + if fixed: + self.fixed_height = True + delegate_class = TableViewDelegate + self.row_height_set = False + self.try_to_set_row_height() + else: + self.fixed_height = False + delegate_class = VariableHeightTableViewDelegate + self.delegate = delegate_class.alloc().init() + self.tableview.setDelegate_(self.delegate) + self.tableview.reloadData() + + def row_of_iter(self, iter): + return self.model.row_of_iter(self.tableview, iter) + + def set_context_menu_callback(self, callback): + self.context_menu_callback = callback + + # disable the drag when the cells are constantly updating. Mac OS X + # deals badly with this.. + def set_volatile(self, volatile): + if volatile: + self.data_source.setDragSource_(None) + self.data_source.setDragDest_(None) + else: + self.data_source.setDragSource_(self.drag_source) + self.data_source.setDragDest_(self.drag_dest) + + def set_drag_source(self, drag_source): + self.drag_source = drag_source + self.data_source.setDragSource_(drag_source) + + def set_drag_dest(self, drag_dest): + self.drag_dest = drag_dest + if drag_dest is None: + self.data_source.setDragDest_(None) + else: + types = drag_dest.allowed_types() + self.data_source.setDragDest_(drag_dest) diff --git a/mvc/widgets/osx/utils.py b/mvc/widgets/osx/utils.py new file mode 100644 index 0000000..c0c2d85 --- /dev/null +++ b/mvc/widgets/osx/utils.py @@ -0,0 +1,2 @@ +def filename_to_unicode(filename): + return filename.decode('utf8') diff --git a/mvc/widgets/osx/viewport.py b/mvc/widgets/osx/viewport.py new file mode 100644 index 0000000..e6564d4 --- /dev/null +++ b/mvc/widgets/osx/viewport.py @@ -0,0 +1,101 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".viewport.py -- Viewport classes + +A Viewport represents the area where a Widget is located. +""" + +from objc import YES, NO, nil +from Foundation import * + +class Viewport(object): + """Used when a widget creates it's own NSView.""" + def __init__(self, view, initial_frame): + self.view = view + self.view.setFrame_(initial_frame) + self.placement = initial_frame + + def at_position(self, rect): + """Check if a viewport is currently positioned at rect.""" + return self.placement == rect + + def reposition(self, rect): + """Move the viewport to a different position.""" + self.view.setFrame_(rect) + self.placement = rect + + def remove(self): + self.view.removeFromSuperview() + + def area(self): + """Area of our view that is occupied by the viewport.""" + return NSRect(self.view.bounds().origin, self.placement.size) + + def get_width(self): + return self.view.frame().size.width + + def get_height(self): + return self.view.frame().size.height + + def queue_redraw(self): + opaque_view = self.view.opaqueAncestor() + if opaque_view is not None: + rect = opaque_view.convertRect_fromView_(self.area(), self.view) + opaque_view.setNeedsDisplayInRect_(rect) + + def redraw_now(self): + self.view.displayRect_(self.area()) + +class BorrowedViewport(Viewport): + """Used when a widget uses the NSView of one of it's ancestors. We store + the view that we borrow as well as an NSRect specifying where on that view + we are placed. + """ + def __init__(self, view, placement): + self.view = view + self.placement = placement + + def at_position(self, rect): + return self.placement == rect + + def reposition(self, rect): + self.placement = rect + + def remove(self): + pass + + def area(self): + return self.placement + + def get_width(self): + return self.placement.size.width + + def get_height(self): + return self.placement.size.height diff --git a/mvc/widgets/osx/widgetset.py b/mvc/widgets/osx/widgetset.py new file mode 100644 index 0000000..1203566 --- /dev/null +++ b/mvc/widgets/osx/widgetset.py @@ -0,0 +1,58 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".widgetset -- Contains all the +platform-specific widgets. This module doesn't have any actual code in it, it +just imports the widgets from their actual locations. +""" + +from .const import * +from .control import (TextEntry, NumberEntry, + SecureTextEntry, MultilineTextEntry) +from .control import Checkbox, Button, OptionMenu, RadioButtonGroup, RadioButton +from .customcontrol import (CustomButton, + ContinuousCustomButton, CustomSlider, DragableCustomButton) +from .contextmenu import ContextMenu +from .drawing import DrawingContext, ImageSurface, Gradient +from .drawingwidgets import DrawingArea, Background +from .rect import Rect +from .layout import VBox, HBox, Alignment, Table, Scroller, Expander, TabContainer, DetachedWindowHolder +from .window import Window, MainWindow, Dialog, FileSaveDialog, FileOpenDialog +from .window import DirectorySelectDialog, AboutDialog, AlertDialog, PreferencesWindow, DonateWindow, DialogWindow, get_first_time_dialog_coordinates +from .simple import (Image, ImageDisplay, Label, + SolidBackground, ClickableImageButton, AnimatedImageDisplay, + ProgressBar, HLine) +from .tableview import (TableView, TableColumn, + CellRenderer, CustomCellRenderer, ImageCellRenderer, + CheckboxCellRenderer, + CUSTOM_HEADER_HEIGHT) +from .tablemodel import (TableModel, + TreeTableModel) +from .osxmenus import (MenuBar, Menu, Separator, MenuItem, RadioMenuItem, CheckMenuItem) +from .base import Widget diff --git a/mvc/widgets/osx/widgetupdates.py b/mvc/widgets/osx/widgetupdates.py new file mode 100644 index 0000000..30677c2 --- /dev/null +++ b/mvc/widgets/osx/widgetupdates.py @@ -0,0 +1,72 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""widgetupdates.py -- Handle updates to our widgets +""" + +from PyObjCTools import AppHelper + +class SizeRequestManager(object): + """Helper object to manage size requests + + If something changes in a widget that makes us want to request a new size, + we avoid calculating it immediately. The reason is that the + new-size-request will cascade all the way up the widget tree, and then + result in our widget being placed. We don't necessary want all of this + action to happen while we are in the middle of handling an event + (especially with TableView). It's also inefficient to calculate things + immediately, since we might do something else to invalidate the size + request in the current event. + + SizeRequestManager stores which widgets need to have their size + recalculated, then calls do_invalidate_size_request() using callAfter + """ + + def __init__(self): + self.widgets_to_request = set() + #app.widgetapp.connect("event-processed", self._on_event_processed) + + def add_widget(self, widget): + if len(self.widgets_to_request) == 0: + AppHelper.callAfter(self._run_requests) + self.widgets_to_request.add(widget) + + def _run_requests(self): + this_run = self.widgets_to_request + self.widgets_to_request = set() + for widget in this_run: + widget.do_invalidate_size_request() + + def _on_event_processed(self, app): + # once we finishing handling an event, process our size requests ASAP + # to avoid any potential weirdness. Note: that we also schedule a + # call using callAfter(), often that will do nothing, but it's + # possible size requests get scheduled outside of an event + while self.widgets_to_request: + self._run_requests() diff --git a/mvc/widgets/osx/window.py b/mvc/widgets/osx/window.py new file mode 100644 index 0000000..b959333 --- /dev/null +++ b/mvc/widgets/osx/window.py @@ -0,0 +1,896 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".window -- Top-level Window class. """ + +import logging + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil +from PyObjCTools import AppHelper + +from mvc import signals +from mvc.widgets import widgetconst +import wrappermap +import osxmenus +from .helpers import NotificationForwarder +from .base import Widget, FlippedView +from .layout import VBox, HBox, Alignment +from .control import Button +from .simple import Label +from .rect import Rect, NSRectWrapper +from .utils import filename_to_unicode + +# Tracks all windows that haven't been destroyed. This makes sure there +# object stay alive as long as the window is alive. +alive_windows = set() + +class MiroResponderInterceptor(NSResponder): + """Intercepts cocoa events and gives our wrappers and chance to handle + them first. + """ + + def initWithResponder_(self, responder): + """Initialize a MiroResponderInterceptor + + We will give the wrapper for responder a chance to handle the event, + then pass it along to responder. + """ + self = super(MiroResponderInterceptor, self).init() + self.responder = responder + return self + + def keyDown_(self, event): + if self.sendKeyDownToWrapper_(event): + return # signal handler returned True, stop processing + + # If our responder is the last in the chain, we can stop intercepting + if self.responder.nextResponder() is None: + self.responder.keyDown_(event) + return + + # Here's the tricky part, we want to call keyDown_ on our responder, + # but if it doesn't handle the event, then it will pass it along to + # it's next responder. We need to set things up so that we will + # intercept that call. + + # Make a new MiroResponderInterceptor whose responder is the next + # responder down the chain. + next_intercepter = MiroResponderInterceptor.alloc().initWithResponder_( + self.responder.nextResponder()) + # Install the interceptor + self.responder.setNextResponder_(next_intercepter) + # Send event along + self.responder.keyDown_(event) + # Restore old nextResponder value + self.responder.setNextResponder_(next_intercepter.responder) + + def sendKeyDownToWrapper_(self, event): + """Give a keyDown event to the wrapper for our responder + + Return True if the wrapper handled the event + """ + key = event.charactersIgnoringModifiers() + if len(key) != 1 or not key.isalnum(): + key = osxmenus.REVERSE_KEYS_MAP.get(key) + mods = osxmenus.translate_event_modifiers(event) + wrapper = wrappermap.wrapper(self.responder) + if isinstance(wrapper, Widget) or isinstance(wrapper, Window): + if wrapper.emit('key-press', key, mods): + return True + return False + +class MiroWindow(NSWindow): + def initWithContentRect_styleMask_backing_defer_(self, rect, mask, + backing, defer): + self = NSWindow.initWithContentRect_styleMask_backing_defer_(self, + rect, mask, backing, defer) + self._last_focus_chain = None + return self + + def handleKeyDown_(self, event): + if self.handle_tab_navigation(event): + return + interceptor = MiroResponderInterceptor.alloc().initWithResponder_( + self.firstResponder()) + interceptor.keyDown_(event) + + def handle_tab_navigation(self, event): + """Handle tab navigation through the window. + + :returns: True if we handled the event + """ + keystr = event.charactersIgnoringModifiers() + if keystr[0] == NSTabCharacter: + # handle cycling through views with Tab. + self.focusNextKeyView_(True) + return True + elif keystr[0] == NSBackTabCharacter: + self.focusNextKeyView_(False) + return True + return False + + def acceptsMouseMovedEvents(self): + # HACK: for some reason calling setAcceptsMouseMovedEvents_() doesn't + # work, we have to forcefully override this method. + return NO + + def sendEvent_(self, event): + if event.type() == NSKeyDown: + self.handleKeyDown_(event) + else: + NSWindow.sendEvent_(self, event) + + def _calc_current_focus_wrapper(self): + responder = self.firstResponder() + while responder: + wrapper = wrappermap.wrapper(responder) + # check if we have a wrapper for the view, if not try the parent + # view + if wrapper is not None: + return wrapper + responder = responder.superview() + return None + + def focusNextKeyView_(self, is_forward): + current_focus = self._calc_current_focus_wrapper() + my_wrapper = wrappermap.wrapper(self) + next_focus = my_wrapper.get_next_tab_focus(current_focus, is_forward) + if next_focus is not None: + next_focus.focus() + + def draggingEntered_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.draggingEntered_(info) or NSDragOperationNone + + def draggingUpdated_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.draggingUpdated_(info) or NSDragOperationNone + + def draggingExited_(self, info): + wrapper = wrappermap.wrapper(self) + wrapper.draggingExited_(info) + + def prepareForDragOperation_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.prepareForDragOperation_(info) or NO + + def performDragOperation_(self, info): + wrapper = wrappermap.wrapper(self) + return wrapper.performDragOperation_(info) or NO + +class MainMiroWindow(MiroWindow): + def isMovableByWindowBackground(self): + return YES + +class Window(signals.SignalEmitter): + """See https://develop.participatoryculture.org/index.php/WidgetAPI for a description of the API for this class.""" + def __init__(self, title, rect=None): + signals.SignalEmitter.__init__(self) + self.create_signal('active-change') + self.create_signal('will-close') + self.create_signal('did-move') + self.create_signal('key-press') + self.create_signal('show') + self.create_signal('hide') + self.create_signal('on-shown') + self.create_signal('file-drag-motion') + self.create_signal('file-drag-received') + self.create_signal('file-drag-leave') + self.is_closing = False + if rect is None: + rect = Rect(0, 0, 470, 600) + self.nswindow = MainMiroWindow.alloc().initWithContentRect_styleMask_backing_defer_( + rect.nsrect, + self.get_style_mask(), + NSBackingStoreBuffered, + NO) + self.nswindow.setTitle_(title) + self.nswindow.setMinSize_(NSSize(470, 600)) + self.nswindow.setReleasedWhenClosed_(NO) + self.content_view = FlippedView.alloc().initWithFrame_(rect.nsrect) + self.content_view.setAutoresizesSubviews_(NO) + self.nswindow.setContentView_(self.content_view) + self.content_widget = None + self.view_notifications = NotificationForwarder.create(self.content_view) + self.view_notifications.connect(self.on_frame_change, 'NSViewFrameDidChangeNotification') + self.window_notifications = NotificationForwarder.create(self.nswindow) + self.window_notifications.connect(self.on_activate, 'NSWindowDidBecomeMainNotification') + self.window_notifications.connect(self.on_deactivate, 'NSWindowDidResignMainNotification') + self.window_notifications.connect(self.on_did_move, 'NSWindowDidMoveNotification') + self.window_notifications.connect(self.on_will_close, 'NSWindowWillCloseNotification') + wrappermap.add(self.nswindow, self) + alive_windows.add(self) + + def get_next_tab_focus(self, current, is_forward): + """Return the next widget to cycle through for keyboard focus + + Subclasses can override this to for find-grained control of keyboard + focus. + + :param current: currently-focused widget + :param is_forward: are we tabbing forward? + """ + return None + + # XXX Use MainWindow not Window for MVCStyle/MiroStyle + def get_style_mask(self): + return (NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask) + + def set_title(self, title): + self.nswindow.setTitle_(title) + + def get_title(self): + return self.nswindow.title() + + def on_frame_change(self, notification): + self.place_child() + + def on_activate(self, notification): + self.emit('active-change') + + def on_deactivate(self, notification): + self.emit('active-change') + + def on_did_move(self, notification): + self.emit('did-move') + + def on_will_close(self, notification): + # unset the first responder. This allows text entry widgets to get + # the NSControlTextDidEndEditingNotification + if self.is_closing: + logging.info('on_will_close: already closing') + return + self.is_closing = True + self.nswindow.makeFirstResponder_(nil) + self.emit('will-close') + self.emit('hide') + self.is_closing = False + + def is_active(self): + return self.nswindow.isMainWindow() + + def is_visible(self): + return self.nswindow.isVisible() + + def show(self): + if self not in alive_windows: + raise ValueError("Window destroyed") + self.nswindow.makeKeyAndOrderFront_(nil) + self.nswindow.makeMainWindow() + self.emit('show') + # Cocoa doesn't apply default selections as forcefully as GTK, so + # currently there's no need for on-shown to actually wait until the + # window has been shown here + self.emit('on-shown') + + def close(self): + self.nswindow.close() + + def destroy(self): + self.close() + self.window_notifications.disconnect() + self.view_notifications.disconnect() + self.nswindow.setContentView_(nil) + wrappermap.remove(self.nswindow) + alive_windows.discard(self) + self.nswindow = None + + def place_child(self): + rect = self.nswindow.contentRectForFrameRect_(self.nswindow.frame()) + self.content_widget.place(NSRect(NSPoint(0, 0), rect.size), + self.content_view) + + def hookup_content_widget_signals(self): + self.size_req_handler = self.content_widget.connect('size-request-changed', + self.on_content_widget_size_request_change) + + def unhook_content_widget_signals(self): + self.content_widget.disconnect(self.size_req_handler) + self.size_req_handler = None + + def on_content_widget_size_request_change(self, widget, old_size): + self.update_size_constraints() + + def set_content_widget(self, widget): + if self.content_widget: + self.content_widget.remove_viewport() + self.unhook_content_widget_signals() + self.content_widget = widget + self.hookup_content_widget_signals() + self.place_child() + self.update_size_constraints() + + def update_size_constraints(self): + width, height = self.content_widget.get_size_request() + # It is possible the window is torn down between the size invalidate + # request and the actual size invalidation invocation. So check + # to see if nswindow is there if not then do not do anything. + if self.nswindow: + # FIXME: I'm not sure that this code does what we want it to do. + # It enforces the min-size when the user drags the window, but I + # think it should also call setContentSize_ if the window is + # currently too small to fit the content - BDK + self.nswindow.setContentMinSize_(NSSize(width, height)) + rect = self.nswindow.contentRectForFrameRect_(self.nswindow.frame()) + if rect.size.width < width or rect.size.height < height: + logging.warn("Content widget too large for this window " + "size available: %dx%d widget size: %dx%d", + rect.size.width, rect.size.height, width, height) + + def get_content_widget(self): + return self.content_widget + + def get_frame(self): + frame = self.nswindow.frame() + frame.size.height -= 22 + return NSRectWrapper(frame) + + def connect_menu_keyboard_shortcuts(self): + # All OS X windows are connected to the menu shortcuts + pass + + def accept_file_drag(self, val): + if not val: + self.drag_dest = None + else: + self.drag_dest = NSDragOperationCopy + self.nswindow.registerForDraggedTypes_([NSFilenamesPboardType]) + + def prepareForDragOperation_(self, info): + return NO if self.drag_dest is None else YES + + def performDragOperation_(self, info): + pb = info.draggingPasteboard() + available_types = set(pb.types()) & set([NSFilenamesPboardType]) + drag_ok = False + if available_types: + type_ = available_types.pop() + # DANCE! Everybody dance for portable Python code! + values = [unicode( + NSURL.fileURLWithPath_(v).filePathURL()).encode('utf-8') + for v in list(pb.propertyListForType_(type_))] + self.emit('file-drag-received', values) + drag_ok = True + self.draggingExited_(info) + return drag_ok + + def draggingEntered_(self, info): + return self.draggingUpdated_(info) + + def draggingUpdated_(self, info): + self.emit('file-drag-motion') + return self.drag_dest + + def draggingExited_(self, info): + self.emit('file-drag-leave') + + def center(self): + self.nswindow.center() + +class MainWindow(Window): + def __init__(self, title, rect): + Window.__init__(self, title, rect) + self.nswindow.setReleasedWhenClosed_(NO) + + def close(self): + self.nswindow.orderOut_(nil) + +class DialogBase(object): + def __init__(self): + self.sheet_parent = None + def set_transient_for(self, window): + self.sheet_parent = window + +class MiroPanel(NSPanel): + def cancelOperation_(self, event): + wrappermap.wrapper(self).end_with_code(-1) + +class Dialog(DialogBase): + def __init__(self, title, description=None): + DialogBase.__init__(self) + self.title = title + self.description = description + self.buttons = [] + self.extra_widget = None + self.window = None + self.running = False + + def add_button(self, text): + button = Button(text) + button.set_size(widgetconst.SIZE_NORMAL) + button.connect('clicked', self.on_button_clicked, len(self.buttons)) + self.buttons.append(button) + + def on_button_clicked(self, button, code): + self.end_with_code(code) + + def end_with_code(self, code): + if self.sheet_parent is not None: + NSApp().endSheet_returnCode_(self.window, code) + else: + NSApp().stopModalWithCode_(code) + + def build_text(self): + vbox = VBox(spacing=6) + if self.description is not None: + description_label = Label(self.description, wrap=True) + description_label.set_bold(True) + description_label.set_size_request(360, -1) + vbox.pack_start(description_label) + return vbox + + def build_buttons(self): + hbox = HBox(spacing=12) + for button in reversed(self.buttons): + hbox.pack_start(button) + alignment = Alignment(xalign=1.0, yscale=1.0) + alignment.add(hbox) + return alignment + + def build_content(self): + vbox = VBox(spacing=12) + vbox.pack_start(self.build_text()) + if self.extra_widget: + vbox.pack_start(self.extra_widget) + vbox.pack_start(self.build_buttons()) + alignment = Alignment(xscale=1.0, yscale=1.0) + alignment.set_padding(12, 12, 17, 17) + alignment.add(vbox) + return alignment + + def build_window(self): + self.content_widget = self.build_content() + width, height = self.content_widget.get_size_request() + width = max(width, 400) + window = MiroPanel.alloc() + window.initWithContentRect_styleMask_backing_defer_( + NSMakeRect(400, 400, width, height), + NSTitledWindowMask, NSBackingStoreBuffered, NO) + view = FlippedView.alloc().initWithFrame_(NSMakeRect(0, 0, width, + height)) + window.setContentView_(view) + window.setTitle_(self.title) + self.content_widget.place(view.frame(), view) + if self.buttons: + self.buttons[0].make_default() + return window + + def hookup_content_widget_signals(self): + self.size_req_handler = self.content_widget.connect( + 'size-request-changed', + self.on_content_widget_size_request_change) + + def unhook_content_widget_signals(self): + self.content_widget.disconnect(self.size_req_handler) + self.size_req_handler = None + + def on_content_widget_size_request_change(self, widget, old_size): + width, height = self.content_widget.get_size_request() + # It is possible the window is torn down between the size invalidate + # request and the actual size invalidation invocation. So check + # to see if nswindow is there if not then do not do anything. + if self.window and (width, height) != old_size: + self.change_content_size(width, height) + + def change_content_size(self, width, height): + content_rect = self.window.contentRectForFrameRect_( + self.window.frame()) + # Cocoa's coordinate system is funky, adjust y so that the top stays + # in place + content_rect.origin.y += (content_rect.size.height - height) + # change our frame to fit the new content. It would be nice to + # animate the change, but timers don't work when we are displaying a + # modal dialog + content_rect.size = NSSize(width, height) + new_frame = self.window.frameRectForContentRect_(content_rect) + self.window.setFrame_display_(new_frame, NO) + # Need to call place() again, since our window has changed size + contentView = self.window.contentView() + self.content_widget.place(contentView.frame(), contentView) + + def run(self): + self.window = self.build_window() + wrappermap.add(self.window, self) + self.hookup_content_widget_signals() + self.running = True + if self.sheet_parent is None: + response = NSApp().runModalForWindow_(self.window) + if self.window: + self.window.close() + else: + delegate = SheetDelegate.alloc().init() + NSApp().beginSheet_modalForWindow_modalDelegate_didEndSelector_contextInfo_( + self.window, self.sheet_parent.nswindow, + delegate, 'sheetDidEnd:returnCode:contextInfo:', 0) + response = NSApp().runModalForWindow_(self.window) + if self.window: + # self.window won't be around if we call destroy() to cancel + # the dialog + self.window.orderOut_(nil) + self.running = False + self.unhook_content_widget_signals() + + if response < 0: + return -1 + return response + + def destroy(self): + if self.running: + NSApp().stopModalWithCode_(-1) + + if self.window is not None: + self.window.setContentView_(None) + self.window.close() + self.window = None + self.buttons = None + self.extra_widget = None + + def set_extra_widget(self, widget): + self.extra_widget = widget + + def get_extra_widget(self): + return self.extra_widget + +class SheetDelegate(NSObject): + @AppHelper.endSheetMethod + def sheetDidEnd_returnCode_contextInfo_(self, sheet, return_code, info): + NSApp().stopModalWithCode_(return_code) + +class FileDialogBase(DialogBase): + def __init__(self): + DialogBase.__init__(self) + self._types = None + self._filename = None + self._directory = None + self._filter_on_run = True + + def run(self): + self._panel.setAllowedFileTypes_(self._types) + if self.sheet_parent is None: + if self._filter_on_run: + response = self._panel.runModalForDirectory_file_types_(self._directory, self._filename, self._types) + else: + response = self._panel.runModalForDirectory_file_(self._directory, self._filename) + else: + delegate = SheetDelegate.alloc().init() + if self._filter_on_run: + self._panel.beginSheetForDirectory_file_types_modalForWindow_modalDelegate_didEndSelector_contextInfo_( + self._directory, self._filename, self._types, + self.sheet_parent.nswindow, delegate, 'sheetDidEnd:returnCode:contextInfo:', 0) + else: + self._panel.beginSheetForDirectory_file_modalForWindow_modalDelegate_didEndSelector_contextInfo_( + self._directory, self._filename, + self.sheet_parent.nswindow, delegate, 'sheetDidEnd:returnCode:contextInfo:', 0) + response = NSApp().runModalForWindow_(self._panel) + self._panel.orderOut_(nil) + return response + +class FileSaveDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._title = title + self._panel = NSSavePanel.savePanel() + self._panel.setCanChooseFiles_(YES) + self._panel.setCanChooseDirectories_(NO) + self._filename = None + self._filter_on_run = False + + def set_filename(self, s): + self._filename = filename_to_unicode(s) + + def get_filename(self): + # Use encode('utf-8') instead of unicode_to_filename, because + # unicode_to_filename has code to make sure nextFilename works, but it's + # more important here to not change the filename. + return self._filename.encode('utf-8') + + def run(self): + response = FileDialogBase.run(self) + if response == NSFileHandlingPanelOKButton: + self._filename = self._panel.filename() + return 0 + self._filename = "" + + def destroy(self): + self._panel = None + + set_path = set_filename + get_path = get_filename + +class FileOpenDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._title = title + self._panel = NSOpenPanel.openPanel() + self._panel.setCanChooseFiles_(YES) + self._panel.setCanChooseDirectories_(NO) + self._filenames = None + + def set_select_multiple(self, value): + if value: + self._panel.setAllowsMultipleSelection_(YES) + else: + self._panel.setAllowsMultipleSelection_(NO) + + def set_directory(self, d): + self._directory = filename_to_unicode(d) + + def set_filename(self, s): + self._filename = filename_to_unicode(s) + + def add_filters(self, filters): + self._types = [] + for _, t in filters: + self._types += t + + def get_filename(self): + if self._filenames is None: + # canceled + return None + return self.get_filenames()[0] + + def get_filenames(self): + if self._filenames is None: + # canceled + return [] + # Use encode('utf-8') instead of unicode_to_filename, because + # unicode_to_filename has code to make sure nextFilename works, but it's + # more important here to not change the filename. + return [f.encode('utf-8') for f in self._filenames] + + def run(self): + response = FileDialogBase.run(self) + if response == NSFileHandlingPanelOKButton: + self._filenames = self._panel.filenames() + return 0 + self._filename = '' + self._filenames = None + + def destroy(self): + self._panel = None + + set_path = set_filename + get_path = get_filename + +class DirectorySelectDialog(FileDialogBase): + def __init__(self, title): + FileDialogBase.__init__(self) + self._title = title + self._panel = NSOpenPanel.openPanel() + self._panel.setCanChooseFiles_(NO) + self._panel.setCanChooseDirectories_(YES) + self._directory = None + + def set_directory(self, d): + self._directory = filename_to_unicode(d) + + def get_directory(self): + # Use encode('utf-8') instead of unicode_to_filename, because + # unicode_to_filename has code to make sure nextFilename + # works, but it's more important here to not change the + # filename. + return self._directory.encode('utf-8') + + def run(self): + response = FileDialogBase.run(self) + if response == NSFileHandlingPanelOKButton: + self._directory = self._panel.filenames()[0] + return 0 + self._directory = "" + + def destroy(self): + self._panel = None + + set_path = set_directory + get_path = get_directory + +class AboutDialog(DialogBase): + def run(self): + optionsDictionary = dict() + #revision = app.config.get(prefs.APP_REVISION_NUM) + #if revision: + # optionsDictionary['Version'] = revision + if not optionsDictionary: + optionsDictionary = nil + NSApplication.sharedApplication().orderFrontStandardAboutPanelWithOptions_(optionsDictionary) + def destroy(self): + pass + +class AlertDialog(DialogBase): + def __init__(self, title, message, alert_type): + DialogBase.__init__(self) + self._nsalert = NSAlert.alloc().init(); + self._nsalert.setMessageText_(title) + self._nsalert.setInformativeText_(message) + self._nsalert.setAlertStyle_(alert_type) + def add_button(self, text): + self._nsalert.addButtonWithTitle_(text) + def run(self): + self._nsalert.runModal() + def destroy(self): + self._nsalert = nil + +class PreferenceItem(NSToolbarItem): + + def setPanel_(self, panel): + self.panel = panel + +class PreferenceToolbarDelegate(NSObject): + + def initWithPanels_identifiers_window_(self, panels, identifiers, window): + self = super(PreferenceToolbarDelegate, self).init() + self.panels = panels + self.identifiers = identifiers + self.window = window + return self + + def toolbarAllowedItemIdentifiers_(self, toolbar): + return self.identifiers + + def toolbarDefaultItemIdentifiers_(self, toolbar): + return self.identifiers + + def toolbarSelectableItemIdentifiers_(self, toolbar): + return self.identifiers + + def toolbar_itemForItemIdentifier_willBeInsertedIntoToolbar_(self, toolbar, + itemIdentifier, + flag): + panel = self.panels[itemIdentifier] + item = PreferenceItem.alloc().initWithItemIdentifier_(itemIdentifier) + item.setLabel_(unicode(panel[1])) + item.setImage_(NSImage.imageNamed_(u"pref_tab_%s" % itemIdentifier)) + item.setAction_("switchPreferenceView:") + item.setTarget_(self) + item.setPanel_(panel[0]) + return item + + def validateToolbarItem_(self, item): + return YES + + def switchPreferenceView_(self, sender): + self.window.do_select_panel(sender.panel, YES) + +class DialogWindow(Window): + def __init__(self, title, rect, allow_miniaturize=False): + self.allow_miniaturize = allow_miniaturize + Window.__init__(self, title, rect) + self.nswindow.setShowsToolbarButton_(NO) + + def get_style_mask(self): + mask = (NSTitledWindowMask | NSClosableWindowMask) + if self.allow_miniaturize: + mask |= NSMiniaturizableWindowMask + return mask + +class DonateWindow(Window): + def __init__(self, title): + Window.__init__(self, title, Rect(0, 0, 640, 440)) + self.panels = dict() + self.identifiers = list() + self.first_show = True + self.nswindow.setShowsToolbarButton_(NO) + self.nswindow.setReleasedWhenClosed_(NO) + self.app_notifications = NotificationForwarder.create(NSApp()) + self.app_notifications.connect(self.on_app_quit, + 'NSApplicationWillTerminateNotification') + + def destroy(self): + super(PreferencesWindow, self).destroy() + self.app_notifications.disconnect() + + def get_style_mask(self): + return (NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask) + + def show(self): + if self.first_show: + self.nswindow.center() + self.first_show = False + Window.show(self) + + def on_app_quit(self, notification): + self.close() + +class PreferencesWindow(Window): + def __init__(self, title): + Window.__init__(self, title, Rect(0, 0, 640, 440)) + self.panels = dict() + self.identifiers = list() + self.first_show = True + self.nswindow.setShowsToolbarButton_(NO) + self.nswindow.setReleasedWhenClosed_(NO) + self.app_notifications = NotificationForwarder.create(NSApp()) + self.app_notifications.connect(self.on_app_quit, + 'NSApplicationWillTerminateNotification') + + def destroy(self): + super(PreferencesWindow, self).destroy() + self.app_notifications.disconnect() + + def get_style_mask(self): + return (NSTitledWindowMask | NSClosableWindowMask | + NSMiniaturizableWindowMask) + + def append_panel(self, name, panel, title, image_name): + self.panels[name] = (panel, title) + self.identifiers.append(name) + + def finish_panels(self): + self.tbdelegate = PreferenceToolbarDelegate.alloc().initWithPanels_identifiers_window_(self.panels, self.identifiers, self) + toolbar = NSToolbar.alloc().initWithIdentifier_(u"Preferences") + toolbar.setAllowsUserCustomization_(NO) + toolbar.setDelegate_(self.tbdelegate) + + self.nswindow.setToolbar_(toolbar) + + def select_panel(self, index): + panel = self.identifiers[index] + self.nswindow.toolbar().setSelectedItemIdentifier_(panel) + self.do_select_panel(self.panels[panel][0], NO) + + def do_select_panel(self, panel, animate): + wframe = self.nswindow.frame() + vsize = list(panel.get_size_request()) + if vsize[0] < 650: + vsize[0] = 650 + if vsize[1] < 200: + vsize[1] = 200 + + toolbarHeight = wframe.size.height - self.nswindow.contentView().frame().size.height + wframe.origin.y += wframe.size.height - vsize[1] - toolbarHeight + wframe.size = (vsize[0], vsize[1] + toolbarHeight) + + self.set_content_widget(panel) + self.nswindow.setFrame_display_animate_(wframe, YES, animate) + + def show(self): + if self.first_show: + self.nswindow.center() + self.first_show = False + Window.show(self) + + def on_app_quit(self, notification): + self.close() + +def get_first_time_dialog_coordinates(width, height): + """Returns the coordinates for the first time dialog. + """ + # windowFrame is None on first run. in that case, we want + # to put librevideoconverter in the middle. + mainscreen = NSScreen.mainScreen() + rect = mainscreen.frame() + + x = (rect.size.width - width) / 2 + y = (rect.size.height - height) / 2 + + return x, y diff --git a/mvc/widgets/osx/wrappermap.py b/mvc/widgets/osx/wrappermap.py new file mode 100644 index 0000000..624a496 --- /dev/null +++ b/mvc/widgets/osx/wrappermap.py @@ -0,0 +1,48 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +""".wrappermap -- Map NSViews and NSWindows to the +Widget that wraps them. +""" + +# Maps NSViews/NSWinows -> wrapper objects. +wrapper_mapping = dict() + +def wrapper(wrapped): + """Find the wrapper object for an NSView/NSWindow.""" + try: + return wrapper_mapping[wrapped] + except KeyError: + return None + +def add(wrapped, wrapper): + wrapper_mapping[wrapped] = wrapper + +def remove(wrapped): + del wrapper_mapping[wrapped] diff --git a/mvc/widgets/tablescroll.py b/mvc/widgets/tablescroll.py new file mode 100644 index 0000000..841e62c --- /dev/null +++ b/mvc/widgets/tablescroll.py @@ -0,0 +1,154 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tablescroll.py -- High-level scroll management. This ensures that behavior +like scroll_to_item works the same way across platforms. +""" + +from mvc.errors import WidgetActionError + + +class ScrollbarOwnerMixin(object): + """Scrollbar management for TableView. + + External methods have undecorated names; internal methods start with _. + + External methods: + - handle failure themselves (e.g. return None or retry later) + - return basic data types (e.g. (x, y) tuples) + - use "tree" coordinates + + Internal methods (intended to be used by ScrollbarOwnerMixin and the + platform implementations): + - raise WidgetActionError subclasses on failure + - use Rect/Point structs + - also use "tree" coordinates + """ + def __init__(self, _work_around_17153=False): + self.__work_around_17153 = _work_around_17153 + self._scroll_to_iter_callback = None + self.create_signal('scroll-range-changed') + + def scroll_to_iter(self, iter_, manual=True, recenter=False): + """Scroll the given item into view. + + manual: scroll even if we were not following the playing item + recenter: scroll even if item is in top half of view + """ + try: + item = self._get_item_area(iter_) + visible = self._get_visible_area() + manually_scrolled = self._manually_scrolled + except WidgetActionError: + if self._scroll_to_iter_callback: + # We just retried and failed. Do nothing; we will retry again + # next time scrollable range changes. + return + # We just tried and failed; schedule a retry when the scrollable + # range changes. + self._scroll_to_iter_callback = self.connect('scroll-range-changed', + lambda *a: self.scroll_to_iter(iter_, manual, recenter)) + return + # If the above succeeded, we know the iter's position; this means we can + # set_scroll_position to that position. That may work now or be + # postponed until later, but either way we're done with scroll_to_iter. + if self._scroll_to_iter_callback: + self.disconnect(self._scroll_to_iter_callback) + self._scroll_to_iter_callback = None + visible_bottom = visible.y + visible.height + visible_middle = visible.y + visible.height // 2 + item_bottom = item.y + item.height + item_middle = item.y + item.height // 2 + in_top = item_bottom >= visible.y and item.y <= visible_middle + in_bottom = item_bottom >= visible_middle and item.y <= visible_bottom + if self._should_scroll( + manual, in_top, in_bottom, recenter, manually_scrolled): + destination = item_middle - visible.height // 2 + self._set_vertical_scroll(destination) + # set_scroll_position will take care of scroll to the position when + # possible; this may or may not be now, but our work here is done. + + def set_scroll_position(self, position, restore_only=False, + _hack_for_17153=False): + """Scroll the top left corner to the given (x, y) offset from the origin + of the view. + + restore_only: set the value only if no other value has been set yet + """ + if _hack_for_17153 and not self.__work_around_17153: + return + if not restore_only or not self._position_set: + self._set_scroll_position(position) + + @classmethod + def _should_scroll(cls, + manual, in_top, in_bottom, recenter, manually_scrolled): + if not manual and manually_scrolled: + # The user has moved the scrollbars since we last autoscrolled, and + # we're deciding whether we should resume autoscrolling. + # We want to do that when the currently-playing item catches up to + # the center of the screen i.e. is part above the center, part below + return in_top and in_bottom + # This is a manual scroll, or we're already autoscrolling - so we no + # longer need to worry about either manual or manually_scrolled + if in_top: + # The item is in the top half; let playback catch up with the + # current scroll position, unless recentering has been requested + return recenter + if in_bottom: + # We land here when: + # - playback has begun with an item in the bottom half of the screen + # - scroll is following sequential playback + # Either way we want to jump down to the item. + return True + # We're scrolling to an item that's not in view because: + # - playback has begun with an item that is out of sight + # - we're autoscrolling on shuffle + # Either way we want to show the item. + return True + + def reset_scroll(self): + """To scroll back to the origin; platform code might want to do + something special to forget the current position when this happens. + """ + self.set_scroll_position((0, 0)) + + def get_scroll_position(self): + """Returns the current scroll position, or None if not ready.""" + try: + return tuple(self._get_scroll_position()) + except WidgetActionError: + return None + + def _set_vertical_scroll(self, pos): + """Helper to set our vertical position without affecting our horizontal + position. + """ + # FIXME: shouldn't reset horizontal position + self.set_scroll_position((0, pos)) diff --git a/mvc/widgets/tablescroll.pyc b/mvc/widgets/tablescroll.pyc Binary files differnew file mode 100644 index 0000000..36d2206 --- /dev/null +++ b/mvc/widgets/tablescroll.pyc diff --git a/mvc/widgets/tableselection.py b/mvc/widgets/tableselection.py new file mode 100644 index 0000000..d087d34 --- /dev/null +++ b/mvc/widgets/tableselection.py @@ -0,0 +1,220 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""tableselection.py -- High-level selection management. Subclasses defined in +the platform tableview modules provide the platform-specific methods used here. +""" + +from contextlib import contextmanager + +from mvc.errors import WidgetActionError, WidgetUsageError + +class SelectionOwnerMixin(object): + """Encapsulates the selection functionality of a TableView, for + consistent behavior across platforms. + + Emits: + :signal selection-changed: the selection has been changed + :signal selection-invalid: the item selected can no longer be selected + :signal deselected: all items have been deselected + """ + def __init__(self): + self._ignore_selection_changed = 0 + self._allow_multiple_select = None + self.create_signal('selection-changed') + self.create_signal('selection-invalid') + self.create_signal('deselected') + + @property + def allow_multiple_select(self): + """Return whether the widget allows multiple selection.""" + if self._allow_multiple_select is None: + self._allow_multiple_select = self._get_allow_multiple_select() + return self._allow_multiple_select + + @allow_multiple_select.setter + def allow_multiple_select(self, allow): + """Set whether to allow multiple selection; this method is expected + always to work. + """ + if self._allow_multiple_select != allow: + self._set_allow_multiple_select(allow) + self._allow_multiple_select = allow + + @property + def num_rows_selected(self): + """Override on platforms with a way to count rows without having to + retrieve them. + """ + if self.allow_multiple_select: + return len(self._get_selected_iters()) + else: + return int(self._get_selected_iter() is not None) + + def select(self, iter_, signal=False): + """Try to select an iter. + + :raises WidgetActionError: iter does not exist or is not selectable + """ + self.select_iters((iter_,), signal) + + def select_iters(self, iters, signal=False): + """Try to select multiple iters (signaling at most once). + + :raises WidgetActionError: iter does not exist or is not selectable + """ + with self._ignoring_changes(not signal): + for iter_ in iters: + self._select(iter_) + if not all(self._is_selected(iter_) for iter_ in iters): + raise WidgetActionError("the specified iter cannot be selected") + + def is_selected(self, iter_): + """Test if an iter is selected""" + return self._is_selected(iter_) + + def unselect(self, iter_): + """Unselect an Iter. Fails silently if the Iter is not selected. + """ + self._validate_iter(iter_) + with self._ignoring_changes(): + self._unselect(iter_) + + def unselect_iters(self, iters): + """Unselect iters. Fails silently if the iters are not selected.""" + with self._ignoring_changes(): + for iter_ in iters: + self.unselect(iter_) + + def unselect_all(self, signal=True): + """Unselect all. emits only the 'deselected' signal.""" + with self._ignoring_changes(): + self._unselect_all() + if signal: + self.emit('deselected') + + def on_selection_changed(self, _widget_or_notification): + """When we receive a selection-changed signal, we forward it if we're + not in a 'with _ignoring_changes' block. Selection-changed + handlers are run in an ignoring block, and anything that changes the + selection to reflect the current state. + """ + # don't bother sending out a second selection-changed signal if + # the handler changes the selection (#15767) + if not self._ignore_selection_changed: + with self._ignoring_changes(): + self.emit('selection-changed') + + def get_selection_as_strings(self): + """Returns the current selection as a list of strings. + """ + return [self._iter_to_string(iter_) for iter_ in self.get_selection()] + + def set_selection_as_strings(self, selected): + """Given a list of selection strings, selects each Iter represented by + the strings. + + Raises WidgetActionError upon failure. + """ + # iter may not be destringable (yet) - bounds error + # destringed iter not selectable if parent isn't open (yet) + self.set_selection(self._iter_from_string(sel) for sel in selected) + + def get_cursor(self): + """Get the location of the keyboard cursor for the tableview. + + Returns a string that represents the row that the keyboard cursor is + on. + """ + + def set_cursor(self, location): + """Set the location of the keyboard cursor for the tableview. + + :param location: return value from a call to get_cursor() + + Raises WidgetActionError upon failure. + """ + + def get_selection(self): + """Returns a list of GTK Iters. Works regardless of whether multiple + selection is enabled. + """ + return self._get_selected_iters() + + def get_selected(self): + """Return the single selected item. + + :raises WidgetUsageError: multiple selection is enabled + """ + if self.allow_multiple_select: + raise WidgetUsageError("table allows multiple selection") + return self._get_selected_iter() + + def _validate_iter(self, iter_): + """Check whether an iter is valid. + + :raises WidgetDomainError: the iter is not valid + :raises WidgetActionError: there is no model right now + """ + + @contextmanager + def _ignoring_changes(self, ignoring=True): + """Use this with with to prevent sending signals when we're changing + our own selection; that way, when we get a signal, we know it's + something important. + """ + if ignoring: + self._ignore_selection_changed += 1 + try: + yield + finally: + if ignoring: + self._ignore_selection_changed -= 1 + + @contextmanager + def preserving_selection(self): + """Prevent selection changes in a block from having any effect or + sticking - no signals will be sent, and the selection will be restored + to its original value when the block exits. + """ + iters = self._get_selected_iters() + with self._ignoring_changes(): + try: + yield + finally: + self.set_selection(iters) + + def set_selection(self, iters, signal=False): + """Set the selection to the given iters, replacing any previous + selection and signaling at most once. + """ + self.unselect_all(signal=False) + for iter_ in iters: + self.select(iter_, signal=False) + if signal: self.emit('selection-changed') diff --git a/mvc/widgets/tableselection.pyc b/mvc/widgets/tableselection.pyc Binary files differnew file mode 100644 index 0000000..76c1f10 --- /dev/null +++ b/mvc/widgets/tableselection.pyc diff --git a/mvc/widgets/widgetconst.py b/mvc/widgets/widgetconst.py new file mode 100644 index 0000000..bbb513c --- /dev/null +++ b/mvc/widgets/widgetconst.py @@ -0,0 +1,44 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""``miro.frontends.widgets.widgetconst`` -- Constants for the widgets +frontend. +""" + +# Control sizes +SIZE_NORMAL = -1 +SIZE_SMALL = -2 + +TEXT_JUSTIFY_LEFT = 0 +TEXT_JUSTIFY_RIGHT = 1 +TEXT_JUSTIFY_CENTER = 2 + +# cursors +CURSOR_NORMAL = 0 +CURSOR_POINTING_HAND = 1 diff --git a/mvc/widgets/widgetconst.pyc b/mvc/widgets/widgetconst.pyc Binary files differnew file mode 100644 index 0000000..79fafc9 --- /dev/null +++ b/mvc/widgets/widgetconst.pyc diff --git a/mvc/widgets/widgetutil.py b/mvc/widgets/widgetutil.py new file mode 100644 index 0000000..5fb3db2 --- /dev/null +++ b/mvc/widgets/widgetutil.py @@ -0,0 +1,225 @@ +from math import pi as PI +from mvc.widgets import widgetset +from mvc.resources import image_path + +def make_surface(image_name, height=None): + path = image_path(image_name + '.png') + image = widgetset.Image(path) + if height is not None: + image = image.resize(image.width, height) + return widgetset.ImageSurface(image) + +def font_scale_from_osx_points(points): + """Create a font scale so that it's points large on OS X. + + Assumptions (these should be true for OS X) + - the default font size is 13pt + - the DPI is 72ppi + """ + return points / 13.0 + +def css_to_color(css_string): + parts = (css_string[1:3], css_string[3:5], css_string[5:7]) + return tuple((int(value, 16) / 255.0) for value in parts) + +def align(widget, xalign=0, yalign=0, xscale=0, yscale=0, + top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Create an alignment, then add widget to it and return the alignment. + """ + alignment = widgetset.Alignment(xalign, yalign, xscale, yscale, + top_pad, bottom_pad, left_pad, right_pad) + alignment.add(widget) + return alignment + +def align_center(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will center it horizontally. + """ + return align(widget, 0.5, 0, 0, 1, + top_pad, bottom_pad, left_pad, right_pad) + +def align_right(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align it left. + """ + return align(widget, 1, 0, 0, 1, top_pad, bottom_pad, left_pad, right_pad) + +def align_left(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align it right. + """ + return align(widget, 0, 0, 0, 1, top_pad, bottom_pad, left_pad, right_pad) + +def align_middle(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will center it vertically. + """ + return align(widget, 0, 0.5, 1, 0, + top_pad, bottom_pad, left_pad, right_pad) + +def align_top(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align to the top. + """ + return align(widget, 0, 0, 1, 0, top_pad, bottom_pad, left_pad, right_pad) + +def align_bottom(widget, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0): + """Wrap a widget in an Alignment that will align to the bottom. + """ + return align(widget, 0, 1, 1, 0, top_pad, bottom_pad, left_pad, right_pad) + +def pad(widget, top=0, bottom=0, left=0, right=0): + """Wrap a widget in an Alignment that will pad it. + """ + alignment = widgetset.Alignment(0, 0, 1, 1, + top, bottom, left, right) + alignment.add(widget) + return alignment + +def round_rect(context, x, y, width, height, edge_radius): + """Specifies path of a rectangle with rounded corners. + """ + edge_radius = min(edge_radius, min(width, height)/2.0) + inner_width = width - edge_radius*2 + inner_height = height - edge_radius*2 + x_inner1 = x + edge_radius + x_inner2 = x + width - edge_radius + y_inner1 = y + edge_radius + y_inner2 = y + height - edge_radius + + context.move_to(x+edge_radius, y) + context.rel_line_to(inner_width, 0) + context.arc(x_inner2, y_inner1, edge_radius, -PI/2, 0) + context.rel_line_to(0, inner_height) + context.arc(x_inner2, y_inner2, edge_radius, 0, PI/2) + context.rel_line_to(-inner_width, 0) + context.arc(x_inner1, y_inner2, edge_radius, PI/2, PI) + context.rel_line_to(0, -inner_height) + context.arc(x_inner1, y_inner1, edge_radius, PI, PI*3/2) + +def round_rect_reverse(context, x, y, width, height, edge_radius): + """Specifies path of a rectangle with rounded corners. + + This specifies the rectangle in a counter-clockwise fashion. + """ + edge_radius = min(edge_radius, min(width, height)/2.0) + inner_width = width - edge_radius*2 + inner_height = height - edge_radius*2 + x_inner1 = x + edge_radius + x_inner2 = x + width - edge_radius + y_inner1 = y + edge_radius + y_inner2 = y + height - edge_radius + + context.move_to(x+edge_radius, y) + context.arc_negative(x_inner1, y_inner1, edge_radius, PI*3/2, PI) + context.rel_line_to(0, inner_height) + context.arc_negative(x_inner1, y_inner2, edge_radius, PI, PI/2) + context.rel_line_to(inner_width, 0) + context.arc_negative(x_inner2, y_inner2, edge_radius, PI/2, 0) + context.rel_line_to(0, -inner_height) + context.arc_negative(x_inner2, y_inner1, edge_radius, 0, -PI/2) + context.rel_line_to(-inner_width, 0) + +def circular_rect(context, x, y, width, height): + """Make a path for a rectangle with the left/right side being circles. + """ + radius = height / 2.0 + inner_width = width - height + inner_y = y + radius + inner_x1 = x + radius + inner_x2 = inner_x1 + inner_width + + context.move_to(inner_x1, y) + context.rel_line_to(inner_width, 0) + context.arc(inner_x2, inner_y, radius, -PI/2, PI/2) + context.rel_line_to(-inner_width, 0) + context.arc(inner_x1, inner_y, radius, PI/2, -PI/2) + +def circular_rect_negative(context, x, y, width, height): + """The same path as ``circular_rect()``, but going counter clockwise. + """ + radius = height / 2.0 + inner_width = width - height + inner_y = y + radius + inner_x1 = x + radius + inner_x2 = inner_x1 + inner_width + + context.move_to(inner_x1, y) + context.arc_negative(inner_x1, inner_y, radius, -PI/2, PI/2) + context.rel_line_to(inner_width, 0) + context.arc_negative(inner_x2, inner_y, radius, PI/2, -PI/2) + context.rel_line_to(-inner_width, 0) + +class Shadow(object): + """Encapsulates all parameters required to draw shadows. + """ + def __init__(self, color, opacity, offset, blur_radius): + self.color = color + self.opacity = opacity + self.offset = offset + self.blur_radius = blur_radius + +class ThreeImageSurface(object): + """Takes a left, center and right image and draws them to an arbitrary + width. If the width is greater than the combined width of the 3 images, + then the center image will be tiled to compensate. + + Example: + + >>> timelinebar = ThreeImageSurface("timelinebar") + + This creates a ``ThreeImageSurface`` using the images + ``images/timelinebar_left.png``, ``images/timelinebar_center.png``, and + ``images/timelinebar_right.png``. + + Example: + + >>> timelinebar = ThreeImageSurface() + >>> img_left = make_surface("timelinebar_left") + >>> img_center = make_surface("timelinebar_center") + >>> img_right = make_surface("timelinebar_right") + >>> timelinebar.set_images(img_left, img_center, img_right) + + This does the same thing, but allows you to explicitly set which images + get used. + """ + def __init__(self, basename=None, height=None): + self.left = self.center = self.right = None + self.height = 0 + self.width = None + if basename is not None: + left = make_surface(basename + '_left', height) + center = make_surface(basename + '_center', height) + right = make_surface(basename + '_right', height) + self.set_images(left, center, right) + + def set_images(self, left, center, right): + """Sets the left, center and right images to use. + """ + self.left = left + self.center = center + self.right = right + if not (self.left.height == self.center.height == self.right.height): + raise ValueError("Images aren't the same height") + self.height = self.left.height + + def set_width(self, width): + """Manually set a width. + + When ThreeImageSurface have a width, then they have pretty much the + same API as ImageSurface does. In particular, they can now be nested + in another ThreeImageSurface. + """ + self.width = width + + def get_size(self): + return self.width, self.height + + def draw(self, context, x, y, width, fraction=1.0): + left_width = min(self.left.width, width) + self.left.draw(context, x, y, left_width, self.height, fraction) + self.draw_right(context, x + left_width, y, width - left_width, fraction) + + def draw_right(self, context, x, y, width, fraction=1.0): + # draws only the right two images + + right_width = min(self.right.width, width) + center_width = int(width - right_width) + + self.center.draw(context, x, y, center_width, self.height, fraction) + self.right.draw(context, x + center_width, y, right_width, self.height, fraction) diff --git a/mvc/widgets/widgetutil.pyc b/mvc/widgets/widgetutil.pyc Binary files differnew file mode 100644 index 0000000..f2953ef --- /dev/null +++ b/mvc/widgets/widgetutil.pyc diff --git a/mvc/windows/__init__.py b/mvc/windows/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/mvc/windows/__init__.py diff --git a/mvc/windows/autoupdate.py b/mvc/windows/autoupdate.py new file mode 100644 index 0000000..8cb5f0a --- /dev/null +++ b/mvc/windows/autoupdate.py @@ -0,0 +1,101 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2012 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""Autoupdate functionality """ + +import ctypes +import _winreg as winreg +import logging + +winsparkle = ctypes.cdll.WinSparkle + +APPCAST_URL = 'http://miro-updates.participatoryculture.org/mvc-appcast.xml' + +def startup(): + enable_automatic_checks() + winsparkle.win_sparkle_set_appcast_url(APPCAST_URL) + winsparkle.win_sparkle_init() + +def shutdown(): + winsparkle.win_sparkle_cleanup() + +def enable_automatic_checks(): + # We should be able to use win_sparkle_set_automatic_check_for_updates, + # but that's only available after version 0.4 and the current release + # version is 0.4 + with open_winsparkle_key() as winsparkle_key: + if not check_for_updates_set(winsparkle_key): + set_default_check_for_updates(winsparkle_key) + +def open_winsparkle_key(): + """Open the MVC WinSparkle registry key + + If any components are not created yet, we will try to create them + """ + with open_or_create_key(winreg.HKEY_CURRENT_USER, "Software") as software: + with open_or_create_key(software, + "Participatory Culture Foundation") as pcf: + with open_or_create_key(pcf, "Libre Video Converter") as mvc: + return open_or_create_key(mvc, "WinSparkle", + write_access=True) + +def open_or_create_key(key, subkey, write_access=False): + if write_access: + sam = winreg.KEY_READ | winreg.KEY_WRITE + else: + sam = winreg.KEY_READ + try: + return winreg.OpenKey(key, subkey, 0, sam) + except OSError, e: + if e.errno == 2: + # Not Found error. We should create the key + return winreg.CreateKey(key, subkey) + else: + raise + +def check_for_updates_set(winsparkle_key): + try: + winreg.QueryValueEx(winsparkle_key, "CheckForUpdates") + except OSError, e: + if e.errno == 2: + # not found error. + return False + else: + raise + else: + return True + + +def set_default_check_for_updates(winsparkle_key): + """Initialize the WinSparkle regstry values with our defaults. + + :param mvc_key winreg.HKey object for to the MVC registry + """ + logging.info("Writing WinSparkle keys") + winreg.SetValueEx(winsparkle_key, "CheckForUpdates", 0, winreg.REG_SZ, "1") diff --git a/mvc/windows/exe_main.py b/mvc/windows/exe_main.py new file mode 100755 index 0000000..bd171d3 --- /dev/null +++ b/mvc/windows/exe_main.py @@ -0,0 +1,22 @@ +# before anything else, settup logging +from mvc.windows import exelogging +exelogging.setup_logging() + +import os +import sys + +from mvc import settings +from mvc.windows import autoupdate +from mvc.widgets import app +from mvc.widgets import initialize +from mvc.ui.widgets import Application + +# add the directories for ffmpeg and avconv to our search path +exe_dir = os.path.dirname(sys.executable) +settings.add_to_search_path(os.path.join(exe_dir, 'ffmpeg')) +settings.add_to_search_path(os.path.join(exe_dir, 'avconv')) +# run the app +app.widgetapp = Application() +app.widgetapp.connect("window-shown", lambda w: autoupdate.startup()) +initialize(app.widgetapp) +autoupdate.shutdown() diff --git a/mvc/windows/exelogging.py b/mvc/windows/exelogging.py new file mode 100644 index 0000000..a90fbfc --- /dev/null +++ b/mvc/windows/exelogging.py @@ -0,0 +1,91 @@ +"""mvc.windows.exelogging -- handle logging inside an exe file + +Most of this is copied from the Miro code. +""" + +import logging +import os +import sys +import tempfile +from StringIO import StringIO +from logging.handlers import RotatingFileHandler + +class ApatheticRotatingFileHandler(RotatingFileHandler): + """The whole purpose of this class is to prevent rotation errors + from percolating up into stdout/stderr and popping up a dialog + that's not particularly useful to users or us. + """ + def doRollover(self): + # If you shut down LibreVideoConverter then start it up again immediately + # afterwards, then we get in this squirrely situation where + # the log is opened by another process. We ignore the + # exception, but make sure we have an open file. (bug #11228) + try: + RotatingFileHandler.doRollover(self) + except WindowsError: + if not self.stream or self.stream.closed: + self.stream = open(self.baseFilename, "a") + try: + RotatingFileHandler.doRollover(self) + except WindowsError: + pass + + def shouldRollover(self, record): + # if doRollover doesn't work, then we don't want to find + # ourselves in a situation where we're trying to do things on + # a closed stream. + if self.stream.closed: + self.stream = open(self.baseFilename, "a") + return RotatingFileHandler.shouldRollover(self, record) + + def handleError(self, record): + # ignore logging errors that occur rather than printing them to + # stdout/stderr which isn't helpful to us + + pass +class AutoLoggingStream(StringIO): + """Create a stream that intercepts write calls and sends them to + the log. + """ + def __init__(self, logging_callback, prefix): + StringIO.__init__(self) + # We init from StringIO to give us a bunch of stream-related + # methods, like closed() and read() automatically. + self.logging_callback = logging_callback + self.prefix = prefix + + def write(self, data): + if isinstance(data, unicode): + data = data.encode('ascii', 'backslashreplace') + if data.endswith("\n"): + data = data[:-1] + if data: + self.logging_callback(self.prefix + data) + +FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" +def setup_logging(): + """Setup logging for when we're running inside a windows exe. + + The object here is to avoid logging anything to stderr since + windows will consider that an error. + + We also catch things written to sys.stdout and forward that to the logging + system. + + Finally we also copy the log output to stdout so that when MVC is run in + console mode we see the logs + """ + + log_path = os.path.join(tempfile.gettempdir(), "MVC.log") + rotater = ApatheticRotatingFileHandler( + log_path, mode="a", maxBytes=100000, backupCount=5) + + formatter = logging.Formatter(FORMAT) + rotater.setFormatter(formatter) + logger = logging.getLogger('') + logger.addHandler(rotater) + logger.addHandler(logging.StreamHandler(sys.stdout)) + logger.setLevel(logging.INFO) + rotater.doRollover() + sys.stdout = AutoLoggingStream(logging.warn, '(from stdout) ') + sys.stderr = AutoLoggingStream(logging.error, '(from stderr) ') diff --git a/mvc/windows/specialfolders.py b/mvc/windows/specialfolders.py new file mode 100644 index 0000000..2e1e7c6 --- /dev/null +++ b/mvc/windows/specialfolders.py @@ -0,0 +1,94 @@ +# @Base: Miro - an RSS based video player application +# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 +# Participatory Culture Foundation +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +# +# In addition, as a special exception, the copyright holders give +# permission to link the code of portions of this program with the OpenSSL +# library. +# +# You must obey the GNU General Public License in all respects for all of +# the code used other than OpenSSL. If you modify file(s) with this +# exception, you may extend this exception to your version of the file(s), +# but you are not obligated to do so. If you do not wish to do so, delete +# this exception statement from your version. If you delete this exception +# statement from all source files in the program, then also delete it here. + +"""Contains the locations of special windows folders like "My +Documents". +""" + +import ctypes +import os +# from miro import u3info + +GetShortPathName = ctypes.windll.kernel32.GetShortPathNameW + +_special_folder_CSIDLs = { + "Fonts": 0x0014, + "AppData": 0x001a, + "My Music": 0x000d, + "My Pictures": 0x0027, + "My Videos": 0x000e, + "My Documents": 0x0005, + "Desktop": 0x0000, + "Common AppData": 0x0023, + "System": 0x0025 +} + +def get_short_path_name(name): + """Given a path, returns the shortened path name. + """ + buf = ctypes.c_wchar_p(name) + buf2 = ctypes.create_unicode_buffer(1024) + + if GetShortPathName(buf, buf2, 1024): + return buf2.value + else: + return buf.value + +def get_special_folder(name): + """Get the location of a special folder. name should be one of + the following: 'AppData', 'My Music', 'My Pictures', 'My Videos', + 'My Documents', 'Desktop'. + + The path to the folder will be returned, or None if the lookup + fails + """ + try: + csidl = _special_folder_CSIDLs[name] + except KeyError: + # FIXME - this will silently fail if the dev did a typo + # for the path name. e.g. My Musc + return None + + buf = ctypes.create_unicode_buffer(260) + SHGetSpecialFolderPath = ctypes.windll.shell32.SHGetSpecialFolderPathW + if SHGetSpecialFolderPath(None, buf, csidl, False): + return buf.value + else: + return None + +common_app_data_directory = get_special_folder("Common AppData") +app_data_directory = get_special_folder("AppData") + +base_movies_directory = get_special_folder('My Videos') +non_video_directory = get_special_folder('Desktop') +# The "My Videos" folder isn't guaranteed to be listed. If it isn't +# there, we do this hack. +if base_movies_directory is None: + base_movies_directory = os.path.join( + get_special_folder('My Documents'), 'My Videos') diff --git a/run-windows.sh b/run-windows.sh new file mode 100755 index 0000000..728a876 --- /dev/null +++ b/run-windows.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +if [ ! -e mvc-env ] ; then + echo "LVC virtualenv is not present. Run " + echo + echo " python helperscripts/windows-virtualenv/ mvc-env" + echo + echo "to build it" + exit 1 +fi + +mvc-env/Scripts/python.exe setup.py py2exe && ./dist/mvcdebug.exe diff --git a/scripts/libre-video-converter.py b/scripts/libre-video-converter.py new file mode 100644 index 0000000..2404a28 --- /dev/null +++ b/scripts/libre-video-converter.py @@ -0,0 +1,10 @@ +#!/usr/bin/python + +try: + from mvc.ui.widgets import Application +except ImportError: + from mvc.ui.console import Application +from mvc.widgets import app +from mvc.widgets import initialize +app.widgetapp = Application() +initialize(app.widgetapp) diff --git a/setup-files/linux/debian-precise/changelog b/setup-files/linux/debian-precise/changelog new file mode 100644 index 0000000..d0c742b --- /dev/null +++ b/setup-files/linux/debian-precise/changelog @@ -0,0 +1,5 @@ +mirovideoconverter (3.0.2-1ubuntu1~ppa1~precise) precise; urgency=low + + * Initial packaging + + -- Ben Dean-Kawamura <ben@pculture.org> Thu, 27 Dec 2012 13:10:14 -0500 diff --git a/setup-files/linux/debian-precise/compat b/setup-files/linux/debian-precise/compat new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/setup-files/linux/debian-precise/compat @@ -0,0 +1 @@ +7 diff --git a/setup-files/linux/debian-precise/control b/setup-files/linux/debian-precise/control new file mode 100644 index 0000000..c0bbda1 --- /dev/null +++ b/setup-files/linux/debian-precise/control @@ -0,0 +1,17 @@ +Source: librevideoconverter +Maintainer: Jesús Eduardo <heckyel@openmailbox.org> +Section: python +Priority: optional +Build-Depends: python-setuptools (>= 0.6b3), + python-all (>= 2.7), + debhelper (>= 7.4.3) +Standards-Version: 3.9.3 + +Package: mirovideoconverter +Architecture: all +Depends: ${misc:Depends}, + ${python:Depends}, + python-gtk2, + ffmpeg, + ffmpeg2theora +Description: Simple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter. diff --git a/setup-files/linux/debian-precise/copyright b/setup-files/linux/debian-precise/copyright new file mode 100644 index 0000000..b5f4d88 --- /dev/null +++ b/setup-files/linux/debian-precise/copyright @@ -0,0 +1,38 @@ +This package was made for the PCF Ubuntu PPA by Ben Dean-Kawamura +<ben@pculture.org> on Thu, 27 Dec 2012 13:10:14 -0500 + +The current maintainer is Jesús Eduardo <heckyel@openmailbox.org> + +It was downloaded from: + + https://notabug.org/Heckyel/LibreVideoConverter + +----- + +Files: * +Copyright: © 2017 Jesús Eduardo (Heckyel) +License: GPL-3+ | other + You must obey the GNU General Public License in all respects for + all of the code used other than OpenSSL. If you modify file(s) + with this exception, you may extend this exception to your version + of the file(s), but you are not obligated to do so. If you do not + wish to do so, delete this exception statement from your + version. If you delete this exception statement from all source + files in the program, then also delete it here. + +On Debian systems, the complete text of the GNU General + +Files: debian/* +Copyright: © 2017 Jesús Eduardo (Heckyel) +License: GPL-3+ | other + You must obey the GNU General Public License in all respects for + all of the code used other than OpenSSL. If you modify file(s) + with this exception, you may extend this exception to your version + of the file(s), but you are not obligated to do so. If you do not + wish to do so, delete this exception statement from your + version. If you delete this exception statement from all source + files in the program, then also delete it here. + +On Debian systems, the complete text of the GNU General +Public License can be found in `/usr/share/common-licenses/GPL'. +Public License can be found in `/usr/share/common-licenses/GPL'. diff --git a/setup-files/linux/debian-precise/rules b/setup-files/linux/debian-precise/rules new file mode 100755 index 0000000..1c69663 --- /dev/null +++ b/setup-files/linux/debian-precise/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f + +# This file was automatically generated by stdeb 0.6.0+git at +# Thu, 27 Dec 2012 13:08:13 -0500 + +%: + dh $@ --with python2 --buildsystem=python_distutils diff --git a/setup-files/linux/debian-precise/source/format b/setup-files/linux/debian-precise/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/setup-files/linux/debian-precise/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/setup-files/linux/debian-quantal/changelog b/setup-files/linux/debian-quantal/changelog new file mode 100644 index 0000000..77fb6fa --- /dev/null +++ b/setup-files/linux/debian-quantal/changelog @@ -0,0 +1,5 @@ +librevideoconverter (3.0.2-1ubuntu1~ppa1~quantal) quantal; urgency=low + + * Initial packaging + + -- Jesús Eduardo <heckyel@openmailbox.org> Thu, 27 Dec 2017 13:10:14 -0500 diff --git a/setup-files/linux/debian-quantal/compat b/setup-files/linux/debian-quantal/compat new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/setup-files/linux/debian-quantal/compat @@ -0,0 +1 @@ +7 diff --git a/setup-files/linux/debian-quantal/control b/setup-files/linux/debian-quantal/control new file mode 100644 index 0000000..fbe150e --- /dev/null +++ b/setup-files/linux/debian-quantal/control @@ -0,0 +1,17 @@ +Source: librevideoconverter +Maintainer: Jesús Eduardo <heckyel@openmailbox.org> +Section: python +Priority: optional +Build-Depends: python-setuptools (>= 0.6b3), + python-all (>= 2.7), + debhelper (>= 7.4.3) +Standards-Version: 3.9.3 + +Package: librevideoconverter +Architecture: all +Depends: ${misc:Depends}, + ${python:Depends}, + python-gtk2, + ffmpeg + ffmpeg2theora +Description: Simple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter. diff --git a/setup-files/linux/debian-quantal/copyright b/setup-files/linux/debian-quantal/copyright new file mode 100644 index 0000000..c7d1a25 --- /dev/null +++ b/setup-files/linux/debian-quantal/copyright @@ -0,0 +1,38 @@ +This package was made for the PCF Ubuntu PPA by Ben Dean-Kawamura +<ben@pculture.org> on Thu, 27 Dec 2012 13:10:14 -0500 + +The current maintainer is Jesús Eduardo <heckyel@openmailbox.org> + +It was downloaded from: + + https://notabug.org/Heckyel/LibreVideoConverter + +----- + +Files: * +Copyright: © 2017 Jesus Eduardo (Heckyel) +License: GPL-2+ | other + You must obey the GNU General Public License in all respects for + all of the code used other than OpenSSL. If you modify file(s) + with this exception, you may extend this exception to your version + of the file(s), but you are not obligated to do so. If you do not + wish to do so, delete this exception statement from your + version. If you delete this exception statement from all source + files in the program, then also delete it here. + +On Debian systems, the complete text of the GNU General + +Files: debian/* +Copyright: © 2017 Jesus Eduardo (Heckyel) +License: GPL-2+ | other + You must obey the GNU General Public License in all respects for + all of the code used other than OpenSSL. If you modify file(s) + with this exception, you may extend this exception to your version + of the file(s), but you are not obligated to do so. If you do not + wish to do so, delete this exception statement from your + version. If you delete this exception statement from all source + files in the program, then also delete it here. + +On Debian systems, the complete text of the GNU General +Public License can be found in `/usr/share/common-licenses/GPL'. +Public License can be found in `/usr/share/common-licenses/GPL'. diff --git a/setup-files/linux/debian-quantal/rules b/setup-files/linux/debian-quantal/rules new file mode 100755 index 0000000..1c69663 --- /dev/null +++ b/setup-files/linux/debian-quantal/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f + +# This file was automatically generated by stdeb 0.6.0+git at +# Thu, 27 Dec 2012 13:08:13 -0500 + +%: + dh $@ --with python2 --buildsystem=python_distutils diff --git a/setup-files/linux/debian-quantal/source/format b/setup-files/linux/debian-quantal/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/setup-files/linux/debian-quantal/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/setup-files/linux/icons/hicolor/16x16/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/16x16/apps/librevideoconverter.png Binary files differnew file mode 100644 index 0000000..da270ee --- /dev/null +++ b/setup-files/linux/icons/hicolor/16x16/apps/librevideoconverter.png diff --git a/setup-files/linux/icons/hicolor/22x22/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/22x22/apps/librevideoconverter.png Binary files differnew file mode 100644 index 0000000..fbf0467 --- /dev/null +++ b/setup-files/linux/icons/hicolor/22x22/apps/librevideoconverter.png diff --git a/setup-files/linux/icons/hicolor/32x32/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/32x32/apps/librevideoconverter.png Binary files differnew file mode 100644 index 0000000..c042992 --- /dev/null +++ b/setup-files/linux/icons/hicolor/32x32/apps/librevideoconverter.png diff --git a/setup-files/linux/icons/hicolor/48x48/apps/librevideoconverter.png b/setup-files/linux/icons/hicolor/48x48/apps/librevideoconverter.png Binary files differnew file mode 100644 index 0000000..24b7f8d --- /dev/null +++ b/setup-files/linux/icons/hicolor/48x48/apps/librevideoconverter.png diff --git a/setup-files/linux/librevideoconverter.desktop b/setup-files/linux/librevideoconverter.desktop new file mode 100644 index 0000000..349bcb2 --- /dev/null +++ b/setup-files/linux/librevideoconverter.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Libre Video Converter +GenericName=Media player +Comment=A siple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter. +Comment[es]=Un convertidor simple de vídeo para los formatos Webm (vp8), Ogg Theora, MP4 y otros, basado en Miro Video Converter. +Icon=librevideoconverter +TryExec=libre-video-converter.py +Exec=libre-video-converter.py %F +Terminal=false +Categories=AudioVideo;AudioVideoEditing;
\ No newline at end of file diff --git a/setup-files/linux/setup.py b/setup-files/linux/setup.py new file mode 100644 index 0000000..841e86a --- /dev/null +++ b/setup-files/linux/setup.py @@ -0,0 +1,95 @@ +import glob +import os +import shutil +import subprocess +import sys +from distutils.cmd import Command +from setuptools import setup + +if sys.version < '2.7': + raise RuntimeError('LVC requires Python 2.7') + +def icon_data_files(): + sizes = [16, 22, 32, 48] + data_files = [] + for size in sizes: + d = os.path.join("icons", "hicolor", "%sx%s" % (size, size), "apps") + source = os.path.join(SETUP_DIR, d, "librevideoconverter.png") + dest = os.path.join("/usr/share/", d) + data_files.append((dest, [source])) + + return data_files + +def application_data_files(): + return [ + ('/usr/share/applications', + [os.path.join(SETUP_DIR, 'librevideoconverter.desktop')]), + ] + +def data_files(): + return application_data_files() + icon_data_files() + +class sdist_deb(Command): + description = ("Build a debian source package.") + user_options = [ + ('dist-dir=', 'd', + "directory to put the source distribution archive(s) in " + "[default: dist]"), + ] + + def initialize_options(self): + self.dist_dir = None + + def finalize_options(self): + if self.dist_dir is None: + self.dist_dir = 'dist' + self.dist_dir = os.path.abspath(self.dist_dir) + + def run(self): + self.run_command("sdist") + self.setup_dirs() + for debian_dir in glob.glob(os.path.join(SETUP_DIR, 'debian-*')): + self.build_for_release(debian_dir) + os.chdir(self.orig_dir) + print + print "debian source build complete" + print "files are in %s" % self.work_dir + + def build_for_release(self, debian_dir): + os.chdir(self.work_dir) + source_tree = os.path.join(self.work_dir, + 'librevideoconverter-%s' % VERSION) + if os.path.exists(source_tree): + shutil.rmtree(source_tree) + self.extract_tarball() + self.copy_debian_directory(debian_dir) + os.chdir('librevideoconverter-%s' % VERSION) + subprocess.check_call(['dpkg-buildpackage', '-S']) + + def setup_dirs(self): + self.orig_dir = os.getcwd() + self.work_dir = os.path.join(self.dist_dir, 'deb') + if os.path.exists(self.work_dir): + shutil.rmtree(self.work_dir) + os.makedirs(self.work_dir) + + def extract_tarball(self): + tarball = os.path.join(self.dist_dir, + "librevideoconverter-%s.tar.gz" % VERSION) + subprocess.check_call(["tar", "zxf", tarball]) + shutil.copyfile(tarball, + "librevideoconverter_%s.orig.tar.gz" % VERSION) + + def copy_debian_directory(self, debian_dir): + dest = os.path.join(self.work_dir, + 'librevideoconverter-%s/debian' % VERSION) + shutil.copytree(debian_dir, dest) + +setup( + cmdclass={ + 'sdist_deb': sdist_deb, + }, + data_files=data_files(), + scripts=['scripts/libre-video-converter.py'], + **SETUP_ARGS +) diff --git a/setup-files/osx/Info.plist b/setup-files/osx/Info.plist new file mode 100644 index 0000000..bf674a1 --- /dev/null +++ b/setup-files/osx/Info.plist @@ -0,0 +1,406 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDocumentTypes</key> + <array> + <!-- + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>miro</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeMIMETypes</key> + <array> + <string>application/x-miro</string> + </array> + <key>CFBundleTypeName</key> + <string>Miro One Click Subscribe Content</string> + <key>CFBundleTypeOSTypes</key> + <array> + <string>TEXT</string> + </array> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>democracy</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeMIMETypes</key> + <array> + <string>application/x-democracy</string> + </array> + <key>CFBundleTypeName</key> + <string>Democracy One Click Subscribe Content</string> + <key>CFBundleTypeOSTypes</key> + <array> + <string>TEXT</string> + </array> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>rdf</string> + <string>rss</string> + <string>atom</string> + </array> + <key>CFBundleTypeIconFile</key> + <string>check.icns</string> + <key>CFBundleTypeMIMETypes</key> + <array> + <string>application/rdf+xml</string> + <string>application/rss+xml</string> + <string>application/atom+xml</string> + </array> + <key>CFBundleTypeName</key> + <string>Syndicated Content</string> + <key>CFBundleTypeOSTypes</key> + <array> + <string>TEXT</string> + </array> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>torrent</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeMIMETypes</key> + <array> + <string>application/x-bittorrent</string> + </array> + <key>CFBundleTypeName</key> + <string>BitTorrent Metainfo</string> + <key>CFBundleTypeRole</key> + <string>Editor</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>avi</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>AVI container</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>mov</string> + <string>moov</string> + <string>qt</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Apple QuickTime container</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>3gp</string> + <string>3g2</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>3GPP Movie</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>asf</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Advanced Streaming Format</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>flv</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Flash Video Movie</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>webm</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>WebM Video Movie</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>gvi</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Google Video</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>wmv</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Windows Media Video</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>mpg</string> + <string>mpeg</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>multiplexed MPEG-1/2</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>mp4</string> + <string>m4v</string> + <string>m4a</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>MPEG-4 File</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>ogg</string> + <string>ogm</string> + <string>ogv</string> + <string>oga</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>OGG Media File</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>mkv</string> + <string>mka</string> + <string>mks</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Matroska File</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>mp3</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>MPEG-1 Audio Layer 3</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>m4a</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Audio only MPEG 4</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>wma</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Windows Media Audio</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>mka</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Matroska Audio</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>rmvb</string> + <string>rm</string> + <string>ram</string> + <string>ra</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>RealMedia</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>divx</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>DivX Video</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>flac</string> + </array> + <key>CFBundleTypeIconFile</key> + <string></string> + <key>CFBundleTypeName</key> + <string>Free Lossless Audio Codec</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + </dict> + --> + </array> + <key>CFBundleGetInfoString</key> + <string>$appVersion ($appRevision), $copyright $publisher</string> + <key>CFBundleIdentifier</key> + <string>org.participatoryculture.$shortAppName</string> + <key>CFBundleExecutable</key> + <string>$shortAppName</string> + <key>CFBundleName</key> + <string>$shortAppName</string> + <key>CFBundleShortVersionString</key> + <string>$appVersion</string> + <key>CFBundleURLTypes</key> + <array> + <!-- + <dict> + <key>CFBundleURLTypes</key> + <key>CFBundleURLName</key> + <string>BitTorrent Magnet URL</string> + <key>CFBundleURLSchemes</key> + <array> + <string>magnet</string> + </array> + </dict> + <dict> + <key>CFBundleURLName</key> + <string>Miro URL</string> + <key>CFBundleURLSchemes</key> + <array> + <string>miro</string> + </array> + </dict> + <dict> + <key>CFBundleURLName</key> + <string>Democracy URL</string> + <key>CFBundleURLSchemes</key> + <array> + <string>democracy</string> + </array> + </dict> + <dict> + <key>CFBundleURLName</key> + <string>RSS URL</string> + <key>CFBundleURLSchemes</key> + <array> + <string>feed</string> + </array> + </dict> + <dict> + <key>CFBundleURLName</key> + <string>HTTP URL</string> + <key>CFBundleURLSchemes</key> + <array> + <string>http</string> + <string>https</string> + </array> + </dict> + --> + </array> + <key>CFBundleVersion</key> + <string>$appVersion</string> + <key>LSMinimumSystemVersion</key> + <string>10.6</string> + <key>NSHumanReadableCopyright</key> + <string>$copyright, $publisher</string> + <key>SUEnableAutomaticChecks</key> + <false/> + <key>SUAllowsAutomaticUpdates</key> + <false/> + <key>SUFeedURL</key> + <string></string> + <key>SUScheduledCheckInterval</key> + <integer>0</integer> + <key>LSApplicationCategoryType</key> + <string>public.app-category.video</string> +</dict> +</plist> diff --git a/setup-files/osx/mvc3.icns b/setup-files/osx/mvc3.icns Binary files differnew file mode 100644 index 0000000..a56fd4d --- /dev/null +++ b/setup-files/osx/mvc3.icns diff --git a/setup-files/osx/mvc3_definition.plist b/setup-files/osx/mvc3_definition.plist new file mode 100644 index 0000000..e14b95a --- /dev/null +++ b/setup-files/osx/mvc3_definition.plist @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>arch</key> + <array> + <string>i386</string> + <string>x86_64</string> + </array> + <key>os</key> + <array> + <string>10.6</string> + </array> +</dict> +</plist> diff --git a/setup-files/osx/setup.py b/setup-files/osx/setup.py new file mode 100644 index 0000000..cddca4c --- /dev/null +++ b/setup-files/osx/setup.py @@ -0,0 +1,123 @@ +""" +This is a setup.py script generated by py2applet + +Usage: + python2.7 setup.py py2app +""" +import sys +if sys.version < '2.7': + raise RuntimeError('MVC requires Python 2.7') +import glob +import os +import plistlib +import shutil +import subprocess + +from setuptools import setup +from setuptools.extension import Extension + +from distutils.file_util import copy_file +from distutils.dir_util import mkpath + +from py2app.build_app import py2app as py2app_cmd + +APP = ['mvc/osx/app_main.py'] +DATA_FILES = ['mvc/widgets/osx/Resources-Widgets/MainMenu.nib'] +OPTIONS = { + 'iconfile': os.path.join(SETUP_DIR, 'mvc3.icns'), + 'excludes': ['mvc.widgets.gtk'], + 'includes': ['mvc.widgets.osx.fasttypes'], + 'packages': ['mvc', 'mvc.widgets', 'mvc.widgets.osx', 'mvc.ui', + 'mvc.qtfaststart', 'mvc.resources'] +} + +# this should work if run from build.sh +BKIT_DIR = os.environ['BKIT_PATH'] + +def copy_binaries(source, target, binaries): + mkpath(target) + for mem in binaries: + src = os.path.join(BKIT_DIR, source, mem) + if os.path.islink(src): + dst = os.path.join(target, mem) + linkto = os.readlink(src) + if os.path.exists(dst): + os.remove(dst) + os.symlink(linkto, dst) + else: + copy_file(src, target, update=True) + +def extract_tarball(tar_file, target_directory): + subprocess.check_call(["tar", "-C", target_directory, "-zxf", tar_file]) + +class py2app_mvc(py2app_cmd): + def run(self): + py2app_cmd.run(self) + self.setup_directories() + self.copy_ffmpeg() + self.copy_sparkle() + self.copy_update_public_key() + self.delete_site_py() + + def setup_directories(self): + self.bundle_root = os.path.join(self.dist_dir, + 'Miro Video Converter.app/Contents') + self.helpers_root = os.path.join(self.bundle_root, 'Helpers') + self.frameworks_root = os.path.join(self.bundle_root, 'Frameworks') + self.resources_root = os.path.join(self.bundle_root, 'Resources') + self.python_lib_root = os.path.join(self.resources_root, 'lib', + 'python2.7') + + if os.path.exists(self.helpers_root): + shutil.rmtree(self.helpers_root) + os.mkdir(self.helpers_root) + + def copy_ffmpeg(self): + ffmpeg_files = ["ffmpeg"] + lib_paths = glob.glob(os.path.join(BKIT_DIR, "ffmpeg", "bin", "*.dylib")) + ffmpeg_files.extend(os.path.basename(p) for p in lib_paths) + copy_binaries('ffmpeg/bin/', self.helpers_root, ffmpeg_files) + + def copy_sparkle(self): + tarball = os.path.join(BKIT_DIR, "frameworks", "sparkle.1.5b6.tar.gz") + extract_tarball(tarball, self.frameworks_root) + + def copy_update_public_key(self): + src = os.path.join(SETUP_DIR, "sparkle-keys", "dsa_pub.pem") + target = os.path.join(self.resources_root, 'dsa_pub.pem') + copy_file(src, target, update=True) + + def delete_site_py(self): + """Delete the site.py symlink. + + This causes issues with codesigning on 10.8 -- possibly because it's a + symlink that links to a location outside the resources dir. In any + case, it's not needed, so let's just nuke it. + """ + os.unlink(os.path.join(self.python_lib_root, 'site.py')) + +plist = plistlib.readPlist(os.path.join(SETUP_DIR, 'Info.plist')) +plist['NSHumanReadableCopyright'] = 'Copyright (C) Participatory Culture Foundation' +plist['CFBundleGetInfoString'] = 'Miro Video Converter' +plist['CFBundleIdentifier'] = 'org.participatoryculture.MiroVideoConverter' +plist['CFBundleShortVersionString'] = '3.0' +plist['CFBundleExecutable'] = 'Miro Video Converter' +plist['CFBundleName'] = 'Miro Video Converter' +plist['CFBundleVersion'] = '3.0' +plist['SUFeedURL'] = ('http://miro-updates.participatoryculture.org/' + 'mvc-appcast-osx.xml') +plist['SUPublicDSAKeyFile'] = 'dsa_pub.pem' + +OPTIONS['plist'] = plist + +setup( + app=APP, + data_files=DATA_FILES, + options={'py2app': OPTIONS}, + setup_requires=['py2app'], + cmdclass={'py2app': py2app_mvc}, + ext_modules=[ + Extension("mvc.widgets.osx.fasttypes", + [os.path.join(ROOT_DIR, 'mvc', 'widgets', 'osx', 'fasttypes.c')])], + **SETUP_ARGS + ) diff --git a/setup-files/osx/sparkle-keys/dsa_pub.pem b/setup-files/osx/sparkle-keys/dsa_pub.pem new file mode 100644 index 0000000..190e191 --- /dev/null +++ b/setup-files/osx/sparkle-keys/dsa_pub.pem @@ -0,0 +1,20 @@ +-----BEGIN PUBLIC KEY----- +MIIDOzCCAi4GByqGSM44BAEwggIhAoIBAQD1EH4NkdNMtS7VHod9Fz96NGxa0Zg8 +DrW4/GTJ5VgauPaRX1HzC2C1urCc05wW1J1sGXT+PupcL5+u+cmSHrVMtR6w90Hg +eZCHiDZHpLJ14tsAB1SZZ6krEZOfQ1Bx5ON3/0qeeXSFgmVku/qzMogvya3O4kYF +GrBqWCAsTymCUw9vbq3TuPEo22VTLJdl9uM4FqDczfQgRenH31buj349lLEfWxjk +qkh4Jd+rm48cqGmWSGi7GB63Ydet+5RmStJnEOGMC4LuYYBHHKG+OmgZ9fN6aryf +9zWSjgouvdbPmJCa5fT/wxUUdxerK3gHvEZeOcZo61Lyqt1D9lgcLjAhAhUAqO1/ +r+wkLSrL+yOCIA0CSJtGMz0CggEBAKCmWZpJNRLwyRmLfBmgNdaz7yNTG1XnRymk +Ogrnj3ht69qQ1KA2qKLl4n03vIiJT3gCv3RcT2zklnIS1rszplKhhvZuH/6fDC3q +SP2EAyKrDQQmqEPghSCFfDtB+qIf/0/VcYJDBZB2WBQr9qa2SUwoqsC/18CBQo+H +YZQTfd92vqAspoNElKymJGqlbJarcDoSpkVbB304pGZcrKBCFapZ3f36dQpUwOcQ +UmDGakKN9pDCvaqTOcKkzC8Q/4c4c1jfOtOblDdzafFMj8KPcFOlWXsifuk/DAA8 +yDRSGICjTfof0qpAgVeQFD6TJ9ng3XrVbc6TMhUsm16xKs/z4w0DggEFAAKCAQBw +Ouduhl50dvpm34IRrs3zitcIeDkmen5wKRun26xup65TOHFmAzJxg1H8ZPaa1QSl +b7ZtiHJggiHEXvjNdx065tz6MIxDEmZBoGf0J9at3AbjXGPf6ZHh/v0evlgPzqq3 +zVCtutPya1Dvu8HfnPmetLN+5haSekCTHRRakJ2JL5LhTe/BhRWgVg4YcFBfuomG +m0OGTHVnp5C/vI6f28AKxFK5DVu7SEoyqPjHIxT0eoHDKae0z+I4Nb+XfObmwnQi +ORmm8oFccTcf1+N5zwSIpemiFVu6M6NzNPJfKuF06AYZWJDIMpRQbAuLFzSLgzEq +Hon4DPjscgbd7IWPu4yK +-----END PUBLIC KEY----- diff --git a/setup-files/windows/mvc.nsi b/setup-files/windows/mvc.nsi new file mode 100755 index 0000000..638d421 --- /dev/null +++ b/setup-files/windows/mvc.nsi @@ -0,0 +1,195 @@ +; Passed in from command line:
+!define CONFIG_VERSION "0.8.0"
+
+; TODO: Add MIROBAR_EXE
+!define CONFIG_PROJECT_URL "http://www.mirovideoconverter.com/"
+!define CONFIG_SHORT_APP_NAME "MVC"
+!define CONFIG_LONG_APP_NAME "Libre Video Converter"
+!define CONFIG_PUBLISHER "Participatory Culture Foundation"
+!define CONFIG_ICON "mvc-logo.ico"
+!define CONFIG_EXECUTABLE "mvc.exe"
+!define CONFIG_OUTPUT_FILE "MiroVideoConverter.exe"
+
+!define INST_KEY "Software\${CONFIG_PUBLISHER}\${CONFIG_LONG_APP_NAME}"
+!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${CONFIG_LONG_APP_NAME}"
+
+!define UNINSTALL_SHORTCUT "Uninstall ${CONFIG_LONG_APP_NAME}.lnk"
+!define MUI_ICON "${CONFIG_ICON}"
+!define MUI_UNICON "${CONFIG_ICON}"
+
+;INCLUDES
+!addplugindir "${CONFIG_PLUGIN_DIR}"
+!addincludedir "${CONFIG_PLUGIN_DIR}"
+
+!include "MUI2.nsh"
+!include "nsProcess.nsh"
+
+!define PRODUCT_NAME "${CONFIG_LONG_APP_NAME}"
+
+;GENERAL SETTINGS
+Name "${CONFIG_LONG_APP_NAME}"
+OutFile "${CONFIG_OUTPUT_FILE}"
+InstallDir "$PROGRAMFILES\${CONFIG_PUBLISHER}\${CONFIG_LONG_APP_NAME}"
+InstallDirRegKey HKLM "${INST_KEY}" "Install_Dir"
+SetCompressor lzma
+
+SetOverwrite on
+CRCCheck on
+
+Icon "${CONFIG_ICON}"
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Macros
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+Function LaunchLink
+ SetShellVarContext all
+ ExecShell "" "$INSTDIR\${CONFIG_EXECUTABLE}"
+FunctionEnd
+
+Function CreateDesktopShortcut
+ CreateShortCut "$DESKTOP\${CONFIG_LONG_APP_NAME}.lnk" \
+ "$INSTDIR\${CONFIG_EXECUTABLE}" "" "$INSTDIR\${CONFIG_ICON}"
+FunctionEnd
+
+Function .onInit
+TestRunning:
+ ${nsProcess::FindProcess} ${CONFIG_EXECUTABLE} $R0
+ StrCmp $R0 0 0 NotRunning
+ MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION \
+ "It looks like you're already running ${CONFIG_LONG_APP_NAME}.$\n\
+ Please shut it down before continuing." \
+ IDOK TestRunning
+ Goto TestRunning
+NotRunning:
+
+FunctionEnd
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Sections ;;
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+Section "-${CONFIG_LONG_APP_NAME}" COM2
+ SectionIn RO
+ ClearErrors
+ SetShellVarContext all
+
+ SetOutPath "$INSTDIR"
+
+ File ${CONFIG_ICON}
+ File *.pyd
+ File *.dll
+ File library.zip
+ File ${CONFIG_EXECUTABLE}
+ SetOutPath "$INSTDIR\ffmpeg"
+ File /r ffmpeg\*.*
+ # SetOutPath "$INSTDIR\avconv"
+ # File /r avconv\*.*
+ SetOutPath "$INSTDIR\resources\converters"
+ File resources\converters\*.*
+ SetOutPath "$INSTDIR\resources\images"
+ File resources\images\*.*
+ SetOutPath "$INSTDIR\etc\gtk-2.0"
+ File etc\gtk-2.0\gtkrc
+ SetOutPath "$INSTDIR\lib\gtk-2.0\2.10.0\engines"
+ File lib\gtk-2.0\2.10.0\engines\libclearlooks.dll
+
+ IfErrors 0 files_ok
+
+ MessageBox MB_OK|MB_ICONEXCLAMATION "Installation failed. An error occured writing to the ${CONFIG_LONG_APP_NAME} Folder."
+ Quit
+files_ok:
+ CreateDirectory "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}"
+ CreateShortCut "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${CONFIG_LONG_APP_NAME}.lnk" \
+ "$INSTDIR\${CONFIG_EXECUTABLE}" "" "$INSTDIR\${CONFIG_ICON}"
+ CreateShortCut "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${UNINSTALL_SHORTCUT}" \
+ "$INSTDIR\uninstall.exe"
+
+SectionEnd
+
+Section -Post
+ WriteUninstaller "$INSTDIR\uninstall.exe"
+ WriteRegStr HKLM "${INST_KEY}" "InstallDir" $INSTDIR
+ WriteRegStr HKLM "${INST_KEY}" "Version" "${CONFIG_VERSION}"
+ WriteRegStr HKLM "${INST_KEY}" "" "$INSTDIR\${CONFIG_EXECUTABLE}"
+
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "$(^Name)"
+ WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${CONFIG_EXECUTABLE}"
+ WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${CONFIG_VERSION}"
+ WriteRegStr HKLM "${UNINST_KEY}" "URLInfoAbout" "${CONFIG_PROJECT_URL}"
+ WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${CONFIG_PUBLISHER}"
+
+SectionEnd
+
+Section "Uninstall" SEC91
+
+ SetShellVarContext all
+
+ Delete "$INSTDIR\uninstall.exe"
+ Delete "$DESKTOP\Libre Video Converter.lnk"
+ Delete "$INSTDIR\${CONFIG_ICON}"
+ Delete "$INSTDIR\*.pyd"
+ Delete "$INSTDIR\*.dll"
+ Delete "$INSTDIR\library.zip"
+ Delete "$INSTDIR\${CONFIG_EXECUTABLE}"
+ Delete "$INSTDIR\resources\converters\*.*"
+ Delete "$INSTDIR\resources\images\*.*"
+ Delete "$INSTDIR\etc\gtk-2.0\gtkrc"
+ Delete "$INSTDIR\lib\gtk-2.0\2.10.0\engines\libclearlooks.dll"
+ # RMDir /r "$INSTDIR\avconv"
+ RMDir /r "$INSTDIR\ffmpeg"
+ RMDir "$INSTDIR\lib\gtk-2.0\2.10.0\engines\"
+ RMDir "$INSTDIR\lib\gtk-2.0\2.10.0\"
+ RMDir "$INSTDIR\lib\gtk-2.0\"
+ RMDir "$INSTDIR\lib\"
+ RMDir "$INSTDIR\etc\gtk-2.0"
+ RMDir "$INSTDIR\etc"
+ RMDir "$INSTDIR\resources\converters"
+ RMDir "$INSTDIR\resources\images"
+ RMDir "$INSTDIR\resources"
+ RMDir "$INSTDIR"
+ RMDIR "$PROGRAMFILES\${CONFIG_PUBLISHER}"
+
+ RMDir "$PROGRAMFILES\${CONFIG_PUBLISHER}"
+
+ ; Remove Start Menu shortcuts
+ Delete "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${UNINSTALL_SHORTCUT}"
+ Delete "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}\${CONFIG_LONG_APP_NAME}.lnk"
+ RMDir "$SMPROGRAMS\${CONFIG_LONG_APP_NAME}"
+
+ SetAutoClose true
+SectionEnd
+
+;PAGE SETUP
+!define MUI_ABORTWARNING ;a confirmation message should be displayed if the user clicks cancel
+
+!define MUI_WELCOMEFINISHPAGE_BITMAP "modern-wizard.bmp"
+!insertmacro MUI_PAGE_WELCOME ;welcome page
+!insertmacro MUI_PAGE_INSTFILES ;install files page
+; Finish page
+!define MUI_FINISHPAGE_RUN
+!define MUI_FINISHPAGE_TITLE "${CONFIG_LONG_APP_NAME} has been installed!"
+!define MUI_FINISHPAGE_TITLE_3LINES
+!define MUI_FINISHPAGE_RUN_TEXT "Run ${CONFIG_LONG_APP_NAME}"
+!define MUI_FINISHPAGE_RUN_FUNCTION "LaunchLink"
+!define MUI_FINISHPAGE_LINK "${CONFIG_PUBLISHER} homepage."
+!define MUI_FINISHPAGE_LINK_LOCATION "${CONFIG_PROJECT_URL}"
+!define MUI_FINISHPAGE_NOREBOOTSUPPORT
+; hijack the showreadme checkbox and use it for the desktop shortcut
+!define MUI_FINISHPAGE_SHOWREADME ""
+!define MUI_FINISHPAGE_SHOWREADME_CHECKED
+!define MUI_FINISHPAGE_SHOWREADME_TEXT "Create Desktop Shortcut"
+!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut
+!insertmacro MUI_PAGE_FINISH
+
+; Uninstaller pages
+!insertmacro MUI_UNPAGE_CONFIRM
+
+!insertmacro MUI_UNPAGE_INSTFILES
+!define MUI_UNWELCOMEFINISHPAGE_BITMAP "modern-wizard.bmp"
+!insertmacro MUI_UNPAGE_FINISH
+
+;LANGUAGE FILES
+!define MUI_LANGSTRINGS
+!insertmacro MUI_LANGUAGE "English"
diff --git a/setup-files/windows/setup.py b/setup-files/windows/setup.py new file mode 100644 index 0000000..663acd9 --- /dev/null +++ b/setup-files/windows/setup.py @@ -0,0 +1,146 @@ +"""setup-windows.py -- setup script for windows + +Usage: + +""" +from distutils import log +from distutils.core import Command, setup +from glob import glob +import itertools +import os +import subprocess +import sys + +import py2exe + +from mvc import resources + +env_path = os.path.abspath(os.path.dirname(os.path.dirname(sys.executable))) +nsis_path = os.path.join(env_path, 'nsis-2.46', 'makensis.exe') +scripts_path = os.path.join(env_path, 'Scripts') + +packages = [ + 'mvc', + 'mvc.widgets', + 'mvc.widgets.gtk', + 'mvc.ui', + 'mvc.resources', + 'mvc.windows', + 'mvc.qtfaststart', +] + +def resources_dir(): + return os.path.dirname(resources.__file__) + +def resource_data_files(subdir, globspec='*.*'): + dest_dir = os.path.join("resources", subdir) + dir_contents = glob(os.path.join(resources_dir(), subdir, globspec)) + return [(dest_dir, dir_contents)] + +def ffmpeg_data_files(): + ffmpeg_dir = os.path.join(env_path, 'ffmpeg') + return [ + ('ffmpeg', + [os.path.join(ffmpeg_dir, 'ffmpeg.exe')]), + #('ffmpeg/presets', + #glob(os.path.join(ffmpeg_dir, 'presets', '*.ffpreset'))), + ] + +def winsparkle_data_files(): + winsparkle_dll = os.path.join(env_path, 'WinSparkle-0.3', + "WinSparkle.dll") + return [ + ('', [winsparkle_dll]), + ] + +def gtk_theme_data_files(): + engine_path = os.path.join(env_path, 'gtk2-themes-2009-09-07-win32_bin', + 'lib', 'gtk-2.0', '2.10.0', 'engines', 'libclearlooks.dll') + gtkrc_path = os.path.join(resources_dir(), 'windows', 'gtkrc') + return [ + ('etc/gtk-2.0', [gtkrc_path]), + ('lib/gtk-2.0/2.10.0/engines', [engine_path]) + ] + +def avconv_data_files(): + avconv_dir = os.path.join(env_path, 'avconv') + return [ + ('avconv', + glob(os.path.join(avconv_dir, '*.*'))), + ] + +def data_files(): + return list(itertools.chain( + resource_data_files("images"), + resource_data_files("converters", "*.py"), + ffmpeg_data_files(), + winsparkle_data_files(), + gtk_theme_data_files(), + #avconv_data_files(), + )) + +def gtk_includes(): + return ['gtk', 'gobject', 'atk', 'pango', 'pangocairo', 'gio'] + +def py2exe_includes(): + return gtk_includes() + +class bdist_nsis(Command): + description = "create MVC installer using NSIS" + user_options = [ + ] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + self.run_command('py2exe') + self.dist_dir = self.get_finalized_command('py2exe').dist_dir + + log.info("building installer") + + nsis_source = os.path.join(SETUP_DIR, 'mvc.nsi') + self.copy_file(nsis_source, self.dist_dir) + for nsis_file in glob(os.path.join(resources_dir(), 'nsis', '*.*')): + self.copy_file(nsis_file, self.dist_dir) + + plugins_dir = os.path.join(resources_dir(), 'nsis', 'plugins') + script_path = os.path.join(self.dist_dir, 'mvc.nsi') + nsis_defines = { + 'CONFIG_PLUGIN_DIR': plugins_dir, + } + cmd_line = [nsis_path] + for name, value in nsis_defines.items(): + cmd_line.append("/D%s=%s" % (name, value)) + cmd_line.append(script_path) + + if subprocess.call(cmd_line) != 0: + print "ERROR creating the 1 stage installer, quitting" + return +setup( + windows=[ + {'script': 'mvc/windows/exe_main.py', + 'dest_base': 'mvc', + 'company_name': 'Participatory Culture Foundation', + }, + ], + console=[ + {'script': 'mvc/windows/exe_main.py', + 'dest_base': 'mvcdebug', + 'company_name': 'Participatory Culture Foundation', + }, + ], + data_files=data_files(), + cmdclass={ + 'bdist_nsis': bdist_nsis, + }, + options={ + 'py2exe': { + 'includes': py2exe_includes(), + }, + }, + **SETUP_ARGS +) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..dc8d6ee --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +import os +import sys + +version = '3.0.2' + +# platform-independent arguments for setup() +setup_args = { + 'name': 'librevideoconverter', + 'description': 'Simple video converter for WebM (vp8), Ogg Theora, MP4 and others, fork of Miro Video Converter', + 'author': 'Jesus Eduardo (Heckyel)', + 'author_email': 'heckyel@openmailbox.org', + 'url': 'https://notabug.org/Heckyel/LibreVideoConverter', + 'license': 'GPL', + 'version': version, + 'packages': [ + 'mvc', + 'mvc.osx', + 'mvc.qtfaststart', + 'mvc.resources', + 'mvc.ui', + 'mvc.widgets', + 'mvc.widgets.gtk', + 'mvc.widgets.osx', + 'mvc.windows', + ], + 'package_data': { + 'mvc.resources': [ + 'converters/*.py', + 'images/*.*', + ], + }, +} + +if sys.platform.startswith("linux"): + platform = 'linux' +elif sys.platform.startswith("win32"): + platform = 'windows' +elif sys.platform.startswith("darwin"): + platform = 'osx' +else: + sys.stderr.write("Unknown platform: %s" % sys.platform) + +root_dir = os.path.abspath(os.path.dirname(__file__)) +setup_dir = os.path.join(root_dir, 'setup-files', platform) + +script_vars = { + 'VERSION': version, + 'ROOT_DIR': root_dir, + 'SETUP_DIR': setup_dir, + 'SETUP_ARGS': setup_args, +} + +execfile(os.path.join(setup_dir, 'setup.py'), script_vars) @@ -0,0 +1,28 @@ +#!/bin/sh + +for i in "dist/Libre Video Converter.app/Contents/Helpers"/* +do + codesign -fs \ + 'Fredom Operating System: Heckyel | 2017' \ + "${i}" +done + +# Fix up the python framework installation from py2app: it doesn't +# copy over all the files that constitutes it to be a valid framework. +pushd "dist/Libre Video Converter.app/Contents/Frameworks/Python.framework" +ln -sf Versions/Current/Python Python +ln -sf Versions/Current/Resources Resources +ln -sf 2.7 Versions/Current +popd + +codesign -fs \ + '3rd Party Mac Developer Application: Participatory Culture Foundation' \ + "dist/Libre Video Converter.app/Contents/Frameworks/Python.framework/Versions/2.7" + +codesign -fs \ + '3rd Party Mac Developer Application: Participatory Culture Foundation' \ + "dist/Libre Video Converter.app/Contents/Frameworks/Sparkle.framework/Versions/A" + +codesign -fs \ + '3rd Party Mac Developer Application: Participatory Culture Foundation' \ + "dist/Libre Video Converter.app" diff --git a/test/base.py b/test/base.py new file mode 100644 index 0000000..397274e --- /dev/null +++ b/test/base.py @@ -0,0 +1,7 @@ +import os.path +import unittest + +class Test(unittest.TestCase): + + def setUp(self): + self.testdata_dir = os.path.join(os.path.dirname(__file__), 'testdata') diff --git a/test/mock.py b/test/mock.py new file mode 100644 index 0000000..93b90d8 --- /dev/null +++ b/test/mock.py @@ -0,0 +1,2356 @@ +# mock.py +# Test tools for mocking and patching. +# Copyright (C) 2007-2012 Michael Foord & the mock team +# E-mail: fuzzyman AT voidspace DOT org DOT uk + +# mock 1.0 +# http://www.voidspace.org.uk/python/mock/ + +# Released subject to the BSD License +# Please see http://www.voidspace.org.uk/python/license.shtml + +# Scripts maintained at http://www.voidspace.org.uk/python/index.shtml +# Comments, suggestions and bug reports welcome. + + +__all__ = ( + 'Mock', + 'MagicMock', + 'patch', + 'sentinel', + 'DEFAULT', + 'ANY', + 'call', + 'create_autospec', + 'FILTER_DIR', + 'NonCallableMock', + 'NonCallableMagicMock', + 'mock_open', + 'PropertyMock', +) + + +__version__ = '1.0b1' + + +import pprint +import sys + +try: + import inspect +except ImportError: + # for alternative platforms that + # may not have inspect + inspect = None + +try: + from functools import wraps +except ImportError: + # Python 2.4 compatibility + def wraps(original): + def inner(f): + f.__name__ = original.__name__ + f.__doc__ = original.__doc__ + f.__module__ = original.__module__ + return f + return inner + +try: + unicode +except NameError: + # Python 3 + basestring = unicode = str + +try: + long +except NameError: + # Python 3 + long = int + +try: + BaseException +except NameError: + # Python 2.4 compatibility + BaseException = Exception + +try: + next +except NameError: + def next(obj): + return obj.next() + + +BaseExceptions = (BaseException,) +if 'java' in sys.platform: + # jython + import java + BaseExceptions = (BaseException, java.lang.Throwable) + +try: + _isidentifier = str.isidentifier +except AttributeError: + # Python 2.X + import keyword + import re + regex = re.compile(r'^[a-z_][a-z0-9_]*$', re.I) + def _isidentifier(string): + if string in keyword.kwlist: + return False + return regex.match(string) + + +inPy3k = sys.version_info[0] == 3 + +# Needed to work around Python 3 bug where use of "super" interferes with +# defining __class__ as a descriptor +_super = super + +self = 'im_self' +builtin = '__builtin__' +if inPy3k: + self = '__self__' + builtin = 'builtins' + +FILTER_DIR = True + + +def _is_instance_mock(obj): + # can't use isinstance on Mock objects because they override __class__ + # The base class for all mocks is NonCallableMock + return issubclass(type(obj), NonCallableMock) + + +def _is_exception(obj): + return ( + isinstance(obj, BaseExceptions) or + isinstance(obj, ClassTypes) and issubclass(obj, BaseExceptions) + ) + + +class _slotted(object): + __slots__ = ['a'] + + +DescriptorTypes = ( + type(_slotted.a), + property, +) + + +def _getsignature(func, skipfirst, instance=False): + if inspect is None: + raise ImportError('inspect module not available') + + if isinstance(func, ClassTypes) and not instance: + try: + func = func.__init__ + except AttributeError: + return + skipfirst = True + elif not isinstance(func, FunctionTypes): + # for classes where instance is True we end up here too + try: + func = func.__call__ + except AttributeError: + return + + if inPy3k: + try: + argspec = inspect.getfullargspec(func) + except TypeError: + # C function / method, possibly inherited object().__init__ + return + regargs, varargs, varkw, defaults, kwonly, kwonlydef, ann = argspec + else: + try: + regargs, varargs, varkwargs, defaults = inspect.getargspec(func) + except TypeError: + # C function / method, possibly inherited object().__init__ + return + + # instance methods and classmethods need to lose the self argument + if getattr(func, self, None) is not None: + regargs = regargs[1:] + if skipfirst: + # this condition and the above one are never both True - why? + regargs = regargs[1:] + + if inPy3k: + signature = inspect.formatargspec( + regargs, varargs, varkw, defaults, + kwonly, kwonlydef, ann, formatvalue=lambda value: "") + else: + signature = inspect.formatargspec( + regargs, varargs, varkwargs, defaults, + formatvalue=lambda value: "") + return signature[1:-1], func + + +def _check_signature(func, mock, skipfirst, instance=False): + if not _callable(func): + return + + result = _getsignature(func, skipfirst, instance) + if result is None: + return + signature, func = result + + # can't use self because "self" is common as an argument name + # unfortunately even not in the first place + src = "lambda _mock_self, %s: None" % signature + checksig = eval(src, {}) + _copy_func_details(func, checksig) + type(mock)._mock_check_sig = checksig + + +def _copy_func_details(func, funcopy): + funcopy.__name__ = func.__name__ + funcopy.__doc__ = func.__doc__ + #funcopy.__dict__.update(func.__dict__) + funcopy.__module__ = func.__module__ + if not inPy3k: + funcopy.func_defaults = func.func_defaults + return + funcopy.__defaults__ = func.__defaults__ + funcopy.__kwdefaults__ = func.__kwdefaults__ + + +def _callable(obj): + if isinstance(obj, ClassTypes): + return True + if getattr(obj, '__call__', None) is not None: + return True + return False + + +def _is_list(obj): + # checks for list or tuples + # XXXX badly named! + return type(obj) in (list, tuple) + + +def _instance_callable(obj): + """Given an object, return True if the object is callable. + For classes, return True if instances would be callable.""" + if not isinstance(obj, ClassTypes): + # already an instance + return getattr(obj, '__call__', None) is not None + + klass = obj + # uses __bases__ instead of __mro__ so that we work with old style classes + if klass.__dict__.get('__call__') is not None: + return True + + for base in klass.__bases__: + if _instance_callable(base): + return True + return False + + +def _set_signature(mock, original, instance=False): + # creates a function with signature (*args, **kwargs) that delegates to a + # mock. It still does signature checking by calling a lambda with the same + # signature as the original. + if not _callable(original): + return + + skipfirst = isinstance(original, ClassTypes) + result = _getsignature(original, skipfirst, instance) + if result is None: + # was a C function (e.g. object().__init__ ) that can't be mocked + return + + signature, func = result + + src = "lambda %s: None" % signature + checksig = eval(src, {}) + _copy_func_details(func, checksig) + + name = original.__name__ + if not _isidentifier(name): + name = 'funcopy' + context = {'_checksig_': checksig, 'mock': mock} + src = """def %s(*args, **kwargs): + _checksig_(*args, **kwargs) + return mock(*args, **kwargs)""" % name + exec (src, context) + funcopy = context[name] + _setup_func(funcopy, mock) + return funcopy + + +def _setup_func(funcopy, mock): + funcopy.mock = mock + + # can't use isinstance with mocks + if not _is_instance_mock(mock): + return + + def assert_called_with(*args, **kwargs): + return mock.assert_called_with(*args, **kwargs) + def assert_called_once_with(*args, **kwargs): + return mock.assert_called_once_with(*args, **kwargs) + def assert_has_calls(*args, **kwargs): + return mock.assert_has_calls(*args, **kwargs) + def assert_any_call(*args, **kwargs): + return mock.assert_any_call(*args, **kwargs) + def reset_mock(): + funcopy.method_calls = _CallList() + funcopy.mock_calls = _CallList() + mock.reset_mock() + ret = funcopy.return_value + if _is_instance_mock(ret) and not ret is mock: + ret.reset_mock() + + funcopy.called = False + funcopy.call_count = 0 + funcopy.call_args = None + funcopy.call_args_list = _CallList() + funcopy.method_calls = _CallList() + funcopy.mock_calls = _CallList() + + funcopy.return_value = mock.return_value + funcopy.side_effect = mock.side_effect + funcopy._mock_children = mock._mock_children + + funcopy.assert_called_with = assert_called_with + funcopy.assert_called_once_with = assert_called_once_with + funcopy.assert_has_calls = assert_has_calls + funcopy.assert_any_call = assert_any_call + funcopy.reset_mock = reset_mock + + mock._mock_delegate = funcopy + + +def _is_magic(name): + return '__%s__' % name[2:-2] == name + + +class _SentinelObject(object): + "A unique, named, sentinel object." + def __init__(self, name): + self.name = name + + def __repr__(self): + return 'sentinel.%s' % self.name + + +class _Sentinel(object): + """Access attributes to return a named object, usable as a sentinel.""" + def __init__(self): + self._sentinels = {} + + def __getattr__(self, name): + if name == '__bases__': + # Without this help(mock) raises an exception + raise AttributeError + return self._sentinels.setdefault(name, _SentinelObject(name)) + + +sentinel = _Sentinel() + +DEFAULT = sentinel.DEFAULT +_missing = sentinel.MISSING +_deleted = sentinel.DELETED + + +class OldStyleClass: + pass +ClassType = type(OldStyleClass) + + +def _copy(value): + if type(value) in (dict, list, tuple, set): + return type(value)(value) + return value + + +ClassTypes = (type,) +if not inPy3k: + ClassTypes = (type, ClassType) + +_allowed_names = set( + [ + 'return_value', '_mock_return_value', 'side_effect', + '_mock_side_effect', '_mock_parent', '_mock_new_parent', + '_mock_name', '_mock_new_name' + ] +) + + +def _delegating_property(name): + _allowed_names.add(name) + _the_name = '_mock_' + name + def _get(self, name=name, _the_name=_the_name): + sig = self._mock_delegate + if sig is None: + return getattr(self, _the_name) + return getattr(sig, name) + def _set(self, value, name=name, _the_name=_the_name): + sig = self._mock_delegate + if sig is None: + self.__dict__[_the_name] = value + else: + setattr(sig, name, value) + + return property(_get, _set) + + + +class _CallList(list): + + def __contains__(self, value): + if not isinstance(value, list): + return list.__contains__(self, value) + len_value = len(value) + len_self = len(self) + if len_value > len_self: + return False + + for i in range(0, len_self - len_value + 1): + sub_list = self[i:i+len_value] + if sub_list == value: + return True + return False + + def __repr__(self): + return pprint.pformat(list(self)) + + +def _check_and_set_parent(parent, value, name, new_name): + if not _is_instance_mock(value): + return False + if ((value._mock_name or value._mock_new_name) or + (value._mock_parent is not None) or + (value._mock_new_parent is not None)): + return False + + _parent = parent + while _parent is not None: + # setting a mock (value) as a child or return value of itself + # should not modify the mock + if _parent is value: + return False + _parent = _parent._mock_new_parent + + if new_name: + value._mock_new_parent = parent + value._mock_new_name = new_name + if name: + value._mock_parent = parent + value._mock_name = name + return True + + + +class Base(object): + _mock_return_value = DEFAULT + _mock_side_effect = None + def __init__(self, *args, **kwargs): + pass + + + +class NonCallableMock(Base): + """A non-callable version of `Mock`""" + + def __new__(cls, *args, **kw): + # every instance has its own class + # so we can create magic methods on the + # class without stomping on other mocks + new = type(cls.__name__, (cls,), {'__doc__': cls.__doc__}) + instance = object.__new__(new) + return instance + + + def __init__( + self, spec=None, wraps=None, name=None, spec_set=None, + parent=None, _spec_state=None, _new_name='', _new_parent=None, + **kwargs + ): + if _new_parent is None: + _new_parent = parent + + __dict__ = self.__dict__ + __dict__['_mock_parent'] = parent + __dict__['_mock_name'] = name + __dict__['_mock_new_name'] = _new_name + __dict__['_mock_new_parent'] = _new_parent + + if spec_set is not None: + spec = spec_set + spec_set = True + + self._mock_add_spec(spec, spec_set) + + __dict__['_mock_children'] = {} + __dict__['_mock_wraps'] = wraps + __dict__['_mock_delegate'] = None + + __dict__['_mock_called'] = False + __dict__['_mock_call_args'] = None + __dict__['_mock_call_count'] = 0 + __dict__['_mock_call_args_list'] = _CallList() + __dict__['_mock_mock_calls'] = _CallList() + + __dict__['method_calls'] = _CallList() + + if kwargs: + self.configure_mock(**kwargs) + + _super(NonCallableMock, self).__init__( + spec, wraps, name, spec_set, parent, + _spec_state + ) + + + def attach_mock(self, mock, attribute): + """ + Attach a mock as an attribute of this one, replacing its name and + parent. Calls to the attached mock will be recorded in the + `method_calls` and `mock_calls` attributes of this one.""" + mock._mock_parent = None + mock._mock_new_parent = None + mock._mock_name = '' + mock._mock_new_name = None + + setattr(self, attribute, mock) + + + def mock_add_spec(self, spec, spec_set=False): + """Add a spec to a mock. `spec` can either be an object or a + list of strings. Only attributes on the `spec` can be fetched as + attributes from the mock. + + If `spec_set` is True then only attributes on the spec can be set.""" + self._mock_add_spec(spec, spec_set) + + + def _mock_add_spec(self, spec, spec_set): + _spec_class = None + + if spec is not None and not _is_list(spec): + if isinstance(spec, ClassTypes): + _spec_class = spec + else: + _spec_class = _get_class(spec) + + spec = dir(spec) + + __dict__ = self.__dict__ + __dict__['_spec_class'] = _spec_class + __dict__['_spec_set'] = spec_set + __dict__['_mock_methods'] = spec + + + def __get_return_value(self): + ret = self._mock_return_value + if self._mock_delegate is not None: + ret = self._mock_delegate.return_value + + if ret is DEFAULT: + ret = self._get_child_mock( + _new_parent=self, _new_name='()' + ) + self.return_value = ret + return ret + + + def __set_return_value(self, value): + if self._mock_delegate is not None: + self._mock_delegate.return_value = value + else: + self._mock_return_value = value + _check_and_set_parent(self, value, None, '()') + + __return_value_doc = "The value to be returned when the mock is called." + return_value = property(__get_return_value, __set_return_value, + __return_value_doc) + + + @property + def __class__(self): + if self._spec_class is None: + return type(self) + return self._spec_class + + called = _delegating_property('called') + call_count = _delegating_property('call_count') + call_args = _delegating_property('call_args') + call_args_list = _delegating_property('call_args_list') + mock_calls = _delegating_property('mock_calls') + + + def __get_side_effect(self): + sig = self._mock_delegate + if sig is None: + return self._mock_side_effect + return sig.side_effect + + def __set_side_effect(self, value): + value = _try_iter(value) + sig = self._mock_delegate + if sig is None: + self._mock_side_effect = value + else: + sig.side_effect = value + + side_effect = property(__get_side_effect, __set_side_effect) + + + def reset_mock(self): + "Restore the mock object to its initial state." + self.called = False + self.call_args = None + self.call_count = 0 + self.mock_calls = _CallList() + self.call_args_list = _CallList() + self.method_calls = _CallList() + + for child in self._mock_children.values(): + if isinstance(child, _SpecState): + continue + child.reset_mock() + + ret = self._mock_return_value + if _is_instance_mock(ret) and ret is not self: + ret.reset_mock() + + + def configure_mock(self, **kwargs): + """Set attributes on the mock through keyword arguments. + + Attributes plus return values and side effects can be set on child + mocks using standard dot notation and unpacking a dictionary in the + method call: + + >>> attrs = {'method.return_value': 3, 'other.side_effect': KeyError} + >>> mock.configure_mock(**attrs)""" + for arg, val in sorted(kwargs.items(), + # we sort on the number of dots so that + # attributes are set before we set attributes on + # attributes + key=lambda entry: entry[0].count('.')): + args = arg.split('.') + final = args.pop() + obj = self + for entry in args: + obj = getattr(obj, entry) + setattr(obj, final, val) + + + def __getattr__(self, name): + if name == '_mock_methods': + raise AttributeError(name) + elif self._mock_methods is not None: + if name not in self._mock_methods or name in _all_magics: + raise AttributeError("Mock object has no attribute %r" % name) + elif _is_magic(name): + raise AttributeError(name) + + result = self._mock_children.get(name) + if result is _deleted: + raise AttributeError(name) + elif result is None: + wraps = None + if self._mock_wraps is not None: + # XXXX should we get the attribute without triggering code + # execution? + wraps = getattr(self._mock_wraps, name) + + result = self._get_child_mock( + parent=self, name=name, wraps=wraps, _new_name=name, + _new_parent=self + ) + self._mock_children[name] = result + + elif isinstance(result, _SpecState): + result = create_autospec( + result.spec, result.spec_set, result.instance, + result.parent, result.name + ) + self._mock_children[name] = result + + return result + + + def __repr__(self): + _name_list = [self._mock_new_name] + _parent = self._mock_new_parent + last = self + + dot = '.' + if _name_list == ['()']: + dot = '' + seen = set() + while _parent is not None: + last = _parent + + _name_list.append(_parent._mock_new_name + dot) + dot = '.' + if _parent._mock_new_name == '()': + dot = '' + + _parent = _parent._mock_new_parent + + # use ids here so as not to call __hash__ on the mocks + if id(_parent) in seen: + break + seen.add(id(_parent)) + + _name_list = list(reversed(_name_list)) + _first = last._mock_name or 'mock' + if len(_name_list) > 1: + if _name_list[1] not in ('()', '().'): + _first += '.' + _name_list[0] = _first + name = ''.join(_name_list) + + name_string = '' + if name not in ('mock', 'mock.'): + name_string = ' name=%r' % name + + spec_string = '' + if self._spec_class is not None: + spec_string = ' spec=%r' + if self._spec_set: + spec_string = ' spec_set=%r' + spec_string = spec_string % self._spec_class.__name__ + return "<%s%s%s id='%s'>" % ( + type(self).__name__, + name_string, + spec_string, + id(self) + ) + + + def __dir__(self): + """Filter the output of `dir(mock)` to only useful members. + XXXX + """ + extras = self._mock_methods or [] + from_type = dir(type(self)) + from_dict = list(self.__dict__) + + if FILTER_DIR: + from_type = [e for e in from_type if not e.startswith('_')] + from_dict = [e for e in from_dict if not e.startswith('_') or + _is_magic(e)] + return sorted(set(extras + from_type + from_dict + + list(self._mock_children))) + + + def __setattr__(self, name, value): + if name in _allowed_names: + # property setters go through here + return object.__setattr__(self, name, value) + elif (self._spec_set and self._mock_methods is not None and + name not in self._mock_methods and + name not in self.__dict__): + raise AttributeError("Mock object has no attribute '%s'" % name) + elif name in _unsupported_magics: + msg = 'Attempting to set unsupported magic method %r.' % name + raise AttributeError(msg) + elif name in _all_magics: + if self._mock_methods is not None and name not in self._mock_methods: + raise AttributeError("Mock object has no attribute '%s'" % name) + + if not _is_instance_mock(value): + setattr(type(self), name, _get_method(name, value)) + original = value + value = lambda *args, **kw: original(self, *args, **kw) + else: + # only set _new_name and not name so that mock_calls is tracked + # but not method calls + _check_and_set_parent(self, value, None, name) + setattr(type(self), name, value) + self._mock_children[name] = value + elif name == '__class__': + self._spec_class = value + return + else: + if _check_and_set_parent(self, value, name, name): + self._mock_children[name] = value + return object.__setattr__(self, name, value) + + + def __delattr__(self, name): + if name in _all_magics and name in type(self).__dict__: + delattr(type(self), name) + if name not in self.__dict__: + # for magic methods that are still MagicProxy objects and + # not set on the instance itself + return + + if name in self.__dict__: + object.__delattr__(self, name) + + obj = self._mock_children.get(name, _missing) + if obj is _deleted: + raise AttributeError(name) + if obj is not _missing: + del self._mock_children[name] + self._mock_children[name] = _deleted + + + + def _format_mock_call_signature(self, args, kwargs): + name = self._mock_name or 'mock' + return _format_call_signature(name, args, kwargs) + + + def _format_mock_failure_message(self, args, kwargs): + message = 'Expected call: %s\nActual call: %s' + expected_string = self._format_mock_call_signature(args, kwargs) + call_args = self.call_args + if len(call_args) == 3: + call_args = call_args[1:] + actual_string = self._format_mock_call_signature(*call_args) + return message % (expected_string, actual_string) + + + def assert_called_with(_mock_self, *args, **kwargs): + """assert that the mock was called with the specified arguments. + + Raises an AssertionError if the args and keyword args passed in are + different to the last call to the mock.""" + self = _mock_self + if self.call_args is None: + expected = self._format_mock_call_signature(args, kwargs) + raise AssertionError('Expected call: %s\nNot called' % (expected,)) + + if self.call_args != (args, kwargs): + msg = self._format_mock_failure_message(args, kwargs) + raise AssertionError(msg) + + + def assert_called_once_with(_mock_self, *args, **kwargs): + """assert that the mock was called exactly once and with the specified + arguments.""" + self = _mock_self + if not self.call_count == 1: + msg = ("Expected to be called once. Called %s times." % + self.call_count) + raise AssertionError(msg) + return self.assert_called_with(*args, **kwargs) + + + def assert_has_calls(self, calls, any_order=False): + """assert the mock has been called with the specified calls. + The `mock_calls` list is checked for the calls. + + If `any_order` is False (the default) then the calls must be + sequential. There can be extra calls before or after the + specified calls. + + If `any_order` is True then the calls can be in any order, but + they must all appear in `mock_calls`.""" + if not any_order: + if calls not in self.mock_calls: + raise AssertionError( + 'Calls not found.\nExpected: %r\n' + 'Actual: %r' % (calls, self.mock_calls) + ) + return + + all_calls = list(self.mock_calls) + + not_found = [] + for kall in calls: + try: + all_calls.remove(kall) + except ValueError: + not_found.append(kall) + if not_found: + raise AssertionError( + '%r not all found in call list' % (tuple(not_found),) + ) + + + def assert_any_call(self, *args, **kwargs): + """assert the mock has been called with the specified arguments. + + The assert passes if the mock has *ever* been called, unlike + `assert_called_with` and `assert_called_once_with` that only pass if + the call is the most recent one.""" + kall = call(*args, **kwargs) + if kall not in self.call_args_list: + expected_string = self._format_mock_call_signature(args, kwargs) + raise AssertionError( + '%s call not found' % expected_string + ) + + + def _get_child_mock(self, **kw): + """Create the child mocks for attributes and return value. + By default child mocks will be the same type as the parent. + Subclasses of Mock may want to override this to customize the way + child mocks are made. + + For non-callable mocks the callable variant will be used (rather than + any custom subclass).""" + _type = type(self) + if not issubclass(_type, CallableMixin): + if issubclass(_type, NonCallableMagicMock): + klass = MagicMock + elif issubclass(_type, NonCallableMock) : + klass = Mock + else: + klass = _type.__mro__[1] + return klass(**kw) + + + +def _try_iter(obj): + if obj is None: + return obj + if _is_exception(obj): + return obj + if _callable(obj): + return obj + try: + return iter(obj) + except TypeError: + # XXXX backwards compatibility + # but this will blow up on first call - so maybe we should fail early? + return obj + + + +class CallableMixin(Base): + + def __init__(self, spec=None, side_effect=None, return_value=DEFAULT, + wraps=None, name=None, spec_set=None, parent=None, + _spec_state=None, _new_name='', _new_parent=None, **kwargs): + self.__dict__['_mock_return_value'] = return_value + + _super(CallableMixin, self).__init__( + spec, wraps, name, spec_set, parent, + _spec_state, _new_name, _new_parent, **kwargs + ) + + self.side_effect = side_effect + + + def _mock_check_sig(self, *args, **kwargs): + # stub method that can be replaced with one with a specific signature + pass + + + def __call__(_mock_self, *args, **kwargs): + # can't use self in-case a function / method we are mocking uses self + # in the signature + _mock_self._mock_check_sig(*args, **kwargs) + return _mock_self._mock_call(*args, **kwargs) + + + def _mock_call(_mock_self, *args, **kwargs): + self = _mock_self + self.called = True + self.call_count += 1 + self.call_args = _Call((args, kwargs), two=True) + self.call_args_list.append(_Call((args, kwargs), two=True)) + + _new_name = self._mock_new_name + _new_parent = self._mock_new_parent + self.mock_calls.append(_Call(('', args, kwargs))) + + seen = set() + skip_next_dot = _new_name == '()' + do_method_calls = self._mock_parent is not None + name = self._mock_name + while _new_parent is not None: + this_mock_call = _Call((_new_name, args, kwargs)) + if _new_parent._mock_new_name: + dot = '.' + if skip_next_dot: + dot = '' + + skip_next_dot = False + if _new_parent._mock_new_name == '()': + skip_next_dot = True + + _new_name = _new_parent._mock_new_name + dot + _new_name + + if do_method_calls: + if _new_name == name: + this_method_call = this_mock_call + else: + this_method_call = _Call((name, args, kwargs)) + _new_parent.method_calls.append(this_method_call) + + do_method_calls = _new_parent._mock_parent is not None + if do_method_calls: + name = _new_parent._mock_name + '.' + name + + _new_parent.mock_calls.append(this_mock_call) + _new_parent = _new_parent._mock_new_parent + + # use ids here so as not to call __hash__ on the mocks + _new_parent_id = id(_new_parent) + if _new_parent_id in seen: + break + seen.add(_new_parent_id) + + ret_val = DEFAULT + effect = self.side_effect + if effect is not None: + if _is_exception(effect): + raise effect + + if not _callable(effect): + result = next(effect) + if _is_exception(result): + raise result + return result + + ret_val = effect(*args, **kwargs) + if ret_val is DEFAULT: + ret_val = self.return_value + + if (self._mock_wraps is not None and + self._mock_return_value is DEFAULT): + return self._mock_wraps(*args, **kwargs) + if ret_val is DEFAULT: + ret_val = self.return_value + return ret_val + + + +class Mock(CallableMixin, NonCallableMock): + """ + Create a new `Mock` object. `Mock` takes several optional arguments + that specify the behaviour of the Mock object: + + * `spec`: This can be either a list of strings or an existing object (a + class or instance) that acts as the specification for the mock object. If + you pass in an object then a list of strings is formed by calling dir on + the object (excluding unsupported magic attributes and methods). Accessing + any attribute not in this list will raise an `AttributeError`. + + If `spec` is an object (rather than a list of strings) then + `mock.__class__` returns the class of the spec object. This allows mocks + to pass `isinstance` tests. + + * `spec_set`: A stricter variant of `spec`. If used, attempting to *set* + or get an attribute on the mock that isn't on the object passed as + `spec_set` will raise an `AttributeError`. + + * `side_effect`: A function to be called whenever the Mock is called. See + the `side_effect` attribute. Useful for raising exceptions or + dynamically changing return values. The function is called with the same + arguments as the mock, and unless it returns `DEFAULT`, the return + value of this function is used as the return value. + + Alternatively `side_effect` can be an exception class or instance. In + this case the exception will be raised when the mock is called. + + If `side_effect` is an iterable then each call to the mock will return + the next value from the iterable. If any of the members of the iterable + are exceptions they will be raised instead of returned. + + * `return_value`: The value returned when the mock is called. By default + this is a new Mock (created on first access). See the + `return_value` attribute. + + * `wraps`: Item for the mock object to wrap. If `wraps` is not None then + calling the Mock will pass the call through to the wrapped object + (returning the real result). Attribute access on the mock will return a + Mock object that wraps the corresponding attribute of the wrapped object + (so attempting to access an attribute that doesn't exist will raise an + `AttributeError`). + + If the mock has an explicit `return_value` set then calls are not passed + to the wrapped object and the `return_value` is returned instead. + + * `name`: If the mock has a name then it will be used in the repr of the + mock. This can be useful for debugging. The name is propagated to child + mocks. + + Mocks can also be called with arbitrary keyword arguments. These will be + used to set attributes on the mock after it is created. + """ + + + +def _dot_lookup(thing, comp, import_path): + try: + return getattr(thing, comp) + except AttributeError: + __import__(import_path) + return getattr(thing, comp) + + +def _importer(target): + components = target.split('.') + import_path = components.pop(0) + thing = __import__(import_path) + + for comp in components: + import_path += ".%s" % comp + thing = _dot_lookup(thing, comp, import_path) + return thing + + +def _is_started(patcher): + # XXXX horrible + return hasattr(patcher, 'is_local') + + +class _patch(object): + + attribute_name = None + _active_patches = set() + + def __init__( + self, getter, attribute, new, spec, create, + spec_set, autospec, new_callable, kwargs + ): + if new_callable is not None: + if new is not DEFAULT: + raise ValueError( + "Cannot use 'new' and 'new_callable' together" + ) + if autospec is not None: + raise ValueError( + "Cannot use 'autospec' and 'new_callable' together" + ) + + self.getter = getter + self.attribute = attribute + self.new = new + self.new_callable = new_callable + self.spec = spec + self.create = create + self.has_local = False + self.spec_set = spec_set + self.autospec = autospec + self.kwargs = kwargs + self.additional_patchers = [] + + + def copy(self): + patcher = _patch( + self.getter, self.attribute, self.new, self.spec, + self.create, self.spec_set, + self.autospec, self.new_callable, self.kwargs + ) + patcher.attribute_name = self.attribute_name + patcher.additional_patchers = [ + p.copy() for p in self.additional_patchers + ] + return patcher + + + def __call__(self, func): + if isinstance(func, ClassTypes): + return self.decorate_class(func) + return self.decorate_callable(func) + + + def decorate_class(self, klass): + for attr in dir(klass): + if not attr.startswith(patch.TEST_PREFIX): + continue + + attr_value = getattr(klass, attr) + if not hasattr(attr_value, "__call__"): + continue + + patcher = self.copy() + setattr(klass, attr, patcher(attr_value)) + return klass + + + def decorate_callable(self, func): + if hasattr(func, 'patchings'): + func.patchings.append(self) + return func + + @wraps(func) + def patched(*args, **keywargs): + # don't use a with here (backwards compatability with Python 2.4) + extra_args = [] + entered_patchers = [] + + # can't use try...except...finally because of Python 2.4 + # compatibility + exc_info = tuple() + try: + try: + for patching in patched.patchings: + arg = patching.__enter__() + entered_patchers.append(patching) + if patching.attribute_name is not None: + keywargs.update(arg) + elif patching.new is DEFAULT: + extra_args.append(arg) + + args += tuple(extra_args) + return func(*args, **keywargs) + except: + if (patching not in entered_patchers and + _is_started(patching)): + # the patcher may have been started, but an exception + # raised whilst entering one of its additional_patchers + entered_patchers.append(patching) + # Pass the exception to __exit__ + exc_info = sys.exc_info() + # re-raise the exception + raise + finally: + for patching in reversed(entered_patchers): + patching.__exit__(*exc_info) + + patched.patchings = [self] + if hasattr(func, 'func_code'): + # not in Python 3 + patched.compat_co_firstlineno = getattr( + func, "compat_co_firstlineno", + func.func_code.co_firstlineno + ) + return patched + + + def get_original(self): + target = self.getter() + name = self.attribute + + original = DEFAULT + local = False + + try: + original = target.__dict__[name] + except (AttributeError, KeyError): + original = getattr(target, name, DEFAULT) + else: + local = True + + if not self.create and original is DEFAULT: + raise AttributeError( + "%s does not have the attribute %r" % (target, name) + ) + return original, local + + + def __enter__(self): + """Perform the patch.""" + new, spec, spec_set = self.new, self.spec, self.spec_set + autospec, kwargs = self.autospec, self.kwargs + new_callable = self.new_callable + self.target = self.getter() + + # normalise False to None + if spec is False: + spec = None + if spec_set is False: + spec_set = None + if autospec is False: + autospec = None + + if spec is not None and autospec is not None: + raise TypeError("Can't specify spec and autospec") + if ((spec is not None or autospec is not None) and + spec_set not in (True, None)): + raise TypeError("Can't provide explicit spec_set *and* spec or autospec") + + original, local = self.get_original() + + if new is DEFAULT and autospec is None: + inherit = False + if spec is True: + # set spec to the object we are replacing + spec = original + if spec_set is True: + spec_set = original + spec = None + elif spec is not None: + if spec_set is True: + spec_set = spec + spec = None + elif spec_set is True: + spec_set = original + + if spec is not None or spec_set is not None: + if original is DEFAULT: + raise TypeError("Can't use 'spec' with create=True") + if isinstance(original, ClassTypes): + # If we're patching out a class and there is a spec + inherit = True + + Klass = MagicMock + _kwargs = {} + if new_callable is not None: + Klass = new_callable + elif spec is not None or spec_set is not None: + this_spec = spec + if spec_set is not None: + this_spec = spec_set + if _is_list(this_spec): + not_callable = '__call__' not in this_spec + else: + not_callable = not _callable(this_spec) + if not_callable: + Klass = NonCallableMagicMock + + if spec is not None: + _kwargs['spec'] = spec + if spec_set is not None: + _kwargs['spec_set'] = spec_set + + # add a name to mocks + if (isinstance(Klass, type) and + issubclass(Klass, NonCallableMock) and self.attribute): + _kwargs['name'] = self.attribute + + _kwargs.update(kwargs) + new = Klass(**_kwargs) + + if inherit and _is_instance_mock(new): + # we can only tell if the instance should be callable if the + # spec is not a list + this_spec = spec + if spec_set is not None: + this_spec = spec_set + if (not _is_list(this_spec) and not + _instance_callable(this_spec)): + Klass = NonCallableMagicMock + + _kwargs.pop('name') + new.return_value = Klass(_new_parent=new, _new_name='()', + **_kwargs) + elif autospec is not None: + # spec is ignored, new *must* be default, spec_set is treated + # as a boolean. Should we check spec is not None and that spec_set + # is a bool? + if new is not DEFAULT: + raise TypeError( + "autospec creates the mock for you. Can't specify " + "autospec and new." + ) + if original is DEFAULT: + raise TypeError("Can't use 'autospec' with create=True") + spec_set = bool(spec_set) + if autospec is True: + autospec = original + + new = create_autospec(autospec, spec_set=spec_set, + _name=self.attribute, **kwargs) + elif kwargs: + # can't set keyword args when we aren't creating the mock + # XXXX If new is a Mock we could call new.configure_mock(**kwargs) + raise TypeError("Can't pass kwargs to a mock we aren't creating") + + new_attr = new + + self.temp_original = original + self.is_local = local + setattr(self.target, self.attribute, new_attr) + if self.attribute_name is not None: + extra_args = {} + if self.new is DEFAULT: + extra_args[self.attribute_name] = new + for patching in self.additional_patchers: + arg = patching.__enter__() + if patching.new is DEFAULT: + extra_args.update(arg) + return extra_args + + return new + + + def __exit__(self, *exc_info): + """Undo the patch.""" + if not _is_started(self): + raise RuntimeError('stop called on unstarted patcher') + + if self.is_local and self.temp_original is not DEFAULT: + setattr(self.target, self.attribute, self.temp_original) + else: + delattr(self.target, self.attribute) + if not self.create and not hasattr(self.target, self.attribute): + # needed for proxy objects like django settings + setattr(self.target, self.attribute, self.temp_original) + + del self.temp_original + del self.is_local + del self.target + for patcher in reversed(self.additional_patchers): + if _is_started(patcher): + patcher.__exit__(*exc_info) + + + def start(self): + """Activate a patch, returning any created mock.""" + result = self.__enter__() + self._active_patches.add(self) + return result + + + def stop(self): + """Stop an active patch.""" + self._active_patches.discard(self) + return self.__exit__() + + + +def _get_target(target): + try: + target, attribute = target.rsplit('.', 1) + except (TypeError, ValueError): + raise TypeError("Need a valid target to patch. You supplied: %r" % + (target,)) + getter = lambda: _importer(target) + return getter, attribute + + +def _patch_object( + target, attribute, new=DEFAULT, spec=None, + create=False, spec_set=None, autospec=None, + new_callable=None, **kwargs + ): + """ + patch.object(target, attribute, new=DEFAULT, spec=None, create=False, + spec_set=None, autospec=None, new_callable=None, **kwargs) + + patch the named member (`attribute`) on an object (`target`) with a mock + object. + + `patch.object` can be used as a decorator, class decorator or a context + manager. Arguments `new`, `spec`, `create`, `spec_set`, + `autospec` and `new_callable` have the same meaning as for `patch`. Like + `patch`, `patch.object` takes arbitrary keyword arguments for configuring + the mock object it creates. + + When used as a class decorator `patch.object` honours `patch.TEST_PREFIX` + for choosing which methods to wrap. + """ + getter = lambda: target + return _patch( + getter, attribute, new, spec, create, + spec_set, autospec, new_callable, kwargs + ) + + +def _patch_multiple(target, spec=None, create=False, spec_set=None, + autospec=None, new_callable=None, **kwargs): + """Perform multiple patches in a single call. It takes the object to be + patched (either as an object or a string to fetch the object by importing) + and keyword arguments for the patches:: + + with patch.multiple(settings, FIRST_PATCH='one', SECOND_PATCH='two'): + ... + + Use `DEFAULT` as the value if you want `patch.multiple` to create + mocks for you. In this case the created mocks are passed into a decorated + function by keyword, and a dictionary is returned when `patch.multiple` is + used as a context manager. + + `patch.multiple` can be used as a decorator, class decorator or a context + manager. The arguments `spec`, `spec_set`, `create`, + `autospec` and `new_callable` have the same meaning as for `patch`. These + arguments will be applied to *all* patches done by `patch.multiple`. + + When used as a class decorator `patch.multiple` honours `patch.TEST_PREFIX` + for choosing which methods to wrap. + """ + if type(target) in (unicode, str): + getter = lambda: _importer(target) + else: + getter = lambda: target + + if not kwargs: + raise ValueError( + 'Must supply at least one keyword argument with patch.multiple' + ) + # need to wrap in a list for python 3, where items is a view + items = list(kwargs.items()) + attribute, new = items[0] + patcher = _patch( + getter, attribute, new, spec, create, spec_set, + autospec, new_callable, {} + ) + patcher.attribute_name = attribute + for attribute, new in items[1:]: + this_patcher = _patch( + getter, attribute, new, spec, create, spec_set, + autospec, new_callable, {} + ) + this_patcher.attribute_name = attribute + patcher.additional_patchers.append(this_patcher) + return patcher + + +def patch( + target, new=DEFAULT, spec=None, create=False, + spec_set=None, autospec=None, new_callable=None, **kwargs + ): + """ + `patch` acts as a function decorator, class decorator or a context + manager. Inside the body of the function or with statement, the `target` + is patched with a `new` object. When the function/with statement exits + the patch is undone. + + If `new` is omitted, then the target is replaced with a + `MagicMock`. If `patch` is used as a decorator and `new` is + omitted, the created mock is passed in as an extra argument to the + decorated function. If `patch` is used as a context manager the created + mock is returned by the context manager. + + `target` should be a string in the form `'package.module.ClassName'`. The + `target` is imported and the specified object replaced with the `new` + object, so the `target` must be importable from the environment you are + calling `patch` from. The target is imported when the decorated function + is executed, not at decoration time. + + The `spec` and `spec_set` keyword arguments are passed to the `MagicMock` + if patch is creating one for you. + + In addition you can pass `spec=True` or `spec_set=True`, which causes + patch to pass in the object being mocked as the spec/spec_set object. + + `new_callable` allows you to specify a different class, or callable object, + that will be called to create the `new` object. By default `MagicMock` is + used. + + A more powerful form of `spec` is `autospec`. If you set `autospec=True` + then the mock with be created with a spec from the object being replaced. + All attributes of the mock will also have the spec of the corresponding + attribute of the object being replaced. Methods and functions being + mocked will have their arguments checked and will raise a `TypeError` if + they are called with the wrong signature. For mocks replacing a class, + their return value (the 'instance') will have the same spec as the class. + + Instead of `autospec=True` you can pass `autospec=some_object` to use an + arbitrary object as the spec instead of the one being replaced. + + By default `patch` will fail to replace attributes that don't exist. If + you pass in `create=True`, and the attribute doesn't exist, patch will + create the attribute for you when the patched function is called, and + delete it again afterwards. This is useful for writing tests against + attributes that your production code creates at runtime. It is off by by + default because it can be dangerous. With it switched on you can write + passing tests against APIs that don't actually exist! + + Patch can be used as a `TestCase` class decorator. It works by + decorating each test method in the class. This reduces the boilerplate + code when your test methods share a common patchings set. `patch` finds + tests by looking for method names that start with `patch.TEST_PREFIX`. + By default this is `test`, which matches the way `unittest` finds tests. + You can specify an alternative prefix by setting `patch.TEST_PREFIX`. + + Patch can be used as a context manager, with the with statement. Here the + patching applies to the indented block after the with statement. If you + use "as" then the patched object will be bound to the name after the + "as"; very useful if `patch` is creating a mock object for you. + + `patch` takes arbitrary keyword arguments. These will be passed to + the `Mock` (or `new_callable`) on construction. + + `patch.dict(...)`, `patch.multiple(...)` and `patch.object(...)` are + available for alternate use-cases. + """ + getter, attribute = _get_target(target) + return _patch( + getter, attribute, new, spec, create, + spec_set, autospec, new_callable, kwargs + ) + + +class _patch_dict(object): + """ + Patch a dictionary, or dictionary like object, and restore the dictionary + to its original state after the test. + + `in_dict` can be a dictionary or a mapping like container. If it is a + mapping then it must at least support getting, setting and deleting items + plus iterating over keys. + + `in_dict` can also be a string specifying the name of the dictionary, which + will then be fetched by importing it. + + `values` can be a dictionary of values to set in the dictionary. `values` + can also be an iterable of `(key, value)` pairs. + + If `clear` is True then the dictionary will be cleared before the new + values are set. + + `patch.dict` can also be called with arbitrary keyword arguments to set + values in the dictionary:: + + with patch.dict('sys.modules', mymodule=Mock(), other_module=Mock()): + ... + + `patch.dict` can be used as a context manager, decorator or class + decorator. When used as a class decorator `patch.dict` honours + `patch.TEST_PREFIX` for choosing which methods to wrap. + """ + + def __init__(self, in_dict, values=(), clear=False, **kwargs): + if isinstance(in_dict, basestring): + in_dict = _importer(in_dict) + self.in_dict = in_dict + # support any argument supported by dict(...) constructor + self.values = dict(values) + self.values.update(kwargs) + self.clear = clear + self._original = None + + + def __call__(self, f): + if isinstance(f, ClassTypes): + return self.decorate_class(f) + @wraps(f) + def _inner(*args, **kw): + self._patch_dict() + try: + return f(*args, **kw) + finally: + self._unpatch_dict() + + return _inner + + + def decorate_class(self, klass): + for attr in dir(klass): + attr_value = getattr(klass, attr) + if (attr.startswith(patch.TEST_PREFIX) and + hasattr(attr_value, "__call__")): + decorator = _patch_dict(self.in_dict, self.values, self.clear) + decorated = decorator(attr_value) + setattr(klass, attr, decorated) + return klass + + + def __enter__(self): + """Patch the dict.""" + self._patch_dict() + + + def _patch_dict(self): + values = self.values + in_dict = self.in_dict + clear = self.clear + + try: + original = in_dict.copy() + except AttributeError: + # dict like object with no copy method + # must support iteration over keys + original = {} + for key in in_dict: + original[key] = in_dict[key] + self._original = original + + if clear: + _clear_dict(in_dict) + + try: + in_dict.update(values) + except AttributeError: + # dict like object with no update method + for key in values: + in_dict[key] = values[key] + + + def _unpatch_dict(self): + in_dict = self.in_dict + original = self._original + + _clear_dict(in_dict) + + try: + in_dict.update(original) + except AttributeError: + for key in original: + in_dict[key] = original[key] + + + def __exit__(self, *args): + """Unpatch the dict.""" + self._unpatch_dict() + return False + + start = __enter__ + stop = __exit__ + + +def _clear_dict(in_dict): + try: + in_dict.clear() + except AttributeError: + keys = list(in_dict) + for key in keys: + del in_dict[key] + + +def _patch_stopall(): + """Stop all active patches.""" + for patch in list(_patch._active_patches): + patch.stop() + + +patch.object = _patch_object +patch.dict = _patch_dict +patch.multiple = _patch_multiple +patch.stopall = _patch_stopall +patch.TEST_PREFIX = 'test' + +magic_methods = ( + "lt le gt ge eq ne " + "getitem setitem delitem " + "len contains iter " + "hash str sizeof " + "enter exit " + "divmod neg pos abs invert " + "complex int float index " + "trunc floor ceil " +) + +numerics = "add sub mul div floordiv mod lshift rshift and xor or pow " +inplace = ' '.join('i%s' % n for n in numerics.split()) +right = ' '.join('r%s' % n for n in numerics.split()) +extra = '' +if inPy3k: + extra = 'bool next ' +else: + extra = 'unicode long nonzero oct hex truediv rtruediv ' + +# not including __prepare__, __instancecheck__, __subclasscheck__ +# (as they are metaclass methods) +# __del__ is not supported at all as it causes problems if it exists + +_non_defaults = set('__%s__' % method for method in [ + 'cmp', 'getslice', 'setslice', 'coerce', 'subclasses', + 'format', 'get', 'set', 'delete', 'reversed', + 'missing', 'reduce', 'reduce_ex', 'getinitargs', + 'getnewargs', 'getstate', 'setstate', 'getformat', + 'setformat', 'repr', 'dir' +]) + + +def _get_method(name, func): + "Turns a callable object (like a mock) into a real function" + def method(self, *args, **kw): + return func(self, *args, **kw) + method.__name__ = name + return method + + +_magics = set( + '__%s__' % method for method in + ' '.join([magic_methods, numerics, inplace, right, extra]).split() +) + +_all_magics = _magics | _non_defaults + +_unsupported_magics = set([ + '__getattr__', '__setattr__', + '__init__', '__new__', '__prepare__' + '__instancecheck__', '__subclasscheck__', + '__del__' +]) + +_calculate_return_value = { + '__hash__': lambda self: object.__hash__(self), + '__str__': lambda self: object.__str__(self), + '__sizeof__': lambda self: object.__sizeof__(self), + '__unicode__': lambda self: unicode(object.__str__(self)), +} + +_return_values = { + '__lt__': NotImplemented, + '__gt__': NotImplemented, + '__le__': NotImplemented, + '__ge__': NotImplemented, + '__int__': 1, + '__contains__': False, + '__len__': 0, + '__exit__': False, + '__complex__': 1j, + '__float__': 1.0, + '__bool__': True, + '__nonzero__': True, + '__oct__': '1', + '__hex__': '0x1', + '__long__': long(1), + '__index__': 1, +} + + +def _get_eq(self): + def __eq__(other): + ret_val = self.__eq__._mock_return_value + if ret_val is not DEFAULT: + return ret_val + return self is other + return __eq__ + +def _get_ne(self): + def __ne__(other): + if self.__ne__._mock_return_value is not DEFAULT: + return DEFAULT + return self is not other + return __ne__ + +def _get_iter(self): + def __iter__(): + ret_val = self.__iter__._mock_return_value + if ret_val is DEFAULT: + return iter([]) + # if ret_val was already an iterator, then calling iter on it should + # return the iterator unchanged + return iter(ret_val) + return __iter__ + +_side_effect_methods = { + '__eq__': _get_eq, + '__ne__': _get_ne, + '__iter__': _get_iter, +} + + + +def _set_return_value(mock, method, name): + fixed = _return_values.get(name, DEFAULT) + if fixed is not DEFAULT: + method.return_value = fixed + return + + return_calulator = _calculate_return_value.get(name) + if return_calulator is not None: + try: + return_value = return_calulator(mock) + except AttributeError: + # XXXX why do we return AttributeError here? + # set it as a side_effect instead? + return_value = AttributeError(name) + method.return_value = return_value + return + + side_effector = _side_effect_methods.get(name) + if side_effector is not None: + method.side_effect = side_effector(mock) + + + +class MagicMixin(object): + def __init__(self, *args, **kw): + _super(MagicMixin, self).__init__(*args, **kw) + self._mock_set_magics() + + + def _mock_set_magics(self): + these_magics = _magics + + if self._mock_methods is not None: + these_magics = _magics.intersection(self._mock_methods) + + remove_magics = set() + remove_magics = _magics - these_magics + + for entry in remove_magics: + if entry in type(self).__dict__: + # remove unneeded magic methods + delattr(self, entry) + + # don't overwrite existing attributes if called a second time + these_magics = these_magics - set(type(self).__dict__) + + _type = type(self) + for entry in these_magics: + setattr(_type, entry, MagicProxy(entry, self)) + + + +class NonCallableMagicMock(MagicMixin, NonCallableMock): + """A version of `MagicMock` that isn't callable.""" + def mock_add_spec(self, spec, spec_set=False): + """Add a spec to a mock. `spec` can either be an object or a + list of strings. Only attributes on the `spec` can be fetched as + attributes from the mock. + + If `spec_set` is True then only attributes on the spec can be set.""" + self._mock_add_spec(spec, spec_set) + self._mock_set_magics() + + + +class MagicMock(MagicMixin, Mock): + """ + MagicMock is a subclass of Mock with default implementations + of most of the magic methods. You can use MagicMock without having to + configure the magic methods yourself. + + If you use the `spec` or `spec_set` arguments then *only* magic + methods that exist in the spec will be created. + + Attributes and the return value of a `MagicMock` will also be `MagicMocks`. + """ + def mock_add_spec(self, spec, spec_set=False): + """Add a spec to a mock. `spec` can either be an object or a + list of strings. Only attributes on the `spec` can be fetched as + attributes from the mock. + + If `spec_set` is True then only attributes on the spec can be set.""" + self._mock_add_spec(spec, spec_set) + self._mock_set_magics() + + + +class MagicProxy(object): + def __init__(self, name, parent): + self.name = name + self.parent = parent + + def __call__(self, *args, **kwargs): + m = self.create_mock() + return m(*args, **kwargs) + + def create_mock(self): + entry = self.name + parent = self.parent + m = parent._get_child_mock(name=entry, _new_name=entry, + _new_parent=parent) + setattr(parent, entry, m) + _set_return_value(parent, m, entry) + return m + + def __get__(self, obj, _type=None): + return self.create_mock() + + + +class _ANY(object): + "A helper object that compares equal to everything." + + def __eq__(self, other): + return True + + def __ne__(self, other): + return False + + def __repr__(self): + return '<ANY>' + +ANY = _ANY() + + + +def _format_call_signature(name, args, kwargs): + message = '%s(%%s)' % name + formatted_args = '' + args_string = ', '.join([repr(arg) for arg in args]) + kwargs_string = ', '.join([ + '%s=%r' % (key, value) for key, value in kwargs.items() + ]) + if args_string: + formatted_args = args_string + if kwargs_string: + if formatted_args: + formatted_args += ', ' + formatted_args += kwargs_string + + return message % formatted_args + + + +class _Call(tuple): + """ + A tuple for holding the results of a call to a mock, either in the form + `(args, kwargs)` or `(name, args, kwargs)`. + + If args or kwargs are empty then a call tuple will compare equal to + a tuple without those values. This makes comparisons less verbose:: + + _Call(('name', (), {})) == ('name',) + _Call(('name', (1,), {})) == ('name', (1,)) + _Call(((), {'a': 'b'})) == ({'a': 'b'},) + + The `_Call` object provides a useful shortcut for comparing with call:: + + _Call(((1, 2), {'a': 3})) == call(1, 2, a=3) + _Call(('foo', (1, 2), {'a': 3})) == call.foo(1, 2, a=3) + + If the _Call has no name then it will match any name. + """ + def __new__(cls, value=(), name=None, parent=None, two=False, + from_kall=True): + name = '' + args = () + kwargs = {} + _len = len(value) + if _len == 3: + name, args, kwargs = value + elif _len == 2: + first, second = value + if isinstance(first, basestring): + name = first + if isinstance(second, tuple): + args = second + else: + kwargs = second + else: + args, kwargs = first, second + elif _len == 1: + value, = value + if isinstance(value, basestring): + name = value + elif isinstance(value, tuple): + args = value + else: + kwargs = value + + if two: + return tuple.__new__(cls, (args, kwargs)) + + return tuple.__new__(cls, (name, args, kwargs)) + + + def __init__(self, value=(), name=None, parent=None, two=False, + from_kall=True): + self.name = name + self.parent = parent + self.from_kall = from_kall + + + def __eq__(self, other): + if other is ANY: + return True + try: + len_other = len(other) + except TypeError: + return False + + self_name = '' + if len(self) == 2: + self_args, self_kwargs = self + else: + self_name, self_args, self_kwargs = self + + other_name = '' + if len_other == 0: + other_args, other_kwargs = (), {} + elif len_other == 3: + other_name, other_args, other_kwargs = other + elif len_other == 1: + value, = other + if isinstance(value, tuple): + other_args = value + other_kwargs = {} + elif isinstance(value, basestring): + other_name = value + other_args, other_kwargs = (), {} + else: + other_args = () + other_kwargs = value + else: + # len 2 + # could be (name, args) or (name, kwargs) or (args, kwargs) + first, second = other + if isinstance(first, basestring): + other_name = first + if isinstance(second, tuple): + other_args, other_kwargs = second, {} + else: + other_args, other_kwargs = (), second + else: + other_args, other_kwargs = first, second + + if self_name and other_name != self_name: + return False + + # this order is important for ANY to work! + return (other_args, other_kwargs) == (self_args, self_kwargs) + + + def __ne__(self, other): + return not self.__eq__(other) + + + def __call__(self, *args, **kwargs): + if self.name is None: + return _Call(('', args, kwargs), name='()') + + name = self.name + '()' + return _Call((self.name, args, kwargs), name=name, parent=self) + + + def __getattr__(self, attr): + if self.name is None: + return _Call(name=attr, from_kall=False) + name = '%s.%s' % (self.name, attr) + return _Call(name=name, parent=self, from_kall=False) + + + def __repr__(self): + if not self.from_kall: + name = self.name or 'call' + if name.startswith('()'): + name = 'call%s' % name + return name + + if len(self) == 2: + name = 'call' + args, kwargs = self + else: + name, args, kwargs = self + if not name: + name = 'call' + elif not name.startswith('()'): + name = 'call.%s' % name + else: + name = 'call%s' % name + return _format_call_signature(name, args, kwargs) + + + def call_list(self): + """For a call object that represents multiple calls, `call_list` + returns a list of all the intermediate calls as well as the + final call.""" + vals = [] + thing = self + while thing is not None: + if thing.from_kall: + vals.append(thing) + thing = thing.parent + return _CallList(reversed(vals)) + + +call = _Call(from_kall=False) + + + +def create_autospec(spec, spec_set=False, instance=False, _parent=None, + _name=None, **kwargs): + """Create a mock object using another object as a spec. Attributes on the + mock will use the corresponding attribute on the `spec` object as their + spec. + + Functions or methods being mocked will have their arguments checked + to check that they are called with the correct signature. + + If `spec_set` is True then attempting to set attributes that don't exist + on the spec object will raise an `AttributeError`. + + If a class is used as a spec then the return value of the mock (the + instance of the class) will have the same spec. You can use a class as the + spec for an instance object by passing `instance=True`. The returned mock + will only be callable if instances of the mock are callable. + + `create_autospec` also takes arbitrary keyword arguments that are passed to + the constructor of the created mock.""" + if _is_list(spec): + # can't pass a list instance to the mock constructor as it will be + # interpreted as a list of strings + spec = type(spec) + + is_type = isinstance(spec, ClassTypes) + + _kwargs = {'spec': spec} + if spec_set: + _kwargs = {'spec_set': spec} + elif spec is None: + # None we mock with a normal mock without a spec + _kwargs = {} + + _kwargs.update(kwargs) + + Klass = MagicMock + if type(spec) in DescriptorTypes: + # descriptors don't have a spec + # because we don't know what type they return + _kwargs = {} + elif not _callable(spec): + Klass = NonCallableMagicMock + elif is_type and instance and not _instance_callable(spec): + Klass = NonCallableMagicMock + + _new_name = _name + if _parent is None: + # for a top level object no _new_name should be set + _new_name = '' + + mock = Klass(parent=_parent, _new_parent=_parent, _new_name=_new_name, + name=_name, **_kwargs) + + if isinstance(spec, FunctionTypes): + # should only happen at the top level because we don't + # recurse for functions + mock = _set_signature(mock, spec) + else: + _check_signature(spec, mock, is_type, instance) + + if _parent is not None and not instance: + _parent._mock_children[_name] = mock + + if is_type and not instance and 'return_value' not in kwargs: + mock.return_value = create_autospec(spec, spec_set, instance=True, + _name='()', _parent=mock) + + for entry in dir(spec): + if _is_magic(entry): + # MagicMock already does the useful magic methods for us + continue + + if isinstance(spec, FunctionTypes) and entry in FunctionAttributes: + # allow a mock to actually be a function + continue + + # XXXX do we need a better way of getting attributes without + # triggering code execution (?) Probably not - we need the actual + # object to mock it so we would rather trigger a property than mock + # the property descriptor. Likewise we want to mock out dynamically + # provided attributes. + # XXXX what about attributes that raise exceptions other than + # AttributeError on being fetched? + # we could be resilient against it, or catch and propagate the + # exception when the attribute is fetched from the mock + try: + original = getattr(spec, entry) + except AttributeError: + continue + + kwargs = {'spec': original} + if spec_set: + kwargs = {'spec_set': original} + + if not isinstance(original, FunctionTypes): + new = _SpecState(original, spec_set, mock, entry, instance) + mock._mock_children[entry] = new + else: + parent = mock + if isinstance(spec, FunctionTypes): + parent = mock.mock + + new = MagicMock(parent=parent, name=entry, _new_name=entry, + _new_parent=parent, **kwargs) + mock._mock_children[entry] = new + skipfirst = _must_skip(spec, entry, is_type) + _check_signature(original, new, skipfirst=skipfirst) + + # so functions created with _set_signature become instance attributes, + # *plus* their underlying mock exists in _mock_children of the parent + # mock. Adding to _mock_children may be unnecessary where we are also + # setting as an instance attribute? + if isinstance(new, FunctionTypes): + setattr(mock, entry, new) + + return mock + + +def _must_skip(spec, entry, is_type): + if not isinstance(spec, ClassTypes): + if entry in getattr(spec, '__dict__', {}): + # instance attribute - shouldn't skip + return False + spec = spec.__class__ + if not hasattr(spec, '__mro__'): + # old style class: can't have descriptors anyway + return is_type + + for klass in spec.__mro__: + result = klass.__dict__.get(entry, DEFAULT) + if result is DEFAULT: + continue + if isinstance(result, (staticmethod, classmethod)): + return False + return is_type + + # shouldn't get here unless function is a dynamically provided attribute + # XXXX untested behaviour + return is_type + + +def _get_class(obj): + try: + return obj.__class__ + except AttributeError: + # in Python 2, _sre.SRE_Pattern objects have no __class__ + return type(obj) + + +class _SpecState(object): + + def __init__(self, spec, spec_set=False, parent=None, + name=None, ids=None, instance=False): + self.spec = spec + self.ids = ids + self.spec_set = spec_set + self.parent = parent + self.instance = instance + self.name = name + + +FunctionTypes = ( + # python function + type(create_autospec), + # instance method + type(ANY.__eq__), + # unbound method + type(_ANY.__eq__), +) + +FunctionAttributes = set([ + 'func_closure', + 'func_code', + 'func_defaults', + 'func_dict', + 'func_doc', + 'func_globals', + 'func_name', +]) + + +file_spec = None + + +def mock_open(mock=None, read_data=''): + """ + A helper function to create a mock to replace the use of `open`. It works + for `open` called directly or used as a context manager. + + The `mock` argument is the mock object to configure. If `None` (the + default) then a `MagicMock` will be created for you, with the API limited + to methods or attributes available on standard file handles. + + `read_data` is a string for the `read` method of the file handle to return. + This is an empty string by default. + """ + global file_spec + if file_spec is None: + # set on first use + if inPy3k: + import _io + file_spec = list(set(dir(_io.TextIOWrapper)).union(set(dir(_io.BytesIO)))) + else: + file_spec = file + + if mock is None: + mock = MagicMock(name='open', spec=open) + + handle = MagicMock(spec=file_spec) + handle.write.return_value = None + handle.__enter__.return_value = handle + handle.read.return_value = read_data + + mock.return_value = handle + return mock + + +class PropertyMock(Mock): + """ + A mock intended to be used as a property, or other descriptor, on a class. + `PropertyMock` provides `__get__` and `__set__` methods so you can specify + a return value when it is fetched. + + Fetching a `PropertyMock` instance from an object calls the mock, with + no args. Setting it calls the mock with the value being set. + """ + def _get_child_mock(self, **kwargs): + return MagicMock(**kwargs) + + def __get__(self, obj, obj_type): + return self() + def __set__(self, obj, val): + self(val) diff --git a/test/runtests.py b/test/runtests.py new file mode 100644 index 0000000..7664487 --- /dev/null +++ b/test/runtests.py @@ -0,0 +1,17 @@ +try: + import mvc +except ImportError: + import os.path, sys + mvc_path = os.path.join(os.path.dirname(__file__), '..') + sys.path.append(mvc_path) + +from test_video import * +from test_converter import * +from test_conversion import * +from test_utils import * + +if __name__ == "__main__": + import unittest + from mvc.widgets import initialize + initialize(None) + unittest.main() diff --git a/test/test_conversion.py b/test/test_conversion.py new file mode 100644 index 0000000..41bbf27 --- /dev/null +++ b/test/test_conversion.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +import json +import os.path +import shutil +import sys +import tempfile +import time + +from mvc import video +from mvc import converter +from mvc import conversion + +import base + + +class FakeConverterInfo(converter.ConverterInfo): + + extension = 'fake' + + def get_executable(self): + return sys.executable + + def get_arguments(self, video, output): + return ['-u', os.path.join( + os.path.dirname(__file__), 'testdata', 'fake_converter.py'), + video.filename, output] + + def process_status_line(self, video, line): + return json.loads(line) + + +class ConversionManagerTest(base.Test): + + def setUp(self): + base.Test.setUp(self) + self.converter = FakeConverterInfo('Fake') + self.manager = conversion.ConversionManager() + self.temp_dir = tempfile.mkdtemp() + self.changes = [] + + def tearDown(self): + base.Test.tearDown(self) + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def changed(self, conversion): + self.changes.append( + {'status': conversion.status, + 'duration': conversion.duration, + 'progress': conversion.progress, + 'eta': conversion.eta + }) + + def spin(self, timeout): + finish_by = time.time() + timeout + while time.time() < finish_by and self.manager.running: + self.manager.check_notifications() + time.sleep(0.1) + + def start_conversion(self, filename, timeout=3): + vf = video.VideoFile(filename) + c = self.manager.start_conversion(vf, self.converter) + # XXX HACK: for test harness change the output directory to be the + # same as the input file (so we can nuke in one go) + c.output_dir = os.path.dirname(filename) + c.output = os.path.join(c.output_dir, + self.converter.get_output_filename(vf)) + c.listen(self.changed) + self.assertTrue(self.manager.running) + self.assertTrue(c in self.manager.in_progress) + self.spin(timeout) + self.assertFalse(self.manager.running) + self.assertFalse(os.path.exists(c.temp_output)) + return c + + def test_initial(self): + self.assertEqual(self.manager.notify_queue, set()) + self.assertEqual(self.manager.in_progress, set()) + self.assertFalse(self.manager.running) + + def test_conversion(self): + filename = os.path.join(self.temp_dir, 'webm-0.webm') + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename) + c = self.start_conversion(filename) + self.assertEqual(c.status, 'finished') + self.assertEqual(c.progress, c.duration) + self.assertEqual(c.progress_percent, 1.0) + self.assertTrue(os.path.exists(c.output)) + self.assertEqual(file(c.output).read(), 'blank') + self.assertEqual(self.changes, [ + {'status': 'converting', 'duration': 5.0, 'eta': 5.0, + 'progress': 0.0}, + {'status': 'converting', 'duration': 5.0, 'eta': 4.0, + 'progress': 1.0}, + {'status': 'converting', 'duration': 5.0, 'eta': 3.0, + 'progress': 2.0}, + {'status': 'converting', 'duration': 5.0, 'eta': 2.0, + 'progress': 3.0}, + {'status': 'converting', 'duration': 5.0, 'eta': 1.0, + 'progress': 4.0}, + {'status': 'finished', 'duration': 5.0, 'eta': 0.0, + 'progress': 5.0} + ]) + + def test_conversion_with_error(self): + filename = os.path.join(self.temp_dir, 'error.webm') + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename) + c = self.start_conversion(filename) + self.assertFalse(os.path.exists(c.output)) + self.assertEqual(c.status, 'failed') + self.assertEqual(c.error, 'test error') + + def test_conversion_with_missing_executable(self): + missing = sys.executable + '.does-not-exist' + self.converter.get_executable = lambda: missing + filename = os.path.join(self.temp_dir, 'webm-0.webm') + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename) + c = self.start_conversion(filename) + self.assertEqual(c.status, 'failed') + self.assertEqual(c.error, '%r does not exist' % missing) + self.assertFalse(os.path.exists(c.output)) + + def test_multiple_simultaneous_conversions(self): + filename = os.path.join(self.temp_dir, 'webm-0.webm') + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename) + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename + '2') + vf = video.VideoFile(filename) + vf2 = video.VideoFile(filename + '2') + c = self.manager.start_conversion(vf, self.converter) + c2 = self.manager.start_conversion(vf2, self.converter) + self.assertEqual(len(self.manager.in_progress), 2) + self.spin(3) # if they're linear, it should take < 5s + self.assertEqual(c.status, 'finished') + self.assertEqual(c2.status, 'finished') + + def test_limit_simultaneous_conversions(self): + self.manager.simultaneous = 1 + filename = os.path.join(self.temp_dir, 'webm-0.webm') + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename) + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename + '2') + vf = video.VideoFile(filename) + vf2 = video.VideoFile(filename + '2') + c = self.manager.start_conversion(vf, self.converter) + c2 = self.manager.start_conversion(vf2, self.converter) + self.assertEqual(len(self.manager.in_progress), 1) + self.assertEqual(len(self.manager.waiting), 1) + self.spin(5) + self.assertEqual(c.status, 'finished') + self.assertEqual(c2.status, 'finished') + + def test_unicode_characters(self): + for filename in ( + u'"TAKE2\'s" REHEARSAL човен поўны вуграмі', + u'ところで早け', + u'Lputefartøavål'): + full_filename = os.path.join(self.temp_dir, filename) + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + full_filename) + c = self.start_conversion(full_filename) + self.assertEqual(c.status, 'finished') + self.assertTrue(filename in c.output, '%r not in %r' % ( + filename, c.output)) + + def test_overwrite(self): + filename = os.path.join(self.temp_dir, 'webm-0.webm') + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename) + c = self.start_conversion(filename) + self.assertEqual(c.status, 'finished') + c2 = self.start_conversion(filename) + self.assertEqual(c2.status, 'finished') + self.assertEqual(c2.output, c.output) + + def test_stop(self): + filename = os.path.join(self.temp_dir, 'webm-0.webm') + shutil.copyfile(os.path.join(self.testdata_dir, 'webm-0.webm'), + filename) + vf = video.VideoFile(filename) + c = self.manager.start_conversion(vf, self.converter) + time.sleep(0.5) + c.stop() + self.spin(1) + self.assertEqual(c.status, 'canceled') + self.assertEqual(c.error, 'manually stopped') diff --git a/test/test_converter.py b/test/test_converter.py new file mode 100644 index 0000000..736b5b0 --- /dev/null +++ b/test/test_converter.py @@ -0,0 +1,1330 @@ +import argparse +import os.path + +from mvc.video import VideoFile +from mvc import converter +from mvc import settings + +import base +import mock + +def make_ffmpeg_arg_parser(): + """Make an args.ArgumentParser for ffmpeg args that we use + """ + parser = argparse.ArgumentParser(argument_default=argparse.SUPPRESS) + # command line options that require an argument after + arguments = [ + "-ab", + "-ac", + "-acodec", + "-aq", + "-ar", + "-b", + "-b:v", + "-bufsize", + "-crf", + "-cpu-used", + "-deadline", + "-f", + '-g', + "-i", + "-lag-in-frames", + "-level", + "-maxrate", + "-preset", + "-profile:v", + "-r", + "-s", + "-slices", + "-strict", + "-threads", + "-qmin", + "-qmax", + "-vb", + "-vcodec", + "-vprofile", + ] + # arguments that set flags + flags = [ + '-vn', + ] + for name in arguments: + parser.add_argument(name) + for name in flags: + parser.add_argument(name, action='store_const', const=True) + + parser.add_argument("output_file") + return parser + +class TestConverterInfo(converter.ConverterInfo): + media_type = 'video' + extension = 'test' + bitrate = 10000 + + def get_executable(self): + return '/bin/true' + + def get_arguments(self, video, output): + return [video.filename, output] + + def process_status_line(self, line): + return {'finished': True} + + +TEST_CONVERTER = TestConverterInfo('Test Converter') + + +class ConverterManagerTest(base.Test): + + def setUp(self): + base.Test.setUp(self) + self.manager = converter.ConverterManager() + + def test_startup(self): + self.manager.startup() + self.assertTrue(self.manager.converters) + + def test_add_converter(self): + self.manager.add_converter(TEST_CONVERTER) + self.assertEqual(len(self.manager.converters), 1) + + def test_list_converters(self): + self.manager.add_converter(TEST_CONVERTER) + self.assertEqual(list(self.manager.list_converters()), + [TEST_CONVERTER]) + + def test_get_by_id(self): + self.manager.add_converter(TEST_CONVERTER) + self.assertEqual(self.manager.get_by_id('testconverter'), + TEST_CONVERTER) + self.assertRaises(KeyError, self.manager.get_by_id, + 'doesnotexist') + + +class ConverterInfoTest(base.Test): + + def setUp(self): + base.Test.setUp(self) + self.converter_info = TEST_CONVERTER + self.video = VideoFile(os.path.join(self.testdata_dir, 'mp4-0.mp4')) + + def test_identifer(self): + self.assertEqual(self.converter_info.identifier, + 'testconverter') + + def test_get_output_filename(self): + self.assertEqual(self.converter_info.get_output_filename(self.video), + 'mp4-0.testconverter.test') + + def test_get_output_size_guess(self): + self.assertEqual(self.converter_info.get_output_size_guess(self.video), + self.video.duration * self.converter_info.bitrate / 8) + + +class ConverterInfoTestMixin(object): + + def setUp(self): + self.video = VideoFile(os.path.join(self.testdata_dir, 'mp4-0.mp4')) + + def assertStatusLineOutput(self, line, **output): + if not output: + output = None + self.assertEqual(self.converter_info.process_status_line(self.video, + line), + output) + + def test_get_executable(self): + self.assertTrue(self.converter_info.get_executable()) + + def test_get_arguments(self): + output = os.path.join(self.testdata_dir, 'output.mp4') + arguments = self.converter_info.get_arguments(self.video, output) + + self.assertTrue(arguments) + self.assertTrue(self.video.filename in arguments) + self.assertTrue(output in arguments) + +class FFmpegConverterInfoTest(ConverterInfoTestMixin, base.Test): + + def setUp(self): + base.Test.setUp(self) + ConverterInfoTestMixin.setUp(self) + self.converter_info = converter.FFmpegConverterInfo('FFmpeg Test', + 1024, 768) + self.converter_info.parameters = '{ssize}' + + def run_get_target_size(self, (src_width, src_height), + (dest_width, dest_height), + dont_upsize=True): + """Create a converter run get_target_size() on a video. + """ + mock_video = mock.Mock(width=src_width, height=src_height) + converter_info = converter.FFmpegConverterInfo( + 'FFmpeg Test', dest_width, dest_height) + converter_info.dont_upsize = dont_upsize + return converter_info.get_target_size(mock_video) + + def test_get_target_size(self): + self.assertEqual(self.run_get_target_size((1024, 768), (640, 480)), + (640, 480)) + + def test_get_target_size_rescale(self): + # Test get_target_size() rescaling an image. It should ensure that + # both dimensions fit inside the target image, and that the aspect + # ratio is unchanged. + self.assertEqual(self.run_get_target_size((1024, 768), (800, 500)), + (666, 500)) + + def test_get_target_size_dont_upsize(self): + # Test that get_target_size only upsizes when dont_upsize is True + self.assertEqual(self.run_get_target_size((640, 480), (800, 600)), + (640, 480)) + self.assertEqual(self.run_get_target_size((640, 480), (800, 600), + dont_upsize=False), + (800, 600)) + + def test_process_status_line_nothing(self): + self.assertStatusLineOutput( + ' built on Mar 31 2012 09:58:16 with gcc 4.6.3') + + + def test_process_status_line_duration(self): + self.assertStatusLineOutput( + ' Duration: 00:00:01.07, start: 0.000000, bitrate: 128 kb/s', + duration=1.07) + + def test_process_status_line_progress(self): + self.assertStatusLineOutput( + 'size= 2697kB time=00:02:52.59 bitrate= 128.0kbits/s ', + progress=172.59) + + def test_process_status_line_progress_with_frame(self): + self.assertStatusLineOutput( + 'frame= 257 fps= 45 q=27.0 size= 1033kB time=00:00:08.70 ' + 'bitrate= 971.4kbits/s ', + progress=8.7) + + def test_process_status_line_finished(self): + self.assertStatusLineOutput( + 'frame=16238 fps= 37 q=-1.0 Lsize= 110266kB time=00:11:16.50 ' + 'bitrate=1335.3kbits/s dup=16 drop=0', + finished=True) + + def test_process_status_line_error(self): + line = ('Error while opening encoder for output stream #0:1 - ' + 'maybe incorrect parameters such as bit_rate, rate, width or ' + 'height') + self.assertStatusLineOutput(line, + finished=True, + error=line) + + def test_process_status_line_unknown(self): + # XXX haven't actually seen this line + line = 'Unknown error' + self.assertStatusLineOutput(line, + finished=True, + error=line) + + def test_process_status_line_error_decoding(self): + # XXX haven't actually seen this line + line = 'Error while decoding stream: something' + self.assertStatusLineOutput(line) + +class TestConverterDefinitions(base.Test): + def setUp(self): + base.Test.setUp(self) + self.manager = converter.ConverterManager() + self.manager.startup() + self.input_path = os.path.join(self.testdata_dir, 'mp4-0.mp4') + self.output_path = os.path.join(self.testdata_dir, 'output.mp4') + + def get_converter_arguments(self, converter_obj): + """Given a converter, get the arguments to that converter + + :returns: dict of arguments that were set + """ + + output_path = self.output_path + video_file = mock.Mock() + # Note: we purposely use weird width/height values here to ensure that + # they are different from the default size + video_file.width = 542 + video_file.height = 320 + video_file.filename = self.input_path + video_file.container = '#container_name#' + video_file.audio_only = False + + cmdline_args = converter_obj.get_arguments(video_file, output_path) + return vars(make_ffmpeg_arg_parser().parse_args(cmdline_args)) + + + + parser = make_ffmpeg_arg_parser() + args = vars(parser.parse_args(cmdline_args)) + return dict((k, args[k]) for k in args + if args[k] != parser.get_default(k)) + + def check_ffmpeg_arguments(self, converter_id, correct_arguments): + """Check the arguments of a ffmpeg-based converter.""" + converter = self.manager.converters[converter_id] + self.assertEquals(converter.get_executable(), + settings.get_ffmpeg_executable_path()) + + self.assertEquals(self.get_converter_arguments(converter), + correct_arguments) + + def check_size(self, converter_id, width, height): + converter = self.manager.converters[converter_id] + self.assertEquals(converter.width, width) + self.assertEquals(converter.height, height) + self.assertEquals(converter.dont_upsize, True) + self.assertEquals(converter.audio_only, False) + + def check_uses_input_size(self, converter_id): + converter = self.manager.converters[converter_id] + self.assertEquals(converter.width, None) + self.assertEquals(converter.height, None) + self.assertEquals(converter.dont_upsize, True) + self.assertEquals(converter.audio_only, False) + + def check_audio_only(self, converter_id): + converter = self.manager.converters[converter_id] + self.assertEquals(converter.audio_only, True) + + def test_all_converters_checked(self): + for converter_id in self.manager.converters.keys(): + if not hasattr(self, "test_%s" % converter_id): + raise AssertionError("No test for converter: %s" % + converter_id) + + def test_droid(self): + self.check_ffmpeg_arguments('droid', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('droid', 854, 480) + + def test_proresingest720p(self): + self.check_ffmpeg_arguments('proresingest720p', { + 'acodec': 'pcm_s16be', + 'ar': '48000', + 'f': 'mov', + 'i': self.input_path, + 'output_file': self.output_path, + 'profile:v': '2', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'prores', + }) + self.check_size('proresingest720p', 1080, 720) + + def test_droidx2(self): + self.check_ffmpeg_arguments('droidx2', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('droidx2', 1280, 720) + + def test_sensation(self): + self.check_ffmpeg_arguments('sensation', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('sensation', 960, 540) + + def test_avcintra1080p(self): + self.check_ffmpeg_arguments('avcintra1080p', { + 'acodec': 'pcm_s16be', + 'ar': '48000', + 'f': 'mov', + 'i': self.input_path, + 'output_file': self.output_path, + 'profile:v': '2', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'prores', + }) + self.check_size('avcintra1080p', 1920, 1080) + + def test_mp4(self): + self.check_ffmpeg_arguments('mp4', { + 'ab': '96k', + 'acodec': 'aac', + 'crf': '22', + 'f': 'mp4', + 'i': self.input_path, + 'output_file': self.output_path, + 'preset': 'slow', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'libx264', + }) + self.check_uses_input_size('mp4') + + def test_ipodtouch4(self): + self.check_ffmpeg_arguments('ipodtouch4', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('ipodtouch4', 960, 640) + + def test_mp3(self): + self.check_ffmpeg_arguments('mp3', { + 'ac': '2', + 'f': 'mp3', + 'i': self.input_path, + 'output_file': self.output_path, + 'strict': 'experimental', + }) + self.check_audio_only('mp3') + + def test_proresingest1080p(self): + self.check_ffmpeg_arguments('proresingest1080p', { + 'acodec': 'pcm_s16be', + 'ar': '48000', + 'f': 'mov', + 'i': self.input_path, + 'output_file': self.output_path, + 'profile:v': '2', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'prores', + }) + self.check_size('proresingest1080p', 1920, 1080) + + def test_galaxyinfuse(self): + self.check_ffmpeg_arguments('galaxyinfuse', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxyinfuse', 1280, 800) + + def test_ipodnanoclassic(self): + self.check_ffmpeg_arguments('ipodnanoclassic', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('ipodnanoclassic', 480, 320) + + def test_oggvorbis(self): + self.check_ffmpeg_arguments('oggvorbis', { + 'acodec': 'libvorbis', + 'aq': '60', + 'f': 'ogg', + 'i': self.input_path, + 'output_file': self.output_path, + 'strict': 'experimental', + 'vn': True, + }) + self.check_audio_only('oggvorbis') + + def test_wildfire(self): + self.check_ffmpeg_arguments('wildfire', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '320x188', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('wildfire', 320, 240) + + def test_ipad(self): + self.check_ffmpeg_arguments('ipad', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('ipad', 1024, 768) + + def test_galaxyadmire(self): + self.check_ffmpeg_arguments('galaxyadmire', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxyadmire', 480, 320) + + def test_droidincredible(self): + self.check_ffmpeg_arguments('droidincredible', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('droidincredible', 800, 480) + + def test_sameformat(self): + self.check_ffmpeg_arguments('sameformat', { + 'acodec': 'copy', + 'i': self.input_path, + 'output_file': self.output_path, + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'copy', + 'f': '#container_name#', + }) + self.check_uses_input_size('sameformat') + + def test_zio(self): + self.check_ffmpeg_arguments('zio', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('zio', 800, 480) + + def test_galaxycharge(self): + self.check_ffmpeg_arguments('galaxycharge', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxycharge', 800, 480) + + def test_large1080p(self): + self.check_ffmpeg_arguments('large1080p', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('large1080p', 1920, 1080) + + def test_appletv(self): + self.check_ffmpeg_arguments('appletv', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('appletv', 1280, 720) + + def test_playstationportable(self): + self.check_ffmpeg_arguments('playstationportable', { + 'ab': '64000', + 'ar': '24000', + 'b': '512000', + 'f': 'psp', + 'i': self.input_path, + 'output_file': self.output_path, + 'r': '29.97', + 's': '320x188', + 'strict': 'experimental', + }) + self.check_size('playstationportable', 320, 240) + + def test_rezound(self): + self.check_ffmpeg_arguments('rezound', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('rezound', 1280, 720) + + def test_large720p(self): + self.check_ffmpeg_arguments('large720p', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('large720p', 1280, 720) + + def test_iphone(self): + self.check_ffmpeg_arguments('iphone', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('iphone', 640, 480) + + def test_galaxyy(self): + self.check_ffmpeg_arguments('galaxyy', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '320x188', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxyy', 320, 240) + + def test_webmhd(self): + self.check_ffmpeg_arguments('webmhd', { + 'ab': '112k', + 'acodec': 'libvorbis', + 'ar': '44100', + 'b:v': '2M', + 'cpu_used': '0', + 'deadline': 'good', + 'f': 'webm', + 'g': '120', + 'i': self.input_path, + 'lag_in_frames': '16', + 'output_file': self.output_path, + 'qmax': '51', + 'qmin': '11', + 's': '542x320', + 'slices': '4', + 'strict': 'experimental', + 'vcodec': 'libvpx', + 'vprofile': '0', + }) + self.check_size('webmhd', 1080, 720) + + def test_galaxytab101(self): + self.check_ffmpeg_arguments('galaxytab101', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxytab101', 1280, 800) + + def test_galaxynexus(self): + self.check_ffmpeg_arguments('galaxynexus', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxynexus', 1280, 720) + + def test_galaxysiii(self): + self.check_ffmpeg_arguments('galaxysiii', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxysiii', 1280, 720) + + def test_desire(self): + self.check_ffmpeg_arguments('desire', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('desire', 800, 480) + + def test_galaxynoteii(self): + self.check_ffmpeg_arguments('galaxynoteii', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxynoteii', 1920, 1080) + + def test_thunderbolt(self): + self.check_ffmpeg_arguments('thunderbolt', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('thunderbolt', 800, 480) + + def test_xoom(self): + self.check_ffmpeg_arguments('xoom', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('xoom', 1280, 800) + + def test_normal800x480(self): + self.check_ffmpeg_arguments('normal800x480', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('normal800x480', 800, 480) + + def test_galaxyepic(self): + self.check_ffmpeg_arguments('galaxyepic', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxyepic', 800, 480) + + def test_avcintra720p(self): + self.check_ffmpeg_arguments('avcintra720p', { + 'acodec': 'pcm_s16be', + 'ar': '48000', + 'f': 'mov', + 'i': self.input_path, + 'output_file': self.output_path, + 'profile:v': '2', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'prores', + }) + self.check_size('avcintra720p', 1080, 720) + + def test_dnxhd720p(self): + self.check_ffmpeg_arguments('dnxhd720p', { + 'acodec': 'pcm_s16be', + 'ar': '48000', + 'b:v': '175M', + 'f': 'mov', + 'i': self.input_path, + 'output_file': self.output_path, + 'r': '23.976', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'dnxhd', + }) + self.check_size('dnxhd720p', 1080, 720) + + def test_iphone5(self): + self.check_ffmpeg_arguments('iphone5', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('iphone5', 1920, 1080) + + def test_webmsd(self): + self.check_ffmpeg_arguments('webmsd', { + 'ab': '112k', + 'acodec': 'libvorbis', + 'ar': '44100', + 'b:v': '768k', + 'cpu_used': '0', + 'deadline': 'good', + 'f': 'webm', + 'g': '120', + 'i': self.input_path, + 'lag_in_frames': '16', + 'output_file': self.output_path, + 'qmax': '53', + 'qmin': '0', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'libvpx', + 'vprofile': '0', + }) + self.check_size('webmsd', 720, 480) + + def test_galaxymini(self): + self.check_ffmpeg_arguments('galaxymini', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '320x188', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxymini', 320, 240) + + def test_onex(self): + self.check_ffmpeg_arguments('onex', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('onex', 1280, 720) + + def test_oggtheora(self): + self.check_ffmpeg_arguments('oggtheora', { + 'acodec': 'libvorbis', + 'aq': '60', + 'f': 'ogg', + 'i': self.input_path, + 'output_file': self.output_path, + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'libtheora', + }) + self.check_uses_input_size('oggtheora') + + def test_ipad3(self): + self.check_ffmpeg_arguments('ipad3', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('ipad3', 1920, 1080) + + def test_galaxyssiisplus(self): + self.check_ffmpeg_arguments('galaxyssiisplus', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxyssiisplus', 800, 480) + + def test_kindlefire(self): + self.check_ffmpeg_arguments('kindlefire', { + 'ab': '96k', + 'acodec': 'aac', + 'crf': '22', + 'f': 'mp4', + 'i': self.input_path, + 'output_file': self.output_path, + 'preset': 'slow', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'libx264', + }) + self.check_size('kindlefire', 1224, 600) + + def test_galaxyace(self): + self.check_ffmpeg_arguments('galaxyace', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxyace', 480, 320) + + def test_dnxhd1080p(self): + self.check_ffmpeg_arguments('dnxhd1080p', { + 'acodec': 'pcm_s16be', + 'ar': '48000', + 'b:v': '175M', + 'f': 'mov', + 'i': self.input_path, + 'output_file': self.output_path, + 'r': '23.976', + 's': '542x320', + 'strict': 'experimental', + 'vcodec': 'dnxhd', + }) + self.check_size('dnxhd1080p', 1920, 1080) + + def test_small480x320(self): + self.check_ffmpeg_arguments('small480x320', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('small480x320', 480, 320) + + def test_iphone4(self): + self.check_ffmpeg_arguments('iphone4', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('iphone4', 960, 640) + + def test_razr(self): + self.check_ffmpeg_arguments('razr', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('razr', 960, 540) + + def test_ipodtouch(self): + self.check_ffmpeg_arguments('ipodtouch', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('ipodtouch', 640, 480) + + def test_galaxytab(self): + self.check_ffmpeg_arguments('galaxytab', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('galaxytab', 1024, 600) + + def test_appleuniversal(self): + self.check_ffmpeg_arguments('appleuniversal', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vb': '1200k', + 'vcodec': 'libx264', + }) + self.check_size('appleuniversal', 1280, 720) + + def test_evo4g(self): + self.check_ffmpeg_arguments('evo4g', { + 'ab': '160k', + 'ac': '2', + 'acodec': 'aac', + 'bufsize': '10000000', + 'f': 'mp4', + 'i': self.input_path, + 'level': '30', + 'maxrate': '10000000', + 'output_file': self.output_path, + 'preset': 'slow', + 'profile:v': 'baseline', + 's': '542x320', + 'strict': 'experimental', + 'threads': '0', + 'vcodec': 'libx264', + }) + self.check_size('evo4g', 800, 480) diff --git a/test/test_utils.py b/test/test_utils.py new file mode 100644 index 0000000..1eb16c6 --- /dev/null +++ b/test/test_utils.py @@ -0,0 +1,48 @@ +from StringIO import StringIO + +from mvc import utils + +import base + +class UtilsTest(base.Test): + + def test_hms_to_seconds(self): + self.assertEqual(utils.hms_to_seconds(3, 2, 1), + 3 * 3600 + + 2 * 60 + + 1) + + def test_hms_to_seconds_floats(self): + self.assertEqual(utils.hms_to_seconds(3.0, 2.0, 1.5), + 3 * 3600 + + 2 * 60 + + 1.5) + + def test_round_even(self): + self.assertEqual(utils.round_even(-1), 0) + self.assertEqual(utils.round_even(0), 0) + self.assertEqual(utils.round_even(1), 0) + self.assertEqual(utils.round_even(2), 2) + self.assertEqual(utils.round_even(2.5), 2) + self.assertEqual(utils.round_even(3), 2) + + def test_rescale_video(self): + target = (1024, 768) + self.assertEqual(utils.rescale_video(target, target), + target) + self.assertEqual(utils.rescale_video((512, 384), target), # small + (512, 384)) + self.assertEqual(utils.rescale_video((2048, 1536), target), # big + target) + self.assertEqual(utils.rescale_video((1400, 768), target, + dont_upsize=False), # widescreen + (1024, 560)) + + def test_line_reader(self): + lines = """line1 +line2 +line3\rline4\r +line5""" + expected = ['line1', 'line2', 'line3', 'line4', 'line5'] + self.assertEqual(list(utils.line_reader(StringIO(lines))), expected) + diff --git a/test/test_video.py b/test/test_video.py new file mode 100644 index 0000000..821066f --- /dev/null +++ b/test/test_video.py @@ -0,0 +1,258 @@ +import os, os.path +import tempfile +import threading +import unittest + +import mock + +from mvc import video +import base + +class GetMediaInfoTest(base.Test): + + def assertClose(self, output, expected): + diff = output - expected + self.assertTrue(diff ** 2 < 0.04, # abs(diff) < 0.2 + "%s != %s" % (output, expected)) + + def assertEqualOutput(self, filename, expected): + full_path = os.path.join(self.testdata_dir, filename) + try: + output = video.get_media_info(full_path) + except Exception, e: + raise AssertionError( + 'Error parsing %r\nException: %r\nOutput: %s' % ( + filename, e, video.get_ffmpeg_output(full_path))) + duration_output = output.pop('duration', None) + duration_expected = expected.pop('duration', None) + if duration_output is not None and duration_expected is not None: + self.assertClose(duration_output, duration_expected) + else: + # put them back in, let assertEqual handle the difference + output['duration'] = duration_output + expected['duration'] = duration_expected + self.assertEqual(output, expected) + + def test_mp3_0(self): + self.assertEqualOutput('mp3-0.mp3', + {'container': 'mp3', + 'audio_codec': 'mp3', + 'title': 'Invisible Walls', + 'artist': 'Revolution Void', + 'album': 'Increase The Dosage', + 'track': '1', + 'genre': 'Blues', + 'duration': 1.07}) + + def test_mp3_1(self): + self.assertEqualOutput('mp3-1.mp3', + {'container': 'mp3', + 'audio_codec': 'mp3', + 'title': 'Race Lieu', + 'artist': 'Ckz', + 'album': 'The Heart EP', + 'track': '2/5', + 'duration': 1.07}) + + def test_mp3_2(self): + self.assertEqualOutput('mp3-2.mp3', + {'container': 'mp3', + 'audio_codec': 'mp3', + 'artist': 'This American Life', + 'genre': 'Podcast', + 'title': '#426: Tough Room 2011', + 'duration': 1.09}) + + def test_theora(self): + self.assertEqualOutput('theora.ogv', + {'container': 'ogg', + 'video_codec': 'theora', + 'audio_codec': 'vorbis', + 'width': 400, + 'height': 304, + 'duration': 5.0}) + + def test_theora_with_ogg_extension(self): + self.assertEqualOutput('theora_with_ogg_extension.ogg', + {'container': 'ogg', + 'video_codec': 'theora', + 'width': 320, + 'height': 240, + 'duration': 0.1}) + + def test_webm_0(self): + self.assertEqualOutput('webm-0.webm', + {'container': ['matroska', 'webm'], + 'video_codec': 'vp8', + 'width': 1920, + 'height': 912, + 'duration': 0.43}) + + def test_mp4_0(self): + self.assertEqualOutput('mp4-0.mp4', + {'container': ['mov', + 'mp4', + 'm4a', + '3gp', + '3g2', + 'mj2', + 'isom', + 'mp41'], + 'video_codec': 'h264', + 'audio_codec': 'aac', + 'width': 640, + 'height': 480, + 'title': 'Africa: Cash for Climate Change?', + 'duration': 312.37}) + + def test_nuls(self): + self.assertEqualOutput('nuls.mp3', + {'container': 'mp3', + 'title': 'Invisible'}) + + @unittest.skip('inconsistent parsing of DRMed files') + def test_drm(self): + self.assertEqualOutput('drm.m4v', + {'container': ['mov', + 'mp4', + 'm4a', + '3gp', + '3g2', + 'mj2', + 'M4V', + 'M4V ', + 'mp42', + 'isom'], + 'video_codec': 'none', + 'audio_codec': 'aac', + 'has_drm': ['audio', 'video'], + 'width': 640, + 'height': 480, + 'title': 'Thinkers', + 'artist': 'The Most Extreme', + 'album': 'The Most Extreme', + 'track': '10', + 'genre': 'Nonfiction', + 'duration': 2668.8}) + + + +class GetThumbnailTest(base.Test): + + def setUp(self): + base.Test.setUp(self) + self.video_path = os.path.join(self.testdata_dir, + 'theora.ogv') + self.temp_path = tempfile.NamedTemporaryFile( + suffix='.png') + + def generate_thumbnail(self, width, height): + completion = mock.Mock() + with mock.patch('mvc.video.idle_add') as mock_idle_add: + with mock.patch('threading.Thread') as mock_thread: + video.get_thumbnail(self.video_path, width, height, + self.temp_path.name, completion, + skip=0) + # get_thumbnail() creates a thread to create the thumbnail. + # Run the function for that thread now. + mock_thread.call_args[1]['target']() + self.assertEquals(mock_idle_add.call_count, 1) + # At the end of the thread it uses add_idle() to call the + # completion function. Run that now. + mock_idle_add.call_args[0][0]() + # Now when we call get_thumbnail() it should return + # immediately with the thumbnail + path = completion.call_args[0][0] + self.assertNotEquals(path, None) + return video.VideoFile(path) + + def test_original_size(self): + thumbnail = self.generate_thumbnail(-1, -1) + self.assertEqual(thumbnail.width, 400) + self.assertEqual(thumbnail.height, 304) + + def test_height_resize(self): + thumbnail = self.generate_thumbnail(200, -1) + self.assertEqual(thumbnail.width, 200) + self.assertEqual(thumbnail.height, 152) + + def test_width_resize(self): + thumbnail = self.generate_thumbnail(-1, 152) + self.assertEqual(thumbnail.width, 200) + self.assertEqual(thumbnail.height, 152) + + def test_both_resize(self): + thumbnail = self.generate_thumbnail(100, 100) + self.assertEqual(thumbnail.width, 100) + self.assertEqual(thumbnail.height, 100) + +class VideoFileTest(base.Test): + + def setUp(self): + base.Test.setUp(self) + self.video_path = os.path.join(self.testdata_dir, + 'theora.ogv') + self.video = video.VideoFile(self.video_path) + self.video.thumbnails = {} + + def get_thumbnail_from_video(self, **kwargs): + """Run Video.get_thumbnail() + + This method uses mock to intercept the threading and idle_add calls and + just runs the code in the current thread + """ + completion = mock.Mock() + with mock.patch('mvc.video.idle_add') as mock_idle_add: + with mock.patch('threading.Thread') as mock_thread: + initial_rv = self.video.get_thumbnail(completion, **kwargs) + if initial_rv is not None: + # we already had a thumbnail and didn't have to do + # anything synchrously + return video.VideoFile(initial_rv) + # We don't already have a thumbnail, so get_thumbnail() + # created a thread to create it. Run the function for that + # thread. + mock_thread.call_args[1]['target']() + self.assertEquals(mock_idle_add.call_count, 1) + # At the end of the thread it uses add_idle() to call the + # completion function. Run that now. + mock_idle_add.call_args[0][0]() + # Now when we call get_thumbnail() it should return + # immediately with the thumbnail + path = self.video.get_thumbnail(completion, **kwargs) + self.assertNotEquals(path, None) + return video.VideoFile(path) + + def test_get_thumbnail_original_size(self): + thumbnail = self.get_thumbnail_from_video() + self.assertEqual(thumbnail.width, 400) + self.assertEqual(thumbnail.height, 304) + + def test_get_thumbnail_scaled_width(self): + thumbnail = self.get_thumbnail_from_video(width=200) + self.assertEqual(thumbnail.width, 200) + self.assertEqual(thumbnail.height, 152) + + def test_get_thumbnail_scaled_height(self): + thumbnail = self.get_thumbnail_from_video(height=152) + self.assertEqual(thumbnail.width, 200) + self.assertEqual(thumbnail.height, 152) + + def test_get_thumbnail_scaled_both(self): + thumbnail = self.get_thumbnail_from_video(width=100, height=100) + self.assertEqual(thumbnail.width, 100) + self.assertEqual(thumbnail.height, 100) + + def test_get_thumbnail_cache(self): + thumbnail = self.get_thumbnail_from_video() + thumbnail2 = self.get_thumbnail_from_video() + self.assertEqual(thumbnail.filename, + thumbnail2.filename) + + def test_get_thumbnail_audio(self): + audio_path = os.path.join(self.testdata_dir, 'mp3-0.mp3') + audio = video.VideoFile(audio_path) + def complete(): + pass + self.assertEqual(audio.get_thumbnail(complete), None) + self.assertEqual(audio.get_thumbnail(complete, 90, 70), None) diff --git a/test/testdata/drm.m4v b/test/testdata/drm.m4v Binary files differnew file mode 100644 index 0000000..e09787c --- /dev/null +++ b/test/testdata/drm.m4v diff --git a/test/testdata/fake_converter.py b/test/testdata/fake_converter.py new file mode 100644 index 0000000..f9ef102 --- /dev/null +++ b/test/testdata/fake_converter.py @@ -0,0 +1,31 @@ +import time +import sys +import os +import json + +filename, output = sys.argv[1:3] +if 'error' in filename: + print json.dumps({'finished': True, 'error': 'test error'}) + sys.exit(1) + +if os.path.exists(output): + print json.dumps({'finished': True, + 'error': '%r existed when we started' % ( + output,)}) + sys.exit(1) + +time.sleep(0.5) +RANGE = 5 +for i in range(RANGE): + print json.dumps({ + 'filename': filename, + 'output': output, + 'duration': RANGE, + 'progress': i, + 'eta': RANGE - i + }) + time.sleep(0.1) + +with file(output, 'w') as f: + f.write('blank') +print json.dumps({'finished': True}) diff --git a/test/testdata/mp3-0.mp3 b/test/testdata/mp3-0.mp3 Binary files differnew file mode 100644 index 0000000..3676cb0 --- /dev/null +++ b/test/testdata/mp3-0.mp3 diff --git a/test/testdata/mp3-1.mp3 b/test/testdata/mp3-1.mp3 Binary files differnew file mode 100644 index 0000000..4a17206 --- /dev/null +++ b/test/testdata/mp3-1.mp3 diff --git a/test/testdata/mp3-2.mp3 b/test/testdata/mp3-2.mp3 Binary files differnew file mode 100644 index 0000000..c7db703 --- /dev/null +++ b/test/testdata/mp3-2.mp3 diff --git a/test/testdata/mp4-0.mp4 b/test/testdata/mp4-0.mp4 Binary files differnew file mode 100644 index 0000000..e91af55 --- /dev/null +++ b/test/testdata/mp4-0.mp4 diff --git a/test/testdata/nuls.mp3 b/test/testdata/nuls.mp3 Binary files differnew file mode 100644 index 0000000..7cbad18 --- /dev/null +++ b/test/testdata/nuls.mp3 diff --git a/test/testdata/theora.ogv b/test/testdata/theora.ogv Binary files differnew file mode 100644 index 0000000..395db96 --- /dev/null +++ b/test/testdata/theora.ogv diff --git a/test/testdata/theora_with_ogg_extension.ogg b/test/testdata/theora_with_ogg_extension.ogg Binary files differnew file mode 100644 index 0000000..6693756 --- /dev/null +++ b/test/testdata/theora_with_ogg_extension.ogg diff --git a/test/testdata/webm-0.webm b/test/testdata/webm-0.webm Binary files differnew file mode 100644 index 0000000..7f472c9 --- /dev/null +++ b/test/testdata/webm-0.webm diff --git a/test/uitests.sikuli/config.py b/test/uitests.sikuli/config.py new file mode 100644 index 0000000..342bbdc --- /dev/null +++ b/test/uitests.sikuli/config.py @@ -0,0 +1,47 @@ +#config.py +import os +import time +from sikuli.Sikuli import * + + +def set_image_dirs(): + """Set the Sikuli image path for the os specific image directory and the main Image dir. + + """ + os_imgs = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Images_"+get_os_name()) + imgs = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Images") + + dir_list = [imgs, os_imgs] + #Add the image dirs to the sikuli search path if it is not in there already + for d in dir_list: + if d not in list(getImagePath()): + addImagePath(d) + +def get_os_name(): + """Returns the os string for the SUT + """ + if "MAC" in str(Env.getOS()): + return "osx" + elif "WINDOWS" in str(Env.getOS()): + return "win" + elif "LINUX" in str(Env.getOS()): + return "lin" + else: + print ("I don't know how to handle platform '%s'", Env.getOS()) + + +def launch_cmd(): + """Returns the launch path for the application. + + launch is an os specific command + """ + if get_os_name() == "osx": + launch_cmd = "/Applications/Miro Video Converter.app" + elif get_os_name() == "win": + launch_cmd = os.path.join(os.getenv("PROGRAMFILES"),"Participatory Culture Foundation","Miro Video Converter","MiroConverter.exe") + else: + print get_os_name() + print launch_cmd + return launch_cmd + + diff --git a/test/uitests.sikuli/datafiles.py b/test/uitests.sikuli/datafiles.py new file mode 100644 index 0000000..4db8b27 --- /dev/null +++ b/test/uitests.sikuli/datafiles.py @@ -0,0 +1,170 @@ +# Default Device Conversion Parameters +import os + +class TestData(object): + _UNITTESTFILES = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)),"..","..","..",'testdata')) + _SIKTESTFILES = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)),'testdata')) + + _FILES = { + 'mp3-0.mp3': { + 'testdir': _UNITTESTFILES, + 'container': 'mp3', + 'audio_codec': 'mp3', + 'title': 'Invisible Walls', + 'artist': 'Revolution Void', + 'album': 'Increase The Dosage', + 'track': '1', + 'genre': 'Blues', + 'duration': 1.07 + }, + 'mp3-1.mp3': + { + 'testdir': _UNITTESTFILES, + 'container': 'mp3', + 'audio_codec': 'mp3', + 'title': 'Race Lieu', + 'artist': 'Ckz', + 'album': 'The Heart EP', + 'track': '2/5', + 'duration': 1.07 + }, + + 'mp3-2.mp3': + { + 'testdir': _UNITTESTFILES, + 'container': 'mp3', + 'audio_codec': 'mp3', + 'artist': 'This American Life', + 'genre': 'Podcast', + 'title': '#426: Tough Room 2011', + 'duration': 1.09 + }, + + 'theora_with_ogg_extension.ogg': + { + 'testdir': _UNITTESTFILES, + 'container': 'ogg', + 'video_codec': 'theora', + 'width': 320, + 'height': 240, + 'duration': 0.1}, + + 'webm-0.webm': + {'testdir': _UNITTESTFILES, + 'container': ['matroska', 'webm'], + 'video_codec': 'vp8', + 'width': 1920, + 'height': 912, + 'duration': 0.43}, + + 'mp4-0.mp4': + {'testdir': _UNITTESTFILES, + 'container': ['mov', + 'mp4', + 'm4a', + '3gp', + '3g2', + 'mj2', + 'isom', + 'mp41'], + 'video_codec': 'h264', + 'audio_codec': 'aac', + 'width': 640, + 'height': 480, + 'title': 'Africa: Cash for Climate Change?', + 'duration': 312.37}, + + + 'nuls.mp3': + { + 'testdir': _UNITTESTFILES, + 'container': 'mp3', + 'title': 'Invisible'}, + + 'drm.m4v': + { + 'testdir': _UNITTESTFILES, + 'container': ['mov', + 'mp4', + 'm4a', + '3gp', + '3g2', + 'mj2', + 'M4V', + 'mp42', + 'isom'], + 'video_codec': 'none', + 'audio_codec': 'aac', + 'has_drm': ['audio', 'video'], + 'width': 640, + 'height': 480, + 'title': 'Thinkers', + 'artist': 'The Most Extreme', + 'album': 'The Most Extreme', + 'track': '10', + 'genre': 'Nonfiction', + 'duration': 2668.8}, + + 'baby_block.m4v': + { + 'testdir': _SIKTESTFILES, + 'container': 'm4v', + 'video_codec': 'h264', + 'audio_codec': 'aac', + 'width': 960, + 'height': 540, + }, + 'fake_video.mp4': # this is a fake mp4 file it is a pdf file renamed to an mp4 extension and should fail conversion + { + 'testdir': _SIKTESTFILES, + 'container': 'mp4', + 'video_codec': None, + 'audio_codec': None, + 'width': None, + 'height': None, + }, + 'story_stuff.mov': + {'testdir': _SIKTESTFILES, + 'container': 'mov', + 'video_codec': 'h264', + 'audio_codec': 'mp3', + 'width': 320, + 'height': 180, + } + + } + + def testfile_attr(self, testfile, default): + try: + return self._FILES[testfile][default] + except: + return None + + def directory_list(self, testdir): + files_list = [] + for k, v in self._FILES.iteritems(): + if v.has_key('testdir') and testdir in v['testdir']: + files_list.append(k) + return files_list + + def test_data(self, many=True, new=False): + """Grab a subset of the test files. + + Default selection is to use the unittest files, + but, if I need extra files, getting them from the sikuli test files dir. + """ + DEFAULT_UNITTESTFILES = ['mp4-0.mp4', 'webm-0.webm'] + DEFAULT_SIKTESTFILES = ['baby_block.m4v', 'story_styff.mov'] + if new: + TESTFILES = DEFAULT_SIKTESTFILES + else: + TESTFILES = DEFAULT_UNITTESTFILES + + DATADIR = self.testfile_attr(TESTFILES[0], 'testdir') + + if not many: + TESTFILES = TESTFILES[:1] + + print TESTFILES + return DATADIR, TESTFILES + diff --git a/test/uitests.sikuli/devices.py b/test/uitests.sikuli/devices.py new file mode 100644 index 0000000..6c4f998 --- /dev/null +++ b/test/uitests.sikuli/devices.py @@ -0,0 +1,109 @@ +# Default Device Conversion Parameters + +_DEVICES = { + 'Xoom': { + 'group': 'Android', + 'width': '1280', + 'height': '800', + }, + + 'Droid': {'group': 'Android', + 'width': '854', + 'height': '480', + }, + + 'G2': { + 'group': 'Android', + 'width': '800', + 'height': '480', + }, + + 'Dream': { + 'group': 'Android', + 'width': '480', + 'height': '320', + }, + + 'Galaxy Tab': { + 'group': 'Android', + 'width': '1024', + 'height': '800', + }, + + 'Epic': { + 'group': 'Android', + 'width': '800', + 'height': '480', + }, + + 'KindleFire': { + 'group': 'Other', + 'width': '1024', + 'height': '600', + }, + 'Playstation': { + 'group': 'Other', + 'width': '320', + 'height': '480', + }, + + 'iPhone': { + 'group': 'Apple', + 'width': '480', + 'height': '320', + }, + 'iPhone 4': { + 'group': 'Apple', + 'width': '640', + 'height': '480', + }, + 'iPad': { + 'group': 'Apple', + 'width': '1024', + 'height': '768', + }, + 'Apple Universal': { + 'group': 'Apple', + 'width': '1280', + 'height': '720', + }, + 'MP4': { + 'group': 'Format', + 'width': None, + 'height': None, + }, + 'MP3': { + 'group': 'Format', + 'width': None, + 'height': None, + }, + 'Ogg Theora': { + 'group': 'Format', + 'width': None, + 'height': None, + }, + 'Ogg Vorbis': { + 'group': 'Format', + 'width': None, + 'height': None, + }, + 'WebM': { + 'group': 'Format', + 'width': None, + 'height': None, + } + } + + +def dev_attr(device, default): + return _DEVICES[device][default] + +def devices(group): + device_list = [] + for k, v in _DEVICES.iteritems(): + if group in v['group']: + device_list.append(k) + return device_list + +# Dream, Magic, Eris, Hero Cliq are all the same +# iphone, ipod touch, ipod nano, ipod classic are all the same diff --git a/test/uitests.sikuli/mvc_steps.py b/test/uitests.sikuli/mvc_steps.py new file mode 100644 index 0000000..0d00cc1 --- /dev/null +++ b/test/uitests.sikuli/mvc_steps.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +from lettuce import step +from lettuce import world +import datafiles +import devices + +data = datafiles.TestData() +DEFAULT_DEVICE = "iPad" + +def test_data(many=False, new=False): + UNITTESTFILES = ['mp4-0.mp4', 'webm-0.webm', 'drm.m4v'] + SIKTESTFILES = ['baby_block.m4v', 'story_styff.mov'] + if new: + TESTFILES = SIKTESTFILES + else: + TESTFILES = UNITTESTFILES + + DATADIR = data.testfile_attr(TESTFILES[0], 'testdir') + + if not many: + TESTFILES = TESTFILES[:1] + + print TESTFILES + return DATADIR, TESTFILES + +def device_group(option): + menu_group = devices.dev_attr(option, 'group') + return menu_group + +def device_output(option): + dev_output_format = devices.dev_attr(option, 'container') + return device_output_format + + +@step('I browse for (?:a|several)( new)? file(s)?') +def browse_for_files(step, new, several): # file or files determines 1 or many + datadir, testfiles = test_data(several, new) + print testfiles + world.mvc.browse_for_files(datadir, testfiles) + +@step('The( new)? file(s)? (?:is|are) added to the list') +def files_added_to_the_list(step, new, several): + _, testfiles = test_data(several, new) + for t in testfiles: + assert world.mvc.verify_file_in_list(t) + +@step('I browse to a directory of files') +def add_a_directory(step): + datadir, _ = test_data(many=True) + world.mvc.add_directory_of_files(datadir) + +@step(u'When I drag (?:a|several)( new)? file(s)? to the drop zone') +def drag_to_the_drop_zone(step, new, several): + datadir, testfiles = test_data(several, new) + world.mvc.drag_and_drop_files(datadir, testfiles) + + +@step('Given I have files in the list') +def given_i_have_some_files(step): + step.given('I browse for several files') + +@step('I start conversion') +def start_conversion(step): + world.mvc.start_conversions() + +@step('I remove "([^"]*)" from the list') +def when_i_remove_it_from_the_list(step, items): + if items == "it": + _, testfile = test_data() + elif items == "them": + _, testfile = test_data(True, False) + else: + testfile = items.split(', ') + world.mvc.remove_files(testfile) + +@step('"([^"]*)" is not in the list') +def not_in_the_list(step, items): + if items == "it": + _, testfile = test_data() + assert False, world.mvc.verify_file_in_list(testfile) + +@step('I remove each of them from the list') +def i_remove_each_of_them_from_the_list(step): + assert False, 'This step must be implemented' + +@step(u'Then the list of files is empty') +def then_the_list_of_files_is_empty(step): + assert False, 'This step must be implemented' + +@step('I have converted (?:a|some) file(s)?') +def have_converted_file(step, amount): + if amount == None: + browse_file = ('I browse for a file') #file or files determines 1 or many + else: + browse_file = ('I browse for some files') + step.given(browse_file) + step.given('I choose the "test_default" device option') + step.given('I start conversion') + + +@step('I clear finished conversions') +def clear_finished_conversions(step, testfiles): + world.mvc.clear_finished_files() + + +@step('I (?:convert|have converted) "(.*?)" to "(.*?)"') +def convert_file_to_format(step, filename, device): + datadir = data.testfile_attr(filename, 'testdir') + world.mvc.browse_for_files(datadir, [filename]) + world.mvc.choose_device_conversion(device) + world.mvc.start_conversions() + + + +@step('the "(.*?)" (?:is|are) removed') +def file_is_removed(step, testfile): + if testfile == "file": + _, testfile = test_data() + assert world.mvc.verify_file_not_in_list(testfile) + +@step('And I have some conversions in progress') +def and_i_have_some_conversions_in_progress(step): + assert False, 'This step must be implemented' + +@step('the completed files are removed') +def completed_files_are_removed(step): + assert world.mvc.verify_completed_removed() + +@step('the in-progress conversions remain') +def and_the_in_progress_conversions_remain(step): + assert world.mvc.verify_in_progress() + +@step('"(.*?)" is a failed conversion') +def have_failed_conversion(step, item): + assert verify_failed(self, item) + +@step('the failed conversions are removed') +def failed_conversions_removed(step): + assert world.mvc.verify_failed_removed() + +@step('I choose the custom size option') +def change_custom_size(step): + world.mvc.choose_custom_size('on', '150', '175') + assert world.mvc.verify_test_img('_custom_size_test') + +@step('I choose the aspect ratio') +def when_i_choose_the_aspect_ratio(step): + assert False, 'This step must be implemented' + +@step('I choose the "([^"]*)" (?:device|format) option') +def choose_conversion_format(step, device): + if device == 'test_default': + device = DEFAULT_DEVICE + world.mvc.choose_device_conversion(device) + + +@step('I open the custom pulldown') +def open_custom_pulldown(step): + world.mvc.open_custom_menu() + +@step('I verify "([^"]*)" and "([^"]*)" size setting entry') +def verify_the_size_value(step, width, height): + assert False, 'This step must be implemented' + +@step('I verify the "([^"]*)" (?:device|format)( not)? selected') +def verify_format_selection_for_device(self, device, removed): + if removed: + assert False, world.mvc.verify_device_format_selected(device) + else: + assert world.mvc.verify_device_format_selected(device) + + +@step('the menu is reset') +def menu_is_reset(step): + assert False, 'This step must be implemented' + +@step(u'Then there should be some smart way to make sure that the size and aspect ratio values are not conflicting') +def then_there_should_be_some_smart_way_to_make_sure_that_the_size_and_aspect_ratio_values_are_not_conflicting(step): + assert False, 'This step must be implemented' + +@step(u'And therefore if you have a size selected, and then select an aspect ratio, a valid size should be calculated based on the chosen width and the size value should be updated.') +def and_therefore_if_you_have_a_size_selected_and_then_select_an_aspect_ratio_a_valid_size_should_be_calculated_based_on_the_chosen_width_and_the_size_value_should_be_updated(step): + assert False, 'This step must be implemented' + + + +@step(u'When I restart mvc') +def when_i_restart_mvc(step): + assert False, 'This step must be implemented' + +@step('I have Send to iTunes checked') +def and_i_have_send_to_itunes_checked(step): + assert False, 'This step must be implemented' + +@step('the file is added to my iTunes library') +def file_added_to_itunes(step): + assert False, 'This step must be implemented' + +@step(u'And I have the (default)? output directory specified') +def and_i_have_the_output_directory_specified(step, default): + assert False, 'This step must be implemented' + +@step('the output file is in the (specified|default) directory') +def output_file_specified_directory(step): + assert False, 'This step must be implemented' + + +@step(u'Then is named with the file name (or even better item title) as the base') +def then_is_named_with_the_file_name_or_even_better_item_title_as_the_base(step): + assert False, 'This step must be implemented' + +@step(u'And the output container is the extension') +def and_the_output_container_is_the_extension(step): + assert False, 'This step must be implemented' + +@step(u'Then the output file is resized correctly') +def then_the_output_file_is_resized_correctly(step): + assert False, 'This step must be implemented' + + +@step(u'When I view the ffmpeg output') +def when_i_view_the_ffmpeg_output(step): + assert False, 'This step must be implemented' + +@step('the ffmpeg output is displayed in a text window') +def then_the_ffmpeg_output_is_displayed_in_a_text_window(step): + assert False, 'This step must be implemented' + diff --git a/test/uitests.sikuli/mvcgui.py b/test/uitests.sikuli/mvcgui.py new file mode 100644 index 0000000..b76e41d --- /dev/null +++ b/test/uitests.sikuli/mvcgui.py @@ -0,0 +1,368 @@ +from sikuli.Sikuli import * +import devices +import config + + +class MVCGui(object): + +# ** APP UI IMAGES ** + +# ADD FILES + _INITIAL_DROP_ZONE = 'fresh_start_dropzone.png' + _CHOOSE_FILES = 'choose_a_file.png' + _DROP_ZONE = 'file_drop_zone.png' + _CONVERSIONS_FINISHED = 'conversions_finished.png' + + +# BIG BOTTOM BUTTONS + _START_CONVERSION = 'convert_now.png' + _STOP_CONVERSION = 'stop_all.png' + _RESET = 'clear_and_start_over.png' + +# INDIVIDUAL FILE OPTIONS + #processing + _IN_PROGRESS = 'progress_bar.png' + _DELETE_FILE = 'delete_icon.png' + _CLEAR_FINISHED = 'clear_finished.png' + _CONVERSION_COMPLETE = 'completed.png' + _CONVERSION_FAILED = 'failed.png' + _PAUSE = 'pause_button.png' + _RESUME = 'resume_button.png' + _QUEUED = 'queued.png' + _ERROR = 'error_icon.png' + _SHOW_FILE = 'show_file.png' + +# CONVERSION OPTIONS SECTION + _SEND_ITUNES = 'send_to_itunes.png' + _APPLE_MENU = 'apple_dropdown.png' + _ANDROID_MENU = 'android_dropdown.png' + _OTHER_MENU = 'other_dropdown.png' + _CUSTOM_MENU = 'custom_menu.png' + +# CUSTOM MENU OPTIONS + _PREFS_CHECKBOX_CHECKED = 'checkbox_checked.png' + _PREFS_CHECKBOX_UNCHECKED = 'checkbox_unchecked.png' + _SAVE_TO_OPTION = 'save_to_pulldown.png' + _OUTPUT_DIRECTORY = 'default_dir_selected.png' + _SAVE_TO_DEFAULT = 'save_to_default_selected.png' + _CONVERT_TO_OPTION = 'convert_to_menu.png' + _CUSTOM_SIZE = 'custom_size.png' + _WIDTH = 'custom_width.png' + _HEIGHT = 'custom_height.png' + _UPSIZE = 'dont_upsize' + _ASPECT = 'custom_aspect.png' + _ASPECT_43 = '43_aspect.png' + _ASPECT_32 = '32_aspect.png' + _ASPECT_169 = '169_aspect.png' + +# SELECTED CONVERSION OPTION + _APPLE_SELECTED = 'apple_selected.png' + _ANDROID_SELECTED = 'android_selected.png' + +# SYSTEM UI + _SYS_TEXT_ENTRY_BUTTON = 'type_a_filename.png' + +# TEST IMAGES for VERIFICATION + _custom_size_test = '150x175size.png' + + def __init__(self): + ''' + Constructor + ''' + config.set_image_dirs() + self.os_name = config.get_os_name() +# CMD or CTRL Key + if self.os_name == 'osx': + self.CMDCTRL = Key.CMD + else: + self.CMDCTRL = Key.CTRL + + def mvc_focus(self): + App.focus("Libre Video Converter") + + def mvc_quit(self): + App.close("Libre Video Converter") + + def item_region(self, item): + find(item) + reg = Region.getLastMatch() + item_reg = Region(reg.x - 30, reg.y - 30, 400, 50) + return(item_reg) + + def choose_directory(self, dirname): + self.type_a_path(dirname) + + def type_a_path(self, dirname): + if config.get_os_name() == "win": + if not exists("Location",5): + click(self.SYS_TEXT_ENTRY_BUTTON) + time.sleep(2) + type(dirname +"\n") + type(Key.ENTER) + + def browse_for_files(self, dirname, testdata): + click(Pattern(self._CHOOSE_FILES)) + time.sleep(2) #osx freaks out if you start typing too fast + self.type_a_path(dirname) + keyDown(self.CMDCTRL) + for f in testdata: + click(f) + keyUp(self.CMDCTRL) + + def add_directory_of_files(self, dirname): + click(self._CHOOSE_FILES) + self.choose_directory(dirname) + type(Key.ENTER) + + def drag_and_drop_files(self, dirname, testdata): + click(self._CHOOSE_FILES) + y = getLastMatch() # y is drop destination + type(dirname) + type(Key.ENTER) + keyDown(self.CMDCTRL) + for f in testdata: + find(f) + x = getLastMatch() # the drag start is the last file we find and select + click(getLastMatch()) + dragDrop(x, y) + keyUp(self.CMDCTRL) + type(Key.ESC) #close the file browser dialog + + + def remove_files(self, *items): + for item in items: + r = self.item_region(item) + r.click(self._DELETE_FILE) + assert r.waitVanish(item) + + def start_conversions(self): + click(self._START_CONVERSION) + + def stop_conversions(self): + click(self._STOP_CONVERSION) + + def clear_and_start_over(self): + click(self._RESET) + + def pause_conversions(self, *items): + for item in items: + r = self.item_region(item) + r.click(self._PAUSE) + assert r.exists(self._RESUME) + + def clear_finished_files(self, items, wait=30): + """Clears out the completed individual conversions. + + """ + for item in items: + r = self.item_region(item) + r.exists(self._CLEAR_FINISHED, wait) + click(r.getLastMatch()) + assert waitVanish(item, 2) + + def show_file(self, item): + for item in items: + r = self.item_region(item) + r.click(self._SHOW_FILE) + # FIXME Verify the file is there and close the window + + + def choose_device_conversion(self, device): + device_group = devices.dev_attr(device, 'group') + menu_img = getattr(self, "".join(["_",device_group.upper(),"_","MENU"])) + click(menu_img) + click(device) + + + def open_custom_menu(self): + if not exists(self._OUTPUT_DIRECTORY): + click(self._CUSTOM_MENU) + + def choose_save_location(self, location='default'): + self.open_custom_menu() + if location == 'default' and not exists(self._SAVE_TO_DEFAULT): + click(self._SAVE_TO_OPTION) + click(self._SAVE_TO_DEFAULT) + else: + click(self._SAVE_TO_OPTION) + self.choose_directory(location) + + def set_pref_checkbox(self, option, setting): + """Check or uncheck the box for a preference setting. + Valid values are ['on' and 'off'] + + """ + valid_settings = ['on', 'off'] + if setting not in valid_settings: + print("valid setting value not proviced, must be 'on' or 'off'") + #CHECK THE BOX + pref_image = getattr(self, "".join(["_",option])) + find(pref_image) + reg = Region(getLastMatch()) + box = Region(reg.getX()-15, sr_loc.getY()-10, pref_reg.getW(), 30) #location of associated checkbox + if setting == "off": + if box.exists(self._PREFS_CHECKBOX_CHECKED): + click(box.getLastMatch()) + if setting == "on": + if box.exists(self._PREFS_CHECKBOX_NOT_CHECKED): + click(box.getLastMatch()) + + def choose_custom_size(self, setting, width=None, height=None): + self.open_custom_menu(self) + if not width or not height: + setting = 'off' + self.set_pref_checkbox(self._CUSTOM_SIZE, setting) + if setting == 'on': + type(Key.TAB) + type(width) + type(Key.TAB) + type(height) + + def choose_dont_upsize(self, setting): + self.open_custom_menu(self) + self.set_pref_checkbox(self._UPSIZE, setting) + + def choose_aspect_ratio(self, setting, ratio=None): + self.open_custom_menu(self) + if ratio == None: + setting = 'off' + self.set_pref_checkbox(self._ASPECT, setting) + if setting == 'on': + ratio_img = getattr(self, "".join(["_", "ASPECT", ratio])) + click(ratio_img) + + def choose_format(self, output): + self.open_custom_menu(self) + mouseMove(self._CONVERT_TO_OPTION.right(30)) + mouseDown(Button.LEFT) + mouseMove(output) + mouseUp(Button.LEFT) + + def choose_itunes(self, setting): + self.set_pref_checkbox(self._SEND_ITUNES, setting) + + + def remove_queued_conversions(self): + while exists(self._QUEUED): + qreg = Region(getLastMatch()) + q_item = Region(reg.getX()+30, sr_loc.getY()-20, 500, 30) + q_item.click(self._DELETE_FILE) + + def verify_device_format_selected(self, device): + device_group = devices.dev_attr(device, 'group') + if device_group == 'Format': + if exists(device): + return True + else: + if exists(device) and exists("MP4"): #all devices are mp4 by default + return True + + + def verify_size(self, item, width, height): + self.show_ffmpeg_output(item) + expected_size_parameter = "-s "+width+"x"+height + type(self.CMDCTRL, 'f') + type('-s '+width+'x'+'height') + type(self.CMDCTRL, 'c') #copy the ffmpeg size command to the clipboard + size_param = Env.getClipboard() + if size_param == expected_size_parameter: + return True + + + + def verify_device_size_default(self, width, height): + self.open_custom_menu() + self.set_pref_checkbox(self._CUSTOM_SIZE, setting) + click('Width') + type(self.CMDCTRL) + displayed_width = Env.getClipboard() + click('Height') + type(self.CMDCTRL) + displayed_height = Env.getClipboard() + if displayed_height == height and displayed_width == width: + return True + + + def verify_converting(self, item): + r = self.item_region(item) + if r.exists(self._IN_PROGRESS): + return True + + def verify_paused(self, item): + r = self.item_region(item) + if r.exists(self._RESUME): + return True + + def verify_completed(self, item, wait=10): + """Verify an individual conversion has completed. + + """ + r = self.item_region(item) + if r.exists(self._CONVERSION_COMPLETE, wait): + return True + + def verify_completed_removed(self): + if not exists(self._CONVERSION_COMPLETE): + return True + + def verify_conversions_finished(self): + """This verifies the entire group of conversions are complete. + + """ + if exists(self._CONVERSIONS_FINISHED): + return True + + def verify_failed(self, item, wait=20): + r = self.item_region(item) + try: + r.exists(self._CONVERSION_FAILED, wait) + return True + except: + return False + + def verify_failed_removed(self): + if not exists(self._CONVERSION_FAILED): + return True + + def verify_file_in_list(self, item): + r = self.item_region(item) + if r.exists(item): + return True + + def verify_file_not_in_list(self, item): + if not exists(item): + return True + + def verify_queued(self, item): + r = self.item_region(item) + if r.exists(self._QUEUED): + return True + + def verify_in_progress(self, item=None): + if item: + r = self.item_region(item) + if r.exists(self._IN_PROGRESS): return True + else: + if exists(self._IN_PROGRESS): return True + + def verify_itunes(self, item): + pass + + def verify_output_dir(self, item, directory): + r = self.item_region(item) + r.click(self._SHOW_FILE) + type(Key.ESC) + + #FIXME need to get what the output name is going to be. + output_file = os.path.join(directory, item) + if os.path.isfile(output_file): + return True + + + def show_ffmpeg_output(self, item): + r = self.item_region(item) + self.verify_completed(item, 30) + + r.click(self._SHOW_FFMPEG) + if exists("STARTING CONVERSION"): + return True diff --git a/test/uitests.sikuli/nose.cfg b/test/uitests.sikuli/nose.cfg new file mode 100644 index 0000000..9e9472c --- /dev/null +++ b/test/uitests.sikuli/nose.cfg @@ -0,0 +1,6 @@ +[nosetests] +with-xunit=0 +xunit-file=tests.sikuli/nosetests.xml +nocapture=0 +where=tests.sikuli + diff --git a/test/uitests.sikuli/readme.md b/test/uitests.sikuli/readme.md new file mode 100644 index 0000000..3996931 --- /dev/null +++ b/test/uitests.sikuli/readme.md @@ -0,0 +1,19 @@ +Libre Video Converter 3 +====================== + +<img src="http://cl.ly/ECBE/o"/></img> + +MVC3 has a complete UI overhaul designed to maintain the simplicity of previous versions but also provide +users with batch processing options and give users greater control over their converted files. + + +This directory holds the UI tests for mvc that can be run like this: + +1. install Sikuli from http://sikuli.org +2. install nose (pip install nose) +3. set 2 environment variables: + - export SIKULI_HOME = *path to sikuli-script.jar* + - export PYTHON_PKGS = *path to where nose packages live* (because jython is annoying and it's the only way I can get it to import) + +i4. cd ../ (on dir level above the tests.sikuli directory +5. java -jar $SIKULI_HOME/sikuli-script.jar uitests.sikuli diff --git a/test/uitests.sikuli/test_android_conversions.py b/test/uitests.sikuli/test_android_conversions.py new file mode 100644 index 0000000..5023da6 --- /dev/null +++ b/test/uitests.sikuli/test_android_conversions.py @@ -0,0 +1,66 @@ +#!/usr/bin/python + +import devices +from sikuli.Sikuli import * +import devices +import config +from mvcgui import MVCGui +import datafiles +data = datafiles.TestData() + +def test_android_conversions(): + """Scenario: test each android conversion option. + + """ + device_list = devices.devices('Android') + for x in device_list: + yield convert_to_format, x + + +def test_android_size_output_default(): + """Scenario: the output format and size are defaults when device selected. + + """ + device_list = devices.devices('Android') + datadir, testfiles = data.test_data(many=True, new=True) + mvc = MVCGui() + mvc.mvc_focus() + mvc.browse_for_files(datadir, testfiles) + + for x in device_list: + yield device_defaults, x, mvc + + +def device_defaults(device_output, mvc): + print device_output + mvc.choose_device_conversion(device_output) + width = device.device_attr(device_output, 'width') + height = device.device_attr(device_output, 'height') + default_format = 'MP4' + assert mvc.verify_device_format_selected(device_output) + assert mvc.verify_device_size_default(str(width), str(height)) + + + +def convert_to_format(device_output): + """Scenario: Test items are converted to the specified format. + """ + print device_output + mvc = MVCGui() + mvc.mvc_focus() + expected_failures = ['fake_video.mp4'] + datadir, testfiles = data.test_data(many=True, new=True) + mvc.browse_for_files(datadir, testfiles) + output_dir = tempfile.mkdtemp() + mvc.choose_save_location(output_dir) + mvc.choose_device_conversion("device_output") + mvc.start_conversions() + for item in testfiles: + if item in expected_failures: + mvc.verify_failed(item, 120) + else: + mvc.verify_completed(item, 120) + mvc.clear_finished_files(item) + mvc.clear_and_start_over() + + diff --git a/test/uitests.sikuli/test_apple_conversions.py b/test/uitests.sikuli/test_apple_conversions.py new file mode 100644 index 0000000..f0702bd --- /dev/null +++ b/test/uitests.sikuli/test_apple_conversions.py @@ -0,0 +1,63 @@ +#!/usr/bin/python + +import devices +from sikuli.Sikuli import * +import devices +import config +from mvcgui import MVCGui +import datafiles + +data = datafiles.TestData() + +def test_apple_conversions(): + """Scenario: test each android conversion option. + + """ + device_list = devices.devices('Apple') + for x in device_list: + yield convert_to_format, x + + +def test_apple_size_output_default(): + """Scenario: the output format and size are defaults when device selected. + + """ + device_list = devices.devices('Android') + datadir, testfiles = data.test_data(many=True, new=True) + mvc = MVCGui() + mvc.mvc_focus() + mvc.browse_for_files(datadir, testfiles) + + for x in device_list: + yield device_defaults, x, mvc + + +def device_defaults(device_output, mvc): + print device_output + mvc.choose_device_conversion(device_output) + width = device.device_attr(device_output, 'width') + height = device.device_attr(device_output, 'height') + default_format = 'MP4' + assert mvc.verify_device_format_selected(device_output) + assert mvc.verify_device_size_default(str(width), str(height)) + +def convert_to_format(device_output): + print device_output + expected_failures = ['fake_video.mp4'] + mvc = MVCGui() + mvc.mvc_focus() + datadir, testfiles = data.test_data(many=True, new=True) + mvc.browse_for_files(datadir, testfiles) + output_dir = tempfile.mkdtemp() + mvc.choose_save_location(output_dir) + mvc.choose_device_conversion("device_output") + mvc.start_conversions() + for item in testfiles: + if item in expected_failures: + mvc.verify_failed(item, 120) + else: + mvc.verify_completed(item, 120) + mvc.clear_finished_files(item) + mvc.clear_and_start_over() + + diff --git a/test/uitests.sikuli/test_choose_files.py b/test/uitests.sikuli/test_choose_files.py new file mode 100644 index 0000000..7b86622 --- /dev/null +++ b/test/uitests.sikuli/test_choose_files.py @@ -0,0 +1,166 @@ +import sys +import os +import tempfile +import shutil +import unittest +from mvcgui import MVCGui +import datafiles +import devices + +data = datafiles.TestData() + + +class Test_Choose_Files(unittest.TestCase): + """Add files to the conversion list either via browse or drag-n-drop. + + """ + + def setUp(self): + """ + setup app for tests + + """ + self.mvc = MVCGui() + self.mvc.mvc_focus() + print "starting test: ", self.shortDescription() + self.output_dir = tempfile.mkdtemp() + self.mvc.choose_save_location(self.output_dir) + + + + def test_browse_for_a_file(self): + """Scenario: Browse for a single file. + + When I browse for a file + Then the file is added to the list + """ + mvc = MVCGui() + datadir, testfiles = data.test_data(many=False) + mvc.browse_for_files(datadir, testfiles) + item = testfiles[0] + assert mvc.verify_file_in_list(item) + + + + + def test_choose_several_files(self): + """Scenario: Browse for several files. + + When I browse for several files + Then the files are added to the list + """ + mvc = MVCGui() + datadir, testfiles = data.test_data(many=True) + mvc.browse_for_files(datadir, testfiles) + for t in testfiles: + assert mvc.verify_file_in_list(t) + + def skip_test_choose_a_directory_files(self): + """Scenario: Choose a directory of files. + + When I browse to a directory of files + Then the files are added to the list + """ + + def test_drag_a_file_to_drop_zone(self): + """Scenario: Drag a single file to drop zone. + + When I drag a file to the drop zone + Then the file is added to the list + """ + mvc = MVCGui() + datadir, testfiles = data.test_data(many=False) + mvc.drag_and_drop_files(datadir, testfiles) + item = testfiles[0] + assert mvc.verify_file_in_list(item) + + def test_drag_and_drop_multiple_files(self): + """Scenario: Drag multiple files. + + When I drag several files to the drop zone + Then the files are added to the list + """ + mvc = MVCGui() + datadir, testfiles = data.test_data(many=True) + mvc.drag_and_drop_files(datadir, testfiles) + for t in testfiles: + assert mvc.verify_file_in_list(t) + + def test_drag_more_files_to_drop_zone(self): + """Scenario: Drag additional files to the existing list. + + Given I have files in the list + When I drag a new file to the drop zone + Then the new file is added to the list + """ + mvc = MVCGui() + datadir, testfiles = data.test_data(many=True) + mvc.browse_for_files(datadir, testfiles) + moredatadir, moretestfiles = data.test_data(many=False, new=True) + item = testfiles[0] + mvc.drag_and_drop_files(moredatadir, item) + assert mvc.verify_file_in_list(item) + + def test_browse_for_more_files_and_add_them(self): + """Scenario: Choose additional files and add to the existing list. + + Given I have files in the list of files + When I browse for several new files + Then the new files are added to the list + """ + + mvc = MVCGui() + datadir, testfiles = data.test_data(many=True) + mvc.browse_for_files(datadir, testfiles) + moredatadir, moretestfiles = data.test_data(many=False, new=True) + item = testfiles[0] + mvc.browse_for_files(moredatadir, item) + assert mvc.verify_file_in_list(item) + + + def test_drag_more_file_while_converting(self): + """Scenario: Drag additional files to the existing list with conversions in progress. + + Given I have files in the list + And I start conversion + When I drag a new file to the drop zone + Then the new file is added to the list and is converted + """ + + mvc = MVCGui() + datadir, testfiles = data.test_data(many=True) + mvc.browse_for_files(datadir, testfiles) + mvc.choose_device_conversion("iPad") + mvc.start_conversion() + + moredatadir, moretestfiles = data.test_data(many=False, new=True) + item = testfiles[0] + mvc.drag_and_drop_files(moredatadir, item) + assert mvc.verify_file_in_list(item) + assert mvc.verify_completed(item, 60) + + def test_browse_more_files_while_converting(self): + """Scenario: Choose additional files and add to list with conversions in progress. + + Given I have files in the list + And I start conversion + When I browse for several new files + Then the new files are added to the list + """ + + mvc = MVCGui() + datadir, testfiles = data.test_data(many=True) + mvc.browse_for_files(datadir, testfiles) + mvc.choose_device_conversion("iPad") + mvc.start_conversion() + + moredatadir, moretestfiles = data.test_data(many=False, new=True) + item = testfiles[0] + mvc.browse_for_files(moredatadir, item) + assert mvc.verify_file_in_list(item) + assert mvc.verify_completed(item, 60) + + def tearDown(self): + shutil.rmtree(self.output_dir) + self.mvc_quit() + diff --git a/test/uitests.sikuli/test_clear_finished_conversions.py b/test/uitests.sikuli/test_clear_finished_conversions.py new file mode 100644 index 0000000..360d282 --- /dev/null +++ b/test/uitests.sikuli/test_clear_finished_conversions.py @@ -0,0 +1,91 @@ +#!/usr/bin/python + +import sys +import os +import tempfile +import shutil +import unittest +from mvcgui import MVCGui +import datafiles +import devices + +data = datafiles.TestData() + + +class Test_Clear_Finished_Conversions(unittest.TestCase): + """Feature: Removed completed conversions from the list. + + When and item has completed or failed convsion + I want to remove it from the list + """ + + def setUp(self): + """ + Each tests assumes that I there are files that have been converted. + + """ + self.mvc = MVCGui() + self.mvc.mvc_focus() + print "starting test: ", self.shortDescription() + datadir, testfiles = data.test_data() + self.mvc.browse_for_files(datadir, testfiles) + self.output_dir = tempfile.mkdtemp() + self.mvc.choose_save_location(self.output_dir) + + def test_clear_finished_conversions(self): + """Feature: Clear a finished conversions. + + Given I have converted a file + When I clear finished conversions + Then the file is removed + """ + mvc = MVCGui() + _, testfiles = data.test_data(many=True) + mvc.start_conversions() + assert mvc.clear_finished_conversions(testfiles) + + + + def test_clear_finished_item_with_in_progress(self): + """Scenario: Clear finished conversions while others are in progress. + + Given I have converted a file + And I have some conversions in progress + When I clear finished conversions + Then the completed files are removed + And the in-progress conversions remain + """ + _, testfiles = data.test_data(many=True) + item = 'slow_conversion.mkv' + item_dir = data.testfile_attr(item, 'testdir') + mvc = MVCGui() + mvc.browse_for_files(item_dir, item) + mvc.start_conversions() + mvc.clear_finished_conversions(testfiles) + assert mvc.verify_converting(item) + + + + def test_clear_finished_after_conversion_errors(self): + """Scenario: Clear finished conversions after conversion errors. + + Given I convert several files and 1 that will fail + When I clear finished conversions + Then the completed files are removed + And the failed conversions are removed + """ + _, testfiles = data.test_data(many=True) + item = 'fake_video.mp4' + item_dir = data.testfile_attr(item, 'testdir') + mvc = MVCGui() + mvc.browse_for_files(item_dir, item) + mvc.start_conversions() + mvc.verify_conversions_finished() + mvc.clear_and_start_over() + assert mvc.verify_file_not_in_list(testfiles[0]) + assert mvc.verify_file_not_in_list(item) + + def tearDown(self): + self.mvc.mvc_quit() + shutil.rmtree(self.output_dir) + diff --git a/test/uitests.sikuli/test_conversions.py b/test/uitests.sikuli/test_conversions.py new file mode 100644 index 0000000..740b61a --- /dev/null +++ b/test/uitests.sikuli/test_conversions.py @@ -0,0 +1,196 @@ +#!/usr/bin/python + +import sys +import os +import tempfile +import shutil +import unittest +from mvcgui import MVCGui +import datafiles +import devices + +data = datafiles.TestData() + + +class Test_Conversions(unittest.TestCase): + """For any completed conversion + I want to be able to locate and play the file + And it should be formatted as I have specified + """ + + def setUp(self): + """ + Each tests assumes that I there are files in the list ready to be converted to some format. + + """ + self.mvc = MVCGui() + self.mvc.mvc_focus() + print "starting test: ", self.shortDescription() + datadir, testfiles = data.test_data(many=True) + self.mvc.browse_for_files(datadir, testfiles) + self.output_dir = tempfile.mkdtemp() + self.mvc.choose_save_location(self.output_dir) + + + def test_send_file_to_itunes(self): + """Scenario: Send to iTunes. + + Given I have "Send to iTunes" checked + When I convert the an apple format + Then the file is added to my iTunes library + """ + item = "mp4-0.mp4" + mvc = MVCGui() + mvc.choose_device_conversion("iPad") + mvc.choose_itunes() + mvc.start_conversions() + mvc.verify_completed(item, 30) + assert mvc.verify_itunes(item) + + + def test_verify_custom_output_directory(self): + """Scenario: File in specific output location. + + Given I have set the output directory to "directory" + When I convert a file + Then the output file is in the specified directory + """ + + custom_output_dir = os.path.join(os.getenv("HOME"),"Desktop") + item = "mp4-0.mp4" + mvc.mvcGui() + mvc.choose_device_conversion("KindleFire") + mvc.choose_save_location(custom_output_dir) + mvc.start_conversions() + mvc.verify_completed(item, 30) + assert mvc.verify_output_dir(self, item, custom_output_dir) + + def test_file_in_default_location(self): + """Scenario: File in default output location. + + Given I have set the output directory to "default" + When I convert a file + Then the output file is in the default directory + """ + + datadir, testfile = data.test_data() + item = testfile[0] + mvc.mvcGui() + mvc.choose_device_conversion("Galaxy Tab") + mvc.choose_save_location('default') + mvc.start_conversions() + mvc.verify_completed(item, 30) + assert mvc.verify_output_dir(self, item, datadir) + + def test_output_file_name_in_default_dir(self): + """Scenario: Output file name when saved in default (same) directory. + + When I convert a file + Then it is named with the file name (or even better item title) as the base + And the output container is the extension + """ + self.fail('I do not know the planned naming convention yet') + + def test_output_file_name_in_custom_dir(self): + """Scenario: Output file name when saved in default (same) directory. + + When I convert a file + Then it is named with the file name (or even better item title) as the base + And the output container is the extension + """ + self.fail('I do not know the planned naminig convention yet') + + def test_output_video_no_upsize(self): + datadir, testfile = data.test_data() + item = testfile[0] #mp4-0.mp4 is smaller than the Apple Universal Setting + mvc.mvcGui() + mvc.choose_device_conversion("Apple Universal") + mvc.choose_dont_upsize('on') + mvc.start_conversion() + assert mvc.verify_size(os.path.join(datadir, item), width, height) + + + """Scenario: Output file video size. + + When I convert a file to "format" + And Don't Upsize is selected + Then the output file dimensions are not changed if the input file is smaller than the device + """ + + ##This test is best covered more completely in unittests to verify that we resize according to device sizes + item = "mp4-0.mp4" #mp4-0.mp4 is smaller than the Apple Universal Setting + mvc.mvcGui() + mvc.choose_device_conversion("Apple Universal") + mvc.choose_dont_upsize('on') + mvc.start_conversion() + assert mvc.verify_size(os.path.join(self.output_dir, item), width, height) + + + + def test_output_video_upsize(self): + """Scenario: Output file video size. + + When I convert a file to "format" + And Don't Upsize is NOT selected + The the output file dimensions are changed to match the device spec. + """ + +##This test is best covered more completely in unittests to verify that we resize according to device sizes + + item = "mp4-0.mp4" #mp4-0.mp4 is smaller than the Apple Universal Setting + mvc.mvcGui() + mvc.choose_device_conversion("Apple Universal") + mvc.choose_dont_upsize('off') + mvc.start_conversion() + assert mvc.verify_size(os.path.join(self.output_dir, item), width, height) + + def test_completed_conversions_display(self): + """Scenario: File displays as completed. + + When I convert a file + Then the file displays as completed + """ + item = "mp4-0.mp4" + mvc.mvcGui() + mvc.choose_device_conversion("Xoom") + mvc.choose_save_location(custom_output_dir) + mvc.start_conversions() + assert mvc.verify_completed(item, 30) + + + def test_failed_conversion_display(self): + """Scenario: File fails conversion. + When I convert a "file" to "format" + And the file conversion fails + Then the file displays as failed. + """ + item = 'fake_video.mp4' + item_dir = data.testfile_attr(item, 'testdir') + mvc.mvcGui() + mvc.browse_for_files(item_dir, item) + mvc.choose_device_conversion("iPhone") + mvc.start_conversion() + assert mvc.verify_failed(item) + + + def test_ffmpeg_log_output_on_failure(self): + """Scenario: Show ffmpeg output. + + Given I convert a file + When I view the ffmpeg output + Then the ffmpeg output is displayed in a text window + """ + item = 'fake_video.mp4' + item_dir = data.testfile_attr(item, 'testdir') + mvc.mvcGui() + mvc.browse_for_files(item_dir, item) + mvc.choose_device_conversion("iPhone") + mvc.start_conversion() + mvc.verify_failed(item) + assert mvc.show_ffmpeg_output(item) + + + def tearDown(self): + shutil.rmtree(self.output_dir) + self.mvc_quit() + diff --git a/test/uitests.sikuli/test_other_conversions.py b/test/uitests.sikuli/test_other_conversions.py new file mode 100644 index 0000000..3299616 --- /dev/null +++ b/test/uitests.sikuli/test_other_conversions.py @@ -0,0 +1,39 @@ +#!/usr/bin/python + +import devices +from sikuli.Sikuli import * +import devices +import config +from mvcgui import MVCGui +import datafiles + +data = datafiles.TestData() + +def test_other_conversions(): + """Scenario: test other output conversion options. + + """ + device_list = devices.devices('Other') + for x in device_list: + yield convert_to_format, x + +def convert_to_format(device_output): + print device_output + expected_failures = ['fake_video.mp4'] + mvc = MVCGui() + mvc.mvc_focus() + datadir, testfiles = data.test_data(many=True, new=True) + mvc.browse_for_files(datadir, testfiles) + output_dir = tempfile.mkdtemp() + mvc.choose_save_location(output_dir) + mvc.choose_device_conversion("device_output") + mvc.start_conversions() + for item in testfiles: + if item in expected_failures: + mvc.verify_failed(item, 120) + else: + mvc.verify_completed(item, 120) + mvc.clear_finished_files(item) + mvc.clear_and_start_over() + + diff --git a/test/uitests.sikuli/test_output_settings.py b/test/uitests.sikuli/test_output_settings.py new file mode 100644 index 0000000..4c65cb0 --- /dev/null +++ b/test/uitests.sikuli/test_output_settings.py @@ -0,0 +1,74 @@ +#!/usr/bin/python + +import sys +import os +import tempfile +import shutil +import unittest +from mvcgui import MVCGui +import datafiles +import devices + +data = datafiles.TestData() + + +class Test_Custom_Settings(unittest.TestCase): + """Features: users can specify custom format, size and aspect ration. + + """ + def setUp(self): + """ + Each tests assumes that I there are files in the list ready to be converted to some format. + + """ + self.mvc = MVCGui() + self.mvc.mvc_focus() + print "starting test: ", self.shortDescription() + datadir, testfiles = data.test_data(many=True) + self.mvc.browse_for_files(datadir, testfiles) + self.output_dir = tempfile.mkdtemp() + self.mvc.choose_save_location(self.output_dir) + + def choose_custom_size(self): + """Scenario: Choose custom size. + + When I enter a custom size option + Then the conversion uses that setting.""" + mvc = MVCGui() + _, testfiles = data.test_data() + item = testfiles[0] + w = '360' + h = '180' + + mvc.choose_custom_size(self, 'on', width=w, height=h) + mvc.mvc.choose_device_conversion('WebM') + mvc.start_conversions() + assert mvc.verify_size(item, width=w, height=h) + + + def choose_aspect_ration(self): + """Scenario: Choose a device, then choose a custom aspect ratio. + + Given I choose a device option + When I set the "aspect ratio" + Then I'm not really sure what will happen + """ + self.fail('need to know how to test this') + + def choose_device_then_change_size(self): + """Scenario: Choose a device, then choose a custom size. + + When I choose a device + And I change size + Then the selected size is used in the conversion + """ + mvc = MVCGui() + _, testfiles = data.test_data() + item = testfiles[0] + w = '240' + h = '180' + mvc.choose_device_conversion('Galaxy Tab') + mvc.choose_custom_size(self, 'on', width=w, height=h) + mvc.start_conversions() + assert mvc.verify_size(item, width=w, height=h) + diff --git a/test/uitests.sikuli/test_remove_files.py b/test/uitests.sikuli/test_remove_files.py new file mode 100644 index 0000000..e55f3b6 --- /dev/null +++ b/test/uitests.sikuli/test_remove_files.py @@ -0,0 +1,96 @@ +import sys +import os +import tempfile +import shutil +import unittest +from mvcgui import MVCGui +import datafiles +import devices + +data = datafiles.TestData() + + + +class Test_Remove_Files(unittest.TestCase): + """Remove files from the conversion list + + """ + + def setUp(self): + """ + setup app for tests + + """ + mvc = MVCGui() + mvc.mvc_focus() + print "starting test: ", self.shortDescription() + datadir, testfiles = data.test_data() + mvc.browse_for_files(datadir, testfiles) + + def test_remove_a_file(self): + """Scenario: Remove a file from the list of files. + + Given I have files in the list + When I remove it from the list + Then it is not in the list + """ + + mvc.mvcGui() + _, testfiles = data.test_data(many=False) + item = testfiles[0] + assert mvc.remove_files(item) + + def test_remove_all_files(self): + """Scenario: Remove all the files from the list. + + Given I have files in the list + When I remove them from the list + Then the list of files is empty + """ + mvc.mvcGui() + _, testfiles = data.test_data() + assert mvc.remove_files(testfiles) + + def test_remove_from_list_with_in_progress_conversions(self): + """Scenario: Remove a file from the list of files with conversions in progress. + + Given I have files in the list + And I start conversion + When I remove it from the list + Then it is not in the list + """ + + item = 'slow_conversion.mkv' + item_dir = data.testfile_attr(item, 'testdir') + mvc.mvcGui() + + mvc.browse_for_files(item_dir, item) + mvc.choose_device_conversion("WebM") + mvc.start_conversion() + + _, origtestfiles = test_data() + mvc.remove_files(origtestfiles[1]) + assert mvc.verify_file_in_list(item) + assert mvc.verify_completed(item, 160) + + def test_remove_last_queued_file_with_in_progress_conversions(self): + """Scenario: Remove the last queued file from the list with conversions in progress. + + Given I have lots of files files in the list + And I start conversion + When I remove the queued file from the list + Then the in_progress conversions finished. + """ + item = 'slow_conversion.mkv' + item_dir = data.testfile_attr(item, 'testdir') + mvc.mvcGui() + + mvc.browse_for_files(item_dir, item) + mvc.choose_device_conversion("Theora") + mvc.start_conversion() + mvc.remove_queued_conversions() + assert mvc.verify_conversions_finished() + + + + diff --git a/test/uitests.sikuli/testdata/baby_block.m4v b/test/uitests.sikuli/testdata/baby_block.m4v Binary files differnew file mode 100644 index 0000000..d567b7a --- /dev/null +++ b/test/uitests.sikuli/testdata/baby_block.m4v diff --git a/test/uitests.sikuli/testdata/fake_video.mp4 b/test/uitests.sikuli/testdata/fake_video.mp4 Binary files differnew file mode 100644 index 0000000..68bf481 --- /dev/null +++ b/test/uitests.sikuli/testdata/fake_video.mp4 diff --git a/test/uitests.sikuli/testdata/story_stuff.mov b/test/uitests.sikuli/testdata/story_stuff.mov Binary files differnew file mode 100644 index 0000000..23fae74 --- /dev/null +++ b/test/uitests.sikuli/testdata/story_stuff.mov diff --git a/test/uitests.sikuli/uitests.py b/test/uitests.sikuli/uitests.py new file mode 100644 index 0000000..f58a1ed --- /dev/null +++ b/test/uitests.sikuli/uitests.py @@ -0,0 +1,13 @@ +import os +import sys +sys.path.append(os.getenv('PYTHON_PKGS')) +import nose +import nose.config + + +myconfig = os.path.join(os.getcwd(), 'tests.sikuli', 'nose.cfg') +nose.config.config_files = [myconfig] +c = nose.config.Config() +c.configure() +nose.run() + |