././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.814081 orange_canvas_core-0.2.5/0000755000175100002000000000000014730024333014723 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/LICENSE.txt0000644000175100002000000010451314730024325016553 0ustar00runnerdocker GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/MANIFEST.in0000644000175100002000000000013714730024325016463 0ustar00runnerdockerinclude LICENSE.txt README.rst recursive-include docs *.* recursive-include i18n *.jaml *.yaml ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.814081 orange_canvas_core-0.2.5/PKG-INFO0000644000175100002000000000410614730024333016021 0ustar00runnerdockerMetadata-Version: 2.1 Name: orange-canvas-core Version: 0.2.5 Summary: Core component of Orange Canvas Home-page: http://orange.biolab.si/ Author: Bioinformatics Laboratory, FRI UL Author-email: contact@orange.biolab.si License: GPLv3 Project-URL: Bug Reports, https://github.com/biolab/orange-canvas-core/issues Project-URL: Source, https://github.com/biolab/orange-canvas-core/ Project-URL: Documentation, https://orange-canvas-core.readthedocs.io/en/latest/ Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-File: LICENSE.txt Requires-Dist: AnyQt>=0.2.0 Requires-Dist: docutils Requires-Dist: commonmark>=0.8.1 Requires-Dist: requests Requires-Dist: requests-cache Requires-Dist: pip>=18.0 Requires-Dist: dictdiffer Requires-Dist: qasync>=0.10.0 Requires-Dist: importlib_metadata>=4.6; python_version < "3.10" Requires-Dist: importlib_resources; python_version < "3.9" Requires-Dist: typing_extensions Requires-Dist: packaging Requires-Dist: numpy Provides-Extra: docbuild Requires-Dist: sphinx; extra == "docbuild" Requires-Dist: sphinx-rtd-theme; extra == "docbuild" Orange Canvas Core ================== .. image:: https://github.com/biolab/orange-canvas-core/workflows/Run%20tests/badge.svg :target: https://github.com/biolab/orange-canvas-core/actions?query=workflow%3A%22Run+tests%22 :alt: Github Actions CI Build Status .. image:: https://readthedocs.org/projects/orange-canvas-core/badge/?version=latest :target: https://orange-canvas-core.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status Orange Canvas Core is a framework for building graphical user interfaces for editing workflows. It is a component used to build the Orange Canvas (http://orange.biolab.si) data-mining application (for which it was developed in the first place). Installation ------------ Orange Canvas Core is pip installable (https://pip.pypa.io/), simply run:: pip install orange-canvas-core Or use the:: pip install ./ to install from the sources. Documentation ------------- Some incomplete documentation is available at https://orange-canvas-core.readthedocs.io ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/README.rst0000644000175100002000000000200314730024325016406 0ustar00runnerdockerOrange Canvas Core ================== .. image:: https://github.com/biolab/orange-canvas-core/workflows/Run%20tests/badge.svg :target: https://github.com/biolab/orange-canvas-core/actions?query=workflow%3A%22Run+tests%22 :alt: Github Actions CI Build Status .. image:: https://readthedocs.org/projects/orange-canvas-core/badge/?version=latest :target: https://orange-canvas-core.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status Orange Canvas Core is a framework for building graphical user interfaces for editing workflows. It is a component used to build the Orange Canvas (http://orange.biolab.si) data-mining application (for which it was developed in the first place). Installation ------------ Orange Canvas Core is pip installable (https://pip.pypa.io/), simply run:: pip install orange-canvas-core Or use the:: pip install ./ to install from the sources. Documentation ------------- Some incomplete documentation is available at https://orange-canvas-core.readthedocs.io ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.766081 orange_canvas_core-0.2.5/docs/0000755000175100002000000000000014730024333015653 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/make.bat0000644000175100002000000001615114730024325017265 0ustar00runnerdocker@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source set I18NSPHINXOPTS=%SPHINXOPTS% source if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled echo. coverage to run coverage check of the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) REM Check if sphinx-build is available and fallback to Python version if any %SPHINXBUILD% 2> nul if errorlevel 9009 goto sphinx_python goto sphinx_ok :sphinx_python set SPHINXBUILD=python -m sphinx.__init__ %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) :sphinx_ok if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\OrangeCanvasCore.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\OrangeCanvasCore.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "coverage" ( %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage if errorlevel 1 exit /b 1 echo. echo.Testing of coverage in the sources finished, look at the ^ results in %BUILDDIR%/coverage/python.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) :end ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/requirements-rtd.txt0000644000175100002000000000052514730024325021731 0ustar00runnerdocker--only-binary PyQt5,numpy setuptools sphinx~=7.4.7 sphinx-rtd-theme PyQt5 AnyQt # sphinx pins docutils version, but the installation in the RTD worker/config # overrides it because docutils is also in our dependencies. # https://docs.readthedocs.io/en/stable/faq.html#i-need-to-install-a-package-in-a-environment-with-pinned-versions -e . ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.766081 orange_canvas_core-0.2.5/docs/source/0000755000175100002000000000000014730024333017153 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/conf.py0000644000175100002000000002305214730024325020455 0ustar00runnerdocker# -*- coding: utf-8 -*- # # Orange Canvas Core documentation build configuration file, created by # sphinx-quickstart on Thu Jun 4 12:15:21 2015. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os import shlex import importlib_metadata dist = importlib_metadata.distribution("orange-canvas-core") # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.coverage', 'sphinx.ext.napoleon', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] source_suffix = {'.rst': 'restructuredtext'} # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Orange Canvas Core' copyright = u'2019, Bioinformatics Laboratory, FRI UL' author = u'Bioinformatics Laboratory, FRI UL' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = dist.version # The full version, including alpha/beta/rc tags. release = dist.version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = [] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { 'collapse_navigation': True, } # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". #html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' #html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value #html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. #html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'OrangeCanvasCoredoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', # Latex figure (float) alignment #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'OrangeCanvasCore.tex', u'Orange Canvas Core Documentation', u'Bioinformatics Laboratory, FRI UL', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'orangecanvascore', u'Orange Canvas Core Documentation', [author], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'OrangeCanvasCore', u'Orange Canvas Core Documentation', author, 'OrangeCanvasCore', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/index.rst0000644000175100002000000000077214730024325021023 0ustar00runnerdocker.. Orange Canvas Core documentation master file, created by sphinx-quickstart on Thu Jun 4 12:15:21 2015. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to Orange Canvas Core's documentation! ============================================== Contents: .. toctree:: :maxdepth: 2 orangecanvas/overview orangecanvas/index Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.770081 orange_canvas_core-0.2.5/docs/source/orangecanvas/0000755000175100002000000000000014730024333021622 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/application.canvasmain.rst0000644000175100002000000000113314730024325026775 0ustar00runnerdocker=================================== Canvas Main Window (``canvasmain``) =================================== .. currentmodule:: orangecanvas.application.canvasmain .. autoclass:: orangecanvas.application.canvasmain.CanvasMainWindow :member-order: bysource :show-inheritance: .. automethod:: set_widget_registry(widget_registry: WidgetRegistry) .. method:: current_document() -> SchemeEditWidget Return the current displayed editor (:class:`.SchemeEditWidget`) .. automethod:: create_new_window() -> CanvasMainWindow .. automethod:: new_workflow_window() -> CanvasMainWindow././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/application.rst0000644000175100002000000000035314730024325024661 0ustar00runnerdocker.. application: ############################# Application (``application``) ############################# .. automodule:: orangecanvas.application .. toctree:: :maxdepth: 1 application.welcomedialog application.canvasmain ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/application.welcomedialog.rst0000644000175100002000000000117014730024325027471 0ustar00runnerdocker================================== Welcome Dialog (``welcomedialog``) ================================== .. currentmodule:: orangecanvas.application.welcomedialog .. autoclass:: orangecanvas.application.welcomedialog.WelcomeDialog :member-order: bysource :show-inheritance: .. method:: triggered(QAction) Signal emitted when an action is triggered by the user .. automethod:: setShowAtStartup(state: bool) .. automethod:: showAtStartup() -> bool .. automethod:: setFeedbackUrl(url: str) .. automethod:: addRow(actions: List[QAction]) .. automethod:: buttonAt(i: int, j: int) -> QAbstractButton././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/canvas.items.annotationitem.rst0000644000175100002000000000073614730024325030006 0ustar00runnerdocker.. canvas-annotation-item: ===================================== Annotation Items (``annotationitem``) ===================================== .. automodule:: orangecanvas.canvas.items.annotationitem .. autoclass:: Annotation :members: :member-order: bysource :show-inheritance: .. autoclass:: TextAnnotation :members: :member-order: bysource :show-inheritance: .. autoclass:: ArrowAnnotation :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/canvas.items.linkitem.rst0000644000175100002000000000035514730024325026566 0ustar00runnerdocker.. canvas-link-item: ======================== Link Item (``linkitem``) ======================== .. automodule:: orangecanvas.canvas.items.linkitem .. autoclass:: LinkItem :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/canvas.items.nodeitem.rst0000644000175100002000000000147314730024325026560 0ustar00runnerdocker.. canvas-node-item: ======================== Node Item (``nodeitem``) ======================== .. automodule:: orangecanvas.canvas.items.nodeitem .. autoclass:: NodeItem :members: :exclude-members: from_node, from_node_meta, setupGraphics, setProgressMessage, positionChanged, anchorGeometryChanged, activated, hovered :member-order: bysource :show-inheritance: .. autoattribute:: positionChanged() .. autoattribute:: anchorGeometryChanged() .. autoattribute:: activated() .. autoclass:: AnchorPoint :members: :exclude-members: scenePositionChanged, anchorDirectionChanged :member-order: bysource :show-inheritance: .. autoattribute:: scenePositionChanged(QPointF) .. autoattribute:: anchorDirectionChanged(QPointF) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/canvas.rst0000644000175100002000000000032314730024325023626 0ustar00runnerdocker=================== Canvas (``canvas``) =================== .. automodule:: orangecanvas.canvas .. toctree:: canvas.scene canvas.items.nodeitem canvas.items.linkitem canvas.items.annotationitem ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/canvas.scene.rst0000644000175100002000000000220114730024325024717 0ustar00runnerdocker.. canvas-scene: ======================== Canvas Scene (``scene``) ======================== .. automodule:: orangecanvas.canvas.scene .. autoclass:: CanvasScene :members: :exclude-members: node_item_added, node_item_removed, link_item_added, link_item_removed, annotation_added, annotation_removed, node_item_position_changed, node_item_double_clicked, node_item_activated, node_item_hovered, link_item_hovered :member-order: bysource :show-inheritance: .. autoattribute:: node_item_added(NodeItem) .. autoattribute:: node_item_removed(NodeItem) .. autoattribute:: link_item_added(LinkItem) .. autoattribute:: link_item_removed(LinkItem) .. autoattribute:: annotation_added(Annotation) .. autoattribute:: annotation_removed(Annotation) .. autoattribute:: node_item_position_changed(NodeItem, QPointF) .. autoattribute:: node_item_double_clicked(NodeItem) .. autoattribute:: node_item_activated(NodeItem) .. autoattribute:: node_item_hovered(NodeItem) .. autoattribute:: link_item_hovered(LinkItem) .. autofunction:: grab_svg ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/document.interactions.rst0000644000175100002000000000164414730024325026701 0ustar00runnerdocker================================== Interactions (:mod:`interactions`) ================================== .. automodule:: orangecanvas.document.interactions .. autoclass:: UserInteraction :members: :exclude-members: started, finished, ended, canceled :member-order: bysource :show-inheritance: .. automethod:: started() .. automethod:: finished() .. automethod:: ended() .. automethod:: canceled() .. autoclass:: DropAction :members: :member-order: bysource :show-inheritance: .. autoclass:: DropHandler :members: :member-order: bysource :show-inheritance: .. autoclass:: DropHandlerAction :members: :member-order: bysource :show-inheritance: .. autoclass:: NodeFromMimeDataDropHandler :members: :member-order: bysource :show-inheritance: .. autoclass:: PluginDropHandler :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/document.quickmenu.rst0000644000175100002000000000075314730024325026200 0ustar00runnerdocker============================= Quick Menu (:mod:`quickmenu`) ============================= .. automodule:: orangecanvas.document.quickmenu .. autoclass:: QuickMenu :members: :exclude-members: triggered, hovered :member-order: bysource :show-inheritance: .. automethod:: triggered(QAction) .. automethod:: hovered(QAction) .. autoclass:: MenuPage :members: :exclude-members: title_, icon_ :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/document.rst0000644000175100002000000000030514730024325024171 0ustar00runnerdocker======================= Document (``document``) ======================= .. automodule:: orangecanvas.document .. toctree:: document.schemeedit document.quickmenu document.interactions ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/document.schemeedit.rst0000644000175100002000000000133714730024325026310 0ustar00runnerdocker================================= Scheme Editor (:mod:`schemeedit`) ================================= .. automodule:: orangecanvas.document.schemeedit .. autoclass:: SchemeEditWidget :members: :exclude-members: undoAvailable, redoAvailable, modificationChanged, undoCommandAdded, selectionChanged, titleChanged, pathChanged, onNewLink :member-order: bysource :show-inheritance: .. autoattribute:: undoAvailable(bool) .. autoattribute:: redoAvailable(bool) .. autoattribute:: modificationChanged(bool) .. autoattribute:: undoCommandAdded() .. autoattribute:: selectionChanged() .. autoattribute:: titleChanged() .. autoattribute:: pathChanged() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.dock.rst0000644000175100002000000000052714730024325024064 0ustar00runnerdocker================================== Collapsible Dock Widget (``dock``) ================================== .. automodule:: orangecanvas.gui.dock .. autoclass:: orangecanvas.gui.dock.CollapsibleDockWidget :members: :member-order: bysource :show-inheritance: :exclude-members: setWidget, animationEnabled, setAnimationEnabled ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.dropshadow.rst0000644000175100002000000000042014730024325025306 0ustar00runnerdocker================================== Drop Shadow Frame (``dropshadow``) ================================== .. automodule:: orangecanvas.gui.dropshadow .. autoclass:: orangecanvas.gui.dropshadow.DropShadowFrame :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.framelesswindow.rst0000644000175100002000000000047314730024325026355 0ustar00runnerdocker============================================= Frameless Window Widget (``framelesswindow``) ============================================= .. automodule:: orangecanvas.gui.framelesswindow .. autoclass:: orangecanvas.gui.framelesswindow.FramelessWindow :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.lineedit.rst0000644000175100002000000000074014730024325024736 0ustar00runnerdocker=============================== Line Edit Widget (``lineedit``) =============================== .. automodule:: orangecanvas.gui.lineedit .. autoclass:: orangecanvas.gui.lineedit.LineEdit :members: :member-order: bysource :exclude-members: triggered, LeftPosition, RightPosition :show-inheritance: .. autoattribute:: LeftPosition Left position flag .. autoattribute:: RightPosition Right position flag .. autoattribute:: triggered(QAction) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.quickhelp.rst0000644000175100002000000000036014730024325025124 0ustar00runnerdocker========================== Quick Help (``quickhelp``) ========================== .. automodule:: orangecanvas.gui.quickhelp .. autoclass:: orangecanvas.gui.quickhelp.QuickHelp :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.rst0000644000175100002000000000046714730024325023150 0ustar00runnerdocker.. gui: ###################### GUI elements (``gui``) ###################### .. automodule:: orangecanvas.gui .. toctree:: :maxdepth: 1 gui.dock gui.dropshadow gui.framelesswindow gui.lineedit gui.quickhelp gui.splashscreen gui.toolbar gui.toolbox gui.toolgrid gui.tooltree ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.splashscreen.rst0000644000175100002000000000041314730024325025630 0ustar00runnerdocker================================ Splash Screen (``splashscreen``) ================================ .. automodule:: orangecanvas.gui.splashscreen .. autoclass:: orangecanvas.gui.splashscreen.SplashScreen :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.stackedwidget.rst0000644000175100002000000000122614730024325025763 0ustar00runnerdocker:orphan: ================================== Stacked Widget (``stackedwidget``) ================================== .. automodule:: orangecanvas.gui.stackedwidget .. autoclass:: orangecanvas.gui.stackedwidget.AnimatedStackedWidget :members: :member-order: bysource :show-inheritance: .. autoattribute:: currentChanged(int) Current widget has changed .. autoattribute:: transitionStarted() Transition animation has started .. autoattribute:: transitionFinished() Transition animation has finished .. autoclass:: orangecanvas.gui.stackedwidget.StackLayout :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.toolbar.rst0000644000175100002000000000035314730024325024603 0ustar00runnerdocker====================== Tool Bar (``toolbar``) ====================== .. automodule:: orangecanvas.gui.toolbar .. autoclass:: orangecanvas.gui.toolbar.DynamicResizeToolBar :members: :member-order: bysource :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.toolbox.rst0000644000175100002000000000054614730024325024633 0ustar00runnerdocker============================= Tool Box Widget (``toolbox``) ============================= .. automodule:: orangecanvas.gui.toolbox .. autoclass:: orangecanvas.gui.toolbox.ToolBox :members: :member-order: bysource :show-inheritance: .. autoattribute:: tabToggled(index: int, state: bool) Signal emitted when a tab at `index` is toggled. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.toolgrid.rst0000644000175100002000000000077614730024325024775 0ustar00runnerdocker=============================== Tool Grid Widget (``toolgrid``) =============================== .. automodule:: orangecanvas.gui.toolgrid .. autoclass:: orangecanvas.gui.toolgrid.ToolGrid :members: :member-order: bysource :exclude-members: actionTriggered, actionHovered :show-inheritance: .. autoattribute:: actionTriggered(QAction) Signal emitted when an action is triggered. .. autoattribute:: actionHovered(QAction) Signal emitted when an action is hovered. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/gui.tooltree.rst0000644000175100002000000000071614730024325025001 0ustar00runnerdocker=============================== Tool Tree Widget (``tooltree``) =============================== .. automodule:: orangecanvas.gui.tooltree .. autoclass:: orangecanvas.gui.tooltree.ToolTree :members: :member-order: bysource :show-inheritance: .. autoattribute:: triggered(QAction) Signal emitted when an action in the widget is triggered. .. autoattribute:: hovered(QAction) Signal emitted when an action in the widget is hovered. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/index.rst0000644000175100002000000000027214730024325023465 0ustar00runnerdocker####################### Orange Canvas Reference ####################### The Orange Canvas API reference .. toctree:: gui scheme registry canvas document application ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/overview.rst0000644000175100002000000000405614730024325024230 0ustar00runnerdocker.. _Overview: Overview ######## .. currentmodule:: orangecanvas Orange Canvas application is build around the a workflow model (scheme), which is implemented in the :mod:`~orangecanvas.scheme` package. Briefly speaking a workflow is a simple graph structure(a Directed Acyclic Graph - DAG). The nodes in this graph represent some action/task to be computed. A node in this graph has a set of inputs and outputs on which it receives and sends objects. The set of available node types for a workflow are kept in a (:class:`~orangecanvas.registry.WidgetRegistry`). :class:`~orangecanvas.registry.WidgetDiscovery` can be used (but not required) to populate the registry. Common reusable gui elements used for building the user interface reside in the :mod:`~orangecanvas.gui` package. Workflow Model ************** The workflow model is implemented by :class:`~scheme.scheme.Scheme`. It is composed by a set of node (:class:`~scheme.node.SchemeNode`) instances and links (:class:`~scheme.link.SchemeLink`) between them. Every node has a corresponding :class:`~registry.WidgetDescription` defining its inputs and outputs (restricting the node's connectivity). In addition, it can also contain workflow annotations. These are only used when displaying the workflow in a GUI. Widget Description ------------------ * :class:`~registry.WidgetDescription` * :class:`~registry.CategoryDescription` Workflow Execution ------------------ The runtime execution (propagation of node's outputs to dependent node inputs) is handled by the signal manager. * :class:`~scheme.signalmanager.SignalManager` Workflow Node GUI ----------------- A WidgetManager is responsible for managing GUI corresponsing to individual nodes in the workflow. * :class:`~scheme.widgetmanager.WidgetManager` Workflow View ************* * The workflow view (:class:`~canvas.scene.CanvasScene`) * The workflow editor (:class:`~document.schemeedit.SchemeEditWidget`) Application *********** Joining everything together, the final application (main window, ...) is implemented in :mod:`orangecanvas.application`. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/registry.rst0000644000175100002000000000137314730024325024231 0ustar00runnerdocker####################### Registry (``registry``) ####################### .. automodule:: orangecanvas.registry :member-order: bysource WidgetRegistry -------------- .. autoclass:: WidgetRegistry :members: :member-order: bysource WidgetDescription ----------------- .. autoclass:: WidgetDescription :members: :member-order: bysource CategoryDescription ------------------- .. autoclass:: CategoryDescription :members: :member-order: bysource InputSignal ----------- .. autoclass:: InputSignal :members: :member-order: bysource OutputSignal ------------ .. autoclass:: OutputSignal :members: :member-order: bysource WidgetDiscovery --------------- .. autoclass:: WidgetDiscovery :members: :member-order: bysource ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.annotation.rst0000644000175100002000000000126014730024325025771 0ustar00runnerdocker.. schemeannotation: ==================================== Scheme Annotations (``annotations``) ==================================== .. automodule:: orangecanvas.scheme.annotations .. autoclass:: BaseSchemeAnnotation :members: :member-order: bysource :show-inheritance: .. autoattribute:: geometry_changed() Signal emitted when the geometry of the annotation changes .. autoclass:: SchemeArrowAnnotation :members: :member-order: bysource :show-inheritance: .. autoclass:: SchemeTextAnnotation :members: :member-order: bysource :show-inheritance: .. autoattribute:: text_changed(str) Signal emitted when the annotation text changes. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.events.rst0000644000175100002000000000435514730024325025133 0ustar00runnerdocker.. workflow-events: ============================ Workflow Events (``events``) ============================ .. py:currentmodule:: orangecanvas.scheme.events .. autoclass:: orangecanvas.scheme.events.WorkflowEvent :show-inheritance: .. autoattribute:: NodeAdded :annotation: = QEvent.Type(...) .. autoattribute:: NodeRemoved :annotation: = QEvent.Type(...) .. autoattribute:: LinkAdded :annotation: = QEvent.Type(...) .. autoattribute:: LinkRemoved :annotation: = QEvent.Type(...) .. autoattribute:: InputLinkAdded :annotation: = QEvent.Type(...) .. autoattribute:: OutputLinkAdded :annotation: = QEvent.Type(...) .. autoattribute:: InputLinkRemoved :annotation: = QEvent.Type(...) .. autoattribute:: OutputLinkRemoved :annotation: = QEvent.Type(...) .. autoattribute:: NodeStateChange :annotation: = QEvent.Type(...) .. autoattribute:: LinkStateChange :annotation: = QEvent.Type(...) .. autoattribute:: InputLinkStateChange :annotation: = QEvent.Type(...) .. autoattribute:: OutputLinkStateChange :annotation: = QEvent.Type(...) .. autoattribute:: NodeActivateRequest :annotation: = QEvent.Type(...) .. autoattribute:: WorkflowEnvironmentChange :annotation: = QEvent.Type(...) .. autoattribute:: AnnotationAdded :annotation: = QEvent.Type(...) .. autoattribute:: AnnotationRemoved :annotation: = QEvent.Type(...) .. autoattribute:: AnnotationChange :annotation: = QEvent.Type(...) .. autoattribute:: ActivateParentRequest :annotation: = QEvent.Type(...) .. autoclass:: orangecanvas.scheme.events.NodeEvent :show-inheritance: .. automethod:: node() -> SchemeNode .. automethod:: pos() -> int .. autoclass:: orangecanvas.scheme.events.LinkEvent :show-inheritance: .. automethod:: link() -> SchemeLink .. automethod:: pos() -> int .. autoclass:: orangecanvas.scheme.events.AnnotationEvent :show-inheritance: .. automethod:: annotation() -> BaseSchemeAnnotation .. automethod:: pos() -> int .. autoclass:: orangecanvas.scheme.events.WorkflowEnvChanged :show-inheritance: .. automethod:: name() -> str .. automethod:: oldValue() -> Any .. automethod:: newValue() -> Any ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.link.rst0000644000175100002000000000061314730024325024555 0ustar00runnerdocker.. schemelink: ====================== Scheme Link (``link``) ====================== .. automodule:: orangecanvas.scheme.link .. autoclass:: SchemeLink :members: :exclude-members: enabled_changed, dynamic_enabled_changed :member-order: bysource :show-inheritance: .. autoattribute:: enabled_changed(enabled) .. autoattribute:: dynamic_enabled_changed(enabled) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.node.rst0000644000175100002000000000103014730024325024537 0ustar00runnerdocker.. scheme-node: ====================== Scheme Node (``node``) ====================== .. automodule:: orangecanvas.scheme.node .. autoclass:: SchemeNode :members: :exclude-members: title_changed, position_changed, progress_changed, processing_state_changed :member-order: bysource :show-inheritance: .. autoattribute:: title_changed(title) .. autoattribute:: position_changed((x, y)) .. autoattribute:: progress_changed(progress) .. autoattribute:: processing_state_changed(state) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.readwrite.rst0000644000175100002000000000030114730024325025600 0ustar00runnerdocker.. schemereadwrite: ==================================== Scheme Serialization (``readwrite``) ==================================== .. automodule:: orangecanvas.scheme.readwrite :members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.rst0000644000175100002000000000042714730024325023624 0ustar00runnerdocker.. scheme: ################### Scheme (``scheme``) ################### .. automodule:: orangecanvas.scheme .. toctree:: scheme.scheme scheme.node scheme.link scheme.annotation scheme.readwrite scheme.widgetmanager scheme.signalmanager scheme.events ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.scheme.rst0000644000175100002000000000266014730024325025070 0ustar00runnerdocker.. scheme: =================== Scheme (``scheme``) =================== .. automodule:: orangecanvas.scheme.scheme .. autoclass:: Scheme :members: :exclude-members: runtime_env_changed :member-order: bysource :show-inheritance: .. autoattribute:: title_changed(title) Signal emitted when the title of scheme changes. .. autoattribute:: description_changed(description) Signal emitted when the description of scheme changes. .. autoattribute:: node_added(node) Signal emitted when a `node` is added to the scheme. .. autoattribute:: node_removed(node) Signal emitted when a `node` is removed from the scheme. .. autoattribute:: link_added(link) Signal emitted when a `link` is added to the scheme. .. autoattribute:: link_removed(link) Signal emitted when a `link` is removed from the scheme. .. autoattribute:: annotation_added(annotation) Signal emitted when a `annotation` is added to the scheme. .. autoattribute:: annotation_removed(annotation) Signal emitted when a `annotation` is removed from the scheme. .. autoattribute:: runtime_env_changed(key: str, newvalue: Optional[str], oldvalue: Optional[str]) .. autoclass:: SchemeCycleError :show-inheritance: .. autoclass:: IncompatibleChannelTypeError :show-inheritance: .. autoclass:: SinkChannelError :show-inheritance: .. autoclass:: DuplicatedLinkError :show-inheritance: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.signalmanager.rst0000644000175100002000000000111014730024325026421 0ustar00runnerdocker.. signalmanager: .. automodule:: orangecanvas.scheme.signalmanager .. autoclass:: SignalManager :members: :member-order: bysource :exclude-members: stateChanged, updatesPending, processingStarted, processingFinished, runtimeStateChanged :show-inheritance: .. autoattribute:: stateChanged(State) .. autoattribute:: updatesPending() .. autoattribute:: processingStarted(SchemeNode) .. autoattribute:: processingFinished(SchemeNode) .. autoattribute:: runtimeStateChanged(RuntimeState) .. autoclass:: Signal :members: ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/docs/source/orangecanvas/scheme.widgetmanager.rst0000644000175100002000000000115214730024325026435 0ustar00runnerdocker.. widgetmanager: ================================= WidgetManager (``widgetmanager``) ================================= .. automodule:: orangecanvas.scheme.widgetmanager .. autoclass:: WidgetManager :members: :exclude-members: widget_for_node_added, widget_for_node_removed :member-order: bysource :show-inheritance: .. autoattribute:: widget_for_node_added(SchemeNode, QWidget) Signal emitted when a QWidget was created and added by the manager. .. autoattribute:: widget_for_node_removed(SchemeNode, QWidget) Signal emitted when a QWidget was removed and will be deleted. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.774081 orange_canvas_core-0.2.5/i18n/0000755000175100002000000000000014730024333015502 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.774081 orange_canvas_core-0.2.5/i18n/si/0000755000175100002000000000000014730024333016115 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/i18n/si/msgs.jaml0000644000175100002000000036472314730024325017753 0ustar00runnerdocker__main__.py: __main__: false config.py: T: false 0.0: false orangecanvas.widgets: false orangecanvas.addon: false keywords: false orange: false add-on: false orangecanvas.examples: false def `standard_location`: Use QStandardPaths.writableLocation: false class `Config`: def `core_packages`: orange-canvas-core >= 0.1a, < 0.2a: false Bug Report: Poročila o napakah Quick Start: Hiter začetek Documentation: Uporabniška navodila Screencasts: Zaslonski posnetki Feedback: Povratne informacije class `Default`: biolab.si: false Orange Canvas Core: false def `application_icon`: icons/orange-canvas.svg: false def `splash_screen`: icons/orange-canvas-core-splash.svg: false svg: false .: false '#231F20': false def `core_packages`: orange-canvas-core >= 0.0, < 0.1a: false def `init`: Activating configuration for {}: false startup/show-splash-screen: false Show splash screen on startup: false startup/show-welcome-screen: false Show Welcome screen on startup: false startup/load-crashed-workflows: false Load crashed scratch workflows on startup: false application/language: false English: true Application language: Jezik application/last-used-language: false If different from application/language, widget discovery is forced: false stylesheet: false QSS stylesheet to use: false schemeinfo/show-at-new-scheme: false Show Workflow Properties when creating a new Workflow: false mainwindow/scheme-margins-enabled: false Show margins around the workflow view: false mainwindow/show-scheme-shadow: false Show shadow around the workflow view: false mainwindow/toolbox-dock-exclusive: false Should the toolbox show only one expanded category at the time: false mainwindow/toolbox-dock-floatable: false Is the canvas toolbox floatable (detachable from the main window): false mainwindow/toolbox-dock-movable: false Is the canvas toolbox movable (between left and right edge): false mainwindow/toolbox-dock-use-popover-menu: false 'Use a popover menu to select a widget when clicking on a category ': false button: false mainwindow/widgets-float-on-top: false Float widgets on top of other windows: false mainwindow/number-of-recent-schemes: false Number of recent workflows to keep in history: false schemeedit/show-channel-names: false Show channel names: false schemeedit/show-link-state: false Show link state hints.: false schemeedit/enable-node-animations: false Enable node animations.: false schemeedit/freeze-on-load: false Freeze signal propagation when loading a workflow.: false quickmenu/trigger-on-double-click: false Show quick menu on double click.: false quickmenu/trigger-on-right-click: false Show quick menu on right click.: false quickmenu/trigger-on-space-key: false Show quick menu on space key press.: false quickmenu/trigger-on-any-key: false quickmenu/show-categories: false Show categories in quick menu.: false logging/level: false Logging level: false logging/show-on-error: false Show log window on error: false logging/dockable: false Allow log window to be docked: false help/open-in-external-browser: false Open help in an external browser: false add-ons/allow-conda: false Install add-ons with conda: false add-ons/pip-install-arguments: false Arguments to pass to "pip install" when installing add-ons.: false network/http-proxy: false HTTP proxy.: false network/https-proxy: false HTTPS proxy.: false def `log_dir`: darwin: false ~/Library/Logs: false def `widget_settings_dir`: "'widget_settings_dir' is deprecated.": false widgets: false def `open_config`: open_config was never used and will be removed in the future: false def `save_config`: save_config was never used and will be removed in the future: false main.py: class `Main`: def `parse_arguments`: -style: false def `splash_screen`: startup/show-splash-screen: false Helvetica: false '#FFD39F': false def `run_discovery`: widget-registry.pck: false rb: false wb: false def `run`: 'Loading paths from argv: %s': false ' ,': false def `record_path`: 'Path from FileOpen event: %s': false def `open_request`: pydevd.py: false run_profiler.py: false Restarting via exit code 96.: false def `setup_logging`: '%(asctime)s:%(levelname)s:%(name)s: %(message)s': false -log-stream: false canvas.log: false w: false def `setup_main_window`: darwin: false def `main_window_stylesheet`: orange.qss: false Switching default stylesheet to darkorange: false darkorange.qss: false Adding search path %r for prefix, %r: false def `show_welcome_screen`: startup/show-welcome-screen: false def `setup_sys_redirections`: -stdout: false -stderr: false def `fix_win_pythonw_std_stream`: win32: false pythonw.exe: false w: false utf-8: false ignore: false def `fix_set_proxy_env`: fix_set_proxy_env is deprecated: false http: false https: false network/: false -proxy: false _proxy: false def `fix_macos_nswindow_tabbing`: "'{__name__}.fix_macos_nswindow_tabbing()' is deprecated. Use ": false "'orangecanvas.gui.utils.macos_set_nswindow_tabbing()' instead": false def `breeze_dark`: {__name__}'.breeze_dark()' has been moved to styles package.: false def `make_file_handler`: w: false utf-8: false def `arg_parser`: def `log_level`: 0: false 1: false 2: false 3: false 4: false 5: false Invalid log level {!r}: false 'usage: %(prog)s [options] [workflow_file]': false --no-discovery: false store_true: false Don't run widget discovery (use full cache instead): false --force-discovery: false Force full widget discovery (invalidate cache): false --no-welcome: false Don't show welcome dialog.: false --no-splash: false Don't show splash screen.: false -l: false --log-level: false Logging level (0, 1, 2, 3, 4): false --stylesheet: false Application level CSS style sheet to use: false --config: false Configuration namespace: false Deprecated: false --qt: false 'Additional arguments for QApplication.\nDeprecated. ': false List all arguments as normally to pass it to QApplication.: false --style: false 'QStyle to use (deprecated: use -style)': false __main__: false resources.py: def `package`: .: false def `search_paths_from_description`: search_paths: false class `resource_loader`: def `split_prefix`: :: false def `is_valid_prefixed`: :: false def `open`: rb: false Cannot find %r: false class `icon_loader`: icons/default-widget.svg: false def `icon_glob`: _*: false def `is_icon_glob`: _*: false def `get`: .svg: false current-color-scheme: false def `load_styled_svg_icon`: icons/{name}: false application/aboutdialog.py: '\

{name}

Version: {version}

': '\

{name}

Različica: {version}

' class `AboutDialog`: def `__init__`: darwin: false application/addons.py: def `description_rich_text`: Description: false Summary: false Description-Content-Type: false text/x-rst: false text/plain: false class `ActionItem`: def `data`: Update: Posodobi Install: Namesti Uninstall: Odstrani class `PluginsModel`: def `__init__`: Name: false Version: false Action: false def `createRow`: Summary: false <: false ->: false {} {} {}: false class `AddonManagerDialog`: def `__init__`: top-hbox-layout: false filter-edit: false Filter...: true Add more...: Dodaj druge... Add an add-on not listed below: Namesti dodatke, ki niso našteti spodaj add-ons-view: false description-text-area: false def `start`: Retrieving package list: Prejemam seznam dodatkov def `__on_query_done`: def `network_warning`: Error fetching package list: Napaka pri prejemu seznama dodatkov There's an issue with the internet connection.: Težava z mrežno povezavo. Error: Napaka Please check you are connected to the internet.\n\n: Preverite delovanje omrežne povezave.\n\n 'If you are behind a proxy, please set it in Preferences ': Če uporabljate dostop prek proxyja, ga lahko nastavite v Nastavitve - Network.: ' - Povezava' def `itemState`: Action: false def `setItemState`: Action: false def `runQueryAndAddResults`: Future[List[_QueryResult]]: false Running query: Poizvedujem def `__run_add_package_dialog`: Add add-on by name: Dodaj dodatek po imenu Package name: Ime dodatka 'Enter a package name as displayed on ': 'Vnesi ime dodatka, kot je ' PyPI (capitalization is not important): naveden na PyPI Name:: ime Add: Dodaj def `__show_error_for_query`: Error: Napaka def `__on_add_query_finish`: Query error:: Napak pri izvajanju: Failed to query package index: Napaka pri poizvedovanju za dodatki The following packages were not found:
    : Naslednji dodatki niso najdeni:
    • {}
    • : false
        : false def `progressDialog`: Retrieving package list: Prejemam seznam dodatkov Progress: Napredek .zip: false .whl: false .tar.gz: false def `dropEvent`: Name: false Version: false Summary: false Description: false Description-Content-Type: false Requires-Dist: false def `__accepted`: Warning: Opozorilo This action will upgrade some core packages:\n: S tem boste posodobili osnovne dele:\n \n: false Do you want to continue?: Želite nadaljevati? ::InstallerThread: false Installing: Nameščam def `__on_installer_error`: An error occurred while running a subprocess: Napaka ob izvajanju opravila Error: Napaka {} exited with non zero status.: Ukaz {} se je končal z napako. def `__on_installer_finished`: Orange: false def `message_restart`: Information: Obvestilo {} needs to be restarted for the changes to take effect.: Za dokončanje se mora {} zagnati znova. Press OK to restart {} now.: Pritisnite OK, da se {} ponovno zažene takoj. Close later: Zapri kasneje def `restart`: Restart Cancelled: Ponovni zagon prekinjen Changes will be applied on {}'s next restart: Spremembe bodo uveljavljene, ko se {} požene naslednjič. def `main`: --config: false CLASSNAME: false orangecanvas.config.default: false The configuration namespace to use: false __main__: false application/application.py: def `fix_qt_plugins_path`: :qt/etc/qt.conf: false Paths/Prefix: false pyqt5: false pyqt6: false pyside2: false pyside6: false setHighDpiScaleFactorRoundingPolicy: false Round: false Ceil: false Floor: false RoundPreferFloor: false PassThrough: false Unset: false class `CanvasApplication`: def `__init__`: AA_EnableHighDpiScaling: false AA_UseHighDpiPixmaps: false setHighDpiScaleFactorRoundingPolicy: false -style: false styleHints: false setShowShortcutsInContextMenus: false win32: false QMessageBox: false def `argumentParser`: -style: false -colortheme: false -enable-high-dpi-scaling: false setHighDpiScaleFactorRoundingPolicy: false QT_SCALE_FACTOR_ROUNDING_POLICY: false -scale-factor-rounding-policy: false -use-high-dpi-pixmaps: false def `parseArguments`: :: false def `configureStyle`: application-style: false style-name: false palette: false def `set_restart_command`: Disabling application restart: false 'Enabling application restart with: %r': false application/canvasmain.py: class `CanvasMainWindow`: def `__init__`: untitled: false mainwindow/recent-items: false title: false path: false def `setup_ui`: __dummy_top_toolbar: false __dummy_bottom_toolbar: false main-area-dock: false canvas-tool-dock: false canvas-toolbox: false 'h3, a {color: orange;}': false Select a widget to show its description.: Ob izboru gradnika se pokaže njegov opis.

        : false "See workflow examples, ": "Poglejte primere delotokov, " "YouTube tutorials, ": "vodiče na YouTube, " or open the welcome screen.: ali odprite začetno okno.. Show Help: Pokaži pomoč Info.svg: false canvas-quick-dock: false Grid.svg: false Text Size.svg: false Arrow.svg: false Pause.svg: false Document Info.svg: false Log: Seznam sporočil output-dock: false Help: Pomoč help-dock: false help: false help-view-cache: false def `setup_actions`: New: Nov action-new: false Open a new workflow.: Odpri nov delotok. New.svg: false Open: Odpri action-open: false Open a workflow.: Odpri delotok. Open.svg: false Open and Freeze: Odpri in zamrzni action-open-and-freeze: false 'Open a new workflow and freeze signal ': Odpri delotok z ustavljenim tokom propagation.: signalov. Ctrl+Alt+O: false Close Window: Zapri okno action-close-window: false Close the window: Zapri okno Save: Shrani action-save: false Save current workflow.: Shrani trenutni delotok. Save As ...: Shrani kot ... action-save-as: false Save current workflow as.: Shrani trenutni delotok z novi imenom Save Workflow Image as SVG ...: Shrani sliko delotoka kot SVG... action-save-to-svg.: false Save workflow image as SVG.: Shrani sliko delotoka kot SVG. Quit: Zapri quit-action: false Welcome: Začetno okno welcome-action: false Show welcome screen.: Pokaži začetno okno Get Started: Začnimo get-started-action: false View a 'Get Started' introduction.: Poglej začetno predstavitev Documentation.svg: false Quick Start: Hitri uvod Video Tutorials: Video vodiči screencasts-action: false View video tutorials: Poglej video posnetke YouTube.svg: false Screencasts: Zaslonski posnetki Documentation: Navodila documentation-action: false View reference documentation.: Poglej navodila Example Workflows: Primeri delotokov examples-action: false Browse example workflows.: Prebrskaj primere delotokov Examples.svg: false About: O programu about-action: false Show about dialog.: Pokaži opis programa recent-action-group: false Browse Recent: Prebrskaj nedavne recent-action: false Browse and open a recent workflow.: Odpri nedavno uporabljeni delotok Ctrl+Shift+R: false Recent.svg: false Reload Last Workflow: Naloži zadnji delotok reload-last-action: false Reload last open workflow.: Ponovno naloži zadnji uporabljeni delotok Ctrl+R: false Clear Menu: Pobriši seznam clear-recent-menu-action: false Clear recent menu.: Pobriši seznam zadnjih delotokov Workflow Info: Podatki o delotoku show-properties-action: false Show workflow properties.: Pokaži podatke o delotoku. Ctrl+I: false Document Info.svg: false Settings: Nastavitve canvas-settings-action: false Set application settings.: Nastavitve programa &Add-ons...: Dodatki... canvas-addons-action: false Manage add-ons.: Upravljaj z dodatki &Log: Dnevnik sporočil Show application standard output.: Pokaži izpise programa Minimize: Pomanjšaj Ctrl+M: false darwin: false Zoom: Povečaj application-zoom: false Freeze: Zamrzni Shift+F: false signal-freeze-action: false Freeze signal propagation (Shift+F): Zamrzni tok signalov (Shift+F) Pause.svg: fals Expand Tool Dock: Razširi polico z gradniki toggle-tool-dock-expand: false Ctrl+Shift+D: false Show Workflow Margins: Rob okrog delotoka Show margins around the workflow view.: Pokaži rob okrog okna z delotokom Display Widgets on Top: Obdrži gradnike na vrhu Widgets are always displayed above other windows.: Gradniki so vedno nad drugimi okni def `setup_menu`: darwin: false &File: Datoteka file-menu: false Open Recent: Odpri nedavne recent-menu: false open-actions-separator: false close-window-actions-separator: false save-actions-separator: false {} ('{}'): false &View: Pogled view-menu: false window-groups-action: false workflow-window-groups-actions-separator: false view-visible-actions-separator: false view-zoom-actions-separator: false &Options: Nastavitve options-menu: false Window: Okna window-menu: false bring-widgets-to-front-action: false &Help: Pomoč help-menu: false def `restore`: mainwindow: false canvasdock/expanded: false toolbox-dock-floatable: false toolbox-dock-exclusive: false scheme-margins-enabled: false output-dock/is-visible: false quick-help/visible: false widgets-float-on-top: false def `__update_window_title`: Untitled [*]: Nepoimenovan [*] def `setWindowFilePath`: def `icon_for_path`: QIcon: false darwin: false ' ': false def `set_document_title`: untitled: nepoimenovan [*]: false def `set_widget_registry`: mainwindow/widgettoolbox/state: false def `on_quick_category_action`: mainwindow/toolbox-dock-use-popover-menu: false def `new_workflow_window`: schemeinfo/show-at-new-scheme: false def `open_scheme_file`: freeze: false def `_open_workflow_dialog`: mainwindow: false last-scheme-dir: false Open Orange Workflow File: Odpri delotok Orange Workflow (*.ows): Orangov delotok (*.ows) def `record_last_dir`: last-scheme-dir: false def `load_scheme`: rb: false Error: Napaka Unsupported format version: Nepodprta različica 'The file was saved in a format not supported by this ': 'Datoteka je shranjena v obliki, ki je ta program ' application.: ne podpira. "Could not open: '{}'": Ne morem odpreti: '{}' 'Error was: {}': Napaka: {} widget_manager: false def `new_scheme_from`: rb: false Error: Napaka "Could not open: '{}'": Ne morem odpreti: '{}' 'Error was: {}': Napaka: {} def `new_scheme_from_contents_and_path`: basedir: false Could not load an Orange Workflow file.: Ne morem naložiti datoteke z delotokom. Error: Napaka 'An unexpected error occurred ': 'Nepričakovana napaka ob ' while loading '%s'.: branju '%s'. Could not load the full workflow.: Ne morem naložiti celotnega delotoka. Workflow Partially Loaded: Delotok je naložen delno. 'Some of the nodes/links could not be reconstructed ': 'Nekaterih gradnikov ali povezav ni mogoče postaviti, ' and were omitted from the workflow.: zato niso vključene v delotok. def `check_requires`:

        Required packages:

          :

          Potrebni dodatki:

          • {}
          • : false
          : false install-requirements-message-box: false Install Additional Packages: Namesti dodatne pakete 'Workflow you are trying to load contains widgets ': 'Delotok vključuje gradnike ' from missing add-ons.: iz manjkajočih dodatkov.
          : false Would you like to install them now?: Jih namestim? 'After installation you will have to restart the ': 'Po namestitvi bo potrebno ponovno ' application and reopen the workflow.: zagnati program in odpreti delotok. Install add-ons: Namesti dodatke Ignore missing widgets: Izpusti manjkajoče gradnike Load partial workflow by omitting missing nodes and links.: Naloži del delotoka in izpusti manjkajoče gradnike in povezave. Please Restart: Ponovni zagon Please restart and reopen the file.: Ponovno zaženi in odpri delotok. def `install_requirements`: Install required packages: Nameščanje potrebnih dodatkov def `reload_last`: mainwindow/recent-items: false path: false def `set_scheme`: signal_manager: false widget_manager: false def `__title_for_scheme`: untitled: nepoimenovan def `ask_save_changes`: Do you want to save changes made to %s?: Shranim spremembe delotoka %s? Do you want to save this workflow?: Shranim delotok? Save Changes?: Shranim spremembe? Your changes will be lost if you do not save them.: Spremembe ne bodo ohranjene, če jih ne shranite. def `save_scheme_as`: last-scheme-dir: false .ows: false Save Orange Workflow File: Shranjevanje delotoka save-as-ows-filedialog: false Orange Workflow (*.ows): Orangov delotok (*.ows) def `save_scheme_to`: untitled: nepoimenovan basedir: false Error saving %r to %r: false 'An error occurred while trying to save workflow ': Napaka pri shranjevanju delotoka '"%s" to "%s"': '"%s" v "%s"' Error saving %s: Napaka pri shranjevanju %s wb: false %s saving '%s': false 'Workflow "%s" could not be saved. The path does ': 'Delotoka "%s" ni mogoče shraniti. ' not exist: Pot ne obstaja. Choose another location.: Izberite drugo mapo. 'Workflow "%s" could not be saved. You do not ': 'Delotoka ni mogoče shraniti, ker nimate pravice ' have write permissions.: pisati v izbrano mapo. 'Change the file system permissions or choose ': 'Spremenite nastavitve mape ali pa izberite ' another location.: drugo mapo. Workflow "%s" could not be saved.: Delotoka "%s" ni mogoče shraniti. def `save_swp_to`: wb: false Could not write swp file %r.: false def `clear_swp`: def `remove`: 'Could not delete swp file: %s': false def `ask_load_swp_if_exists`: startup/load-crashed-workflows: false def `ask_load_swp`: Restore unsaved changes from crash?: Obnovim neshranjene spremembe? Orange: false Restore Changes?: Obnovitev neshranjenih sprememb. {} seems to have crashed at some point.\n: Videti je, da se je {} nepričakovano zaprl.\n Changes will be discarded if not restored now.: Če sprememb ne obnovite, bodo zavržene. def `load_swp_from`: rb: false 'Could not load swp file: %r': false Could not load restore data.: Ne morem obnoviti podatkov. Error: Napaka def `_settings`: mainwindow: false def `save_as_svg`: save-as-svg-filedialog: false path: false .svg: false untitled.svg: nepoimenovan.svg Scalable Vector Graphics (*.svg): true def `save`: path: false def `__save_as_svg`: wt: false utf-8: false def `_handle_os_write_error`: Write error: Napaka pri pisanju '"%(path)s" could not be saved. You do not ': '"%(path)s" ni mogoče shraniti. Morda nimate ' have write permissions (%(strerror)s).: pravice pisati v direktorij (%(strerror)s). path: false strerror: false 'Change the file system permissions or choose ': 'Nastavite ustrezna pravice ali izberite ' another location.: drugo mesto. '"%(path)s" could not be saved.': '"%(path)s" ni mogoče shraniti.' def `recent_scheme`: mainwindow/recent-items: false title: false path: false Recent Workflows: Nedavni delotoki '

          \n': false {0}\n: false

          : false def `examples_dialog`: Example Workflows: Primeri delotokov '

          \n': false {0}\n: false

          : false def `welcome_dialog`: Welcome to {}: Dobrodošli v programu {} Welcome: Dobrodošli Feedback: false New: Nov Open a new workflow.: Odpri nov delotok New.svg: false Open: Odpri welcome-action-open: false Open a workflow.: Odpri nov delotok Open.svg: false Recent: Nedavni welcome-recent-action: false Browse and open a recent workflow.: Prebrskaj in odpri nedavne delotoke Ctrl+Shift+R: false Recent.svg: false Examples: Primeri welcome-examples-action: false Browse example workflows.: Prebrskaj primere delotokov Examples.svg: false light-grass: false light-orange: false startup/show-welcome-screen: false def `scheme_properties_dialog`: schemeinfo/show-at-new-scheme: false Workflow Info: Podatki o delotoku def `show_scheme_properties`: Change Info: Spremeni podatke def `set_signal_freeze`: signal_manager: false widget_manager: false def `open_canvas_settings`: Preferences: Nastavitve def `open_addons`: Orange: false 'Add-ons: insufficient permissions': Dodatki: nezadostne pravice 'Insufficient permissions to install add-ons. Try starting {name} ': Nimate pravice nameščati dodatkov. Poskusite zagnati {name} as a system administrator or install {name} in user folders.: kot sistemski administrator ali pa namestiti {name} v uporabniško mapo. Installer: Nameščanje def `add_recent_scheme`: {} ('{}'): false mainwindow: false recent-items: false title: false path: false def `clear_recent_schemes`: mainwindow/recent-items: false def `closeEvent`: mainwindow: false geometry: false state: false canvasdock/expanded: false scheme-margins-enabled: false widgettoolbox/state: false quick-help/visible: false widgets-float-on-top: false def `showEvent`: mainwindow: false geometry: false state: false def `__handle_help_query_response`: There is no documentation for this widget.: Pomoč za ta gradnik ni na voljo. No help found: Temu gradniku ni pomoči no-help-found-message-box: false def `whatsThisClickedEvent`: help: false search: false def `run`: No help topic found for %r: false action: false No target action found for %r: false def `show_help`: 'Setting help to url: %r': false help/open-in-external-browser: false def `__update_from_settings`: mainwindow: false toolbox-dock-floatable: false toolbox-dock-exclusive: false num-recent-schemes: false widgets-float-on-top: false quickmenu: false trigger-on-double-click: false trigger-on-right-click: false trigger-on-space-key: false trigger-on-any-key: false schemeedit: false show-channel-names: false open-anchors-on-hover: false enable-node-animations: false def `index`: key: false predicate: false %r not in sequence: false class `UrlDropEventFilter`: def `acceptsDrop`: file: false .ows: false K: false V: false def `render_error_details`: Missing node definitions:: Manjkajoči gradniki: ' \N{BULLET} ': false Incompatible connection types:: Nezdružljive vrste povezav: Unqualified errors:: Ostale napake: \n: false application/canvastooldock.py: class `SplitterResizer`: def `__init__`: size_: false toggle-expanded: false def `setSplitterAndWidget`: Widget must be in a splitter.: false def `toogleExpandedAction`: "'toogleExpandedAction is deprecated, use 'toggleExpandedAction' ": false instead.: false class `CanvasToolDock`: def `__setupUi`: quick-help: false def `toogleQuickHelpAction`: "'toogleQuickHelpAction' is deprecated, use ": false "'toggleQuickHelpAction' instead.": false class `QuickCategoryToolbar`: def `setColumnCount`: Cannot set the column count on a Toolbar: false def `createButtonForAction`: quick-category-toolbutton: false QToolButton {\n: false ' background: %s;\n': false ' border: none;\n': false ' border-bottom: 1px solid palette(mid);\n': false }: false class `CategoryPopupMenu`: def `__init__`: darwin: false def `setCategoryItem`: setCategoryItem is deprecated. Use the more general 'setModel': false and setRootIndex: false def `exec_`: exec_ is deprecated, use exec: false def `__onDragStarted`: application/vnd.orange-canvas.registry.qualified-name: false utf-8: false application/examples.py: def `list_workflows`: def `is_ows`: .ows: false def `workflows`: tutorials_entry_points: false Could not load examples from %r: false 'A callable entry point (%r) raised an ': false unexpected error.: false class `ExampleWorkflow`: def `abspath`: cannot resolve resource to an absolute name: false def `stream`: rb: false application/outputview.py: class `TerminalView`: def `sizeHint`: X: false class `TerminalTextDocument`: def `__init__`: defaultFont: false TextStream: false Formatter: false def `connectedStreams`: TextStream: false def `connectStream`: TextStream: false "'charformat' and kwargs cannot be used together": false def `disconnectStream`: TextStream: false def `clone`: TerminalTextDocument: false class `OutputView`: def `formated`: "'Use 'formatted'": false class `Formatter`: def `formated`: Use 'formatted': false class `formater`: def `__init__`: 'Deprecated: Renamed to Formatter.': false class `TextStream`: def `write`: write operation on a closed stream.: false def `writelines`: write operation on a closed stream.: false def `flush`: write operation on a closed stream.: false def `detach`: detach: false def `read`: read: false def `readline`: readline: false def `readlines`: readlines: false def `fileno`: fileno: false def `seek`: seek: false def `tell`: tell: false class `ExceptHook`: def `__call__`: ' Exception': false ' (in non-GUI thread)': false {:-^79}\n: false ' ': false -: false \n: false application/schemeinfo.py: class `SchemeInfoEdit`: def `__setupUi`: untitled: nepoimenovan Title: Naslov Description: Opis def `setScheme`: untitled: nepoimenovan def `commit`: untitled: nepoimenovan class `SchemeInfoDialog`: def `__setupUi`: Workflow Info: Podatki o delotoku

          {0}

          : false heading: false auto-show-container: false Show when I make a New Workflow.: Pokaži ob sestavljanju novega delotoka auto-show-check: false 'You can also edit Workflow Info later ': ' Te podatke lahko urejate tudi kasneje ' (File -> Workflow Info).: (Datoteka -> Podatki o delotoku). auto-show-info: false application/settings.py: class `UserSettingsModel`: def `__init__`: Name: false Status: false Type: false Value: false def `data`: Default: false User: false def `setData`: Failed to set value (%r) for key %r: false _State: false visible: false position: false class `UserSettingsDialog`: def `__init__`: darwin: false def `__setupUi`: General: Splošno General Options: Splošne nastavitve Changes will take effect on next application startup.: Spremembe bodo uveljavljene ob naslednjem zagonu programa. combo-language: false Select the application language.: Izberite jezik aplikacije. currentText: false application/language: false Language: Jezik nodes: false Enable node animations: Animiraj gradnike enable-node-animations: false 'Enable shadow and ping animations for nodes ': 'Dodaj sence in animacijo ob dotiku gradnikov ' in the workflow.: v delotoku. Open anchors on hover: Pokaži vhode in izhode ob prehodu miške open-anchors-on-hover: false 'Open/expand node anchors on mouse hover (if unchecked the ': 'Razširi in poimenuj vhode in izhode ob povezovanju ' anchors are expanded when Shift key is pressed).: (če ni vključeno, dosežemo isti učinek s pritiskom tipke Shift) checked: false schemeedit/enable-node-animations: false schemeedit/open-anchors-on-hover: false Nodes: Gradniki links: false Show channel names between widgets: Pokaži imena povezav med gradniki show-channel-names: false 'Show source and sink channel names ': 'Pokaži ime izhoda in vhoda ' over the links.: nad povezavami. schemeedit/show-channel-names: false Links: Povezave quickmenu-options: false Open on double click: Odpri z dvojnim klikom 'Open quick menu on a double click ': Ob dvojnem kliku na praznem mestu on an empty spot in the canvas: odpri menu za izbor gradnikov Open on right click: Odpri z desnim klikom 'Open quick menu on a right click ': Ob kliku na desno tipko miške odpri menu za izbor gradnikov. Open on space key press: Odpri s preslednico 'Open quick menu on Space key press ': 'Ob pritisku na preslednico odpri menu za izbor gradnikov, ' while the mouse is hovering over the canvas.: če se miška nahaja na platnu. Open on any key press: Odpri s poljubno tipko 'Open quick menu on any key press ': Odpri menu s pritiskom na poljubno tipko Show categories: Pokaži kategorije 'In addition to searching, allow filtering ': Poleg iskanja omogoči iskanje po kategorijah. by categories.: "" quickmenu/trigger-on-double-click: false quickmenu/trigger-on-right-click: false quickmenu/trigger-on-space-key: false quickmenu/trigger-on-any-key: false quickmenu/show-categories: false Quick menu: Hitri menu startup-group: false Show splash screen: Pokaži pozdravno okno show-splash-screen: false Show welcome screen: Pokaži začetno okno show-welcome-screen: false Load crashed scratch workflows: Po nepričakovanem zaprtju ponovno naloži delotok load-crashed-workflows: false startup/show-splash-screen: false startup/show-welcome-screen: false startup/load-crashed-workflows: false On startup: Ob zagonu toolbox-group: false Only one tab can be open at a time: Naenkrat kaži le eno kategorijo mainwindow/toolbox-dock-exclusive: false Tool box: Polica z gradniki &Style: Slog Application style: Slog aplikacije selectedStyle_: false application-style/style-name: false selectedPalette_: false application-style/palette: false Output: Izhod Output Redirection: Preusmeritev izhoda Critical: Kritična Error: Napake Warn: Opozorila Info: Informacije Debug: Razhroščevanje currentIndex: false logging/level: false Logging: Prikaži sporočila Open in external browser: Odpri v zunanjem brskalniku open-in-external-browser: false help/open-in-external-browser: false Help window: Okno s pomočjo Categories: Katerogije Add-ons: Dodatki Settings related to add-on installation: Nastavitve, povezane z dodatki conda-group: false Install add-ons with conda: Dodatke nameščaj s condo allow-conda: false add-ons/allow-conda: false Conda: true Pip: true Pip install arguments:: Argumenti za pip: text: false add-ons/pip-install-arguments: false Network: Omrežje Settings related to networking: Nastavitve povezane z omrežjem network/http-proxy: false HTTP proxy:: true network/https-proxy: false HTTPS proxy:: true def `reset`: Error reseting %r: false def `exec_`: exec_ is deprecated, use exec: false class `StyleConfigWidget`: windowsvista: false Windows (default): Windows (privzeto) macintosh: false macOS (default): macOS (privzeto) windows: false MS Windows 9x: false def `__init__`: windows: false fusion: false Default: Privzeto style-cb: false palette-cb: false Breeze Light: false breeze-light: false Breeze Dark: false breeze-dark: false Zion Reversed: false zion-reversed: false Dark: Temno dark: false Style: Slog Color theme: Barvna tema Changes will be applied on next application startup.: Spremembe bodo uveljavljene po ponovnem zagonu. def `_update_colors_enabled_state`: fusion: false windows: false def `category_state`: mainwindow/categories/{0}/visible: false mainwindow/categories/{0}/position: false def `save_category_state`: mainwindow/categories/{0}/visible: false mainwindow/categories/{0}/position: false application/welcomedialog.py: class `DecoratedIconEngine`: def `paint`: QPainter: false def `clone`: QIconEngine: false ' WelcomeActionButton { border: 1px solid transparent; border-radius: 10px; font-size: 13px; icon-size: 75px; } WelcomeActionButton:pressed { background-color: palette(highlight); color: palette(highlighted-text); } WelcomeActionButton:focus { border: 1px solid palette(highlight); } ': false class `WelcomeActionButton`: def `__init__`: QAbstractButton: false class `WelcomeDialog`: def `__init__`: showAtStartup: false feedbackUrl: false def `setupUi`: bottom-bar: false Show at startup: Pokaži ob zagonu def `setFeedbackUrl`: Help us improve!: Pomagaj, da bomo še boljši {text}: false def `addRow`: light-orange: false def `insertRow`: light-orange: false icon-row: false def `insertAction`: light-orange: false def `createButton`: light-orange: false application/widgettoolbox.py: class `WidgetToolGrid`: def `__startDrag`: application/vnd.orange-canvas.registry.qualified-name: false utf-8: false class `WidgetToolBox`: def `__init__`: Search.svg: false Search: Iskanje filter-edit-line: false Filter...: Filtriranje... Filter/search the list of available widgets.: Filtriranje/iskanje po seznamu razpoložljivih gradnikov. Open all: Odpri vse Close all: Zapri vse def `__insertItem`: tab-title: false widgets-toolbox-grid: false def `__openAllTabsForFilter`: !__exclusive: false def `__restoreAllTabsForFilter`: !__exclusive: false application/utils/addons.py: https://pypi.org/pypi/{name}/json: false A: false B: false def `prettify_name`: -: false orange: false orange3: false ' ': false (?{0}  \u2192  {1}: false def `commit_scheme_node`: Scheme not editable.: false No 'NodeItem' for node.: false An error occurred while committing node '%s': false Commited node '%s' from '%s' to '%s': false def `commit_scheme_link`: Scheme not editable: false No 'LinkItem' for link.: false Commited link '%s' from '%s' to '%s': false def `neighbor_nodes`: sourceItem: false sinkItem: false def `set_user_interaction_handler`: Setting interaction '%s' to '%s': false def `__str__`: %s(objectName=%r, ...): false def `font_from_dict`: family: false size: false def `grab_svg`: utf-8: false canvas/view.py: class `CanvasView`: def `__init__`: Zoom in: Povečaj action-zoom-in: false Cmd: false darwin: false Ctrl: false +=: false Zoom out: Pomanjšaj action-zoom-out: false Reset Zoom: Ponastavi velikost action-zoom-reset: false Ctrl+0: false def `__should_scroll_horizontally`: darwin: false def `__startAutoScroll`: Auto scroll timer started: false def `__stopAutoScroll`: Auto scroll timer stopped: false def `__autoScrollAdvance`: Auto scroll advance: false def `setBackgroundIcon`: A QIcon expected.: false canvas/items/annotationitem.py: class `GraphicsTextEdit`: def `__init__`: editTriggers: false Placeholder text: false class `TextAnnotation`: def `__init__`: text/plain: false Enter text here: Vnesi besedilo def `setContent`: text/plain: false def `setPlainText`: text/plain: false def `setHtml`: text/html: false def `endEdit`: __updateRenderedContent: false def `contextMenuEvent`: Render as: Pokaži kot Plain Text: Golo besedlo Render contents as plain text: Pokaži v obliki golega besedila text/plain: false HTML: true Render contents as HTML: Pokaži vsebino kot HTML text/html: false RST: RST 'Render contents as RST ': Pokaži vsebino kot RST (reStructuredText): true text/rst: false Markdown: true Render contents as Markdown: Pokaži besedilo kot Markdown text/markdown: false canvas/items/graphicspathobject.py: class `GraphicsPathObject`: QPointF: false canvas/items/graphicstextitem.py: def `createStandardContextMenu`: def `createMimeDataFromSelection`: utf-8: false def `canPaste`: text/plain: false text/html: false &Undo: Razveljavi edit-undo: false &Redo: Ponovno uveljavi edit-redo: false Cu&t: Izreži edit-cut: false &Copy: Kopiraj edit-copy: false Copy &Link Location: Kopiraj povezavo na mesto link-copy: false &Paste: Prilepi edit-paste: false Delete: Pobriši edit-delete: false Select All: Izberi vse select-all: false class `GraphicsTextEdit`: def `__init__`: editTriggers: false alignment: false returnKeyEndsEditing: false darwin: false def `setTextInteractionFlags`: Qt.TextInteractionFlag: false Qt.TextInteractionFlags: false def `qgraphicsitem_accent_color`: QGraphicsItem: false QMacStyle: false canvas/items/linkitem.py: class `LinkCurveItem`: def `__init__`: '#9CACB4': false blurRadius: false class `LinkItem`: def `__init__`: opacity: false def `setSourceItem`: Anchor must be belong to the item: false def `setSinkItem`: Anchor must be belong to the item: false def `__updateText`: {0} \u2192 {1}: false '
          {0}
          ': false def `__updatePen`: '#9CACB4': false '#959595': false canvas/items/nodeitem.py: def `create_palette`: '#515151': false def `default_palette`: light-yellow: false yellow: false '#9CACB4': false '#609ED7': false class `NodeBodyItem`: def `__init__`: shadow-shape-item: false blurRadius: false scale: false class `LinkAnchorIndicator`: def `__init__`: '#9CACB4': false '#959595': false def `setLinkState`: LinkItem.State: false class `AnchorPoint`: def `__init__`: pos: false def `setLinkState`: LinkItem.State: false class `NodeAnchorItem`: def `__init__`: '#CDD5D9': false '#9CACB4': false '#959595': false shadow-shape-item: false blurRadius: false def `setSignals`:
          {0}
          ': false opacity: false def `insertAnchor`: %s already added.: false class `GraphicsIconItem`: def `__init__`: opacity: false class `NodeItem`: Node title text.: false Node progress state.: false def `newInputAnchor`: Widget has no inputs.: false def `newOutputAnchor`: Widget has no outputs.: false def `__updateTitleText`:
          %s: false progress: false {0:.0f}%: true %i%%: false
          : false '': false : false
          : false def `__updateMessages`: _error: false _warn: false _info: false severity: false
          : false '\ {tooltip} ': false def `NodeItem_toolTipHelper`:
        • {0}
        • : false {title}
          : false Inputs:
            {inputs}

          : Vhodi:
            {inputs}

          Outputs:
            {outputs}
          : Izhodi:
            {outputs}
          No inputs
          : Ni vhodov
          No outputs: Ni izhodov
          'ul { margin-top: 1px; margin-bottom: 1px; }': false canvas/items/utils.py: T: false A: false B: false C: false def `linspace`: Count must be non-negative: false document/__init__.py: quickmenu: false schemeedit: false document/commands.py: class `UndoCommand`: def `__getstate__`: _UndoCommand__initialized: false _UndoCommand__text: false _UndoCommand__children: false def `__setstate__`: _UndoCommand__initialized: false _UndoCommand__text: false _UndoCommand__parent: false _UndoCommand__child_states: false class `AddNodeCommand`: def `__init__`: Add %s: dodajanje gradnika %s class `RemoveNodeCommand`: def `__init__`: Remove %s: odstranitev gradnika %s class `AddLinkCommand`: def `__init__`: Add link: dodajanje povezave class `RemoveLinkCommand`: def `__init__`: Remove link: odstranitev povezave class `InsertNodeCommand`: def `__init__`: Insert widget into link: vstavljanje gradnika class `AddAnnotationCommand`: def `__init__`: Add annotation: dodajanje oznake class `RemoveAnnotationCommand`: def `__init__`: Remove annotation: odstranitev oznake class `MoveNodeCommand`: def `__init__`: Move: premik class `ResizeCommand`: def `__init__`: Resize: spremembo velikosti class `ArrowChangeCommand`: def `__init__`: Move arrow: premik puščice class `AnnotationGeometryChange`: def `__init__`: Change Annotation Geometry: spremembo oznake class `RenameNodeCommand`: def `__init__`: Rename: preimenovanje class `TextChangeCommand`: def `__init__`: Change text: spremembo besedila class `SetAttrCommand`: def `__init__`: Set %r: nastavljanje %r class `SetWindowGroupPresets`: def `__init__`: Scheme: false Scheme.WindowGroup: false text: false Set Window Presets: nastavitve oken document/editlinksdialog.py: class `EditLinksDialog`: def `__setupUi`: Clear All: false _Link: false output: false input: false lineItem: false class `LinksEditWidget`: def `addLink`: %r is not an output channel of %r: false %r is not an input channel of %r: false Click to remove the link.: Kliknite, da odstranite povezavo. def `removeLink`: No such link {0.name!r} -> {1.name!r}.: false def `__updateState`:
          {0}
          : false def `__updateAnchorState`: Click and drag to connect widgets!: Kliknite in vlecite, da povežete gradnike No compatible input channel.: Ni primernega izhoda. No compatible output channel.: Ni primernega vhoda. class `EditLinksNode`: def `setSchemeNode`:
          : false {name}: false
          : false right: false left: false description: false document/interactions.py: A: false class `NewLinkAction`: def `mousePressEvent`:

          Create new link

          :

          Dodaj novo povezavo

          '

          Drag a link to an existing node or release on ': '

          Povlecite povezavo do obstoječega gradnika ali pa ' an empty spot to create a new node.

          : na prazno mesto, da tam postavite nov gradnik.

          '

          Hold Shift when releasing the mouse button to ':

          Držite tipko Shift za urejanje povezav.

          edit connections.

          : "" def `mouseMoveEvent`: %r is no longer the target.: false %r is the new target.: false %r does not have compatible channels: false def `mouseReleaseEvent`: Add link: false Failed to create a new node, ending.: false def `create_new`: item: false def `connect_nodes`: 'proposed (weighted) links: %r': false Failed to edit the links: false 'Cannot connect: invalid channel types.': false 'Cannot connect: connection creates a cycle.': false 'Cannot connect: no possible links.': false User canceled a new link action.: false An error occurred during the creation of a new link.: false def `edit_links`: Optional[List[OIPair]]: false Tuple[int, List[Link], List[Link]]: false def `edit_links`: Optional[List[OIPair]]: false Optional[QWidget]: false Tuple[int, List[OIPair], List[OIPair]]: false Constructing a Link Editor dialog.: false Edit Links: Urejanje povezav Executing a Link Editor Dialog.: false class `NewNodeAction`: def `create_new`: item: false class `EditNodeLinksAction`: def `edit_links`: Constructing a Link Editor dialog.: false Edit Links: Urejanje povezav Executing a Link Editor Dialog.: false class `NewArrowAnnotation`: def `__init__`: red: false def `start`:

          New arrow annotation

          :

          Risanje oznake s puščico

          Click and drag to create a new arrow annotation

          :

          Povlecite in spustite, da ustvarite oznako s puščico.

          class `NewTextAnnotation`: def `start`:

          New text annotation

          :

          Nova besedilna oznaka

          '

          Click (and drag to resize) on the canvas to create ':

          Kliknite (in vlecite, da določite velikost), da dodate novo besedilno oznako.

          a new text annotation item.

          : "" '

          Right click on the annotation to change how it is ': '

          Desni klik na oznako omogoča spreminjanje načina prikaza ' rendered (Markdown, HTML, ...).

          : (Markdown, HTML, ...).

          def `createNewAnnotation`: family: false size: false class `ResizeTextAnnotation`: def `commit`: rect: false Edit text geometry: false def `start`:

          Edit Text Annotation

          :

          Urejanje besedilne oznake

          Drag control points to resize the annotation.

          :

          Povlecite kontrolne točke za spremembo velikosti oznake.

          '

          Right click on the annotation to change how it is ': '

          Desni klik na oznako omogoča spreminjanje načina prikaza ' rendered (Markdown, HTML, ...).

          : (Markdown, HTML, ...).

          def `cancel`: ResizeTextAnnotation.cancel(%s): false class `ResizeArrowAnnotation`: def `commit`: geometry: false Edit arrow geometry: false def `cancel`: ResizeArrowAnnotation.cancel(%s): false class `DropHandler`: def `accepts`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false def `doDrop`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false class `DropHandlerAction`: def `actionFromDropEvent`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false class `NodeFromMimeDataDropHandler`: def `canDropMimeData`: SchemeEditWidget: false QMimeData: false def `parametersFromMimeData`: SchemeEditWidget: false QMimeData: false Dict[str, Any]: false def `accepts`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false def `nodeFromMimeData`: SchemeEditWidget: false QMimeData: false Node: false def `doDrop`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false def `activateNode`: SchemeEditWidget: false Node: false QWidget: false def `actionFromDropEvent`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false {self.qualifiedName()}: false def `load_entry_point`: EntryPoint: false Could not load %s: false Unexpected Error; %s will be skipped: false class `PluginDropHandler`: orangecanvas.document.interactions.DropHandler: false def `iterEntryPoints`: EntryPoint: false def `entryPoints`: EntryPoint: false DropHandler: false '{ep} yielded {type(value)}, expected a ': false {DropHandler} subtype: false Error in default constructor of %s: false def `accepts`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false def `doDrop`: SchemeEditWidget: false QGraphicsSceneDragDropEvent: false class `Title`: def `createWidget`: Select a widget: Izberi gradnik {ep_.name} ({ep_.module_name}): false class `DropAction`: def `canHandleDrop`: QGraphicsSceneDragDropEvent: false document/quickmenu.py: class `_MenuItemDelegate`: def `paint`: FFA840: false \u21B5: false class `MenuPage`: Title of the page.: false Page icon: false class `ItemDisableFilter`: def `setFilterFunc`: A callable object or None expected.: false class `SortFilterProxyModel`: def `setFilterFunc`: A callable object or None expected.: false def `lessThan`: ' ': false class `SearchWidget`: def `__setupUi`: Search.svg: false Search: Poišči class `TabButton`: def `__init__`: '#000000': false _Tab: false text: false icon: false toolTip: false button: false data: false palette: false class `TabBarWidget`: def `insertTab`: tab-button: false lastCategoryButton: false class `PagedMenu`: def `update_from_settings`: quickmenu/show-categories: false "\ TabButton {{ qproperty-flat_: false; qproperty-shadowColor_: {2}; background: {0}; border: none; border-right: 3px solid {0}; border-bottom: 1px solid #9CACB4; border-top: 1px solid {0} }} TabButton:checked {{ background: {1}; border: none; }} TabButton[lastCategoryButton='true']:checked {{ border-bottom: 1px solid #9CACB4; }} ": false class `QuickMenu`: def `__init__`: search-line: false Search for a widget...: Poišči gradnik... menu-frame: false paged-menu: false suggest-page: false icons/Search.svg: false darwin: false Quick Search: Hitro iskanje def `createPage`: darwin: false def `popup`: quickmenu/size: false def `exec_`: exec_ is deprecated, use exec: false def `hideEvent`: quickmenu/size: false class `WindowSizeGrip`: def `setCorner`: Qt.Corner flag expected: false def `sizeHint`: QMacStyle: false document/schemeedit.py: application/vnd.{}-ows-fragment+xml: false class `NoWorkflowError`: def `__init__`: No workflow model is set: false class `SchemeEditWidget`: class `OpenAnchors`: Never: false Always: false OnShift: false def `__init__`: &Edit: Uredi Widget: Gradnik &Widget: Gradnik Link: Povezava def `__setupActions`: Clean Up: Počisti cleanup-action: false Shift+A: false Align widgets to a grid (Shift+A): Poravnaj vse gradnike na mrežo (Shift+A) Text: Besedilo new-text-action: false Add a text annotation to the workflow.: V delotok dodaj besedilno oznako. Font Size: Velikost pisave %ipx: false Arrow: Puščica new-arrow-action: false Add a arrow annotation to the workflow.: V delotok dodaj oznako s puščico. Arrow Color: Barva puščice '#000': false '#C1272D': false '#662D91': false '#1F9CDF': false '#39B54A': false undo-action: false redo-action: false Select all: Izberi vse select-all-action: false Select all items.: Izberi vse elemente. Open: Odpri open-action: false Open selected widget: Odpri izbrane gradnike Remove: Odstrani remove-selected: false Remove selected items: Odstrani izbrane gradnike Ctrl+Backspace: fales Rename: Preimenuj rename-action: false Rename selected widget: Preimenuj izbrane gradnike darwin: false Help: Pomoč help-action: false Show widget help: Pokaži pomoč za gradnik F1: false Enabled: Vključeno link-enable-action: false link-remove-action: false Remove link.: Odstrani povezavo. Insert Widget: Vstavi gradnik node-insert-action: false Insert widget.: Vstavi gradnik. Reset Signals: Ponastavi signale link-reset-action: false Duplicate: Podvoji duplicate-action: false Ctrl+D: false Copy: Kopiraj copy-action: false Ctrl+C: false Paste: Prilepi paste-action: false Ctrl+V: false Window Groups: Skupine oken window-groups-action: false Manage preset widget groups: Upravljaj postavljene skupine gradnikov window-groups-action-group: false Save Window Group...: Shrani skupine oken... window-groups-save-action: false Create and save a new window group.: Ustvari in shrani novo skupino oken. Delete All Groups: Pobriši vse skupine window-groups-clear-action: false Delete all saved widget presets: Pobriši vse prednastavitve gradnikov. groups-separator: false Bring Widgets to Front: Pokaži gradnike v ospredju bring-widgets-to-front-action: false Ctrl+Down: false def `toolbarActions`: action-zoom-in: false action-zoom-out: false action-zoom-reset: false def `isModifiedStrict`: 'Modified strict check (modified flag: %s, ': false 'undo stack clean: %s, properties: %s)': false def `uncleanProperties`: context_settings: false def `setScheme`: widget_manager: false def `setTitle`: title: false def `setDescription`: description: false def `enumerateTitle`: ' ({0})': false def `removeSelected`: Remove: odstranjevanje def `alignToGrid`: position: false Align To Grid: poravnavo na mrežo def `__desc_from_mime_data`: application/vnd.orange-canvas.registry.qualified-name: false application/vnv.orange-canvas.registry.qualified-name: false utf-8: false def `sceneMousePressEvent`: darwin: false def `sceneMouseReleaseEvent`: Move: premik def `__onSelectionChanged`: Open All: Odpri vse Open: Odpri Remove All: Zapri vse Remove: Zapri def `__onAnnotationAdded`: Annotation added (%r): false def `__onAnnotationRemoved`: Annotation removed (%r): false def `__toggleNewArrowAnnotation`: Canceled new arrow annotation: false def `__toggleNewTextAnnotation`: Canceled new text annotation: false def `__onHelpAction`: help://search?: false id: false def `__showHelpFor`: 'Sorry there is no documentation available for ': 'Dokumentacija za ta gradnik ' this widget.: ne obstaja. def `__toggleLinkEnabled`: enabled: false Set enabled: false def `__nodeInsert`: item: false 'Cannot insert node: links not possible.': false def `__duplicateSelected`: Duplicate: false def `__copyToClipboard`: copyToClipboard:: false def `__pasteFromClipboard`: pasteFromClipboard:: false def `__paste`: {item} ({_}): false Paste: false def `__startControlPointEdit`: Unknown annotation item type %r: false Control point editing started (%r).: false def `__endControlPointEdit`: Control point editing finished.: false def `__reset_window_group_menu`: groups-separator: false Meta+P, Ctrl+{}: false def `__saveWindowGroup`: Save Group as...: Shrani skupino oken def `store_group`: Store Window Group: Shrani skupino oken Update Window Group: Prenovi skupino oken def `__clearWindowGroups`: Delete All Window Groups: Pobriši vse skupine oken class `SaveWindowGroup`: def `__init__`: Window Group 1: Skupina oken 1 Save As:: Shrani kot: Use as default: Uporabi privzeto Automatically use this preset when opening the workflow.: Samodejno uporabi to nastavitev ob odpiranju delotoka. "Save the current open widgets' window arrangement to the ": 'Shrani trenuten razpored odprtih gradnikov ' workflow view presets.: kot prednastavitev delotoka. def `__accept_check`: Confirm Overwrite: Potrdi spremembo "The window group '{}' already exists. Do you want ": "Skupina '{}' že obstaja. " to replace it?: Jo zamenjam s trenutno? Replace: Zamenjaj Cc: false Cf: false Cs: false Co: false Cn: false def `remove_copy_number`: \s+\(\d+\)\s*$: false def `uniquify`: {item}-{_}: false document/suggestions.py: class `Suggestions`: class `__Suggestions`: def `__init__`: widget-use-frequency.pickle: false def `load_link_frequency`: rb: false Failed to open widget link frequencies.: false def `default_link_frequency`: File: false Data Table: false def `write_link_frequency`: wb: false Failed to write widget link frequencies.: false document/usagestatistics.py: class `UsageStatistics`: def `set_enabled`: {} usage statistics tracking: false Enabling: false Disabling: false def `begin_action`: 'Tried to set ': false ' but ': false ' was already set.': false def `begin_extend_action`: Tried to start extend action while current action already has events: false Attempted to extend widget before it was logged. No action type was set.: false Extended Widget: false def `begin_insert_action`: Tried to start insert action while current action already has events: false 'Attempted to log insert action between unknown widgets. ': false No action was logged.: false Source Widget: false Sink Widget: false def `end_action`: End action called but no events were logged.: false Type: false Events: false Query: false def `log_node_add`: Type: false Widget Name: false Widget: false def `log_node_remove`: Attempted to log node removal before its addition. No action was logged.: false Type: false Widget: false def `_log_link`: Attempted to log link action between unknown widgets. No action was logged.: false Type: false Source Widget: false Sink Widget: false Source Channel: false Sink Channel: false Source Open: false Sink Open:: false def `write_statistics`: Date: false Application Version: false Operating System: false ' ': false Launch Count: false startup/launch-count: false Session: false def `filename`: usage-statistics.json: false def `load`: List[dict]: false r: false utf-8: false def `store`: w: false utf-8: false gui/dock.py: class `CollapsibleDockWidget`: def `__init__`: qt_dockwidget_closebutton: false def `setWidget`: "Please use the 'setExpandedWidget'/'setCollapsedWidget' ": false methods to set the contents of the dock widget.: false gui/dropshadow.py: class `DropShadowFrame`: Drop shadow color: false Drop shadow blur radius.: false Drop shadow offset.: false gui/framelesswindow.py: class `FramelessWindow`: Window border radius: false gui/iconengine.py: StyledIconEngine: false SymbolIconEngine: false class `StyledIconEngine`: __palette: false __styleObject: false def `paletteFromStyleObject`: palette: false class `SymbolIconEngine`: def `__renderStyledPixmap`: {__name__}:SymbolIconEngine/{self.__cache_key}: false {size.width()}x{size.height()}: false {hex(palette.cacheKey())}-{cg}-{role}: false {namespace}/{cachekey}/{style_key}: false def `clone`: QIconEngine: false gui/iconview.py: class `LinearIconView`: def `sizeHint`: X: false \nX: false gui/itemmodels.py: class `FilterProxyModel`: Filter: false column: false role: false predicate: false gui/lineedit.py: _ActionSlot: false position: false int: false action: false QAction: false button: false LineEditButton: false autoHide: false Any: false class `LineEditButton`: def `__init__`: '#000000': false class `LineEdit`: def `_checkPosition`: Invalid position: false gui/quickhelp.py: class `StatusTipPromoter`: def `eventFilter`: whatsThis: false gui/splashscreen.py: mightBeRichText: false gui/stackedwidget.py: class `AnimatedStackedWidget`: def `__init__`: blendingFactor_: false def `__transitionStart`: Stack transition start (%s): false def `__onTransitionFinished`: Stack transition finished (%s): false gui/svgiconengine.py: class `SvgIconEngine`: __contents: false __generator: false __cache_id: false def `pixmap`: {}.SVGIconEngine/{}/{}x{}: false class `StyledSvgIconEngine`: __contents: false __styled_contents_cache: false __renderer: false __cache_key: false def `__init__`: only one of palette or styleObject can be defined: false def `__renderStyledPixmap`: {__name__}:{__class__.__name__}/{self.__cache_key}: false {hex(palette.cacheKey())}-{cg}-{role}: false {namespace}/{style_key}/{size.width()}x{size.height()}: false def `clone`: QIconEngine: false ' * {{ color: {text}; }} .ColorScheme-Text {{ color: {text}; }} .ColorScheme-Background {{ color: {background}; }} .ColorScheme-Highlight {{ color: {highlight}; }} .ColorScheme-Disabled-Text {{ color: {disabled_text}; }} .ColorScheme-Contrast {{ color: {contrast}; }} .ColorScheme-Complement {{ color: {complement}; }} ': false def `replace_css_style`: current-color-scheme: false class `StyleReplaceFilter`: def `startElement`: style: false id: false \n: false def `endElement`: style: false utf-8: false gui/test.py: class `QCoreAppTestCase`: def `setUpClass`: orangecanvas.testing: false biolab.si: false def `singleShot`: Callable[[], Any]: false gui/toolbox.py: ToolBox: false _ToolBoxPage: false index: false widget: false action: false button: false class `ToolBoxTabButton`: def `__init__`: font: false QAbstractButton: false class `ToolBox`: Exclusive tabs: false def `__init__`: toolbox-scroll-area: false toolbox-contents: false toolbox-tab-action-group: false def `createTabButton`: toolbox-tab-button: false def `__onTabActionToggled`: action: false A: false B: false C: false gui/toolgrid.py: ToolGrid: false _ToolGridSlot: false button: false action: false row: false column: false class `ToolGridButton`: def `__init__`: darwin: false QWidget: false def `__textLayout`: ' ': false \n: false &: false &&: false class `ToolGrid`: def `__init__`: sizePolicy: false def `createButtonForAction`: tool-grid-button: false gui/tooltree.py: ToolTree: false FlattenedTreeItemModel: false class `ToolTree`: def `__init__`: tool-tree-view: false gui/utils.py: def `disabled`: setEnabled: false isEnabled: false %r does not have 'enabled' property: false def `is_transparency_supported`: win32: false cygwin: false darwin: false linux: false freebsd: false def `is_x11_compositing_enabled`: isCompositingManagerRunning: false def `windows_set_current_process_app_user_model_id`: nt: false def `macos_set_nswindow_tabbing`: darwin: false .: false libobjc: false AppKit: false NSWindow: false setAllowsAutomaticWindowTabbing:: false def `css_gradient`: \n: false ' stop: {0:f} {1}': false qlineargradient(\n: false ' x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2},\n': false {stops}): false def `message_critical`: An unexpected error occurred.: Prišlo je do nepričakovane napake Error: Napaka def `message_warning`: Death could come at any moment.: Smrt pride nepričakovano. Murphy lurks about. Remember to save frequently.: Murphy je vedno na preži. Ne pozabi na pogosto shranjevanje. Warning: Opozorilo def `message_information`: Information: Informacije I am not a number.: Nisem številka. def `message`: Message: Sporočilo I am neither a postman nor a doctor.: Nisem ne poštar ne dohtar. def `innerGlowBackgroundPixmap`: 'InnerGlowBackground ': false ' ': false def `shadowTemplatePixmap`: 'InnerShadowTemplate ': false ' ': false def `innerShadowPixmap`: 'InnerShadow ': false ' ': false gui/windowlistmanager.py: WindowListManager: false class `WindowListManager`: def `instance`: WindowListManager: false def `__init__`: window-list-manager-action-group: false def `addWindow`: {window} already added: false def `createActionForWindow`: action-canvas-window-list-manager-window-action: false gui/examples/dock.py: def `main`: Expand: false Ctrl+D: false __main__: false gui/examples/toolbox.py: def `main`: A Label: false Another\nlabel: false Tab 1: false Tab 2: false The second tab: false Tab 3: false Tab 4: false

          Hello Visitor

          : false

          Are you interested in some of our wares?

          : false Dear friend: false __main__: false gui/examples/toolgrid.py: def `main`: A: false B: false This one is longer.: false Not done yet!: false The quick brown fox ... does something I guess: false __main__: false help/intersphinx.py: utf-8: false def `read_inventory_v1`: mod: false py:module: false '#module-': false py:: false '#': false -: false def `read_inventory_v2`: utf-8: false zlib: false def `split_lines`: \n: false utf-8: false (?x)(.+?)\s+(\S*:\S*)\s+(\S+)\s+(\S+)\s+(.*): false $: false help/manager.py: class `HelpManager`: def `initialize`: `HelpManager.initialize` is deprecated and does nothing.: false def `get_provider`: Could not get distribution for '%s': false 'Error while initializing help ': false provider for %r: false def `get_help`: help: false search: false def `description_by_id`: No registry set. Cannot resolve: false def `search`: id: false def `search_async`: id: false def `_replacements_for_dist`: PROJECT_NAME: false PROJECT_NAME_LOWER: false PROJECT_VERSION: false DATA_DIR: false data: false URL: false DEVELOP_ROOT: false def `create_intersphinx_provider`: objects.inv: false Local doc root '%s' does not exist.: false def `create_html_provider`: Local doc root '%s' does not exist.: false def `create_html_inventory_provider`: Local doc root '%s' does not exist: false intersphinx: false html-simple: false html-index: false def `get_help_provider_for_distribution`: Distribution: false orange.canvas.help: false orangecanvas.help: false Exception {}: false Created %s provider for %s: false help/provider.py: class `HelpProvider`: def `_networkAccessManagerInstance`: help: false cache_size_mb: false def `search_async`: QUrl: false class `BaseInventoryProvider`: def `__init__`: file: false def `_fetch_inventory`: rb: false def `_on_finished`: \nGET:: false ' (served from cache)': false latin-1: false :: false 'An error occurred while fetching ': false help inventory '{0}': false def `search_async`: QUrl: false class `IntersphinxHelpProvider`: def `search`: std:label: false def `_load_inventory`: '# Sphinx inventory version 1': false '# Sphinx inventory version 2': false Invalid/unknown intersphinx inventory format.: false '{0} does not seem to be an intersphinx ': false inventory file: false class `SimpleHelpProvider`: def `search`: {}.html: false index.html: false http: false https: false .html: false /: false class `HtmlIndexProvider`: def `_load_inventory`: Error reading help index.: false Could not determine html charset from contents.: false utf-8: false Error parsing: false def `_parse`: .//*[@id='widgets']//li/a: false href: false No help references found. Wrong configuration??: false def `sniff_html_charset`: def `parse_content_type`: Tuple[str, List[Tuple[str, str]]]: false ;: false =: false class `CharsetSniff`: def `handle_starttag`: List[Tuple[str, Optional[str]]]: false meta: false charset: false http-equiv: false content-type: false content: false utf-16: false latin-1: false localization/__init__.py: def `pl`: '|': false yY: false aeiouAEIOU: false ies: false s: false def `get_languages`: orangecanvas: false i18n: false .json: false 'Invalid language file ': false English: false def `language_changed`: application/language: false application/last-used-language: false def `update_last_used_language`: application/language: false English: true application/last-used-language: false class `Translator`: def `__init__`: biolab.si: false Orange: false application/language: false i18n: false {lang_eng}.json: false {DEFAULT_LANGUAGE}.json: false Missing language file {path}: false def `c`: : false eval: false localization/si.py: def `plsi`: '|': false a: false i: false e: false ov: false def `plsi_sz`: {n:_}: false _: false 1: false z: false zszzzzsssssssssszzzzzz: false s: false 0: false zzzssssszz: false def `z_besedo`: nič: false m: false en: false enega: false enemu: false enem: false enim: false f: false ena: false ene: false eni: false eno: false n: false dva: false dveh: false dvema: false dve: false tri: false treh: false trem: false tremi: false štiri: false štirih: false štirim: false štirimi: false pet: false petih: false petim: false petimi: false šest: false šestih: false šestim: false šestimi: false sedem: false sedmih: false sedmim: false sedmimi: false osem: false osmih: false osmim: false osmimi: false devet: false devetih: false devetim: false devetimi: false deset: false desetih: false desetim: false desetimi: false preview/previewbrowser.py: ' ': false '

          {name}

          {description}

          ': false class `PreviewBrowser`: def `__init__`: top-layout: false heading: false description-label: false preview-image: false {0!s}: false Path:: false path-label: false path-text: false preview-list-view: false def `setPreviewMargins`: top-layout: false def `__update`: No description.: Brez opisa. \n: false
          : false Untitled: Nepoimenovano utf-8: false def `contractuser`: ~/: false ~: false preview/previewdialog.py: class `PreviewDialog`: def `__setupUi`:

          {0}

          : false Preview: Ogled button-container: false preview/previewmodel.py: ' ': false class `PreviewModel`: def `delayedScanUpdate`: 'delayedScanUpdate: Start': false def `__process_next`: 'delayedScanUpdate: Next %i': false 'delayedScanUpdate: Stop': false 'An unexpected error occurred while ': false scanning '%s'.: false class `PreviewItem`: def `__init__`: Untitled: Nepoimenovano No description.: Brez opisa. def `setName`: untitled: false def `setThumbnail`: utf-8: false preview/scanner.py: class `PreviewHandler`: def `startElement`: scheme: false version: false 1.0: false 2.0: false title: false description: false thumbnail: false def `endElement`: name: false description: false thumbnail: false def `filter_properties`: class `PropertiesFilter`: def `startElement`: properties: false def `endElement`: properties: false utf-8: false def `scheme_svg_thumbnail`: rb: false def `scan_update`: %r is malformed (%r): false Could not render scheme preview for %r: false registry/__init__.py: light-orange: false '#FFD39F': false orange: false '#FFA840': false light-red: false '#FFB7B1': false red: false '#FF7063': false light-pink: false '#FAC1D9': false pink: false '#F584B4': false light-purple: false '#E5BBFB': false purple: false '#CB77F7': false light-blue: false '#CAE1FC': false blue: false '#95C3F9': false light-turquoise: false '#C3F3F3': false turquoise: false '#87E8E8': false light-green: false '#ACE3CE': false green: false '#5AC79E': false light-grass: false '#DFECB0': false grass: false '#C0D962': false light-yellow: false '#F7F5A7': false yellow: false '#F0EC4F': false def `global_registry`: _default: false "'global_registry()' - running widget discovery.": false "'global_registry()' discovery finished.": false def `set_global_registry`: _default: false "'set_global_registry()' - setting registry.": false registry/base.py: class `WidgetRegistry`: def `__init__`: Expected a 'WidgetRegistry' got %r.: false def `widgets`: priority: false def `register_widget`: Expected a 'WidgetDescription' got %r.: false %r already exists in the registry.: false Unspecified: false Creating a default category %r.: false def `register_category`: Expected a 'CategoryDescription' got %r.: false Creating a default category name.: false default: false A category with %r name already exists: false registry/cache.py: def `registry_cache_filename`: registry-cache.pck: false registry.registry-cache: false Creating directory %r: false def `registry_cache`: Loading widget registry cache (%r).: false rb: false Could not load registry cache.: false def `save_registry_cache`: Saving widget registry cache with %i entries (%r).: false wb: false Could not save registry cache: false registry/description.py: DescriptionError: false WidgetSpecificationError: false SignalSpecificationError: false CategorySpecificationError: false Single: false Multiple: false Default: false NonDefault: false Explicit: false Dynamic: false InputSignal: false OutputSignal: false WidgetDescription: false CategoryDescription: false class `InputSignal`: def `__str__`: '{0.__name__}(name={name!r}, type={type!r}, ': false handler={handler!r}, ...): false def `input_channel_from_args`: 'tuple, dict or InputSignal expected ': false (got {0!r}): false class `OutputSignal`: def `__str__`: '{0.__name__}(name={name!r}, type={type!r}, ': false ...): false def `output_channel_from_args`: 'tuple, dict or OutputSignal expected ': false (got {0!r}): false class `WidgetDescription`: def `__init__`: "'qualified_name' must be supplied.": false def `__str__`: 'WidgetDescription(name=%(name)r, id=%(id)r), ': false category=%(category)r, ...): false def `from_module`: "'WidgetDescription.from_module' is deprecated": false class `CategoryDescription`: def `__str__`: CategoryDescription(name=%(name)r, ...): false def `from_package`: "'CategoryDescription.from_package' is deprecated": false registry/discovery.py: _CacheEntry: false mod_path: false name: false mtime: false project_name: false project_version: false exc_type: false exc_val: false description: false def `default_category_name_for_module`: .: false widgets: false class `WidgetDiscovery`: def `__init__`: "'WidgetRegistry', 'Handler' or None expected": false !VERSION: false def `run`: 'An exception occurred while loading ': false entry point '%s': false __path__: false Cannot handle entry point %r: false An exception occurred while processing %r.: false def `process_widget_module`: Invalid widget specification.: false def `process_category_package`: widget_discovery: false category_description: false Error calling 'category_description' in %r.: false Package %r does not describe a category.: false def `process_loader`: Error calling %r: false def `process_iter`: 'Category or Widget Description instance ': false expected. Got %r.: false def `iter_widget_descriptions`: .: false .py: false Ignoring %r.: false Could not import %r.: false Error while importing %r.: false Problem parsing %r: false def `fix_pyext`: .pyo: false pyc: false def `widget_descriptions_from_package`: .: false Error importing %r.: false Error in %r: false registry/qt.py: class `QtWidgetRegistry`: def `item_for_widget`: Unspecified: false def `create_action_for_item`: item: false def `_cat_desc_to_std_item`: icons/default-category.svg: false def `_widget_desc_to_std_item`: icons/default-widget.svg: false 'ul { margin-top: 1px; margin-bottom: 1px; }': false '\ {tooltip} ': false def `tooltip_helper`: {name}: false Orange: false ' (from {0})': false {0}: false
        • {name} ({class_name})
        • : false Inputs:
            {0}
          : Vhodi:
            {0}
          No inputs: Ni vhodov Outputs:
            {0}
          : Izhodi:
            {0}
          No outputs: Ni izhodov
          : false def `whats_this_helper`: help://search?: false id: false

          {0}

          : false

          {0}

          : false more...: false \n: false def `run_discovery`: run_discovery is deprecated and will be removed.: false registry/utils.py: def `widget_from_module_globals`: .: false WIDGET_CLASS: false NAME: false %s.%s: false ID: false INPUTS: false OUTPUTS: false CATEGORY: false VERSION: false DESCRIPTION: false LONG_DESCRIPTION: false AUTHOR: false AUTHOR_EMAIL: false MAINTAINER: false MAINTAINER_EMAIL: false HELP: false HELP_REF: false URL: false ICON: false PRIORITY: false KEYWORDS: false BACKGROUND: false REPLACES: false def `category_from_package_globals`: NAME: false DESCRIPTION: false LONG_DESCRIPTION: false AUTHOR: false AUTHOR_EMAIL: false MAINTAINER: false MAINTAINER_MAIL: false URL: false HELP: false KEYWORDS: false WIDGETS: false PRIORITY: false ICON: false BACKGROUND: false HIDDEN: false prototypes: false def `search_filter_query_helper`: -: false ' ': false registry/tests/__init__.py: def `make_module`: {}.{}: false Test: false This is a test.: false This. Is. A. Test.: false constants: false Constants: false zero: false Zero: false value: false one: false One: false operators: false Operators: false add: false Add: false left: false set_left: false right: false set_right: false sub: false Subtract: false mult: false Multiply: false div: false Divide: false def `small_testing_registry`: Constants: false light-orange: false zero: false value: false int: false val: false one: false unit: false tuple: false Operators: false grass: false add: false left: false set_left: false right: false set_right: false droite: false result: false sub: false mult: false div: false negate: false set_value: false Structure: false red: false cons: false first: false object: false set_first: false second: false set_second: false decons: false set_cons: false First matched: false Second matched: false empty: false No match: false scheme/annotations.py: class `SchemeArrowAnnotation`: def `__init__`: red: false class `SchemeTextAnnotation`: def `__init__`: text/plain: false def `set_text`: text/plain: false def `set_content`: text/plain: false scheme/events.py: WorkflowEvent: false NodeEvent: false LinkEvent: false AnnotationEvent: false WorkflowEnvChanged: false scheme/link.py: def `resolve_types`: An unexpected error while resolving type {!r}:\n{}: false def `_get_first_type`: 'Multiple types specified, but using only the first. ': false Use `{newname}` instead.: false no type spec: false {!r} does not resolve to a type: false class `SchemeLink`: def `__init__`: %r not in in nodes output channels.: false %r not in in nodes input channels.: false Cannot connect %r to %r: false def `source_type`: `source_type()` is deprecated. Use `source_types()`.: false source_types: false def `sink_type`: `sink_type()` is deprecated. Use `sink_types()`.: false sink_types: false def `__str__`: {0}(({1}, {2}) -> ({3}, {4})): false scheme/node.py: class `SchemeNode`: def `input_channel`: %r is not a valid input channel for %r.: false def `output_channel`: %r is not a valid output channel for %r.: false def `set_state_message`: "'message' with no id was ignored. ": false This will raise an error in the future.: false def `__str__`: SchemeNode(description_id=%r, title=%r, ...): false scheme/readwrite.py: def `_ast_parse_expr`: : false eval: false def `string_eval`: %r is not a string literal: false def `tuple_eval`: %r is not a tuple literal: false Can only contain numbers or strings: false def `_terminal_value`: Not a terminal: false _scheme: false title: false version: false description: false nodes: false _node: false links: false _link: false annotations: false _annotation: false session_state: false _session_data: false id: false name: false position: false project_name: false qualified_name: false data: false _data: false format: false source_node_id: false sink_node_id: false source_channel: false source_channel_id: false sink_channel: false sink_channel_id: false enabled: false type: false params: false _text_params: false _arrow_params: false geometry: false text: false font: false content_type: false color: false _window_group: false default: false state: false groups: false def `parse_ows_etree_v_2_0`: version: false node_properties/properties: false node_id: false format: false 2.0: false data: false nodes/node: false id: false position: false title: false name: false project_name: false qualified_name: false links/link: false source_node_id: false sink_node_id: false source_channel: false source_channel_id: false sink_channel: false sink_channel_id: false enabled: false true: false annotations/*: false text: false rect: false (0.0, 0.0, 20.0, 20.0): false font-family: false font-size: false family: false size: false type: false text/plain: false arrow: false start: false (0, 0): false end: false fill: false red: false Unknown annotation '%s'. Skipping.: false session_state/window_groups/group: false default: false false: false window_state: false ascii: false description: false def `parse_ows_stream`: scheme: false Invalid Orange Workflow Scheme file: false version: false widgets: false 'Cannot open Orange Workflow Scheme v1.0. This format is no ': false longer supported: false Invalid Orange Workflow Scheme file (missing version).: false 2.0: false 2.1: false Unsupported format version {version}: false def `scheme_load`: Could not load properties for %r.: false text: false arrow: false 'Ignoring unknown annotation type: %r': false def `_find_source_channel`: '{link.source_channel!r} is not a valid output channel ': false for {node.description.name!r}.: false def `_find_sink_channel`: '{link.sink_channel!r} is not a valid input channel ': false for {node.description.name!r}.: false def `scheme_to_etree`: literal: false scheme: false version: false 2.0: false title: false description: false nodes: false id: false name: false qualified_name: false project_name: false position: false scheme_node_type: false %s.%s: false node: false links: false source_node_id: false sink_node_id: false source_channel: false sink_channel: false enabled: false true: false false: false source_channel_id: false sink_channel_id: false link: false annotations: false text: false type: false rect: false font-family: false family: false font-size: false size: false arrow: false start: false end: false fill: false Can't save %r: false thumbnail: false node_properties: false Error serializing properties for node %r: false properties: false node_id: false format: false session_state: false window_groups: false group: false default: false window_state: false ascii: false window_group: false def `scheme_to_ows_stream`: literal: false utf-8: false def `indent`: \t: false def `indent_`: \n: false def `dumps`: literal: false Could not serialize to a literal string: false json: false Could not serialize to a json string: false pickle: false ascii: false Unsupported format %r: false Using pickle fallback: false Something strange happened.: false def `loads`: literal: false json: false pickle: false ascii: false Unknown format: false def `literal_dumps`: def `check`: 'Non-finite values can not be ': false serialized as a python literal: false {0} is a recursive structure: false '{0} can not be serialized as a python ': false literal: false def `check_relaxed`: 'Non-finite values can not be ': false serialized as a python literal: false {0} is a recursive structure: false '{0} can not be serialized as a python ': false literal: false scheme/scheme.py: T: false class `Scheme`: def `insert_node`: Node already in scheme.: false Added node %r to scheme %r.: false def `new_node`: Expected %r, got %r.: false def `remove_node`: Node is not in the scheme.: false Removed node %r from scheme %r.: false def `insert_link`: Added link %r (%r) -> %r (%r) to scheme %r.: false def `remove_link`: Link is not in the scheme.: false Removed link %r (%r) -> %r (%r) from scheme %r.: false def `check_connect`: Cannot create self cycle in the scheme: false Cannot create cycles in the scheme: false Cannot connect %r to %r.: false A link from %r (%r) -> %r (%r) already exists: false %r is already connected.: false def `insert_annotation`: Cannot add the same annotation multiple times: false def `save_to`: wb: false def `load_from`: Scheme is not empty.: false rb: false def `window_group_presets`: _presets: false def `set_window_group_presets`: _presets: false scheme/signalmanager.py: V: false K: false class `Signal`: Signal: false link: false value: false id: false index: false Type[New]: false Type[Update]: false Type[Close]: false enabled: false class `_LazyValueType`: class `LazyValueMeta`: def `__repr__`: LazyValue[{cls.type().__name__}]: false class `_OutputState`: flags: false outputs: false def `__repr__`: State(flags={}, outputs={!r}): false class `_LinkExtra`: flags: false class `SignalManager`: def `__on_node_removed`: Removing pending signals for '%s'.: false def `__on_link_added`: Scheduling signal data update for '%s'.: false def `eventFilter`: Scheduling close signal (%s).: false def `__on_link_enabled_changed`: Link %s enabled. Scheduling signal data update.: false def `send`: "'send' called with no workflow!.": false `id` parameter is deprecated and will be removed in v0.2: false '%r sending %r (id: %r) on channel %r': false 'Sending multiple values on the same output channel via ': false different ids is no longer supported.: false %r clear invalidated flag on channel %r: false def `invalidate`: %r invalidating channel %r: false def `purge_link`: `purge_link` is deprecated.: false def `process_queued`: `max_nodes` is deprecated and will be removed in the future: false Cannot re-enter 'process_queued': false Can't process in state %i: false def `process_node`: Processing %r, sending %i signals.: false def `__process_next`: Received 'UpdateRequest' while not in 'Running' state: false "Received 'UpdateRequest' while in 'process_queued'. ": false 'An update will be re-scheduled when exiting the ': false current update.: false def `__process_next_helper`: 'Process next, queued signals: %i, nactive: %i ': false '(max_active: %i)': false title: false 'Pending nodes: %s': false 'Blocking nodes: %s': false 'Invalidated nodes: %s': false 'Nodes ready for update: %s': false def `max_active`: MAX_ACTIVE_NODES: false max-active-nodes: false def `compress_signals`: Must preserve signal id: false def `compress_single`: def `is_none_update`: Optional[Signal]: false def `is_update`: Optional[Signal]: false def `is_close`: Optional[Signal]: false scheme/widgetmanager.py: WidgetManager: false class `WidgetManager`: class `CreationPolicy`: Normal: false Immediate: false OnDemand: false def `__init__`: action-canvas-windows-list-head: false def `__add_widget_for_node`: Creating widget for node %s: false widgetmanager-error-placeholder: false
          : false
                      
          : false Raise Canvas to Front: Pokaži platno action-canvas-raise-canvas: false Raise containing canvas workflow window: Postavi platno z delotokom v ospredje Ctrl+Up: false Raise Descendants: Pokaži naslednike action-canvas-raise-descendants: false Raise all immediate descendants of this node: Postavi v ospredje gradnike, ki sledijo izbranemu Ctrl+Shift+Right: false Raise Ancestors: Pokaži prednike action-canvas-raise-ancestors: false Raise all immediate ancestors of this node: Postavi v ospredje gradnike, ki so pred izbranim Ctrl+Shift+Left: false def `__process_init_queue`: "__process_init_queue: '%s'": false def `__update_actions_state`: action-canvas-raise-ancestors: false action-canvas-raise-descendants: false styles/__init__.py: _T: false def `breeze_light`: '#30363C': false '#888786': false '#EFF0F1': false '#FCFCFC': false '#00B0EF': false '#0057B4': false '#ffffff': false '#c4c9cd': false '#888e93': false '#474a4c': false breeze-light: false breeze-dark: false zion-reversed: false dark: false def `style_sheet`: def `process_qss`: ^\s*@([a-zA-Z0-9_]+?)\s*:\s*([a-zA-Z0-9_/]+?);\s*$: false r: false utf-8: false qss: false utils/__init__.py: dotted_getattr: false qualified_name: false name_lookup: false type_lookup: false type_lookup_: false asmodule: false check_type: false check_arg: false check_subclass: false unique: false assocv: false assocf: false group_by_all: false mapping_get: false findf: false set_flag: false is_flag_set: false qsizepolicy_is_expanding: false qsizepolicy_is_shrinking: false is_event_source_mouse: false UNUSED: false H: false A: false B: false C: false K: false V: false F: false def `dotted_getattr`: .: false def `qualified_name`: builtins: false %s.%s: false def `type_str`: Union[: false ', ': false ]: false builtin.: false def `name_lookup`: .: false builtins.: false def `type_lookup`: "'{}' is a {!r} not a type": false def `check_type`: Expected %r. Got %r: false def `check_subclass`: Expected %r. Got %r: false def `unique`: Callable[[A], H]: false def `findf`: Union[A, B]: false utils/after_exit.py: def `run_after_exit`: --arg=: false --log=: false -m: false register_at_fork: false def `main`: -f: false -a: false --arg: false append: false --log: false Log file: false w: false 'Blocking on read from fd: %d': false Unexpected content %r from parent: false Parent closed fd; %d: false 'Starting new process with cmd: %r': false __main__: false utils/asyncutils.py: def `get_event_loop`: QCoreApplication is not running: false Called from non-main thread: false utils/graph.py: traverse_bf: false strongly_connected_components: false H: false utils/image.py: def `qimage_copy_from_buffer`: Wrong data.shape (expected ({w}, {h}) got {data.shape}): false utils/markup.py: def `render_plain`: '

          ': false

          : false def `render_rst`: report_level: false output-encoding: false utf-8: false html: false text/plain: false text/rst: false text/x-rst: false text/markdown: false text/html: false def `render_as_rich_text`: text/plain: false ;: false utils/overlay.py: class `NotificationMessageWidget`: _Button: false button: false role: false stdbutton: false def `__init__`: Ok: true No: Ne icon-label: false title-label: false text-label: false darwin: false def `addButton`: 'Wrong number of arguments for ': false addButton(QAbstractButton, role): false addButton(StandardButton): false addButton(str, ButtonRole): false darwin: false def `proxydoc`: __doc__: false class `NotificationWidget`: def `__init__`: Ok: true No: Ne darwin: false def `resizeEvent`: darwin: false def `fromNotification`: title: false text: false icon: false acceptLabel: false rejectLabel: false standardButtons: false class `NotificationOverlay`: def `nextWidget`: Received next notification signal while no notification is displayed: false class `NotificationServer`: def `_nextNotification`: Received next notification signal while no notification is enqueued: false utils/pickle.py: class `Pickler`: def `persistent_id`: scheme: false SchemeNode_: false SchemeLink_: false BaseSchemeAnnotation_: false class `Unpickler`: def `persistent_load`: scheme: false SchemeNode_: false _: false SchemeLink_: false BaseSchemeAnnotation_: false Unsupported persistent object: false def `scratch_swp_base_name`: scratch.swp.p: false scratch-crashes: false def `swp_name`: .: false .swp.p: false startup/load-crashed-workflows: false def `glob_scratch_swps`: .*: false utils/pkgmeta.py: Distribution: false EntryPoint: false entry_points: false normalize_name: false trim: false trim_leading_lines: false trim_trailing_lines: false parse_meta: false get_dist_meta: false get_distribution: false develop_root: false get_dist_url: false def `normalize_name`: [-_.]+: false -: false _: false def `_direct_url`: direct_url.json: false def `develop_root`: dir_info: false editable: false url: false file: false {normalize_name(dist.name)}.egg-info/: false setup.py: false def `trim`: \n: false Platform: false Supported-Platform: false Classifier: false Requires-Dist: false Provides-Dist: false Obsoletes-Dist: false Project-URL: false def `parse_meta`: Description: false Metadata-Version: false 1.3: false def `get_dist_url`: Home-page: false def `get_dist_meta`: Description: false utils/propertybindings.py: def `find_meta_property`: %s does no have a property named %r.: false def `find_notifier`: %s does not have a notifier signal.: false utf-8: false (: false class `PropertyBindingExpr`: def `__init__`: eval: false : false def `set`: Cannot set a value of an expression: false def `bindTo`: Cannot bind an expression: false class `BindingManager`: darwin: false class `UnboundBindingWrapper`: def `to`: Can only call 'to' once.: false utils/qinvoke.py: A: false T1: false T2: false T3: false T4: false T5: false T6: false utils/qobjref.py: qobjref: false qobjref_weak: false Q: false class `qobjref`: __obj: false __state: false __weakref__: false def `__repr__`: 'to ': false dead: false ': false class `qobjref_weak`: __obj_ref: false __state: false __weakref__: false def `__repr__`: 'to ': false dead: false ': false utils/qtcompat.py: def `toPyObject`: toPyObject is deprecated and will be removed.: false def `qunwrap`: qunwrap is deprecated and will be removed.: false def `qwrap`: qwrap is deprecated and will be removed.: false utils/redirect.py: "'{}' is deprecated use contextlib.redirect_{stderr,stdin}.": false utils/settings.py: config_slot: false key: false value_type: false default_value: false doc: false class `_pickledvalue`: def `__init__`: "'_pickledvalue' instances should not be created": false class `Settings`: def `__init__`: /: false def `__key`: /: false def `__getitem__`: {0!r} is a group: false def `__setitem__`: Expected {0!r} got {1!r}: false def `__iter__`: /: false def `group`: /: false def `isgroup`: {0!r} is not a valid key: false def `customEvent`: /: false _T: false def `QSettings_readArray`: def `normalize_spec`: len(spec) != 2: false utils/shtools.py: def `python_process`: nt: false pythonw.exe: false python.exe: false def `__nt_kwargs_defaults`: CREATE_NO_WINDOW: false creationflags: false def `python_run`: nt: false pythonw.exe: false python.exe: false def `create_process`: nt: false def `temp_named_file`: utf-8: false wt: false utils/localization/__init__.py: import 'orangecanvas.localization', not 'orangecanvas.utils.localization': false utils/localization/si.py: import 'orangecanvas.localization.si', not 'orangecanvas.utils.localization.si': false ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/i18n/trubar-config.yaml0000644000175100002000000000066314730024325021136 0ustar00runnerdockerlanguages: en: name: English original: true si: name: Slovenščina international-name: Slovenian auto-import: from orangecanvas.localization.si import plsi, plsi_sz, z_besedo # pylint: disable=wrong-import-order auto-import: |2 from orangecanvas.localization import Translator # pylint: disable=wrong-import-order _tr = Translator("orangecanvas", "biolab.si", "Orange") del Translator encoding: "utf-8"././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.810081 orange_canvas_core-0.2.5/orange_canvas_core.egg-info/0000755000175100002000000000000014730024333022233 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355162.0 orange_canvas_core-0.2.5/orange_canvas_core.egg-info/PKG-INFO0000644000175100002000000000410614730024332023330 0ustar00runnerdockerMetadata-Version: 2.1 Name: orange-canvas-core Version: 0.2.5 Summary: Core component of Orange Canvas Home-page: http://orange.biolab.si/ Author: Bioinformatics Laboratory, FRI UL Author-email: contact@orange.biolab.si License: GPLv3 Project-URL: Bug Reports, https://github.com/biolab/orange-canvas-core/issues Project-URL: Source, https://github.com/biolab/orange-canvas-core/ Project-URL: Documentation, https://orange-canvas-core.readthedocs.io/en/latest/ Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-File: LICENSE.txt Requires-Dist: AnyQt>=0.2.0 Requires-Dist: docutils Requires-Dist: commonmark>=0.8.1 Requires-Dist: requests Requires-Dist: requests-cache Requires-Dist: pip>=18.0 Requires-Dist: dictdiffer Requires-Dist: qasync>=0.10.0 Requires-Dist: importlib_metadata>=4.6; python_version < "3.10" Requires-Dist: importlib_resources; python_version < "3.9" Requires-Dist: typing_extensions Requires-Dist: packaging Requires-Dist: numpy Provides-Extra: docbuild Requires-Dist: sphinx; extra == "docbuild" Requires-Dist: sphinx-rtd-theme; extra == "docbuild" Orange Canvas Core ================== .. image:: https://github.com/biolab/orange-canvas-core/workflows/Run%20tests/badge.svg :target: https://github.com/biolab/orange-canvas-core/actions?query=workflow%3A%22Run+tests%22 :alt: Github Actions CI Build Status .. image:: https://readthedocs.org/projects/orange-canvas-core/badge/?version=latest :target: https://orange-canvas-core.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status Orange Canvas Core is a framework for building graphical user interfaces for editing workflows. It is a component used to build the Orange Canvas (http://orange.biolab.si) data-mining application (for which it was developed in the first place). Installation ------------ Orange Canvas Core is pip installable (https://pip.pypa.io/), simply run:: pip install orange-canvas-core Or use the:: pip install ./ to install from the sources. Documentation ------------- Some incomplete documentation is available at https://orange-canvas-core.readthedocs.io ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355162.0 orange_canvas_core-0.2.5/orange_canvas_core.egg-info/SOURCES.txt0000644000175100002000000002356614730024332024132 0ustar00runnerdockerLICENSE.txt MANIFEST.in README.rst pyproject.toml setup.cfg setup.py docs/make.bat docs/requirements-rtd.txt docs/source/conf.py docs/source/index.rst docs/source/orangecanvas/application.canvasmain.rst docs/source/orangecanvas/application.rst docs/source/orangecanvas/application.welcomedialog.rst docs/source/orangecanvas/canvas.items.annotationitem.rst docs/source/orangecanvas/canvas.items.linkitem.rst docs/source/orangecanvas/canvas.items.nodeitem.rst docs/source/orangecanvas/canvas.rst docs/source/orangecanvas/canvas.scene.rst docs/source/orangecanvas/document.interactions.rst docs/source/orangecanvas/document.quickmenu.rst docs/source/orangecanvas/document.rst docs/source/orangecanvas/document.schemeedit.rst docs/source/orangecanvas/gui.dock.rst docs/source/orangecanvas/gui.dropshadow.rst docs/source/orangecanvas/gui.framelesswindow.rst docs/source/orangecanvas/gui.lineedit.rst docs/source/orangecanvas/gui.quickhelp.rst docs/source/orangecanvas/gui.rst docs/source/orangecanvas/gui.splashscreen.rst docs/source/orangecanvas/gui.stackedwidget.rst docs/source/orangecanvas/gui.toolbar.rst docs/source/orangecanvas/gui.toolbox.rst docs/source/orangecanvas/gui.toolgrid.rst docs/source/orangecanvas/gui.tooltree.rst docs/source/orangecanvas/index.rst docs/source/orangecanvas/overview.rst docs/source/orangecanvas/registry.rst docs/source/orangecanvas/scheme.annotation.rst docs/source/orangecanvas/scheme.events.rst docs/source/orangecanvas/scheme.link.rst docs/source/orangecanvas/scheme.node.rst docs/source/orangecanvas/scheme.readwrite.rst docs/source/orangecanvas/scheme.rst docs/source/orangecanvas/scheme.scheme.rst docs/source/orangecanvas/scheme.signalmanager.rst docs/source/orangecanvas/scheme.widgetmanager.rst i18n/trubar-config.yaml i18n/si/msgs.jaml orange_canvas_core.egg-info/PKG-INFO orange_canvas_core.egg-info/SOURCES.txt orange_canvas_core.egg-info/dependency_links.txt orange_canvas_core.egg-info/requires.txt orange_canvas_core.egg-info/top_level.txt orangecanvas/__init__.py orangecanvas/__main__.py orangecanvas/config.py orangecanvas/main.py orangecanvas/resources.py orangecanvas/application/__init__.py orangecanvas/application/aboutdialog.py orangecanvas/application/addons.py orangecanvas/application/application.py orangecanvas/application/canvasmain.py orangecanvas/application/canvastooldock.py orangecanvas/application/examples.py orangecanvas/application/outputview.py orangecanvas/application/schemeinfo.py orangecanvas/application/settings.py orangecanvas/application/welcomedialog.py orangecanvas/application/widgettoolbox.py orangecanvas/application/tests/__init__.py orangecanvas/application/tests/test_addons.py orangecanvas/application/tests/test_addons_utils.py orangecanvas/application/tests/test_application.py orangecanvas/application/tests/test_canvastooldock.py orangecanvas/application/tests/test_main.py orangecanvas/application/tests/test_mainwindow.py orangecanvas/application/tests/test_outputview.py orangecanvas/application/tests/test_schemeinfo.py orangecanvas/application/tests/test_settings.py orangecanvas/application/tests/test_welcomedialog.py orangecanvas/application/tests/test_widgettoolbox.py orangecanvas/application/utils/__init__.py orangecanvas/application/utils/addons.py orangecanvas/canvas/__init__.py orangecanvas/canvas/layout.py orangecanvas/canvas/scene.py orangecanvas/canvas/view.py orangecanvas/canvas/items/__init__.py orangecanvas/canvas/items/annotationitem.py orangecanvas/canvas/items/controlpoints.py orangecanvas/canvas/items/graphicspathobject.py orangecanvas/canvas/items/graphicstextitem.py orangecanvas/canvas/items/linkitem.py orangecanvas/canvas/items/nodeitem.py orangecanvas/canvas/items/utils.py orangecanvas/canvas/items/tests/__init__.py orangecanvas/canvas/items/tests/test_annotationitem.py orangecanvas/canvas/items/tests/test_controlpoints.py orangecanvas/canvas/items/tests/test_graphicspathobject.py orangecanvas/canvas/items/tests/test_graphicstextitem.py orangecanvas/canvas/items/tests/test_linkitem.py orangecanvas/canvas/items/tests/test_nodeitem.py orangecanvas/canvas/items/tests/test_utils.py orangecanvas/canvas/tests/__init__.py orangecanvas/canvas/tests/test_layout.py orangecanvas/canvas/tests/test_scene.py orangecanvas/canvas/tests/test_view.py orangecanvas/document/__init__.py orangecanvas/document/commands.py orangecanvas/document/editlinksdialog.py orangecanvas/document/interactions.py orangecanvas/document/quickmenu.py orangecanvas/document/schemeedit.py orangecanvas/document/suggestions.py orangecanvas/document/usagestatistics.py orangecanvas/document/tests/__init__.py orangecanvas/document/tests/test_editlinksdialog.py orangecanvas/document/tests/test_quickmenu.py orangecanvas/document/tests/test_schemeedit.py orangecanvas/document/tests/test_usagestatistics.py orangecanvas/gui/__init__.py orangecanvas/gui/dock.py orangecanvas/gui/dropshadow.py orangecanvas/gui/framelesswindow.py orangecanvas/gui/iconengine.py orangecanvas/gui/iconview.py orangecanvas/gui/itemmodels.py orangecanvas/gui/lineedit.py orangecanvas/gui/quickhelp.py orangecanvas/gui/splashscreen.py orangecanvas/gui/stackedwidget.py orangecanvas/gui/svgiconengine.py orangecanvas/gui/test.py orangecanvas/gui/textlabel.py orangecanvas/gui/toolbar.py orangecanvas/gui/toolbox.py orangecanvas/gui/toolgrid.py orangecanvas/gui/tooltree.py orangecanvas/gui/utils.py orangecanvas/gui/windowlistmanager.py orangecanvas/gui/tests/__init__.py orangecanvas/gui/tests/test_dock.py orangecanvas/gui/tests/test_dropshadow.py orangecanvas/gui/tests/test_framelesswindow.py orangecanvas/gui/tests/test_iconengine.py orangecanvas/gui/tests/test_lineedit.py orangecanvas/gui/tests/test_splashscreen.py orangecanvas/gui/tests/test_stackedwidget.py orangecanvas/gui/tests/test_toolbar.py orangecanvas/gui/tests/test_toolbox.py orangecanvas/gui/tests/test_toolgrid.py orangecanvas/gui/tests/test_tooltree.py orangecanvas/help/__init__.py orangecanvas/help/intersphinx.py orangecanvas/help/manager.py orangecanvas/help/provider.py orangecanvas/help/tests/__init__.py orangecanvas/help/tests/test_manager.py orangecanvas/help/tests/test_provider.py orangecanvas/icons/Arrow.svg orangecanvas/icons/Back.svg orangecanvas/icons/Document Info.svg orangecanvas/icons/Documentation.svg orangecanvas/icons/Dropdown.svg orangecanvas/icons/Examples.svg orangecanvas/icons/Get Started.svg orangecanvas/icons/Grid.svg orangecanvas/icons/Info.svg orangecanvas/icons/Maximize Toolbar.svg orangecanvas/icons/Minimize Toolbar.svg orangecanvas/icons/New.svg orangecanvas/icons/Open.svg orangecanvas/icons/Pause.svg orangecanvas/icons/Recent.svg orangecanvas/icons/Search.svg orangecanvas/icons/Text Size.svg orangecanvas/icons/Tutorials.svg orangecanvas/icons/YouTube.svg orangecanvas/icons/arrow-right.svg orangecanvas/icons/default-category.svg orangecanvas/icons/default-widget.svg orangecanvas/icons/orange-canvas-core-splash.svg orangecanvas/icons/orange-canvas.svg orangecanvas/icons/orange-splash-screen.png orangecanvas/localization/__init__.py orangecanvas/localization/si.py orangecanvas/localization/tests/__init__.py orangecanvas/localization/tests/test_localization.py orangecanvas/localization/tests/test_si.py orangecanvas/preview/__init__.py orangecanvas/preview/previewbrowser.py orangecanvas/preview/previewdialog.py orangecanvas/preview/previewmodel.py orangecanvas/preview/scanner.py orangecanvas/preview/tests/__init__.py orangecanvas/preview/tests/test_previewbrowser.py orangecanvas/preview/tests/test_previewdialog.py orangecanvas/preview/tests/test_scanner.py orangecanvas/registry/__init__.py orangecanvas/registry/base.py orangecanvas/registry/cache.py orangecanvas/registry/description.py orangecanvas/registry/discovery.py orangecanvas/registry/qt.py orangecanvas/registry/utils.py orangecanvas/registry/tests/__init__.py orangecanvas/registry/tests/test_base.py orangecanvas/registry/tests/test_discovery.py orangecanvas/scheme/__init__.py orangecanvas/scheme/annotations.py orangecanvas/scheme/errors.py orangecanvas/scheme/events.py orangecanvas/scheme/link.py orangecanvas/scheme/node.py orangecanvas/scheme/readwrite.py orangecanvas/scheme/scheme.py orangecanvas/scheme/signalmanager.py orangecanvas/scheme/widgetmanager.py orangecanvas/scheme/tests/__init__.py orangecanvas/scheme/tests/test_annotations.py orangecanvas/scheme/tests/test_links.py orangecanvas/scheme/tests/test_nodes.py orangecanvas/scheme/tests/test_readwrite.py orangecanvas/scheme/tests/test_scheme.py orangecanvas/scheme/tests/test_signalmanager.py orangecanvas/scheme/tests/test_widgetmanager.py orangecanvas/styles/__init__.py orangecanvas/styles/darkorange.qss orangecanvas/styles/orange.qss orangecanvas/styles/orange/Arrow.svg orangecanvas/styles/orange/Document Info.svg orangecanvas/styles/orange/Dropdown.svg orangecanvas/styles/orange/Grid.svg orangecanvas/styles/orange/Info.svg orangecanvas/styles/orange/Pause.svg orangecanvas/styles/orange/Search.svg orangecanvas/styles/orange/Text Size.svg orangecanvas/utils/__init__.py orangecanvas/utils/after_exit.py orangecanvas/utils/asyncutils.py orangecanvas/utils/graph.py orangecanvas/utils/image.py orangecanvas/utils/markup.py orangecanvas/utils/overlay.py orangecanvas/utils/pickle.py orangecanvas/utils/pkgmeta.py orangecanvas/utils/propertybindings.py orangecanvas/utils/qinvoke.py orangecanvas/utils/qobjref.py orangecanvas/utils/qtcompat.py orangecanvas/utils/redirect.py orangecanvas/utils/settings.py orangecanvas/utils/shtools.py orangecanvas/utils/localization/__init__.py orangecanvas/utils/localization/si.py orangecanvas/utils/tests/__init__.py orangecanvas/utils/tests/test_after_exit.py orangecanvas/utils/tests/test_graph.py orangecanvas/utils/tests/test_markup.py orangecanvas/utils/tests/test_overlay.py orangecanvas/utils/tests/test_pkgmeta.py orangecanvas/utils/tests/test_propertybindings.py orangecanvas/utils/tests/test_qinvoke.py orangecanvas/utils/tests/test_qobjref.py orangecanvas/utils/tests/test_resources.py orangecanvas/utils/tests/test_settings.py orangecanvas/utils/tests/test_shtools.py orangecanvas/utils/tests/test_utils.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355162.0 orange_canvas_core-0.2.5/orange_canvas_core.egg-info/dependency_links.txt0000644000175100002000000000000114730024332026300 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355162.0 orange_canvas_core-0.2.5/orange_canvas_core.egg-info/requires.txt0000644000175100002000000000041514730024332024632 0ustar00runnerdockerAnyQt>=0.2.0 docutils commonmark>=0.8.1 requests requests-cache pip>=18.0 dictdiffer qasync>=0.10.0 typing_extensions packaging numpy [:python_version < "3.10"] importlib_metadata>=4.6 [:python_version < "3.9"] importlib_resources [DOCBUILD] sphinx sphinx-rtd-theme ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355162.0 orange_canvas_core-0.2.5/orange_canvas_core.egg-info/top_level.txt0000644000175100002000000000001514730024332024760 0ustar00runnerdockerorangecanvas ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.774081 orange_canvas_core-0.2.5/orangecanvas/0000755000175100002000000000000014730024333017372 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/__init__.py0000644000175100002000000000000014730024325021472 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/__main__.py0000644000175100002000000000015014730024325021461 0ustar00runnerdockerimport sys from orangecanvas.main import main if __name__ == "__main__": sys.exit(main(sys.argv)) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.778081 orange_canvas_core-0.2.5/orangecanvas/application/0000755000175100002000000000000014730024333021675 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/__init__.py0000644000175100002000000000010014730024325023776 0ustar00runnerdocker""" Main Orange Canvas Application and supporting classes. """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/aboutdialog.py0000644000175100002000000000261414730024325024545 0ustar00runnerdocker""" Application about dialog. ------------------------- """ import sys from xml.sax.saxutils import escape from AnyQt.QtWidgets import ( QDialog, QDialogButtonBox, QVBoxLayout, QLabel, QApplication ) from AnyQt.QtCore import Qt from .. import config ABOUT_TEMPLATE = """\

          {name}

          Version: {version}

          """ class AboutDialog(QDialog): def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) if sys.platform == "darwin": self.setAttribute(Qt.WA_MacSmallSize, True) self.__setupUi() def __setupUi(self): layout = QVBoxLayout() label = QLabel(self) pixmap, _ = config.splash_screen() label.setPixmap(pixmap) layout.addWidget(label, Qt.AlignCenter) name = QApplication.applicationName() version = QApplication.applicationVersion() text = ABOUT_TEMPLATE.format( name=escape(name), version=escape(version), ) # TODO: Also list all known add-on versions??. text_label = QLabel(text) layout.addWidget(text_label, Qt.AlignCenter) buttons = QDialogButtonBox( QDialogButtonBox.Close, Qt.Horizontal, self) layout.addWidget(buttons) buttons.rejected.connect(self.accept) layout.setSizeConstraint(QVBoxLayout.SetFixedSize) self.setLayout(layout) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/addons.py0000644000175100002000000010531714730024325023527 0ustar00runnerdockerimport sys import os import logging import traceback import typing from xml.sax.saxutils import escape from concurrent.futures import ThreadPoolExecutor, Future from typing import List, Any, Optional, Tuple from packaging.requirements import Requirement from AnyQt.QtWidgets import ( QDialog, QLineEdit, QTreeView, QHeaderView, QTextBrowser, QDialogButtonBox, QProgressDialog, QVBoxLayout, QPushButton, QFormLayout, QHBoxLayout, QMessageBox, QStyledItemDelegate, QStyle, QApplication, QStyleOptionViewItem, QShortcut ) from AnyQt.QtGui import ( QStandardItemModel, QStandardItem, QTextOption, QDropEvent, QDragEnterEvent, QKeySequence ) from AnyQt.QtCore import ( QSortFilterProxyModel, QItemSelectionModel, Qt, QSize, QTimer, QThread, QEvent, QAbstractItemModel, QModelIndex, ) from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot from AnyQt import sip from orangecanvas.application.utils.addons import ( Installed, prettify_name, is_updatable, Available, Install, Upgrade, Uninstall, installable_items, list_available_versions, Installable, Item, query_pypi, get_meta_from_archive, Installer, ) from orangecanvas.utils import name_lookup, markup, qualified_name, enum_as_int from ..utils.pkgmeta import get_dist_meta from ..utils.qinvoke import qinvoke from ..gui.utils import message_warning, message_critical as message_error from .. import config from ..config import Config from ..utils.pkgmeta import Distribution log = logging.getLogger(__name__) HasConstraintRole = Qt.UserRole + 0xf45 DetailedText = HasConstraintRole + 1 def description_rich_text(item): # type: (Item) -> str if isinstance(item, Installed): remote, dist = item.installable, item.local if remote is None: meta = get_dist_meta(dist) description = meta.get("Description", "") or \ meta.get('Summary', "") content_type = meta.get("Description-Content-Type") else: description = remote.description content_type = remote.description_content_type else: description = item.installable.description content_type = item.installable.description_content_type if not content_type: # if not defined try rst and fallback to plain text content_type = "text/x-rst" try: html = markup.render_as_rich_text(description, content_type) except Exception: html = markup.render_as_rich_text(description, "text/plain") return html class ActionItem(QStandardItem): def data(self, role=Qt.UserRole + 1) -> Any: if role == Qt.DisplayRole: model = self.model() modelindex = self._sibling(PluginsModel.StateColumn) item = model.data(modelindex, Qt.UserRole) state = model.data(modelindex, Qt.CheckStateRole) flags = model.flags(modelindex) if flags & Qt.ItemIsUserTristate and state == Qt.Checked: return "Update" elif isinstance(item, Available) and state == Qt.Checked: return "Install" elif isinstance(item, Installed) and state == Qt.Unchecked: return "Uninstall" else: return "" elif role == DetailedText: item = self.data(Qt.UserRole) if isinstance(item, (Available, Installed)): return description_rich_text(item) return super().data(role) def _sibling(self, column) -> QModelIndex: model = self.model() if model is None: return QModelIndex() index = model.indexFromItem(self) return index.sibling(self.row(), column) def _siblingData(self, column: int, role: int): return self._sibling(column).data(role) class StateItem(QStandardItem): def setData(self, value: Any, role: int = Qt.UserRole + 1) -> None: if role == Qt.CheckStateRole: super().setData(value, role) # emit the dependent ActionColumn's data changed sib = self.index().sibling(self.row(), PluginsModel.ActionColumn) if sib.isValid(): self.model().dataChanged.emit(sib, sib, (Qt.DisplayRole,)) return return super().setData(value, role) def data(self, role=Qt.UserRole + 1): if role == DetailedText: item = self.data(Qt.UserRole) if isinstance(item, (Available, Installed)): return description_rich_text(item) return super().data(role) class PluginsModel(QStandardItemModel): StateColumn, NameColumn, VersionColumn, ActionColumn = range(4) def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) self.setHorizontalHeaderLabels( ["", self.tr("Name"), self.tr("Version"), self.tr("Action")] ) @staticmethod def createRow(item): # type: (Item) -> List[QStandardItem] dist = None # type: Optional[Distribution] if isinstance(item, Installed): installed = True ins, dist = item.installable, item.local name = prettify_name(dist.name) summary = get_dist_meta(dist).get("Summary", "") version = dist.version item_is_core = item.required else: installed = False ins = item.installable dist = None name = prettify_name(ins.name) summary = ins.summary version = ins.version item_is_core = False updatable = is_updatable(item) item1 = StateItem() item1.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | (Qt.ItemIsUserTristate if updatable else Qt.NoItemFlags)) item1.setEnabled(not (item_is_core and not updatable)) item1.setData(item_is_core, HasConstraintRole) if installed and updatable: item1.setCheckState(Qt.PartiallyChecked) elif installed: item1.setCheckState(Qt.Checked) else: item1.setCheckState(Qt.Unchecked) item1.setData(item, Qt.UserRole) item2 = QStandardItem(name) item2.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) item2.setToolTip(summary) item2.setData(item, Qt.UserRole) if updatable: assert dist is not None assert ins is not None comp = "<" if not ins.force else "->" version = "{} {} {}".format(dist.version, comp, ins.version) item3 = QStandardItem(version) item3.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) item4 = ActionItem() item4.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) return [item1, item2, item3, item4] def itemState(self): # type: () -> List['Action'] """ Return the current `items` state encoded as a list of actions to be performed. Return ------ actions : List['Action'] For every item that is has been changed in the GUI interface return a tuple of (command, item) where Command is one of `Install`, `Uninstall`, `Upgrade`. """ steps = [] for i in range(self.rowCount()): modelitem = self.item(i, 0) item = modelitem.data(Qt.UserRole) state = modelitem.checkState() if modelitem.flags() & Qt.ItemIsUserTristate and state == Qt.Checked: steps.append((Upgrade, item)) elif isinstance(item, Available) and state == Qt.Checked: steps.append((Install, item)) elif isinstance(item, Installed) and state == Qt.Unchecked: steps.append((Uninstall, item)) return steps def setItemState(self, steps): # type: (List['Action']) -> None """ Set the current state as a list of actions to perform. i.e. `w.setItemState([(Install, item1), (Uninstall, item2)])` will mark item1 for installation and item2 for uninstallation, all other items will be reset to their default state Parameters ---------- steps : List[Tuple[Command, Item]] State encoded as a list of commands. """ if self.rowCount() == 0: return for row in range(self.rowCount()): modelitem = self.item(row, 0) # type: QStandardItem item = modelitem.data(Qt.UserRole) # type: Item # Find the action command in the steps list for the item cmd = None # type: Optional[Command] for cmd_, item_ in steps: if item == item_: cmd = cmd_ break if isinstance(item, Available): modelitem.setCheckState( Qt.Checked if cmd == Install else Qt.Unchecked ) elif isinstance(item, Installed): if cmd == Upgrade: modelitem.setCheckState(Qt.Checked) elif cmd == Uninstall: modelitem.setCheckState(Qt.Unchecked) elif is_updatable(item): modelitem.setCheckState(Qt.PartiallyChecked) else: modelitem.setCheckState(Qt.Checked) else: assert False class TristateCheckItemDelegate(QStyledItemDelegate): """ A QStyledItemDelegate with customizable Qt.CheckStateRole state toggle on user interaction. """ def editorEvent(self, event, model, option, index): # type: (QEvent, QAbstractItemModel, QStyleOptionViewItem, QModelIndex) -> bool """ Reimplemented. """ flags = model.flags(index) if not flags & Qt.ItemIsUserCheckable or \ not option.state & QStyle.State_Enabled or \ not flags & Qt.ItemIsEnabled: return False checkstate = model.data(index, Qt.CheckStateRole) if checkstate is None: return False widget = option.widget style = widget.style() if widget is not None else QApplication.style() if event.type() in {QEvent.MouseButtonPress, QEvent.MouseButtonRelease, QEvent.MouseButtonDblClick}: pos = event.pos() opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) rect = style.subElementRect( QStyle.SE_ItemViewItemCheckIndicator, opt, widget) if event.button() != Qt.LeftButton or not rect.contains(pos): return False if event.type() in {QEvent.MouseButtonPress, QEvent.MouseButtonDblClick}: return True elif event.type() == QEvent.KeyPress: if event.key() != Qt.Key_Space and event.key() != Qt.Key_Select: return False else: return False checkstate = self.nextCheckState(checkstate, index) return model.setData(index, checkstate, Qt.CheckStateRole) def nextCheckState(self, state, index): # type: (Qt.CheckState, QModelIndex) -> Qt.CheckState """ Return the next check state for index. """ constraint = index.data(HasConstraintRole) flags = index.flags() if flags & Qt.ItemIsUserTristate and constraint: return Qt.PartiallyChecked if state == Qt.Checked else Qt.Checked elif flags & Qt.ItemIsUserTristate: return Qt.CheckState((enum_as_int(state) + 1) % 3) else: return Qt.Unchecked if state == Qt.Checked else Qt.Checked class AddonManagerDialog(QDialog): """ A add-on manager dialog. """ #: cached packages list. __packages = None # type: List[Installable] __f_pypi_addons = None __config = None # type: Optional[Config] stateChanged = Signal() def __init__(self, parent=None, acceptDrops=True, *, enableFilterAndAdd=True, **kwargs): super().__init__(parent, acceptDrops=acceptDrops, **kwargs) layout = QVBoxLayout() self.setLayout(layout) self.__tophlayout = tophlayout = QHBoxLayout( objectName="top-hbox-layout" ) tophlayout.setContentsMargins(0, 0, 0, 0) self.__search = QLineEdit( objectName="filter-edit", placeholderText=self.tr("Filter...") ) self.__addmore = QPushButton( self.tr("Add more..."), toolTip=self.tr("Add an add-on not listed below"), autoDefault=False ) self.__view = view = QTreeView( objectName="add-ons-view", rootIsDecorated=False, editTriggers=QTreeView.NoEditTriggers, selectionMode=QTreeView.SingleSelection, alternatingRowColors=True ) view.setItemDelegateForColumn(0, TristateCheckItemDelegate(view)) self.__details = QTextBrowser( objectName="description-text-area", readOnly=True, lineWrapMode=QTextBrowser.WidgetWidth, openExternalLinks=True, ) self.__details.setWordWrapMode(QTextOption.WordWrap) self.__buttons = buttons = QDialogButtonBox( orientation=Qt.Horizontal, standardButtons=QDialogButtonBox.Ok | QDialogButtonBox.Cancel, ) self.__model = model = PluginsModel() model.dataChanged.connect(self.__data_changed) proxy = QSortFilterProxyModel( filterKeyColumn=1, filterCaseSensitivity=Qt.CaseInsensitive ) proxy.setSourceModel(model) self.__search.textChanged.connect(proxy.setFilterFixedString) view.setModel(proxy) view.selectionModel().selectionChanged.connect( self.__update_details ) header = self.__view.header() header.setSectionResizeMode(0, QHeaderView.Fixed) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) self.__addmore.clicked.connect(self.__run_add_package_dialog) buttons.accepted.connect(self.__accepted) buttons.rejected.connect(self.reject) tophlayout.addWidget(self.__search) tophlayout.addWidget(self.__addmore) layout.addLayout(tophlayout) layout.addWidget(self.__view) layout.addWidget(self.__details) layout.addWidget(self.__buttons) self.__progress = None # type: Optional[QProgressDialog] self.__executor = ThreadPoolExecutor(max_workers=1) # The installer thread self.__thread = None # The installer object self.__installer = None self.__add_package_by_name_dialog = None # type: Optional[QDialog] sh = QShortcut(QKeySequence.Find, self.__search) sh.activated.connect(self.__search.setFocus) self.__updateTopLayout(enableFilterAndAdd) def sizeHint(self): return super().sizeHint().expandedTo(QSize(620, 540)) def __updateTopLayout(self, enabled): layout = self.__tophlayout if not enabled and layout.parentWidget() is self: for i in range(layout.count()): item = layout.itemAt(i) if item.widget() is not None: item.widget().hide() self.layout().removeItem(layout) elif enabled and layout.parentWidget() is not self: for i in range(layout.count()): item = layout.itemAt(i) if item.widget() is not None: item.widget().show() self.layout().insertLayout(0, layout) def __data_changed( self, topleft: QModelIndex, bottomright: QModelIndex, roles=() ) -> None: if topleft.column() <= 0 <= bottomright.column(): if roles and Qt.CheckStateRole in roles: self.stateChanged.emit() else: self.stateChanged.emit() def __update_details(self): selmodel = self.__view.selectionModel() idcs = selmodel.selectedRows(PluginsModel.StateColumn) if idcs: text = idcs[0].data(DetailedText) if not isinstance(text, str): text = "" else: text = "" self.__details.setText(text) def setConfig(self, config): self.__config = config def config(self): # type: () -> Config if self.__config is None: return config.default else: return self.__config @Slot() def start(self, config): # type: (Config) -> None """ Initialize the dialog/manager for the specified configuration namespace. Calling this method will start an async query of ... At the end the found items will be set using `setItems` overriding any previously set items. Parameters ---------- config : config.Config """ self.__config = config if self.__packages is not None: # method_queued(self.setItems, (object,))(self.__packages) installed = [ep.dist for ep in config.addon_entry_points() if ep.dist is not None] items = installable_items(self.__packages, installed) self.setItems(items) return progress = self.progressDialog() self.show() progress.show() progress.setLabelText( self.tr("Retrieving package list") ) self.__f_pypi_addons = self.__executor.submit( lambda config=config: (config, list_available_versions(config)), ) self.__f_pypi_addons.add_done_callback( qinvoke(self.__on_query_done, context=self) ) @Slot(object) def __on_query_done(self, f): # type: (Future[Tuple[Config, List[Installable]]]) -> None assert f.done() if self.__progress is not None: self.__progress.hide() def network_warning(exc): etype, tb = type(exc), exc.__traceback__ log.error( "Error fetching package list", exc_info=(etype, exc, tb) ) message_warning( "There's an issue with the internet connection.", title="Error", informative_text= "Please check you are connected to the internet.\n\n" "If you are behind a proxy, please set it in Preferences " "- Network.", details= "".join(traceback.format_exception(etype, exc, tb)), parent=self ) if f.exception() is not None: exc = typing.cast(BaseException, f.exception()) network_warning(exc) self.__f_pypi_addons = None return config, (packages, exc) = f.result() if len(exc): network_warning(exc[0]) assert all(isinstance(p, Installable) for p in packages) AddonManagerDialog.__packages = packages installed = [ep.dist for ep in config.addon_entry_points() if ep.dist is not None] items = installable_items(packages, installed) core_constraints = { r.name.casefold(): r for r in map(Requirement, config.core_packages()) } def constrain(item): # type: (Item) -> Item """Include constraint in Installed when in core_constraint""" if isinstance(item, Installed): name = item.local.name.casefold() if name in core_constraints: return item._replace( required=True, constraint=core_constraints[name] ) return item self.setItems([constrain(item) for item in items]) @Slot(object) def setItems(self, items): # type: (List[Item]) -> None """ Set items Parameters ---------- items: List[Items] """ model = self.__model model.setRowCount(0) for item in items: row = model.createRow(item) model.appendRow(row) self.__view.resizeColumnToContents(0) self.__view.setColumnWidth( 1, max(150, self.__view.sizeHintForColumn(1)) ) if self.__view.model().rowCount(): self.__view.selectionModel().select( self.__view.model().index(0, 0), QItemSelectionModel.Select | QItemSelectionModel.Rows ) self.stateChanged.emit() def items(self) -> List[Item]: """ Return a list of items. Return ------ items: List[Item] """ model = self.__model data, index = model.data, model.index return [data(index(i, 1), Qt.UserRole) for i in range(model.rowCount())] def itemState(self) -> List['Action']: """ Return the current `items` state encoded as a list of actions to be performed. Return ------ actions : List['Action'] For every item that is has been changed in the GUI interface return a tuple of (command, item) where Command is one of `Install`, `Uninstall`, `Upgrade`. """ return self.__model.itemState() def setItemState(self, steps: List['Action']) -> None: """ Set the current state as a list of actions to perform. i.e. `w.setItemState([(Install, item1), (Uninstall, item2)])` will mark item1 for installation and item2 for uninstallation, all other items will be reset to their default state. Parameters ---------- steps : List[Tuple[Command, Item]] State encoded as a list of commands. """ self.__model.setItemState(steps) def runQueryAndAddResults( self, names: List[str] ) -> 'Future[List[_QueryResult]]': """ Run a background query for the specified names and add results to the model. Parameters ---------- names: List[str] List of package names to query. """ f = self.__executor.submit(query_pypi, names) f.add_done_callback( qinvoke(self.__on_add_query_finish, context=self) ) progress = self.progressDialog() progress.setLabelText("Running query") progress.setMinimumDuration(1000) # make sure self is also visible, when progress dialog is, so it is # clear from where it came. self.show() progress.show() f.add_done_callback( qinvoke(lambda f: progress.hide(), context=progress) ) return f @Slot(object) def addInstallable(self, installable): # type: (Installable) -> None """ Add/append a single Installable item. Parameters ---------- installable: Installable """ items = self.items() installed = [ep.dist for ep in self.config().addon_entry_points()] new_ = installable_items([installable], filter(None, installed)) def match(item): # type: (Item) -> bool if isinstance(item, Available): return item.installable.name == installable.name elif item.installable is not None: return item.installable.name == installable.name else: return item.local.name.lower() == installable.name.lower() new = next(filter(match, new_), None) assert new is not None state = self.itemState() replace = next(filter(match, items), None) if replace is not None: items[items.index(replace)] = new self.setItems(items) # the state for the replaced item will be removed by setItemState else: self.setItems(items + [new]) self.setItemState(state) # restore state def addItems(self, items: List[Item]): state = self.itemState() items = self.items() + items self.setItems(items) self.setItemState(state) # restore state def __run_add_package_dialog(self): self.__add_package_by_name_dialog = dlg = QDialog( self, windowTitle="Add add-on by name", ) dlg.setAttribute(Qt.WA_DeleteOnClose) vlayout = QVBoxLayout() form = QFormLayout() form.setContentsMargins(0, 0, 0, 0) nameentry = QLineEdit( placeholderText="Package name", toolTip="Enter a package name as displayed on " "PyPI (capitalization is not important)") nameentry.setMinimumWidth(250) form.addRow("Name:", nameentry) vlayout.addLayout(form) buttons = QDialogButtonBox( standardButtons=QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) okb = buttons.button(QDialogButtonBox.Ok) okb.setEnabled(False) okb.setText("Add") def changed(name): okb.setEnabled(bool(name)) nameentry.textChanged.connect(changed) vlayout.addWidget(buttons) vlayout.setSizeConstraint(QVBoxLayout.SetFixedSize) dlg.setLayout(vlayout) def query(): name = nameentry.text() okb.setDisabled(True) self.runQueryAndAddResults([name]) dlg.accept() buttons.accepted.connect(query) buttons.rejected.connect(dlg.reject) dlg.exec() @Slot(str, str) def __show_error_for_query(self, text, error_details): message_error(text, title="Error", details=error_details) @Slot(object) def __on_add_query_finish(self, f): # type: (Future[List[_QueryResult]]) -> None error_text = "" error_details = "" result = None try: result = f.result() except Exception: log.error("Query error:", exc_info=True) error_text = "Failed to query package index" error_details = traceback.format_exc() else: not_found = [r.queryname for r in result if r.installable is None] if not_found: error_text = "".join([ "The following packages were not found:
            ", *["
          • {}
          • ".format(escape(n)) for n in not_found], "
              " ]) if result: for r in result: if r.installable is not None: self.addInstallable(r.installable) if error_text: self.__show_error_for_query(error_text, error_details) def progressDialog(self): # type: () -> QProgressDialog if self.__progress is None: self.__progress = QProgressDialog( self, minimum=0, maximum=0, labelText=self.tr("Retrieving package list"), sizeGripEnabled=False, windowTitle="Progress" ) self.__progress.setWindowModality(Qt.WindowModal) self.__progress.hide() self.__progress.canceled.connect(self.reject) return self.__progress def done(self, retcode): super().done(retcode) if self.__thread is not None: self.__thread.quit() self.__thread = None def closeEvent(self, event): super().closeEvent(event) if self.__thread is not None: self.__thread.quit() self.__thread = None ADDON_EXTENSIONS = ('.zip', '.whl', '.tar.gz') def dragEnterEvent(self, event): # type: (QDragEnterEvent) -> None """Reimplemented.""" urls = event.mimeData().urls() if any(url.toLocalFile().endswith(self.ADDON_EXTENSIONS) for url in urls): event.acceptProposedAction() def dropEvent(self, event): # type: (QDropEvent) -> None """ Reimplemented. Allow dropping add-ons (zip or wheel archives) on this dialog to install them. """ packages = [] names = [] for url in event.mimeData().urls(): path = url.toLocalFile() if path.endswith(self.ADDON_EXTENSIONS): meta = get_meta_from_archive(path) or {} name = meta.get("Name", os.path.basename(path)) vers = meta.get("Version", "") summary = meta.get("Summary", "") descr = meta.get("Description", "") content_type = meta.get("Description-Content-Type", None) requirements = meta.get("Requires-Dist", "") names.append(name) packages.append( Installable(name, vers, summary, descr or summary, path, [path], requirements, content_type, True) ) for installable in packages: self.addInstallable(installable) items = self.items() # lookup items for the new entries new_items = [item for item in items if item.installable in packages] state_new = [(Install, item) if isinstance(item, Available) else (Upgrade, item) for item in new_items] state = self.itemState() self.setItemState(state + state_new) event.acceptProposedAction() def __accepted(self): steps = self.itemState() # warn about implicit upgrades of required core packages core_required = {} for item in self.items(): if isinstance(item, Installed) and item.required: core_required[item.local.name] = item.local.version core_upgrade = set() for step in steps: if step[0] in [Upgrade, Install]: inst = step[1].installable if inst.name in core_required: # direct upgrade of a core package core_upgrade.add(inst.name) if inst.requirements: # indirect upgrade of a core package as a requirement for req in map(Requirement, inst.requirements): if req.name in core_required and not req.specifier.contains(core_required[req.name], prereleases=True): core_upgrade.add(req.name) # current doesn't meet requirements if core_upgrade: icon = QMessageBox.Warning buttons = QMessageBox.Ok | QMessageBox.Cancel title = "Warning" text = "This action will upgrade some core packages:\n" text += "\n".join(sorted(core_upgrade)) msg_box = QMessageBox(icon, title, text, buttons, self) msg_box.setInformativeText("Do you want to continue?") msg_box.setDefaultButton(QMessageBox.Ok) if msg_box.exec() != QMessageBox.Ok: steps = [] if steps: # Move all uninstall steps to the front steps = sorted( steps, key=lambda step: 0 if step[0] == Uninstall else 1 ) self.__installer = Installer(steps=steps) self.__thread = QThread( objectName=qualified_name(type(self)) + "::InstallerThread", ) # transfer ownership to c++; the instance is (deferred) deleted # from the finished signal (keep alive until then). sip.transferto(self.__thread, None) self.__thread.finished.connect(self.__thread.deleteLater) self.__installer.moveToThread(self.__thread) self.__installer.finished.connect(self.__on_installer_finished) self.__installer.error.connect(self.__on_installer_error) self.__thread.start() progress = self.progressDialog() self.__installer.installStatusChanged.connect(progress.setLabelText) progress.show() progress.setLabelText("Installing") self.__installer.start() else: self.accept() def __on_installer_finished_common(self): if self.__progress is not None: self.__progress.close() self.__progress = None if self.__thread is not None: self.__thread.quit() self.__thread = None def __on_installer_error(self, command, pkg, retcode, output): self.__on_installer_finished_common() message_error( "An error occurred while running a subprocess", title="Error", informative_text="{} exited with non zero status.".format(command), details="".join(output), parent=self ) self.reject() def __on_installer_finished(self): self.__on_installer_finished_common() name = QApplication.applicationName() or 'Orange' def message_restart(parent): icon = QMessageBox.Information buttons = QMessageBox.Ok | QMessageBox.Cancel title = 'Information' text = ('{} needs to be restarted for the changes to take effect.' .format(name)) msg_box = QMessageBox(icon, title, text, buttons, parent) msg_box.setDefaultButton(QMessageBox.Ok) msg_box.setInformativeText('Press OK to restart {} now.' .format(name)) msg_box.button(QMessageBox.Cancel).setText('Close later') return msg_box.exec() if QMessageBox.Ok == message_restart(self): self.accept() def restart(): quit_temp_val = QApplication.quitOnLastWindowClosed() QApplication.setQuitOnLastWindowClosed(False) QApplication.closeAllWindows() windows = QApplication.topLevelWindows() if any(w.isVisible() for w in windows): # if a window close was cancelled QApplication.setQuitOnLastWindowClosed(quit_temp_val) QMessageBox( text="Restart Cancelled", informativeText="Changes will be applied on {}'s next restart" .format(name), icon=QMessageBox.Information ).exec() else: QApplication.exit(96) QTimer.singleShot(0, restart) else: self.reject() def main(argv=None): # noqa import argparse from AnyQt.QtWidgets import QApplication app = QApplication(argv if argv is not None else []) argv = app.arguments() parser = argparse.ArgumentParser() parser.add_argument( "--config", metavar="CLASSNAME", default="orangecanvas.config.default", help="The configuration namespace to use" ) args = parser.parse_args(argv[1:]) config_ = name_lookup(args.config) config_ = config_() config_.init() config.set_default(config_) dlg = AddonManagerDialog() dlg.start(config_) dlg.show() dlg.raise_() return app.exec() if __name__ == "__main__": sys.exit(main(sys.argv)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/application.py0000644000175100002000000002326314730024325024561 0ustar00runnerdocker""" """ import atexit import sys import os import argparse import logging from typing import Optional, List, Sequence import AnyQt from AnyQt.QtWidgets import QApplication from AnyQt.QtGui import QPixmapCache from AnyQt.QtCore import ( Qt, QUrl, QEvent, QSettings, QLibraryInfo, pyqtSignal as Signal, QT_VERSION_INFO ) from orangecanvas.utils.after_exit import run_after_exit from orangecanvas.utils.asyncutils import get_event_loop from orangecanvas.gui.utils import macos_set_nswindow_tabbing def fix_qt_plugins_path(): """ Attempt to fix qt plugins path if it is invalid. https://www.riverbankcomputing.com/pipermail/pyqt/2018-November/041089.html """ # PyQt5 loads a runtime generated qt.conf file into qt's resource system # but does not correctly (INI) encode non-latin1 characters in paths # (https://www.riverbankcomputing.com/pipermail/pyqt/2018-November/041089.html) # Need to be careful not to mess the plugins path when not installed as # a (delocated) wheel. s = QSettings(":qt/etc/qt.conf", QSettings.IniFormat) path = s.value("Paths/Prefix", type=str) # does the ':qt/etc/qt.conf' exist and has prefix path that does not exist if path and os.path.exists(path): return # Use QLibraryInfo.location to resolve the plugins dir pluginspath = QLibraryInfo.path(QLibraryInfo.PluginsPath) # Check effective library paths. Someone might already set the search # paths (including via QT_PLUGIN_PATH). QApplication.libraryPaths() returns # existing paths only. paths = QApplication.libraryPaths() if paths: return if AnyQt.USED_API == "pyqt5": import PyQt5.QtCore as qc elif AnyQt.USED_API == "pyqt6": import PyQt6.QtCore as qc elif AnyQt.USED_API == "pyside2": import PySide2.QtCore as qc elif AnyQt.USED_API == "pyside6": import PySide6.QtCore as qc else: return def normpath(path): return os.path.normcase(os.path.normpath(path)) # guess the appropriate path relative to the installation dir based on the # PyQt5 installation dir and the 'recorded' plugins path. I.e. match the # 'PyQt5' directory name in the recorded path and replace the 'invalid' # prefix with the real PyQt5 install dir. def maybe_match_prefix(prefix: str, path: str) -> Optional[str]: """ >>> maybe_match_prefix("aa/bb/cc", "/a/b/cc/a/b") "aa/bb/cc/a/b" >>> maybe_match_prefix("aa/bb/dd", "/a/b/cc/a/b") None """ prefix = normpath(prefix) path = normpath(path) basename = os.path.basename(prefix) path_components = path.split(os.sep) # find the (rightmost) basename in the prefix_components idx = None try: start = 0 while True: idx = path_components.index(basename, start) start = idx + 1 except ValueError: pass if idx is None: return None return os.path.join(prefix, *path_components[idx + 1:]) newpath = maybe_match_prefix( os.path.dirname(qc.__file__), pluginspath ) if newpath is not None and os.path.exists(newpath): QApplication.addLibraryPath(newpath) if hasattr(QApplication, "setHighDpiScaleFactorRoundingPolicy"): HighDpiScaleFactorRoundingPolicyLookup = { "Round": Qt.HighDpiScaleFactorRoundingPolicy.Round, "Ceil": Qt.HighDpiScaleFactorRoundingPolicy.Ceil, "Floor": Qt.HighDpiScaleFactorRoundingPolicy.Floor, "RoundPreferFloor": Qt.HighDpiScaleFactorRoundingPolicy.RoundPreferFloor, "PassThrough": Qt.HighDpiScaleFactorRoundingPolicy.PassThrough, "Unset": None } else: HighDpiScaleFactorRoundingPolicyLookup = {} class CanvasApplication(QApplication): fileOpenRequest = Signal(QUrl) applicationPaletteChanged = Signal() __args = None def __init__(self, argv): CanvasApplication.__args, argv_ = self.parseArguments(argv) ns = CanvasApplication.__args fix_qt_plugins_path() self.__fileOpenUrls = [] self.__in_exec = False if ns.enable_high_dpi_scaling \ and hasattr(Qt, "AA_EnableHighDpiScaling"): # Turn on HighDPI support when available QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) if ns.use_high_dpi_pixmaps \ and hasattr(Qt, "AA_UseHighDpiPixmaps"): QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) if hasattr(QApplication, "setHighDpiScaleFactorRoundingPolicy") \ and ns.scale_factor_rounding_policy is not None: QApplication.setHighDpiScaleFactorRoundingPolicy( ns.scale_factor_rounding_policy ) if ns.style: argv_ = argv_ + ["-style", self.__args.style] super().__init__(argv_) # Make sure there is an asyncio event loop that runs on the # Qt event loop. _ = get_event_loop() argv[:] = argv_ self.setAttribute(Qt.AA_DontShowIconsInMenus, True) if hasattr(self, "styleHints"): sh = self.styleHints() if hasattr(sh, 'setShowShortcutsInContextMenus'): # PyQt5.13 and up sh.setShowShortcutsInContextMenus(True) if QT_VERSION_INFO < (5, 15): # QTBUG-61707 macos_set_nswindow_tabbing(False) if QT_VERSION_INFO < (6, 0) and sys.platform == "win32": # QTBUG-58610 # https://github.com/musescore/MuseScore/pull/5820 QApplication.setFont(QApplication.font("QMessageBox")) self.configureStyle() def event(self, event): if event.type() == QEvent.FileOpen: if not self.__in_exec: self.__fileOpenUrls.append(event.url()) else: self.fileOpenRequest.emit(event.url()) elif event.type() == QEvent.PolishRequest: self.configureStyle() elif event.type() == QEvent.Type.ApplicationPaletteChange: self.applicationPaletteChanged.emit() return super().event(event) def exec(self) -> int: while self.__fileOpenUrls: self.fileOpenRequest.emit(self.__fileOpenUrls.pop(0)) self.__in_exec = True try: return super().exec() finally: self.__in_exec = False exec_ = exec @staticmethod def argumentParser(): parser = argparse.ArgumentParser() parser.add_argument("-style", type=str, default=None) parser.add_argument("-colortheme", type=str, default=None) parser.add_argument("-enable-high-dpi-scaling", type=bool, default=True) if hasattr(QApplication, "setHighDpiScaleFactorRoundingPolicy"): default = HighDpiScaleFactorRoundingPolicyLookup.get( os.environ.get("QT_SCALE_FACTOR_ROUNDING_POLICY"), Qt.HighDpiScaleFactorRoundingPolicy.PassThrough ) def converter(value): # dict.get wrapper due to https://bugs.python.org/issue16516 return HighDpiScaleFactorRoundingPolicyLookup.get(value) parser.add_argument( "-scale-factor-rounding-policy", type=converter, choices=[*HighDpiScaleFactorRoundingPolicyLookup.values(), None], default=default, ) parser.add_argument("-use-high-dpi-pixmaps", type=bool, default=True) return parser @staticmethod def parseArguments(argv): parser = CanvasApplication.argumentParser() ns, rest = parser.parse_known_args(argv) if ns.style is not None: if ":" in ns.style: ns.style, colortheme = ns.style.split(":", 1) if ns.colortheme is None: ns.colortheme = colortheme return ns, rest @staticmethod def configureStyle(): from orangecanvas import styles args = CanvasApplication.__args settings = QSettings() settings.beginGroup("application-style") name = settings.value("style-name", "", type=str) if args is not None and args.style: # command line params take precedence name = args.style if name != "": inst = QApplication.instance() if inst is not None: if inst.style().objectName().lower() != name.lower(): QApplication.setStyle(name) theme = settings.value("palette", "", type=str) if args is not None and args.colortheme: theme = args.colortheme if theme and theme in styles.colorthemes: palette = styles.colorthemes[theme]() QApplication.setPalette(palette) QPixmapCache.setCacheLimit(64 * (2 ** 10)) __restart_command: Optional[List[str]] = None def set_restart_command(cmd: Optional[Sequence[str]]): """ Set or unset the restart command. This command will be run after this process exits. Pass cmd=None to unset the current command. """ global __restart_command log = logging.getLogger(__name__) atexit.unregister(__restart) if cmd is None: __restart_command = None log.info("Disabling application restart") else: __restart_command = list(cmd) atexit.register(__restart) log.info("Enabling application restart with: %r", cmd) def restart_command() -> Optional[List[str]]: """Return the current set restart command.""" return __restart_command def restart_cancel() -> None: set_restart_command(None) def default_restart_command(): """Return the default restart command.""" return [sys.executable, sys.argv[0]] def __restart(): if __restart_command: run_after_exit(__restart_command) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/canvasmain.py0000644000175100002000000030411614730024325024375 0ustar00runnerdocker""" Orange Canvas Main Window """ import os import sys import logging import operator import io import traceback from concurrent import futures from contextlib import contextmanager from xml.sax.saxutils import escape from functools import partial, reduce from types import SimpleNamespace from typing import ( Optional, List, Union, Any, cast, Dict, Callable, IO, Sequence, Iterable, Tuple, TypeVar, Awaitable, ) from AnyQt.QtWidgets import ( QMainWindow, QWidget, QAction, QActionGroup, QMenu, QMenuBar, QDialog, QFileDialog, QMessageBox, QVBoxLayout, QSizePolicy, QToolBar, QToolButton, QDockWidget, QApplication, QShortcut, QFileIconProvider ) from AnyQt.QtGui import ( QColor, QDesktopServices, QKeySequence, QWhatsThisClickedEvent, QShowEvent, QCloseEvent ) from AnyQt.QtCore import ( Qt, QObject, QEvent, QSize, QUrl, QByteArray, QFileInfo, QSettings, QStandardPaths, QAbstractItemModel, QMimeData, QT_VERSION) try: from AnyQt.QtWebEngineWidgets import QWebEngineView except ImportError: QWebEngineView = None # type: ignore try: from AnyQt.QtWebKitWidgets import QWebView from AnyQt.QtNetwork import QNetworkDiskCache except ImportError: QWebView = None # type: ignore from AnyQt.QtCore import ( pyqtProperty as Property, pyqtSignal as Signal ) from ..scheme import Scheme, IncompatibleChannelTypeError, SchemeNode from ..scheme import readwrite from ..scheme.readwrite import UnknownWidgetDefinition from ..gui.dropshadow import DropShadowFrame from ..gui.dock import CollapsibleDockWidget from ..gui.quickhelp import QuickHelpTipEvent from ..gui.utils import message_critical, message_question, \ message_warning, message_information from ..document.usagestatistics import UsageStatistics from ..help import HelpManager from .canvastooldock import CanvasToolDock, QuickCategoryToolbar, \ CategoryPopupMenu, popup_position_from_source from .aboutdialog import AboutDialog from .schemeinfo import SchemeInfoDialog from .outputview import OutputView, TextStream from .settings import UserSettingsDialog, category_state from .utils.addons import normalize_name, is_requirement_available from ..document.schemeedit import SchemeEditWidget from ..document.quickmenu import QuickMenu from ..document.commands import UndoCommand from ..document import interactions from ..gui.itemmodels import FilterProxyModel from ..gui.windowlistmanager import WindowListManager from ..registry import WidgetRegistry, WidgetDescription, CategoryDescription from ..registry.qt import QtWidgetRegistry from ..utils.settings import QSettings_readArray, QSettings_writeArray from ..utils.qinvoke import qinvoke from ..utils.pickle import Pickler, Unpickler, glob_scratch_swps, swp_name, \ canvas_scratch_name_memo, register_loaded_swp from ..utils import unique, group_by_all, set_flag, findf from ..utils.asyncutils import get_event_loop from ..utils.qobjref import qobjref from . import welcomedialog from . import addons from ..preview import previewdialog, previewmodel from .. import config from . import examples from ..resources import load_styled_svg_icon from ..canvas import scene log = logging.getLogger(__name__) def user_documents_path(): """ Return the users 'Documents' folder path. """ return QStandardPaths.writableLocation(QStandardPaths.DocumentsLocation) class FakeToolBar(QToolBar): """A Toolbar with no contents (used to reserve top and bottom margins on the main window). """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setFloatable(False) self.setMovable(False) # Don't show the tool bar action in the main window's # context menu. self.toggleViewAction().setVisible(False) def paintEvent(self, event): # Do nothing. pass class DockWidget(QDockWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) shortcuts = [ QKeySequence(QKeySequence.Close), QKeySequence(QKeySequence(Qt.Key_Escape)), ] for kseq in shortcuts: QShortcut(kseq, self, self.close, context=Qt.WidgetWithChildrenShortcut) class CanvasMainWindow(QMainWindow): SETTINGS_VERSION = 3 def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__scheme_margins_enabled = True self.__document_title = "untitled" self.__first_show = True self.__is_transient = True self.widget_registry = None # type: Optional[WidgetRegistry] self.__registry_model = None # type: Optional[QAbstractItemModel] # Proxy widget registry model self.__proxy_model = None # type: Optional[FilterProxyModel] # TODO: Help view and manager to separate singleton instance. self.help = None # type: HelpManager self.help_view = None self.help_dock = None # TODO: Log view to separate singleton instance. self.output_dock = None # TODO: sync between CanvasMainWindow instances?. settings = QSettings() recent = QSettings_readArray( settings, "mainwindow/recent-items", {"title": str, "path": str} ) recent = [RecentItem(**item) for item in recent] recent = [item for item in recent if os.path.exists(item.path)] self.recent_schemes = recent self.num_recent_schemes = 15 self.help = HelpManager(self) self.setup_actions() self.setup_ui() self.setup_menu() windowmanager = WindowListManager.instance() windowmanager.addWindow(self) self.window_menu.addSeparator() self.window_menu.addActions(windowmanager.actions()) windowmanager.windowAdded.connect(self.__window_added) windowmanager.windowRemoved.connect(self.__window_removed) self.restore() def setup_ui(self): """Setup main canvas ui """ # Two dummy tool bars to reserve space self.__dummy_top_toolbar = FakeToolBar( objectName="__dummy_top_toolbar") self.__dummy_bottom_toolbar = FakeToolBar( objectName="__dummy_bottom_toolbar") self.__dummy_top_toolbar.setFixedHeight(20) self.__dummy_bottom_toolbar.setFixedHeight(20) self.addToolBar(Qt.TopToolBarArea, self.__dummy_top_toolbar) self.addToolBar(Qt.BottomToolBarArea, self.__dummy_bottom_toolbar) self.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) self.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea) self.setDockOptions(QMainWindow.AnimatedDocks) # Create an empty initial scheme inside a container with fixed # margins. w = QWidget() w.setLayout(QVBoxLayout()) w.layout().setContentsMargins(20, 0, 10, 0) self.scheme_widget = SchemeEditWidget() self.scheme_widget.setDropHandlers([interactions.PluginDropHandler(),]) self.set_scheme(config.workflow_constructor(parent=self)) # Save crash recovery swap file on changes to workflow self.scheme_widget.undoCommandAdded.connect(self.save_swp) dropfilter = UrlDropEventFilter(self) dropfilter.urlDropped.connect(self.open_scheme_file) self.scheme_widget.setAcceptDrops(True) self.scheme_widget.view().viewport().installEventFilter(dropfilter) w.layout().addWidget(self.scheme_widget) self.setCentralWidget(w) # Drop shadow around the scheme document frame = DropShadowFrame(radius=15) frame.setColor(QColor(0, 0, 0, 100)) frame.setWidget(self.scheme_widget) # Window 'title' self.__update_window_title() self.setWindowFilePath(self.scheme_widget.path()) self.scheme_widget.pathChanged.connect(self.__update_window_title) self.scheme_widget.modificationChanged.connect(self.setWindowModified) # QMainWindow's Dock widget self.dock_widget = CollapsibleDockWidget(objectName="main-area-dock") self.dock_widget.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetClosable) self.dock_widget.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) # Main canvas tool dock (with widget toolbox, common actions. # This is the widget that is shown when the dock is expanded. canvas_tool_dock = CanvasToolDock(objectName="canvas-tool-dock") canvas_tool_dock.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) # Bottom tool bar self.canvas_toolbar = canvas_tool_dock.toolbar self.canvas_toolbar.setIconSize(QSize(24, 24)) self.canvas_toolbar.setMinimumHeight(28) self.canvas_toolbar.layout().setSpacing(1) # Widgets tool box self.widgets_tool_box = canvas_tool_dock.toolbox self.widgets_tool_box.setObjectName("canvas-toolbox") self.widgets_tool_box.setTabButtonHeight(30) self.widgets_tool_box.setTabIconSize(QSize(26, 26)) self.widgets_tool_box.setButtonSize(QSize(68, 84)) self.widgets_tool_box.setIconSize(QSize(48, 48)) self.widgets_tool_box.triggered.connect( self.on_tool_box_widget_activated ) self.dock_help = canvas_tool_dock.help self.dock_help.setMaximumHeight(150) self.dock_help.document().setDefaultStyleSheet("h3, a {color: orange;}") self.dock_help.setDefaultText( "Select a widget to show its description." "

              " "See workflow examples, " "YouTube tutorials, " "or open the welcome screen." ) self.dock_help_action = canvas_tool_dock.toggleQuickHelpAction() self.dock_help_action.setText(self.tr("Show Help")) self.dock_help_action.setIcon(load_styled_svg_icon("Info.svg", self.canvas_toolbar)) self.canvas_tool_dock = canvas_tool_dock # Dock contents when collapsed (a quick category tool bar, ...) dock2 = QWidget(objectName="canvas-quick-dock") dock2.setLayout(QVBoxLayout()) dock2.layout().setContentsMargins(0, 0, 0, 0) dock2.layout().setSpacing(0) dock2.layout().setSizeConstraint(QVBoxLayout.SetFixedSize) self.quick_category = QuickCategoryToolbar() self.quick_category.setButtonSize(QSize(38, 30)) self.quick_category.setIconSize(QSize(26, 26)) self.quick_category.actionTriggered.connect( self.on_quick_category_action ) tool_actions = self.current_document().toolbarActions() (self.zoom_in_action, self.zoom_out_action, self.zoom_reset_action, self.canvas_align_to_grid_action, self.canvas_text_action, self.canvas_arrow_action,) = tool_actions self.canvas_align_to_grid_action.setIcon(load_styled_svg_icon("Grid.svg", self.canvas_toolbar)) self.canvas_text_action.setIcon(load_styled_svg_icon("Text Size.svg", self.canvas_toolbar)) self.canvas_arrow_action.setIcon(load_styled_svg_icon("Arrow.svg", self.canvas_toolbar)) self.freeze_action.setIcon(load_styled_svg_icon('Pause.svg', self.canvas_toolbar)) self.show_properties_action.setIcon(load_styled_svg_icon("Document Info.svg", self.canvas_toolbar)) dock_actions = [ self.show_properties_action, self.canvas_align_to_grid_action, self.canvas_text_action, self.canvas_arrow_action, self.freeze_action, self.dock_help_action ] # Tool bar in the collapsed dock state (has the same actions as # the tool bar in the CanvasToolDock actions_toolbar = QToolBar(orientation=Qt.Vertical) actions_toolbar.setFixedWidth(38) actions_toolbar.layout().setSpacing(0) actions_toolbar.setToolButtonStyle(Qt.ToolButtonIconOnly) for action in dock_actions: self.canvas_toolbar.addAction(action) button = self.canvas_toolbar.widgetForAction(action) button.setPopupMode(QToolButton.DelayedPopup) actions_toolbar.addAction(action) button = actions_toolbar.widgetForAction(action) button.setFixedSize(38, 30) button.setPopupMode(QToolButton.DelayedPopup) dock2.layout().addWidget(self.quick_category) dock2.layout().addWidget(actions_toolbar) self.dock_widget.setAnimationEnabled(False) self.dock_widget.setExpandedWidget(self.canvas_tool_dock) self.dock_widget.setCollapsedWidget(dock2) self.dock_widget.setExpanded(True) self.dock_widget.expandedChanged.connect(self._on_tool_dock_expanded) self.addDockWidget(Qt.LeftDockWidgetArea, self.dock_widget) self.dock_widget.dockLocationChanged.connect( self._on_dock_location_changed ) self.output_dock = DockWidget( self.tr("Log"), self, objectName="output-dock", allowedAreas=Qt.BottomDockWidgetArea, visible=self.show_output_action.isChecked(), ) self.output_dock.setWidget(OutputView()) self.output_dock.visibilityChanged[bool].connect( self.show_output_action.setChecked ) self.addDockWidget(Qt.BottomDockWidgetArea, self.output_dock) self.help_dock = DockWidget( self.tr("Help"), self, objectName="help-dock", allowedAreas=Qt.NoDockWidgetArea, visible=False, floating=True, ) if QWebEngineView is not None: self.help_view = QWebEngineView() elif QWebView is not None: self.help_view = QWebView() manager = self.help_view.page().networkAccessManager() cache = QNetworkDiskCache() cachedir = os.path.join( QStandardPaths.writableLocation(QStandardPaths.CacheLocation), "help", "help-view-cache" ) cache.setCacheDirectory(cachedir) manager.setCache(cache) self.help_dock.setWidget(self.help_view) self.setMinimumSize(600, 500) def setup_actions(self): """Initialize main window actions. """ self.new_action = QAction( self.tr("New"), self, objectName="action-new", toolTip=self.tr("Open a new workflow."), triggered=self.new_workflow_window, shortcut=QKeySequence.New, icon=load_styled_svg_icon("New.svg") ) self.open_action = QAction( self.tr("Open"), self, objectName="action-open", toolTip=self.tr("Open a workflow."), triggered=self.open_scheme, shortcut=QKeySequence.Open, icon=load_styled_svg_icon("Open.svg") ) self.open_and_freeze_action = QAction( self.tr("Open and Freeze"), self, objectName="action-open-and-freeze", toolTip=self.tr("Open a new workflow and freeze signal " "propagation."), triggered=self.open_and_freeze_scheme ) self.open_and_freeze_action.setShortcut( QKeySequence("Ctrl+Alt+O") ) self.close_window_action = QAction( self.tr("Close Window"), self, objectName="action-close-window", toolTip=self.tr("Close the window"), shortcut=QKeySequence.Close, triggered=self.close, ) self.save_action = QAction( self.tr("Save"), self, objectName="action-save", toolTip=self.tr("Save current workflow."), triggered=self.save_scheme, shortcut=QKeySequence.Save, ) self.save_as_action = QAction( self.tr("Save As ..."), self, objectName="action-save-as", toolTip=self.tr("Save current workflow as."), triggered=self.save_scheme_as, shortcut=QKeySequence.SaveAs, ) self.save_as_svg_action = QAction( self.tr("Save Workflow Image as SVG ..."), self, objectName="action-save-to-svg.", toolTip=self.tr("Save workflow image as SVG."), triggered=self.save_as_svg, ) self.quit_action = QAction( self.tr("Quit"), self, objectName="quit-action", triggered=QApplication.closeAllWindows, menuRole=QAction.QuitRole, shortcut=QKeySequence.Quit, ) self.welcome_action = QAction( self.tr("Welcome"), self, objectName="welcome-action", toolTip=self.tr("Show welcome screen."), triggered=self.welcome_dialog, ) def open_url_for(name): url = config.default.APPLICATION_URLS.get(name) if url is not None: QDesktopServices.openUrl(QUrl(url)) def has_url_for(name): # type: (str) -> bool url = config.default.APPLICATION_URLS.get(name) return url is not None and QUrl(url).isValid() def config_url_action(action, role): # type: (QAction, str) -> None enabled = has_url_for(role) action.setVisible(enabled) action.setEnabled(enabled) if enabled: action.triggered.connect(lambda: open_url_for(role)) self.get_started_action = QAction( self.tr("Get Started"), self, objectName="get-started-action", toolTip=self.tr("View a 'Get Started' introduction."), icon=load_styled_svg_icon("Documentation.svg") ) config_url_action(self.get_started_action, "Quick Start") self.get_started_screencasts_action = QAction( self.tr("Video Tutorials"), self, objectName="screencasts-action", toolTip=self.tr("View video tutorials"), icon=load_styled_svg_icon("YouTube.svg"), ) config_url_action(self.get_started_screencasts_action, "Screencasts") self.documentation_action = QAction( self.tr("Documentation"), self, objectName="documentation-action", toolTip=self.tr("View reference documentation."), icon=load_styled_svg_icon("Documentation.svg"), ) config_url_action(self.documentation_action, "Documentation") self.examples_action = QAction( self.tr("Example Workflows"), self, objectName="examples-action", toolTip=self.tr("Browse example workflows."), triggered=self.examples_dialog, icon=load_styled_svg_icon("Examples.svg") ) self.about_action = QAction( self.tr("About"), self, objectName="about-action", toolTip=self.tr("Show about dialog."), triggered=self.open_about, menuRole=QAction.AboutRole, ) # Action group for for recent scheme actions self.recent_scheme_action_group = QActionGroup( self, objectName="recent-action-group", triggered=self._on_recent_scheme_action ) self.recent_scheme_action_group.setExclusive(False) self.recent_action = QAction( self.tr("Browse Recent"), self, objectName="recent-action", toolTip=self.tr("Browse and open a recent workflow."), triggered=self.recent_scheme, shortcut=QKeySequence("Ctrl+Shift+R"), icon=load_styled_svg_icon("Recent.svg") ) self.reload_last_action = QAction( self.tr("Reload Last Workflow"), self, objectName="reload-last-action", toolTip=self.tr("Reload last open workflow."), triggered=self.reload_last, shortcut=QKeySequence("Ctrl+R") ) self.clear_recent_action = QAction( self.tr("Clear Menu"), self, objectName="clear-recent-menu-action", toolTip=self.tr("Clear recent menu."), triggered=self.clear_recent_schemes ) self.show_properties_action = QAction( self.tr("Workflow Info"), self, objectName="show-properties-action", toolTip=self.tr("Show workflow properties."), triggered=self.show_scheme_properties, shortcut=QKeySequence("Ctrl+I"), icon=load_styled_svg_icon("Document Info.svg") ) self.canvas_settings_action = QAction( self.tr("Settings"), self, objectName="canvas-settings-action", toolTip=self.tr("Set application settings."), triggered=self.open_canvas_settings, menuRole=QAction.PreferencesRole, shortcut=QKeySequence.Preferences ) self.canvas_addons_action = QAction( self.tr("&Add-ons..."), self, objectName="canvas-addons-action", toolTip=self.tr("Manage add-ons."), triggered=self.open_addons, ) self.show_output_action = QAction( self.tr("&Log"), self, toolTip=self.tr("Show application standard output."), checkable=True, triggered=lambda checked: self.output_dock.setVisible( checked), ) # Actions for native Mac OSX look and feel. self.minimize_action = QAction( self.tr("Minimize"), self, triggered=self.showMinimized, shortcut=QKeySequence("Ctrl+M"), visible=sys.platform == "darwin", ) self.zoom_action = QAction( self.tr("Zoom"), self, objectName="application-zoom", triggered=self.toggleMaximized, visible=sys.platform == "darwin", ) self.freeze_action = QAction( self.tr("Freeze"), self, shortcut=QKeySequence("Shift+F"), objectName="signal-freeze-action", checkable=True, toolTip=self.tr("Freeze signal propagation (Shift+F)"), toggled=self.set_signal_freeze, icon=load_styled_svg_icon("Pause.svg") ) self.toggle_tool_dock_expand = QAction( self.tr("Expand Tool Dock"), self, objectName="toggle-tool-dock-expand", checkable=True, shortcut=QKeySequence("Ctrl+Shift+D"), triggered=self.set_tool_dock_expanded ) self.toggle_tool_dock_expand.setChecked(True) # Gets assigned in setup_ui (the action is defined in CanvasToolDock) # TODO: This is bad (should be moved here). self.dock_help_action = None self.toogle_margins_action = QAction( self.tr("Show Workflow Margins"), self, checkable=True, toolTip=self.tr("Show margins around the workflow view."), ) self.toogle_margins_action.setChecked(True) self.toogle_margins_action.toggled.connect( self.set_scheme_margins_enabled) self.float_widgets_on_top_action = QAction( self.tr("Display Widgets on Top"), self, checkable=True, toolTip=self.tr("Widgets are always displayed above other windows.") ) self.float_widgets_on_top_action.toggled.connect( self.set_float_widgets_on_top_enabled) def setup_menu(self): # QTBUG - 51480 if sys.platform == "darwin" and QT_VERSION >= 0x50000: self.__menu_glob = QMenuBar(None) menu_bar = QMenuBar(self) # File menu file_menu = QMenu( self.tr("&File"), menu_bar, objectName="file-menu" ) file_menu.addAction(self.new_action) file_menu.addAction(self.open_action) file_menu.addAction(self.open_and_freeze_action) file_menu.addAction(self.reload_last_action) # File -> Open Recent submenu self.recent_menu = QMenu( self.tr("Open Recent"), file_menu, objectName="recent-menu", ) file_menu.addMenu(self.recent_menu) # An invisible hidden separator action indicating the end of the # actions that with 'open' (new window/document) disposition sep = QAction( "", file_menu, objectName="open-actions-separator", visible=False, enabled=False ) # qt/cocoa native menu bar menu displays hidden separators # sep.setSeparator(True) file_menu.addAction(sep) file_menu.addAction(self.close_window_action) sep = file_menu.addSeparator() sep.setObjectName("close-window-actions-separator") file_menu.addAction(self.save_action) file_menu.addAction(self.save_as_action) file_menu.addAction(self.save_as_svg_action) sep = file_menu.addSeparator() sep.setObjectName("save-actions-separator") file_menu.addAction(self.show_properties_action) file_menu.addAction(self.quit_action) self.recent_menu.addAction(self.recent_action) # Store the reference to separator for inserting recent # schemes into the menu in `add_recent_scheme`. self.recent_menu_begin = self.recent_menu.addSeparator() icons = QFileIconProvider() # Add recent items. for item in self.recent_schemes: text = os.path.basename(item.path) if item.title: text = "{} ('{}')".format(text, item.title) icon = icons.icon(QFileInfo(item.path)) action = QAction( icon, text, self, toolTip=item.path, iconVisibleInMenu=True ) action.setData(item.path) self.recent_menu.addAction(action) self.recent_scheme_action_group.addAction(action) self.recent_menu.addSeparator() self.recent_menu.addAction(self.clear_recent_action) menu_bar.addMenu(file_menu) editor_menus = self.scheme_widget.menuBarActions() # WARNING: Hard coded order, should lookup the action text # and determine the proper order self.edit_menu = editor_menus[0].menu() self.widget_menu = editor_menus[1].menu() # Edit menu menu_bar.addMenu(self.edit_menu) # View menu self.view_menu = QMenu( self.tr("&View"), menu_bar, objectName="view-menu" ) # find and insert window group presets submenu window_groups = self.scheme_widget.findChild( QAction, "window-groups-action" ) if window_groups is not None: self.view_menu.addAction(window_groups) sep = self.view_menu.addSeparator() sep.setObjectName("workflow-window-groups-actions-separator") # Actions that toggle visibility of editor views self.view_menu.addAction(self.toggle_tool_dock_expand) self.view_menu.addAction(self.show_output_action) sep = self.view_menu.addSeparator() sep.setObjectName("view-visible-actions-separator") self.view_menu.addAction(self.zoom_in_action) self.view_menu.addAction(self.zoom_out_action) self.view_menu.addAction(self.zoom_reset_action) sep = self.view_menu.addSeparator() sep.setObjectName("view-zoom-actions-separator") self.view_menu.addAction(self.toogle_margins_action) menu_bar.addMenu(self.view_menu) # Options menu self.options_menu = QMenu( self.tr("&Options"), menu_bar, objectName="options-menu" ) self.options_menu.addAction(self.canvas_settings_action) self.options_menu.addAction(self.canvas_addons_action) # Widget menu menu_bar.addMenu(self.widget_menu) # Mac OS X native look and feel. self.window_menu = QMenu( self.tr("Window"), menu_bar, objectName="window-menu" ) self.window_menu.addAction(self.minimize_action) self.window_menu.addAction(self.zoom_action) self.window_menu.addSeparator() raise_widgets_action = self.scheme_widget.findChild( QAction, "bring-widgets-to-front-action" ) if raise_widgets_action is not None: self.window_menu.addAction(raise_widgets_action) self.window_menu.addAction(self.float_widgets_on_top_action) menu_bar.addMenu(self.window_menu) menu_bar.addMenu(self.options_menu) # Help menu. self.help_menu = QMenu( self.tr("&Help"), menu_bar, objectName="help-menu", ) self.help_menu.addActions([ self.about_action, self.welcome_action, self.get_started_screencasts_action, self.examples_action, self.documentation_action ]) menu_bar.addMenu(self.help_menu) self.setMenuBar(menu_bar) def restore(self): """Restore the main window state from saved settings. """ QSettings.setDefaultFormat(QSettings.IniFormat) settings = QSettings() settings.beginGroup("mainwindow") self.dock_widget.setExpanded( settings.value("canvasdock/expanded", True, type=bool) ) floatable = settings.value("toolbox-dock-floatable", False, type=bool) if floatable: self.dock_widget.setFeatures( self.dock_widget.features() | QDockWidget.DockWidgetFloatable ) self.widgets_tool_box.setExclusive( settings.value("toolbox-dock-exclusive", False, type=bool) ) self.toogle_margins_action.setChecked( settings.value("scheme-margins-enabled", False, type=bool) ) self.show_output_action.setChecked( settings.value("output-dock/is-visible", False, type=bool)) self.canvas_tool_dock.setQuickHelpVisible( settings.value("quick-help/visible", True, type=bool) ) self.float_widgets_on_top_action.setChecked( settings.value("widgets-float-on-top", False, type=bool) ) self.__update_from_settings() def __window_added(self, _, action: QAction) -> None: self.window_menu.addAction(action) def __window_removed(self, _, action: QAction) -> None: self.window_menu.removeAction(action) def __update_window_title(self): path = self.current_document().path() if path: self.setWindowTitle("") self.setWindowFilePath(path) else: self.setWindowFilePath("") self.setWindowTitle(self.tr("Untitled [*]")) def setWindowFilePath(self, filePath): # type: (str) -> None def icon_for_path(path: str) -> 'QIcon': iconprovider = QFileIconProvider() finfo = QFileInfo(path) if finfo.exists(): return iconprovider.icon(finfo) else: return iconprovider.icon(QFileIconProvider.File) if sys.platform == "darwin": super().setWindowFilePath(filePath) # If QApplication.windowIcon() is not null then it is used instead # of the file type specific one. This is wrong so we set it # explicitly. if not QApplication.windowIcon().isNull() and filePath: self.setWindowIcon(icon_for_path(filePath)) else: # use non-empty path to 'force' Qt to add '[*]' modified marker # in the displayed title. if not filePath: filePath = " " super().setWindowFilePath(filePath) def set_document_title(self, title): """Set the document title (and the main window title). If `title` is an empty string a default 'untitled' placeholder will be used. """ if self.__document_title != title: self.__document_title = title if not title: # TODO: should the default name be platform specific title = self.tr("untitled") self.setWindowTitle(title + "[*]") def document_title(self): """Return the document title. """ return self.__document_title def set_widget_registry(self, widget_registry): # type: (WidgetRegistry) -> None """ Set widget registry. Parameters ---------- widget_registry : WidgetRegistry """ if self.widget_registry is not None: # Clear the dock widget and popup. self.widgets_tool_box.setModel(None) self.quick_category.setModel(None) self.scheme_widget.setRegistry(None) self.help.set_registry(None) if self.__proxy_model is not None: self.__proxy_model.deleteLater() self.__proxy_model = None self.widget_registry = WidgetRegistry(widget_registry) qreg = QtWidgetRegistry(self.widget_registry, parent=self) self.__registry_model = qreg.model() # Restore category hidden/sort order state proxy = FilterProxyModel(self) proxy.setSourceModel(qreg.model()) self.__proxy_model = proxy self.__update_registry_filters() self.widgets_tool_box.setModel(proxy) self.quick_category.setModel(proxy) self.scheme_widget.setRegistry(qreg) self.scheme_widget.quickMenu().setModel(proxy) self.help.set_registry(widget_registry) # Restore possibly saved widget toolbox tab states settings = QSettings() state = settings.value("mainwindow/widgettoolbox/state", defaultValue=QByteArray(), type=QByteArray) if state: self.widgets_tool_box.restoreState(state) def set_quick_help_text(self, text): # type: (str) -> None self.canvas_tool_dock.help.setText(text) def current_document(self): # type: () -> SchemeEditWidget return self.scheme_widget def on_tool_box_widget_activated(self, action): """A widget action in the widget toolbox has been activated. """ widget_desc = action.data() if isinstance(widget_desc, WidgetDescription): scheme_widget = self.current_document() if scheme_widget: statistics = scheme_widget.usageStatistics() statistics.begin_action(UsageStatistics.ToolboxClick) scheme_widget.createNewNode(widget_desc) scheme_widget.view().setFocus(Qt.OtherFocusReason) def on_quick_category_action(self, action): """The quick category menu action triggered. """ category = action.text() settings = QSettings() use_popover = settings.value( "mainwindow/toolbox-dock-use-popover-menu", defaultValue=True, type=bool) if use_popover: # Show a popup menu with the widgets in the category popup = CategoryPopupMenu(self.quick_category) popup.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) model = self.__registry_model assert model is not None i = index(self.widget_registry.categories(), category, predicate=lambda name, cat: cat.name == name) if i != -1: popup.setModel(model) popup.setRootIndex(model.index(i, 0)) popup.adjustSize() button = self.quick_category.buttonForAction(action) pos = popup_position_from_source(popup, button) action = popup.exec(pos) if action is not None: self.on_tool_box_widget_activated(action) else: # Expand the dock and open the category under the triggered button for i in range(self.widgets_tool_box.count()): cat_act = self.widgets_tool_box.tabAction(i) cat_act.setChecked(cat_act.text() == category) self.dock_widget.expand() def set_scheme_margins_enabled(self, enabled): # type: (bool) -> None """Enable/disable the margins around the scheme document. """ if self.__scheme_margins_enabled != enabled: self.__scheme_margins_enabled = enabled self.__update_scheme_margins() def _scheme_margins_enabled(self): # type: () -> bool return self.__scheme_margins_enabled scheme_margins_enabled: bool scheme_margins_enabled = Property( # type: ignore bool, _scheme_margins_enabled, set_scheme_margins_enabled) def __update_scheme_margins(self): """Update the margins around the scheme document. """ enabled = self.__scheme_margins_enabled self.__dummy_top_toolbar.setVisible(enabled) self.__dummy_bottom_toolbar.setVisible(enabled) central = self.centralWidget() margin = 20 if enabled else 0 if self.dockWidgetArea(self.dock_widget) == Qt.LeftDockWidgetArea: margins = (margin // 2, 0, margin, 0) else: margins = (margin, 0, margin // 2, 0) central.layout().setContentsMargins(*margins) def is_transient(self): # type: () -> bool """ Is this window a transient window. I.e. a window that was created empty and does not contain any modified contents. In particular it can be reused to load a workflow model without any detrimental effects (like lost information). """ return self.__is_transient # All instances created through the create_new_window below. # They are removed on `destroyed` _instances = [] # type: List[CanvasMainWindow] def create_new_window(self): # type: () -> CanvasMainWindow """ Create a new top level CanvasMainWindow instance. The window is positioned slightly offset to the originating window (`self`). Note ---- The window has `Qt.WA_DeleteOnClose` flag set. If this flag is unset it is the callers responsibility to explicitly delete the widget (via `deleteLater` or `sip.delete`). Returns ------- window: CanvasMainWindow """ window = type(self)() # 'preserve' subclass type window.setAttribute(Qt.WA_DeleteOnClose) window.setGeometry(self.geometry().translated(20, 20)) window.setStyleSheet(self.styleSheet()) window.setWindowIcon(self.windowIcon()) if self.widget_registry is not None: window.set_widget_registry(self.widget_registry) window.restoreState(self.saveState(self.SETTINGS_VERSION), self.SETTINGS_VERSION) window.set_tool_dock_expanded(self.dock_widget.expanded()) window.set_float_widgets_on_top_enabled(self.float_widgets_on_top_action.isChecked()) output = window.output_view() # type: OutputView doc = self.output_view().document() doc = doc.clone(output) output.setDocument(doc) def is_connected(stream: TextStream) -> bool: item = findf(doc.connectedStreams(), lambda s: s is stream) return item is not None # # route the stdout/err if possible # TODO: Deprecate and remove this behaviour (use connectStream) stdout, stderr = sys.stdout, sys.stderr if isinstance(stdout, TextStream) and not is_connected(stdout): doc.connectStream(stdout) if isinstance(stderr, TextStream) and not is_connected(stderr): doc.connectStream(stderr, color=Qt.red) CanvasMainWindow._instances.append(window) window.destroyed.connect( lambda: CanvasMainWindow._instances.remove(window)) return window def new_workflow_window(self): # type: () -> None """ Create and show a new CanvasMainWindow instance. """ newwindow = self.create_new_window() newwindow.ask_load_swp_if_exists() newwindow.raise_() newwindow.show() newwindow.activateWindow() settings = QSettings() show = settings.value("schemeinfo/show-at-new-scheme", False, type=bool) if show: newwindow.show_scheme_properties() def open_scheme_file(self, filename, **kwargs): # type: (Union[str, QUrl], Any) -> None """ Open and load a scheme file. """ if isinstance(filename, QUrl): filename = filename.toLocalFile() if self.is_transient(): window = self else: window = self.create_new_window() window.show() window.raise_() window.activateWindow() if kwargs.get("freeze", False): window.freeze_action.setChecked(True) window.load_scheme(filename) def open_example_scheme(self, path): # type: (str) -> None # open an workflow without filename/directory tracking. if self.is_transient(): window = self else: window = self.create_new_window() window.show() window.raise_() window.activateWindow() new_scheme = window.new_scheme_from(path) if new_scheme is not None: window.set_scheme(new_scheme) def _open_workflow_dialog(self): # type: () -> QFileDialog """ Create and return an initialized QFileDialog for opening a workflow file. The dialog is a child of this window and has the `Qt.WA_DeleteOnClose` flag set. """ settings = QSettings() settings.beginGroup("mainwindow") start_dir = settings.value("last-scheme-dir", "", type=str) if not os.path.isdir(start_dir): start_dir = user_documents_path() dlg = QFileDialog( self, windowTitle=self.tr("Open Orange Workflow File"), acceptMode=QFileDialog.AcceptOpen, fileMode=QFileDialog.ExistingFile, ) dlg.setAttribute(Qt.WA_DeleteOnClose) dlg.setDirectory(start_dir) dlg.setNameFilters(["Orange Workflow (*.ows)"]) def record_last_dir(): path = dlg.directory().canonicalPath() settings.setValue("last-scheme-dir", path) dlg.accepted.connect(record_last_dir) return dlg def open_scheme(self): # type: () -> None """ Open a user selected workflow in a new window. """ dlg = self._open_workflow_dialog() dlg.fileSelected.connect(self.open_scheme_file) dlg.exec() def open_and_freeze_scheme(self): # type: () -> None """ Open a user selected workflow file in a new window and freeze signal propagation. """ dlg = self._open_workflow_dialog() dlg.fileSelected.connect(partial(self.open_scheme_file, freeze=True)) dlg.exec() def load_scheme(self, filename): # type: (str) -> None """ Load a scheme from a file (`filename`) into the current document, updates the recent scheme list and the loaded scheme path property. """ new_scheme = None # type: Optional[Scheme] try: with open(filename, "rb") as f: res = self.check_requires(f) if not res: return f.seek(0, os.SEEK_SET) new_scheme = self.new_scheme_from_contents_and_path(f, filename) except readwrite.UnsupportedFormatVersionError: mb = QMessageBox( self, windowTitle=self.tr("Error"), icon=QMessageBox.Critical, text=self.tr("Unsupported format version"), informativeText=self.tr( "The file was saved in a format not supported by this " "application." ), detailedText="".join(traceback.format_exc()), ) mb.setAttribute(Qt.WA_DeleteOnClose) mb.setWindowModality(Qt.WindowModal) mb.open() except Exception as err: mb = QMessageBox( parent=self, windowTitle=self.tr("Error"), icon=QMessageBox.Critical, text=self.tr("Could not open: '{}'") .format(os.path.basename(filename)), informativeText=self.tr("Error was: {}").format(err), detailedText="".join(traceback.format_exc()) ) mb.setAttribute(Qt.WA_DeleteOnClose) mb.setWindowModality(Qt.WindowModal) mb.open() if new_scheme is not None: self.set_scheme(new_scheme, freeze_creation=True) scheme_doc_widget = self.current_document() scheme_doc_widget.setPath(filename) self.add_recent_scheme(new_scheme.title, filename) if not self.freeze_action.isChecked(): # activate the default window group. scheme_doc_widget.activateDefaultWindowGroup() self.ask_load_swp_if_exists() wm = getattr(new_scheme, "widget_manager", None) if wm is not None: wm.set_creation_policy(wm.Normal) def new_scheme_from(self, filename): # type: (str) -> Optional[Scheme] """ Create and return a new :class:`scheme.Scheme` from a saved `filename`. Return `None` if an error occurs. """ f = None # type: Optional[IO] try: f = open(filename, "rb") except OSError as err: mb = QMessageBox( parent=self, windowTitle="Error", icon=QMessageBox.Critical, text=self.tr("Could not open: '{}'") .format(os.path.basename(filename)), informativeText=self.tr("Error was: {}").format(err), ) mb.setAttribute(Qt.WA_DeleteOnClose) mb.setWindowModality(Qt.WindowModal) mb.open() return None else: return self.new_scheme_from_contents_and_path(f, filename) finally: if f is not None: f.close() def new_scheme_from_contents_and_path( self, fileobj: IO, path: str) -> Optional[Scheme]: """ Create and return a new :class:`scheme.Scheme` from contents of `fileobj`. Return `None` if an error occurs. In case of an error show an error message dialog and return `None`. Parameters ---------- fileobj: IO An open readable IO stream. path: str Associated filesystem path. Returns ------- workflow: Optional[Scheme] """ new_scheme = config.workflow_constructor(parent=self) new_scheme.set_runtime_env( "basedir", os.path.abspath(os.path.dirname(path))) errors = [] # type: List[Exception] try: new_scheme.load_from( fileobj, registry=self.widget_registry, error_handler=errors.append ) except Exception: # pylint: disable=broad-except log.exception("") message_critical( self.tr("Could not load an Orange Workflow file."), title=self.tr("Error"), informative_text=self.tr("An unexpected error occurred " "while loading '%s'.") % path, exc_info=True, parent=self) return None if errors: details = render_error_details(errors) message_warning( self.tr("Could not load the full workflow."), title=self.tr("Workflow Partially Loaded"), informative_text=self.tr( "Some of the nodes/links could not be reconstructed " "and were omitted from the workflow." ), details=details, parent=self, ) return new_scheme def check_requires(self, fileobj: IO) -> bool: requires = scheme_requires(fileobj, self.widget_registry) requires = [req for req in requires if not is_requirement_available(req)] if requires: details_ = [ "

              Required packages:

                ", *["
              • {}
              • ".format(escape(r)) for r in requires], "
              " ] details = "".join(details_) mb = QMessageBox( parent=self, objectName="install-requirements-message-box", icon=QMessageBox.Question, windowTitle="Install Additional Packages", text="Workflow you are trying to load contains widgets " "from missing add-ons." "
              " + details + "
              " "Would you like to install them now?", standardButtons=QMessageBox.Ok | QMessageBox.Abort | QMessageBox.Ignore, informativeText=( "After installation you will have to restart the " "application and reopen the workflow."), ) mb.setDefaultButton(QMessageBox.Ok) bok = mb.button(QMessageBox.Ok) bok.setText("Install add-ons") bignore = mb.button(QMessageBox.Ignore) bignore.setText("Ignore missing widgets") bignore.setToolTip( "Load partial workflow by omitting missing nodes and links." ) mb.setWindowModality(Qt.WindowModal) mb.setAttribute(Qt.WA_DeleteOnClose, True) status = mb.exec() if status == QMessageBox.Abort: return False elif status == QMessageBox.Ignore: return True status = self.install_requirements(requires) if status == QDialog.Rejected: return False else: message_information( title="Please Restart", text="Please restart and reopen the file.", parent=self ) return False return True def install_requirements(self, requires: Sequence[str]) -> int: dlg = addons.AddonManagerDialog( parent=self, windowTitle="Install required packages", enableFilterAndAdd=False, modal=True ) dlg.setStyle(QApplication.style()) dlg.setConfig(config.default) req = addons.Requirement names = [req(r).name for r in requires] normalized_names = {normalize_name(r) for r in names} def set_state(*args): # select all query items for installation # TODO: What if some of the `names` failed. items = dlg.items() state = dlg.itemState() for item in items: if item.normalized_name in normalized_names: normalized_names.remove(item.normalized_name) state.append((addons.Install, item)) dlg.setItemState(state) f = dlg.runQueryAndAddResults(names) f.add_done_callback(qinvoke(set_state, context=dlg)) return dlg.exec() def reload_last(self): # type: () -> None """ Reload last opened scheme. """ settings = QSettings() recent = QSettings_readArray( settings, "mainwindow/recent-items", {"path": str} ) # type: List[Dict[str, str]] if recent: path = recent[0]["path"] self.open_scheme_file(path) def set_scheme(self, new_scheme: Scheme, freeze_creation=False): """ Set new_scheme as the current shown scheme in this window. The old scheme will be deleted. """ scheme_doc = self.current_document() old_scheme = scheme_doc.scheme() if old_scheme: self.__is_transient = False freeze_signals = self.freeze_action.isChecked() manager = getattr(new_scheme, "signal_manager", None) if freeze_signals and manager is not None: manager.pause() wm = getattr(new_scheme, "widget_manager", None) if wm is not None: wm.set_float_widgets_on_top( self.float_widgets_on_top_action.isChecked() ) wm.set_creation_policy( wm.OnDemand if freeze_creation else wm.Normal ) scheme_doc.setScheme(new_scheme) if old_scheme is not None: # Send a close event to the Scheme, it is responsible for # closing/clearing all resources (widgets). QApplication.sendEvent(old_scheme, QEvent(QEvent.Close)) old_scheme.deleteLater() def __title_for_scheme(self, scheme): # type: (Optional[Scheme]) -> str title = self.tr("untitled") if scheme is not None: title = scheme.title or title return title def ask_save_changes(self): # type: () -> int """Ask the user to save the changes to the current scheme. Return QDialog.Accepted if the scheme was successfully saved or the user selected to discard the changes. Otherwise return QDialog.Rejected. """ document = self.current_document() scheme = document.scheme() path = document.path() if path: filename = os.path.basename(document.path()) message = self.tr('Do you want to save changes made to %s?') % filename else: message = self.tr('Do you want to save this workflow?') selected = message_question( message, self.tr("Save Changes?"), self.tr("Your changes will be lost if you do not save them."), buttons=QMessageBox.Save | QMessageBox.Cancel | \ QMessageBox.Discard, default_button=QMessageBox.Save, parent=self) if selected == QMessageBox.Save: return self.save_scheme() elif selected == QMessageBox.Discard: return QDialog.Accepted elif selected == QMessageBox.Cancel: return QDialog.Rejected else: assert False def save_scheme(self): # type: () -> int """Save the current scheme. If the scheme does not have an associated path then prompt the user to select a scheme file. Return QDialog.Accepted if the scheme was successfully saved and QDialog.Rejected if the user canceled the file selection. """ document = self.current_document() curr_scheme = document.scheme() if curr_scheme is None: return QDialog.Rejected assert curr_scheme is not None path = document.path() if path: if self.save_scheme_to(curr_scheme, path): document.setModified(False) self.add_recent_scheme(curr_scheme.title, document.path()) return QDialog.Accepted else: return QDialog.Rejected else: return self.save_scheme_as() def save_scheme_as(self): # type: () -> int """ Save the current scheme by asking the user for a filename. Return `QFileDialog.Accepted` if the scheme was saved successfully and `QFileDialog.Rejected` if not. """ document = self.current_document() curr_scheme = document.scheme() assert curr_scheme is not None title = self.__title_for_scheme(curr_scheme) settings = self._settings() if document.path(): start_dir = document.path() else: start_dir = settings.value("last-scheme-dir", "", type=str) if not os.path.isdir(start_dir): start_dir = user_documents_path() start_dir = os.path.join(start_dir, title + ".ows") dialog = QFileDialog( self, windowTitle=self.tr("Save Orange Workflow File"), directory=start_dir, fileMode=QFileDialog.AnyFile, acceptMode=QFileDialog.AcceptSave, windowModality=Qt.WindowModal, objectName="save-as-ows-filedialog", ) dialog.setNameFilter(self.tr("Orange Workflow (*.ows)")) dialog.exec() files = dialog.selectedFiles() dialog.deleteLater() if files: filename = files[0] settings.setValue("last-scheme-dir", os.path.dirname(filename)) if self.save_scheme_to(curr_scheme, filename): document.setPath(filename) document.setModified(False) self.add_recent_scheme(curr_scheme.title, document.path()) return QFileDialog.Accepted return QFileDialog.Rejected def save_scheme_to(self, scheme, filename): # type: (Scheme, str) -> bool """ Save a Scheme instance `scheme` to `filename`. On success return `True`, else show a message to the user explaining the error and return `False`. """ dirname, basename = os.path.split(filename) title = scheme.title or "untitled" # First write the scheme to a buffer so we don't truncate an # existing scheme file if `scheme.save_to` raises an error. buffer = io.BytesIO() try: scheme.set_runtime_env("basedir", os.path.abspath(dirname)) scheme.save_to(buffer, pretty=True, pickle_fallback=True) except Exception: log.error("Error saving %r to %r", scheme, filename, exc_info=True) message_critical( self.tr('An error occurred while trying to save workflow ' '"%s" to "%s"') % (title, basename), title=self.tr("Error saving %s") % basename, exc_info=True, parent=self ) return False try: with open(filename, "wb") as f: f.write(buffer.getvalue()) self.clear_swp() return True except FileNotFoundError as ex: log.error("%s saving '%s'", type(ex).__name__, filename, exc_info=True) message_warning( self.tr('Workflow "%s" could not be saved. The path does ' 'not exist') % title, title="", informative_text=self.tr("Choose another location."), parent=self ) return False except PermissionError as ex: log.error("%s saving '%s'", type(ex).__name__, filename, exc_info=True) message_warning( self.tr('Workflow "%s" could not be saved. You do not ' 'have write permissions.') % title, title="", informative_text=self.tr( "Change the file system permissions or choose " "another location."), parent=self ) return False except OSError as ex: log.error("%s saving '%s'", type(ex).__name__, filename, exc_info=True) message_warning( self.tr('Workflow "%s" could not be saved.') % title, title="", informative_text=os.strerror(ex.errno), exc_info=True, parent=self ) return False except Exception: # pylint: disable=broad-except log.error("Error saving %r to %r", scheme, filename, exc_info=True) message_critical( self.tr('An error occurred while trying to save workflow ' '"%s" to "%s"') % (title, basename), title=self.tr("Error saving %s") % basename, exc_info=True, parent=self ) return False def save_swp(self): """ Save a difference of node properties and the undostack to '..swp.p' in the same directory. If the workflow has not yet been saved, save to 'scratch.ows.p' in configdir/scratch-crashes. """ document = self.current_document() undoStack = document.undoStack() if not document.isModifiedStrict() and undoStack.isClean(): return swpname = swp_name(self) if swpname is not None: self.save_swp_to(swpname) def save_swp_to(self, filename): """ Save a tuple of properties diff and undostack diff to a file. """ document = self.current_document() undoStack = document.undoStack() propertiesDiff = document.uncleanProperties() undoDiff = [UndoCommand.from_QUndoCommand(undoStack.command(i)) for i in range(undoStack.cleanIndex(), undoStack.count())] diff = (propertiesDiff, undoDiff) try: with open(filename, "wb") as f: Pickler(f, document).dump(diff) except Exception: log.error("Could not write swp file %r.", filename, exc_info=True) def clear_swp(self): """ Delete the document's swp file, should it exist. """ document = self.current_document() path = document.path() def remove(filename: str) -> None: try: os.remove(filename) except FileNotFoundError: pass except OSError as e: log.warning("Could not delete swp file: %s", e) if path or self in canvas_scratch_name_memo: remove(swp_name(self)) else: swpnames = glob_scratch_swps() for swpname in swpnames: remove(swpname) def ask_load_swp_if_exists(self): """ Should a swp file for this canvas exist, ask the user if they wish to restore changes, loading on yes, discarding on no. Returns True if swp was loaded, False if not. """ document = self.current_document() path = document.path() if path: swpname = swp_name(self) if not os.path.exists(swpname): return False else: if not QSettings().value('startup/load-crashed-workflows', True, type=bool): return False swpnames = glob_scratch_swps() if not swpnames or \ all([s in canvas_scratch_name_memo.values() for s in swpnames]): return False return self.ask_load_swp() def ask_load_swp(self): """ Ask to restore changes, loading swp file on yes, clearing swp file on no. """ title = self.tr('Restore unsaved changes from crash?') name = QApplication.applicationName() or "Orange" selected = message_information( title, self.tr("Restore Changes?"), self.tr("{} seems to have crashed at some point.\n" "Changes will be discarded if not restored now.").format(name), buttons=QMessageBox.Yes | QMessageBox.No, default_button=QMessageBox.Yes, parent=self) if selected == QMessageBox.Yes: self.load_swp() return True elif selected == QMessageBox.No: self.clear_swp() return False else: assert False def load_swp(self): """ Load and restore the undostack and widget properties from '..swp.p' in the same directory, or 'scratch.ows.p' in configdir/scratch-crashes if the workflow has not yet been saved. """ document = self.scheme_widget undoStack = document.undoStack() if document.path(): # load hidden file in same directory swpname = swp_name(self) if not os.path.exists(swpname): return self.load_swp_from(swpname) else: # load scratch files in config directory swpnames = [name for name in glob_scratch_swps() if name not in canvas_scratch_name_memo.values()] if not swpnames: return self.load_swp_from(swpnames[0]) for swpname in swpnames[1:]: w = self.create_new_window() w.load_swp_from(swpname) w.raise_() w.show() w.activateWindow() def load_swp_from(self, filename): """ Load a diff of node properties and UndoCommands from a file """ document = self.current_document() undoStack = document.undoStack() try: with open(filename, "rb") as f: loaded: Tuple[Dict[SchemeNode, dict], List[UndoCommand]] loaded = Unpickler(f, document.scheme()).load() except Exception: log.error("Could not load swp file: %r", filename, exc_info=True) message_critical( "Could not load restore data.", title="Error", exc_info=True, ) # delete corrupted swp file try: os.remove(filename) except OSError: pass return register_loaded_swp(self, filename) document.undoCommandAdded.disconnect(self.save_swp) commands = loaded[1] for c in commands: undoStack.push(c) properties = loaded[0] document.restoreProperties(properties) document.undoCommandAdded.connect(self.save_swp) def load_diff(self, properties_and_commands): """ Load a diff of node properties and UndoCommands Parameters --------- properties_and_commands : ({SchemeNode : {}}, [UndoCommand]) """ document = self.scheme_widget undoStack = document.undoStack() commands = properties_and_commands[1] for c in commands: undoStack.push(c) properties = properties_and_commands[0] document.restoreProperties(properties) def _settings(self) -> QSettings: s = QSettings() s.beginGroup("mainwindow") return s def save_as_svg(self): settings = self._settings() settings.beginGroup("save-as-svg-filedialog") path = settings.value("path", defaultValue="", type=str) if path: directory = os.path.dirname(path) else: directory = user_documents_path() document_path = self.current_document().path() if document_path: document_basename = os.path.basename(document_path) basename, _ = os.path.splitext(document_basename) basename = basename + ".svg" else: basename = self.tr("untitled.svg") dialog = QFileDialog( self, acceptMode=QFileDialog.AcceptSave, fileMode=QFileDialog.AnyFile, directory=directory, windowModality=Qt.WindowModal, objectName="save-as-svg-filedialog", ) dialog.setAttribute(Qt.WA_DeleteOnClose) dialog.setNameFilter(self.tr("Scalable Vector Graphics (*.svg)")) dialog.selectFile(os.path.join(directory, basename)) def save(): files = dialog.selectedFiles() if files: self.__save_as_svg(files[0]) settings.setValue("path", files[0]) dialog.accepted.connect(save) dialog.exec() def __save_as_svg(self, path): doc = self.current_document() content = scene.grab_svg(doc.scene()) with self._handle_os_write_error(): with open(path, "wt", encoding="utf-8") as f: f.write(content) @contextmanager def _handle_os_write_error(self): try: yield except PermissionError as ex: log.error("Write error", exc_info=True) message_warning( self.tr('"%(path)s" could not be saved. You do not ' 'have write permissions (%(strerror)s).') % {"path": ex.filename, "strerror": ex.strerror}, title="", informative_text=self.tr( "Change the file system permissions or choose " "another location."), parent=self ) except OSError as ex: log.error("Write error", exc_info=True) message_warning( self.tr('"%(path)s" could not be saved.') % {"path": ex.filename}, title="", informative_text=ex.strerror ) def recent_scheme(self): # type: () -> int """ Browse recent schemes. Return QDialog.Rejected if the user canceled the operation and QDialog.Accepted otherwise. """ settings = QSettings() recent_items = QSettings_readArray( settings, "mainwindow/recent-items", { "title": (str, ""), "path": (str, "") } ) # type: List[Dict[str, str]] recent = [RecentItem(**item) for item in recent_items] recent = [item for item in recent if os.path.exists(item.path)] items = [previewmodel.PreviewItem(name=item.title, path=item.path) for item in recent] dialog = previewdialog.PreviewDialog(self) model = previewmodel.PreviewModel(dialog, items=items) title = self.tr("Recent Workflows") dialog.setWindowTitle(title) template = ('

              \n' #'\n' '{0}\n' '

              ') dialog.setHeading(template.format(title)) dialog.setModel(model) model.delayedScanUpdate() status = dialog.exec() index = dialog.currentIndex() dialog.deleteLater() model.deleteLater() if status == QDialog.Accepted: selected = model.item(index) self.open_scheme_file(selected.path()) return status def examples_dialog(self): # type: () -> int """ Browse a collection of tutorial/example schemes. Returns QDialog.Rejected if the user canceled the dialog else loads the selected scheme into the canvas and returns QDialog.Accepted. """ tutors = examples.workflows(config.default) items = [previewmodel.PreviewItem(path=t.abspath()) for t in tutors] dialog = previewdialog.PreviewDialog(self) model = previewmodel.PreviewModel(dialog, items=items) title = self.tr("Example Workflows") dialog.setWindowTitle(title) template = ('

              \n' '{0}\n' '

              ') dialog.setHeading(template.format(title)) dialog.setModel(model) model.delayedScanUpdate() status = dialog.exec() index = dialog.currentIndex() dialog.deleteLater() if status == QDialog.Accepted: selected = model.item(index) self.open_example_scheme(selected.path()) return status def welcome_dialog(self): # type: () -> int """Show a modal welcome dialog for Orange Canvas. """ name = QApplication.applicationName() if name: title = self.tr("Welcome to {}").format(name) else: title = self.tr("Welcome") dialog = welcomedialog.WelcomeDialog(self, windowTitle=title) feedback = config.default.APPLICATION_URLS.get("Feedback", "") if feedback: dialog.setFeedbackUrl(feedback) def new_scheme(): if not self.is_transient(): self.new_workflow_window() dialog.accept() def open_scheme(): dlg = self._open_workflow_dialog() dlg.setParent(dialog, Qt.Dialog) dlg.fileSelected.connect(self.open_scheme_file) dlg.accepted.connect(dialog.accept) dlg.exec() def open_recent(): if self.recent_scheme() == QDialog.Accepted: dialog.accept() def browse_examples(): if self.examples_dialog() == QDialog.Accepted: dialog.accept() new_action = QAction( self.tr("New"), dialog, toolTip=self.tr("Open a new workflow."), triggered=new_scheme, shortcut=QKeySequence.New, icon=load_styled_svg_icon("New.svg") ) open_action = QAction( self.tr("Open"), dialog, objectName="welcome-action-open", toolTip=self.tr("Open a workflow."), triggered=open_scheme, shortcut=QKeySequence.Open, icon=load_styled_svg_icon("Open.svg") ) recent_action = QAction( self.tr("Recent"), dialog, objectName="welcome-recent-action", toolTip=self.tr("Browse and open a recent workflow."), triggered=open_recent, shortcut=QKeySequence("Ctrl+Shift+R"), icon=load_styled_svg_icon("Recent.svg") ) examples_action = QAction( self.tr("Examples"), dialog, objectName="welcome-examples-action", toolTip=self.tr("Browse example workflows."), triggered=browse_examples, icon=load_styled_svg_icon("Examples.svg") ) bottom_row = [self.get_started_action, examples_action, self.documentation_action] if self.get_started_screencasts_action.isEnabled(): bottom_row.insert(0, self.get_started_screencasts_action) self.new_action.triggered.connect(dialog.accept) top_row = [new_action, open_action, recent_action] dialog.addRow(top_row, background="light-grass") dialog.addRow(bottom_row, background="light-orange") settings = QSettings() dialog.setShowAtStartup( settings.value("startup/show-welcome-screen", True, type=bool) ) status = dialog.exec() settings.setValue("startup/show-welcome-screen", dialog.showAtStartup()) dialog.deleteLater() return status def scheme_properties_dialog(self): # type: () -> SchemeInfoDialog """Return an empty `SchemeInfo` dialog instance. """ settings = QSettings() value_key = "schemeinfo/show-at-new-scheme" dialog = SchemeInfoDialog( self, windowTitle=self.tr("Workflow Info"), ) dialog.setFixedSize(725, 450) dialog.setShowAtNewScheme(settings.value(value_key, False, type=bool)) def onfinished(): # type: () -> None settings.setValue(value_key, dialog.showAtNewScheme()) dialog.finished.connect(onfinished) return dialog def show_scheme_properties(self): # type: () -> int """ Show current scheme properties. """ current_doc = self.current_document() scheme = current_doc.scheme() assert scheme is not None dlg = self.scheme_properties_dialog() dlg.setAutoCommit(False) dlg.setScheme(scheme) status = dlg.exec() if status == QDialog.Accepted: editor = dlg.editor stack = current_doc.undoStack() stack.beginMacro(self.tr("Change Info")) current_doc.setTitle(editor.title()) current_doc.setDescription(editor.description()) stack.endMacro() return status def set_signal_freeze(self, freeze): # type: (bool) -> None scheme = self.current_document().scheme() manager = getattr(scheme, "signal_manager", None) if manager is not None: if freeze: manager.pause() else: manager.resume() wm = getattr(scheme, "widget_manager", None) if wm is not None: wm.set_creation_policy( wm.OnDemand if freeze else wm.Normal ) def remove_selected(self): # type: () -> None """Remove current scheme selection. """ self.current_document().removeSelected() def select_all(self): # type: () -> None self.current_document().selectAll() def open_widget(self): # type: () -> None """Open/raise selected widget's GUI. """ self.current_document().openSelected() def rename_widget(self): # type: () -> None """Rename the current focused widget. """ doc = self.current_document() nodes = doc.selectedNodes() if len(nodes) == 1: doc.editNodeTitle(nodes[0]) def open_canvas_settings(self): # type: () -> None """Open canvas settings/preferences dialog """ dlg = UserSettingsDialog(self) dlg.setWindowTitle(self.tr("Preferences")) dlg.show() status = dlg.exec() if status == 0: self.user_preferences_changed_notify_all() @staticmethod def user_preferences_changed_notify_all(): # type: () -> None """ Notify all top level `CanvasMainWindow` instances of user preferences change. """ for w in QApplication.topLevelWidgets(): if isinstance(w, CanvasMainWindow) or isinstance(w, QuickMenu): w.update_from_settings() def open_addons(self): # type: () -> int """Open the add-on manager dialog. """ name = QApplication.applicationName() or "Orange" from orangecanvas.application.utils.addons import have_install_permissions if not have_install_permissions(): QMessageBox(QMessageBox.Warning, "Add-ons: insufficient permissions", "Insufficient permissions to install add-ons. Try starting {name} " "as a system administrator or install {name} in user folders." .format(name=name), parent=self).exec() dlg = addons.AddonManagerDialog( self, windowTitle=self.tr("Installer"), modal=True ) dlg.setStyle(QApplication.style()) dlg.setAttribute(Qt.WA_DeleteOnClose) dlg.start(config.default) return dlg.exec() def set_float_widgets_on_top_enabled(self, enabled): # type: (bool) -> None if self.float_widgets_on_top_action.isChecked() != enabled: self.float_widgets_on_top_action.setChecked(enabled) wm = self.current_document().widgetManager() if wm is not None: wm.set_float_widgets_on_top(enabled) def output_view(self): # type: () -> OutputView """Return the output text widget. """ return self.output_dock.widget() def open_about(self): # type: () -> None """Open the about dialog. """ dlg = AboutDialog(self) dlg.setAttribute(Qt.WA_DeleteOnClose) dlg.exec() def add_recent_scheme(self, title, path): # type: (str, str) -> None """Add an entry (`title`, `path`) to the list of recent schemes. """ if not path: # No associated persistent path so we can't do anything. return text = os.path.basename(path) if title: text = "{} ('{}')".format(text, title) settings = QSettings() settings.beginGroup("mainwindow") recent_ = QSettings_readArray( settings, "recent-items", {"title": str, "path": str} ) # type: List[Dict[str, str]] recent = [RecentItem(**d) for d in recent_] filename = os.path.abspath(os.path.realpath(path)) filename = os.path.normpath(filename) actions_by_filename = {} for action in self.recent_scheme_action_group.actions(): path = action.data() if isinstance(path, str): actions_by_filename[path] = action if filename in actions_by_filename: # reuse/update the existing action action = actions_by_filename[filename] self.recent_menu.removeAction(action) self.recent_scheme_action_group.removeAction(action) action.setText(text) else: icons = QFileIconProvider() icon = icons.icon(QFileInfo(filename)) action = QAction( icon, text, self, toolTip=filename, iconVisibleInMenu=True ) action.setData(filename) # Find the separator action in the menu (after 'Browse Recent') recent_actions = self.recent_menu.actions() begin_index = index(recent_actions, self.recent_menu_begin) action_before = recent_actions[begin_index + 1] self.recent_menu.insertAction(action_before, action) self.recent_scheme_action_group.addAction(action) recent.insert(0, RecentItem(title=title, path=filename)) for i in reversed(range(1, len(recent))): try: same = os.path.samefile(recent[i].path, filename) except OSError: same = False if same: del recent[i] recent = recent[:self.num_recent_schemes] QSettings_writeArray( settings, "recent-items", [{"title": item.title, "path": item.path} for item in recent] ) def clear_recent_schemes(self): # type: () -> None """Clear list of recent schemes """ actions = self.recent_scheme_action_group.actions() for action in actions: self.recent_menu.removeAction(action) self.recent_scheme_action_group.removeAction(action) settings = QSettings() QSettings_writeArray(settings, "mainwindow/recent-items", []) def _on_recent_scheme_action(self, action): # type: (QAction) -> None """ A recent scheme action was triggered by the user """ filename = str(action.data()) self.open_scheme_file(filename) def _on_dock_location_changed(self, location): # type: (Qt.DockWidgetArea) -> None """Location of the dock_widget has changed, fix the margins if necessary. """ self.__update_scheme_margins() def set_tool_dock_expanded(self, expanded): # type: (bool) -> None """ Set the dock widget expanded state. """ self.dock_widget.setExpanded(expanded) def _on_tool_dock_expanded(self, expanded): # type: (bool) -> None """ 'dock_widget' widget was expanded/collapsed. """ if expanded != self.toggle_tool_dock_expand.isChecked(): self.toggle_tool_dock_expand.setChecked(expanded) def createPopupMenu(self): # Override the default context menu popup (we don't want the user to # be able to hide the tool dock widget). return None def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.ModifiedChange: # clear transient flag on any change self.__is_transient = False super().changeEvent(event) def closeEvent(self, event): # type: (QCloseEvent) -> None """ Close the main window. """ document = self.current_document() if document.isModifiedStrict(): if self.ask_save_changes() == QDialog.Rejected: # Reject the event event.ignore() return self.clear_swp() old_scheme = document.scheme() # Set an empty scheme to clear the document document.setScheme(config.workflow_constructor(parent=self)) if old_scheme is not None: QApplication.sendEvent(old_scheme, QEvent(QEvent.Close)) old_scheme.deleteLater() document.usageStatistics().close() geometry = self.saveGeometry() state = self.saveState(version=self.SETTINGS_VERSION) settings = QSettings() settings.beginGroup("mainwindow") settings.setValue("geometry", geometry) settings.setValue("state", state) settings.setValue("canvasdock/expanded", self.dock_widget.expanded()) settings.setValue("scheme-margins-enabled", self.scheme_margins_enabled) settings.setValue("widgettoolbox/state", self.widgets_tool_box.saveState()) settings.setValue("quick-help/visible", self.canvas_tool_dock.quickHelpVisible()) settings.setValue("widgets-float-on-top", self.float_widgets_on_top_action.isChecked()) settings.endGroup() self.help_dock.close() self.output_dock.close() super().closeEvent(event) windowlist = WindowListManager.instance() windowlist.removeWindow(self) __did_restore = False def restoreState(self, state, version=0): # type: (Union[QByteArray, bytes, bytearray], int) -> bool restored = super().restoreState(state, version) self.__did_restore = self.__did_restore or restored return restored def showEvent(self, event): # type: (QShowEvent) -> None if self.__first_show: settings = QSettings() settings.beginGroup("mainwindow") # Restore geometry if not already positioned if not (self.testAttribute(Qt.WA_Moved) or self.testAttribute(Qt.WA_Resized)): geom_data = settings.value("geometry", QByteArray(), type=QByteArray) if geom_data: self.restoreGeometry(geom_data) state = settings.value("state", QByteArray(), type=QByteArray) # Restore dock/toolbar state if not already done so if state and not self.__did_restore: self.restoreState(state, version=self.SETTINGS_VERSION) self.__first_show = False super().showEvent(event) def quickHelpEvent(self, event: QuickHelpTipEvent) -> None: if event.priority() == QuickHelpTipEvent.Normal: self.dock_help.showHelp(event.html()) elif event.priority() == QuickHelpTipEvent.Temporary: self.dock_help.showHelp(event.html(), event.timeout()) elif event.priority() == QuickHelpTipEvent.Permanent: self.dock_help.showPermanentHelp(event.html()) event.accept() def __handle_help_query_response(self, res: Optional[QUrl]): if res is None: mb = QMessageBox( text=self.tr("There is no documentation for this widget."), windowTitle=self.tr("No help found"), icon=QMessageBox.Information, parent=self, objectName="no-help-found-message-box" ) mb.setAttribute(Qt.WA_DeleteOnClose) mb.setWindowModality(Qt.ApplicationModal) mb.show() else: self.show_help(res) def whatsThisClickedEvent(self, event: QWhatsThisClickedEvent) -> None: url = QUrl(event.href()) if url.scheme() == "help" and url.authority() == "search": loop = get_event_loop() qself = qobjref(self) async def run(query_coro: Awaitable[QUrl], query: QUrl): url: Optional[QUrl] = None try: url = await query_coro except (KeyError, futures.TimeoutError): log.info("No help topic found for %r", query) self_ = qself() if self_ is not None: self_.__handle_help_query_response(url) loop.create_task(run(self.help.search_async(url), url)) elif url.scheme() == "action" and url.path(): action = self.findChild(QAction, url.path()) if action is not None: action.trigger() else: log.warning("No target action found for %r", url.toString()) def event(self, event): # type: (QEvent) -> bool if event.type() == QEvent.StatusTip and \ isinstance(event, QuickHelpTipEvent): self.quickHelpEvent(event) if event.isAccepted(): return True elif event.type() == QEvent.WhatsThisClicked: event = cast(QWhatsThisClickedEvent, event) self.whatsThisClickedEvent(event) return True return super().event(event) def show_help(self, url): # type: (QUrl) -> None """ Show `url` in a help window. """ log.info("Setting help to url: %r", url) settings = QSettings() use_external = settings.value( "help/open-in-external-browser", defaultValue=False, type=bool) if use_external or self.help_view is None: url = QUrl(url) QDesktopServices.openUrl(url) else: self.help_view.load(QUrl(url)) self.help_dock.show() self.help_dock.raise_() def toggleMaximized(self) -> None: """Toggle normal/maximized window state. """ if self.isMinimized(): # Do nothing if window is minimized return if self.isMaximized(): self.showNormal() else: self.showMaximized() def sizeHint(self): # type: () -> QSize """ Reimplemented from QMainWindow.sizeHint """ hint = super().sizeHint() return hint.expandedTo(QSize(1024, 720)) def update_from_settings(self): # type: () -> None """ Update the state from changed user preferences. This method is called on all top level windows (that are subclasses of CanvasMainWindow) after the preferences dialog is closed. """ self.__update_from_settings() def __update_from_settings(self): # type: () -> None settings = QSettings() settings.beginGroup("mainwindow") toolbox_floatable = settings.value("toolbox-dock-floatable", defaultValue=False, type=bool) features = self.dock_widget.features() features = updated_flags(features, QDockWidget.DockWidgetFloatable, toolbox_floatable) self.dock_widget.setFeatures(features) toolbox_exclusive = settings.value("toolbox-dock-exclusive", defaultValue=False, type=bool) self.widgets_tool_box.setExclusive(toolbox_exclusive) self.num_recent_schemes = settings.value("num-recent-schemes", defaultValue=15, type=int) float_widgets_on_top = settings.value("widgets-float-on-top", defaultValue=False, type=bool) self.set_float_widgets_on_top_enabled(float_widgets_on_top) settings.endGroup() settings.beginGroup("quickmenu") triggers = 0 dbl_click = settings.value("trigger-on-double-click", defaultValue=True, type=bool) if dbl_click: triggers |= SchemeEditWidget.DoubleClicked right_click = settings.value("trigger-on-right-click", defaultValue=True, type=bool) if right_click: triggers |= SchemeEditWidget.RightClicked space_press = settings.value("trigger-on-space-key", defaultValue=True, type=bool) if space_press: triggers |= SchemeEditWidget.SpaceKey any_press = settings.value("trigger-on-any-key", defaultValue=False, type=bool) if any_press: triggers |= SchemeEditWidget.AnyKey self.scheme_widget.setQuickMenuTriggers(triggers) settings.endGroup() settings.beginGroup("schemeedit") show_channel_names = settings.value("show-channel-names", defaultValue=True, type=bool) self.scheme_widget.setChannelNamesVisible(show_channel_names) open_anchors_ = settings.value( "open-anchors-on-hover", defaultValue=False, type=bool ) if open_anchors_: open_anchors = SchemeEditWidget.OpenAnchors.Always else: open_anchors = SchemeEditWidget.OpenAnchors.OnShift self.scheme_widget.setOpenAnchorsMode(open_anchors) node_animations = settings.value("enable-node-animations", defaultValue=False, type=bool) self.scheme_widget.setNodeAnimationEnabled(node_animations) settings.endGroup() self.__update_registry_filters() def __update_registry_filters(self): # type: () -> None if self.widget_registry is None: return settings = QSettings() visible_state = {} for cat in self.widget_registry.categories(): visible, _ = category_state(cat, settings) visible_state[cat.name] = visible if self.__proxy_model is not None: self.__proxy_model.setFilters([ FilterProxyModel.Filter( 0, QtWidgetRegistry.CATEGORY_DESC_ROLE, category_filter_function(visible_state)) ]) def connect_output_stream(self, stream: TextStream): """ Connect a :class:`TextStream` instance to this window's output view. The `stream` will be 'inherited' by new windows created by `create_new_window`. """ doc = self.output_view().document() doc.connectStream(stream) def disconnect_output_stream(self, stream: TextStream): """ Disconnect a :class:`TextStream` instance from this window's output view. """ doc = self.output_view().document() doc.disconnectStream(stream) def updated_flags(flags, mask, state): return set_flag(flags, mask, state) def identity(item): return item def index(sequence, *what, **kwargs): """index(sequence, what, [key=None, [predicate=None]]) Return index of `what` in `sequence`. """ what = what[0] key = kwargs.get("key", identity) predicate = kwargs.get("predicate", operator.eq) for i, item in enumerate(sequence): item_key = key(item) if predicate(what, item_key): return i raise ValueError("%r not in sequence" % what) def category_filter_function(state): # type: (Dict[str, bool]) -> Callable[[Any], bool] def category_filter(desc): if not isinstance(desc, CategoryDescription): # Is not a category item return True return state.get(desc.name, not desc.hidden) return category_filter class UrlDropEventFilter(QObject): urlDropped = Signal(QUrl) def acceptsDrop(self, mime: QMimeData) -> bool: if mime.hasUrls() and len(mime.urls()) == 1: url = mime.urls()[0] if url.scheme() == "file": filename = url.toLocalFile() _, ext = os.path.splitext(filename) if ext == ".ows": return True return False def eventFilter(self, obj, event): etype = event.type() if etype == QEvent.DragEnter or etype == QEvent.DragMove: if self.acceptsDrop(event.mimeData()): event.acceptProposedAction() return True elif etype == QEvent.Drop: if self.acceptsDrop(event.mimeData()): urls = event.mimeData().urls() if urls: url = urls[0] self.urlDropped.emit(url) return True return super().eventFilter(obj, event) class RecentItem(SimpleNamespace): title = "" # type: str path = "" # type: str def scheme_requires( stream: IO, registry: Optional[WidgetRegistry] = None ) -> List[str]: """ Inspect the given ows workflow `stream` and return a list of project names recorded as implementers of the contained nodes. Nodes are first mapped through any `replaces` entries in `registry` first. """ # parse to 'intermediate' form and run replacements with registry. desc = readwrite.parse_ows_stream(stream) if registry is not None: desc = readwrite.resolve_replaced(desc, registry) return list(unique(m.project_name for m in desc.nodes if m.project_name)) K = TypeVar("K") V = TypeVar("V") def render_error_details(errors: Iterable[Exception]) -> str: """ Render a detailed error report for observed errors during workflow load. Parameters ---------- errors : Iterable[Exception] Returns ------- text: str """ def collectall( items: Iterable[Tuple[K, Iterable[V]]], pred: Callable[[K], bool] ) -> Sequence[V]: return reduce( list.__iadd__, (v for k, v in items if pred(k)), [] ) errors_by_type = group_by_all(errors, key=type) missing_node_defs = collectall( errors_by_type, lambda k: issubclass(k, UnknownWidgetDefinition) ) link_type_erors = collectall( errors_by_type, lambda k: issubclass(k, IncompatibleChannelTypeError) ) other = collectall( errors_by_type, lambda k: not issubclass(k, (UnknownWidgetDefinition, IncompatibleChannelTypeError)) ) contents = [] if missing_node_defs is not None: contents.extend([ "Missing node definitions:", *[" \N{BULLET} " + e.args[0] for e in missing_node_defs], "", # "(possibly due to missing install requirements)" ]) if link_type_erors: contents.extend([ "Incompatible connection types:", *[" \N{BULLET} " + e.args[0] for e in link_type_erors], "" ]) if other: def format_exception(e: BaseException): return "".join(traceback.format_exception_only(type(e), e)) contents.extend([ "Unqualified errors:", *[" \N{BULLET} " + format_exception(e) for e in other] ]) return "\n".join(contents) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/canvastooldock.py0000644000175100002000000005653614730024325025301 0ustar00runnerdocker""" Orange Canvas Tool Dock widget """ import sys import warnings from typing import Optional, Any from AnyQt.QtWidgets import ( QWidget, QSplitter, QVBoxLayout, QAction, QSizePolicy, QApplication, QToolButton, QTreeView ) from AnyQt.QtGui import ( QPalette, QBrush, QDrag, QResizeEvent, QHideEvent, QPaintEvent ) from AnyQt.QtCore import ( Qt, QSize, QObject, QPropertyAnimation, QEvent, QRect, QPoint, QAbstractItemModel, QModelIndex, QPersistentModelIndex, QEventLoop, QMimeData ) from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal from .. import styles from ..gui.iconengine import StyledIconEngine from ..gui.toolgrid import ToolGrid, ToolGridButton from ..gui.toolbar import DynamicResizeToolBar from ..gui.quickhelp import QuickHelp from ..gui.framelesswindow import FramelessWindow from ..gui.utils import create_css_gradient, available_screen_geometry from ..document.quickmenu import MenuPage from .widgettoolbox import WidgetToolBox, iter_index, item_text, item_icon, item_tooltip from ..registry.qt import QtWidgetRegistry class SplitterResizer(QObject): """ An object able to control the size of a widget in a QSplitter instance. """ def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.__splitter = None # type: Optional[QSplitter] self.__widget = None # type: Optional[QWidget] self.__updateOnShow = True # Need __update on next show event self.__animationEnabled = True self.__size = -1 self.__expanded = False self.__animation = QPropertyAnimation( self, b"size_", self, duration=200 ) self.__action = QAction("toggle-expanded", self, checkable=True) self.__action.triggered[bool].connect(self.setExpanded) def setSize(self, size): # type: (int) -> None """ Set the size of the controlled widget (either width or height depending on the orientation). .. note:: The controlled widget's size is only updated when it it is shown. """ if self.__size != size: self.__size = size self.__update() def size(self): # type: () -> int """ Return the size of the widget in the splitter (either height of width) depending on the splitter orientation. """ if self.__splitter and self.__widget: index = self.__splitter.indexOf(self.__widget) sizes = self.__splitter.sizes() return sizes[index] else: return -1 size_ = Property(int, fget=size, fset=setSize) def setAnimationEnabled(self, enable): # type: (bool) -> None """Enable/disable animation.""" self.__animation.setDuration(0 if enable else 200) def animationEnabled(self): # type: () -> bool return self.__animation.duration() == 0 def setSplitterAndWidget(self, splitter, widget): # type: (QSplitter, QWidget) -> None """Set the QSplitter and QWidget instance the resizer should control. .. note:: the widget must be in the splitter. """ if splitter and widget and not splitter.indexOf(widget) > 0: raise ValueError("Widget must be in a splitter.") if self.__widget is not None: self.__widget.removeEventFilter(self) if self.__splitter is not None: self.__splitter.removeEventFilter(self) self.__splitter = splitter self.__widget = widget if widget is not None: widget.installEventFilter(self) if splitter is not None: splitter.installEventFilter(self) self.__update() size = self.size() if self.__expanded and size == 0: self.open() elif not self.__expanded and size > 0: self.close() def toggleExpandedAction(self): # type: () -> QAction """Return a QAction that can be used to toggle expanded state. """ return self.__action def toogleExpandedAction(self): warnings.warn( "'toogleExpandedAction is deprecated, use 'toggleExpandedAction' " "instead.", DeprecationWarning, stacklevel=2 ) return self.toggleExpandedAction() def open(self): # type: () -> None """Open the controlled widget (expand it to sizeHint). """ self.__expanded = True self.__action.setChecked(True) if self.__splitter is None or self.__widget is None: return hint = self.__widget.sizeHint() if self.__splitter.orientation() == Qt.Vertical: end = hint.height() else: end = hint.width() self.__animation.setStartValue(0) self.__animation.setEndValue(end) self.__animation.start() def close(self): # type: () -> None """Close the controlled widget (shrink to size 0). """ self.__expanded = False self.__action.setChecked(False) if self.__splitter is None or self.__widget is None: return self.__animation.setStartValue(self.size()) self.__animation.setEndValue(0) self.__animation.start() def setExpanded(self, expanded): # type: (bool) -> None """Set the expanded state.""" if self.__expanded != expanded: if expanded: self.open() else: self.close() def expanded(self): # type: () -> bool """Return the expanded state.""" return self.__expanded def __update(self): # type: () -> None """Update the splitter sizes.""" if self.__splitter and self.__widget: if sum(self.__splitter.sizes()) == 0: # schedule update on next show event self.__updateOnShow = True return splitter = self.__splitter index = splitter.indexOf(self.__widget) sizes = splitter.sizes() current = sizes[index] diff = current - self.__size sizes[index] = self.__size sizes[index - 1] = sizes[index - 1] + diff self.__splitter.setSizes(sizes) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.Resize and obj is self.__widget and \ self.__animation.state() == QPropertyAnimation.Stopped: # Update the expanded state when the user opens/closes the widget # by dragging the splitter handle. assert self.__splitter is not None assert isinstance(event, QResizeEvent) if self.__splitter.orientation() == Qt.Vertical: size = event.size().height() else: size = event.size().width() if self.__expanded and size == 0: self.__action.setChecked(False) self.__expanded = False elif not self.__expanded and size > 0: self.__action.setChecked(True) self.__expanded = True if event.type() == QEvent.Show and obj is self.__splitter and \ self.__updateOnShow: # Update the splitter state after receiving valid geometry self.__updateOnShow = False self.__update() return super().eventFilter(obj, event) class QuickHelpWidget(QuickHelp): def minimumSizeHint(self): # type: () -> QSize """Reimplemented to allow the Splitter to resize the widget with a continuous animation. """ hint = super().minimumSizeHint() return QSize(hint.width(), 0) class CanvasToolDock(QWidget): """Canvas dock widget with widget toolbox, quick help and canvas actions. """ def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.__setupUi() def __setupUi(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.toolbox = WidgetToolBox() self.help = QuickHelpWidget(objectName="quick-help") self.__splitter = QSplitter() self.__splitter.setOrientation(Qt.Vertical) self.__splitter.addWidget(self.toolbox) self.__splitter.addWidget(self.help) self.toolbar = DynamicResizeToolBar() self.toolbar.setMovable(False) self.toolbar.setFloatable(False) self.toolbar.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) layout.addWidget(self.__splitter, 10) layout.addWidget(self.toolbar) self.setLayout(layout) self.__splitterResizer = SplitterResizer(self) self.__splitterResizer.setSplitterAndWidget(self.__splitter, self.help) def setQuickHelpVisible(self, state): # type: (bool) -> None """Set the quick help box visibility status.""" self.__splitterResizer.setExpanded(state) def quickHelpVisible(self): # type: () -> bool return self.__splitterResizer.expanded() def setQuickHelpAnimationEnabled(self, enabled): # type: (bool) -> None """Enable/disable the quick help animation.""" self.__splitterResizer.setAnimationEnabled(enabled) def toggleQuickHelpAction(self): # type: () -> QAction """Return a checkable QAction for help show/hide.""" return self.__splitterResizer.toggleExpandedAction() def toogleQuickHelpAction(self): warnings.warn( "'toogleQuickHelpAction' is deprecated, use " "'toggleQuickHelpAction' instead.", DeprecationWarning, stacklevel=2 ) return self.toggleQuickHelpAction() class _ToolGridButton(ToolGridButton): def paintEvent(self, event: QPaintEvent) -> None: with StyledIconEngine.setOverridePalette(styles.breeze_light()): super().paintEvent(event) class QuickCategoryToolbar(ToolGrid): """A toolbar with category buttons.""" def __init__(self, parent=None, buttonSize=QSize(), iconSize=QSize(), **kwargs): # type: (Optional[QWidget], QSize, QSize, Any) -> None super().__init__(parent, 1, buttonSize, iconSize, Qt.ToolButtonIconOnly, **kwargs) self.__model = None # type: Optional[QAbstractItemModel] def setColumnCount(self, count): raise Exception("Cannot set the column count on a Toolbar") def setModel(self, model): # type: (Optional[QAbstractItemModel]) -> None """ Set the registry model. """ if self.__model is not None: self.__model.dataChanged.disconnect(self.__on_dataChanged) self.__model.rowsInserted.disconnect(self.__on_rowsInserted) self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) self.clear() self.__model = model if model is not None: model.dataChanged.connect(self.__on_dataChanged) model.rowsInserted.connect(self.__on_rowsInserted) model.rowsRemoved.connect(self.__on_rowsRemoved) self.__initFromModel(model) def __initFromModel(self, model): # type: (QAbstractItemModel) -> None """ Initialize the toolbar from the model. """ for index in iter_index(model, QModelIndex()): action = self.createActionForItem(index) self.addAction(action) def createActionForItem(self, index): # type: (QModelIndex) -> QAction """ Create the QAction instance for item at `index` (`QModelIndex`). """ action = QAction( item_icon(index), item_text(index), self, toolTip=item_tooltip(index) ) action.setData(QPersistentModelIndex(index)) return action def createButtonForAction(self, action): # type: (QAction) -> QToolButton """ Create a button for the action. """ button = _ToolGridButton( self, toolButtonStyle=self.toolButtonStyle(), iconSize=self.iconSize(), ) button.setDefaultAction(action) if self.buttonSize().isValid(): button.setFixedSize(self.buttonSize()) item = action.data() # QPersistentModelIndex assert isinstance(item, QPersistentModelIndex) brush = item.data(Qt.BackgroundRole) if not isinstance(brush, QBrush): brush = item.data(QtWidgetRegistry.BACKGROUND_ROLE) if not isinstance(brush, QBrush): brush = self.palette().brush(QPalette.Button) palette = button.palette() palette.setColor(QPalette.Button, brush.color()) palette.setColor(QPalette.Window, brush.color()) button.setPalette(palette) button.setProperty("quick-category-toolbutton", True) style_sheet = ("QToolButton {\n" " background: %s;\n" " border: none;\n" " border-bottom: 1px solid palette(mid);\n" "}") button.setStyleSheet(style_sheet % create_css_gradient(brush.color())) return button def __on_dataChanged(self, topLeft, bottomRight): # type: (QModelIndex, QModelIndex) -> None assert self.__model is not None parent = topLeft.parent() if not parent.isValid(): for row in range(topLeft.row(), bottomRight.row() + 1): item = self.__model.index(row, 0) action = self.actions()[row] action.setText(item_text(item)) action.setIcon(item_icon(item)) action.setToolTip(item_tooltip(item)) def __on_rowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None assert self.__model is not None if not parent.isValid(): for row in range(start, end + 1): item = self.__model.index(row, 0) self.insertAction(row, self.createActionForItem(item)) def __on_rowsRemoved(self, parent, start, end): # type: (QModelIndex, int, int) -> None assert self.__model is not None if not parent.isValid(): for row in range(end, start - 1, -1): action = self.actions()[row] self.removeAction(action) # This implements the (single category) node selection popup when the # tooldock is not expanded. class CategoryPopupMenu(FramelessWindow): """ A menu popup from which nodes can be dragged or clicked/activated. """ triggered = Signal(QAction) hovered = Signal(QAction) def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.setWindowFlags(self.windowFlags() | Qt.Popup) layout = QVBoxLayout() layout.setContentsMargins(6, 6, 6, 6) self.__menu = MenuPage() self.__menu.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) if sys.platform == "darwin": self.__menu.view().setAttribute(Qt.WA_MacShowFocusRect, False) self.__menu.triggered.connect(self.__onTriggered) self.__menu.hovered.connect(self.hovered) self.__dragListener = ItemViewDragStartEventListener(self) self.__dragListener.dragStarted.connect(self.__onDragStarted) self.__menu.view().viewport().installEventFilter(self.__dragListener) self.__menu.view().installEventFilter(self) layout.addWidget(self.__menu) self.setLayout(layout) self.__action = None # type: Optional[QAction] self.__loop = None # type: Optional[QEventLoop] def setCategoryItem(self, item): """ Set the category root item (:class:`QStandardItem`). """ warnings.warn( "setCategoryItem is deprecated. Use the more general 'setModel'" "and setRootIndex", DeprecationWarning, stacklevel=2 ) model = item.model() self.__menu.setModel(model) self.__menu.setRootIndex(item.index()) def setModel(self, model): # type: (QAbstractItemModel) -> None """ Set the model. Parameters ---------- model : QAbstractItemModel """ self.__menu.setModel(model) def setRootIndex(self, index): # type: (QModelIndex) -> None """ Set the root index in `model`. Parameters ---------- index : QModelIndex """ self.__menu.setRootIndex(index) def setActionRole(self, role): # type: (Qt.ItemDataRole) -> None """ Set the action role in model. This is an item role in `model` that returns a QAction for the item. Parameters ---------- role : Qt.ItemDataRole """ self.__menu.setActionRole(role) def popup(self, pos=None): # type: (Optional[QPoint]) -> None """ Show the popup at `pos`. Parameters ---------- pos : Optional[QPoint] The position in global screen coordinates """ if pos is None: pos = self.pos() self.adjustSize() geom = widget_popup_geometry(pos, self) self.setGeometry(geom) self.show() self.__menu.view().setFocus() def exec(self, pos=None): # type: (Optional[QPoint]) -> Optional[QAction] self.popup(pos) self.__loop = QEventLoop() self.__action = None self.__loop.exec() self.__loop = None if self.__action is not None: action = self.__action else: action = None return action def exec_(self, *args, **kwargs): warnings.warn( "exec_ is deprecated, use exec", DeprecationWarning, stacklevel=2 ) return self.exec(*args, **kwargs) def hideEvent(self, event): # type: (QHideEvent) -> None if self.__loop is not None: self.__loop.exit(0) super().hideEvent(event) def __onTriggered(self, action): # type: (QAction) -> None self.__action = action self.triggered.emit(action) self.hide() if self.__loop: self.__loop.exit(0) def __onDragStarted(self, index): # type: (QModelIndex) -> None desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE) icon = index.data(Qt.DecorationRole) drag_data = QMimeData() drag_data.setData( "application/vnd.orange-canvas.registry.qualified-name", desc.qualified_name.encode('utf-8') ) drag = QDrag(self) drag.setPixmap(icon.pixmap(38)) drag.setMimeData(drag_data) # TODO: Should animate (accept) hide. self.hide() # When a drag is started and the menu hidden the item's tool tip # can still show for a short time UNDER the cursor preventing a # drop. viewport = self.__menu.view().viewport() filter = ToolTipEventFilter() viewport.installEventFilter(filter) drag.exec(Qt.CopyAction) viewport.removeEventFilter(filter) def eventFilter(self, obj, event): if isinstance(obj, QTreeView) and event.type() == QEvent.KeyPress: key = event.key() if key in [Qt.Key_Return, Qt.Key_Enter]: curr = obj.currentIndex() if curr.isValid(): obj.activated.emit(curr) return True return super().eventFilter(obj, event) class ItemViewDragStartEventListener(QObject): dragStarted = Signal(QModelIndex) def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self._pos = None # type: Optional[QPoint] self._index = None # type: Optional[QPersistentModelIndex] def eventFilter(self, viewport, event): # type: (QObject, QEvent) -> bool view = viewport.parent() if event.type() == QEvent.MouseButtonPress and \ event.button() == Qt.LeftButton: index = view.indexAt(event.pos()) if index is not None: self._pos = event.pos() self._index = QPersistentModelIndex(index) elif event.type() == QEvent.MouseMove and self._pos is not None and \ ((self._pos - event.pos()).manhattanLength() >= QApplication.startDragDistance()): assert self._index is not None if self._index.isValid(): # Map to a QModelIndex in the model. index = QModelIndex(self._index) self._pos = None self._index = None self.dragStarted.emit(index) return super().eventFilter(view, event) class ToolTipEventFilter(QObject): def eventFilter(self, receiver, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.ToolTip: return True return super().eventFilter(receiver, event) def widget_popup_geometry(pos, widget): # type: (QPoint, QWidget) -> QRect widget.ensurePolished() if widget.testAttribute(Qt.WA_Resized): size = widget.size() else: size = widget.sizeHint() screen = QApplication.screenAt(pos) if screen is None: screen = QApplication.primaryScreen() screen_geom = screen.availableGeometry() size = size.boundedTo(screen_geom.size()) geom = QRect(pos, size) if geom.top() < screen_geom.top(): geom.moveTop(screen_geom.top()) if geom.left() < screen_geom.left(): geom.moveLeft(screen_geom.left()) bottom_margin = screen_geom.bottom() - geom.bottom() right_margin = screen_geom.right() - geom.right() if bottom_margin < 0: # Falls over the bottom of the screen, move it up. geom.translate(0, bottom_margin) # TODO: right to left locale if right_margin < 0: # Falls over the right screen edge, move the menu to the # other side of pos. geom.translate(-size.width(), 0) return geom def popup_position_from_source(popup, source, orientation=Qt.Vertical): # type: (QWidget, QWidget, Qt.Orientation) -> QPoint popup.ensurePolished() source.ensurePolished() if popup.testAttribute(Qt.WA_Resized): size = popup.size() else: size = popup.sizeHint() screen_geom = available_screen_geometry(source) source_rect = QRect(source.mapToGlobal(QPoint(0, 0)), source.size()) if orientation == Qt.Vertical: if source_rect.right() + size.width() < screen_geom.right(): x = source_rect.right() else: x = source_rect.left() - size.width() # bottom overflow dy = source_rect.top() + size.height() - screen_geom.bottom() if dy < 0: y = source_rect.top() else: y = max(screen_geom.top(), source_rect.top() - dy) else: # right overflow dx = source_rect.left() + size.width() - screen_geom.right() if dx < 0: x = source_rect.left() else: x = max(source_rect.left() - dx, screen_geom.left()) if source_rect.bottom() + size.height() < screen_geom.bottom(): y = source_rect.bottom() else: y = source_rect.top() - size.height() return QPoint(x, y) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/examples.py0000644000175100002000000000664514730024325024101 0ustar00runnerdocker""" Example workflows discovery. """ import os import logging import pathlib import types from typing import List, Optional, IO from orangecanvas import config as _config from orangecanvas.utils.pkgmeta import Distribution try: from importlib.resources import files as _files except ImportError: from importlib_resources import files as _files log = logging.getLogger(__name__) def list_workflows(package): # type: (types.ModuleType) -> List[str] """ Return a list of .ows files in the located next to `package`. """ def is_ows(filename): # type: (str) -> bool return filename.endswith(".ows") resources = _files(package.__name__).iterdir() return sorted(filter(is_ows, (r.name for r in resources))) def workflows(config=None): # type: (Optional[_config.Config]) -> List[ExampleWorkflow] """ Return all known example workflows. """ if config is None: config = _config.default workflows = [] # type: List[ExampleWorkflow] if hasattr(config, "tutorials_entry_points") and \ callable(config.tutorials_entry_points): # back compatibility examples_entry_points = config.tutorials_entry_points else: examples_entry_points = config.examples_entry_points for ep in examples_entry_points(): try: examples = ep.load() except Exception: log.error("Could not load examples from %r", ep.dist, exc_info=True) continue if isinstance(examples, types.ModuleType): package = examples examples = [ExampleWorkflow(t, package, ep.dist) for t in list_workflows(package)] elif isinstance(examples, (types.FunctionType, types.MethodType)): try: examples = examples() except Exception as ex: log.error("A callable entry point (%r) raised an " "unexpected error.", ex, exc_info=True) continue examples = [ExampleWorkflow(t, package=None, distribution=ep.dist) for t in examples] workflows.extend(examples) return workflows class ExampleWorkflow: def __init__(self, resource, package=None, distribution=None): # type: (str, Optional[types.ModuleType], Optional[Distribution]) -> None self.resource = resource self.package = package self.distribution = distribution def abspath(self) -> str: """ Return absolute filename for the workflow if possible else raise an ValueError. """ if self.package is not None: item = _files(self.package) / self.resource if isinstance(item, pathlib.Path): return str(item) elif isinstance(self.resource, str): if os.path.isabs(self.resource): return self.resource raise ValueError("cannot resolve resource to an absolute name") def stream(self) -> IO[bytes]: """ Return the example file as an open stream. """ if self.package is not None: item = _files(self.package) / self.resource return item.open('rb') elif isinstance(self.resource, str): if os.path.isabs(self.resource) and os.path.exists(self.resource): return open(self.resource, "rb") raise ValueError ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/outputview.py0000644000175100002000000003440414730024325024510 0ustar00runnerdocker""" """ import io import sys import warnings import traceback from types import TracebackType from typing import Any, Optional, List, Type, Iterable, Tuple, Union, Mapping from AnyQt.QtWidgets import ( QWidget, QPlainTextEdit, QVBoxLayout, QSizePolicy, QPlainTextDocumentLayout ) from AnyQt.QtGui import ( QTextCursor, QTextCharFormat, QTextOption, QFontDatabase, QTextDocument, QTextDocumentFragment ) from AnyQt.QtCore import Qt, QObject, QCoreApplication, QThread, QSize from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot from orangecanvas.gui.utils import update_char_format from orangecanvas.utils import findf class TerminalView(QPlainTextEdit): def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) self.setFrameStyle(QPlainTextEdit.NoFrame) self.setTextInteractionFlags(Qt.TextBrowserInteraction) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) font = QFontDatabase.systemFont(QFontDatabase.FixedFont) self.setFont(font) self.setAttribute(Qt.WA_SetFont, False) def sizeHint(self): # type: () -> QSize metrics = self.fontMetrics() width = metrics.boundingRect("X" * 81).width() height = metrics.lineSpacing() scroll_width = self.verticalScrollBar().width() size = QSize(width + scroll_width, height * 25) return size class TerminalTextDocument(QTextDocument): def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) self.setDocumentLayout(QPlainTextDocumentLayout(self)) self.__currentCharFormat = QTextCharFormat() if 'defaultFont' not in kwargs: defaultFont = QFontDatabase.systemFont(QFontDatabase.FixedFont) self.setDefaultFont(defaultFont) self.__streams = [] def setCurrentCharFormat(self, charformat: QTextCharFormat) -> None: """Set the QTextCharFormat to be used when writing.""" assert QThread.currentThread() is self.thread() if self.__currentCharFormat != charformat: self.__currentCharFormat = QTextCharFormat(charformat) def currentCharFormat(self) -> QTextCharFormat: """Return the current char format.""" return QTextCharFormat(self.__currentCharFormat) def textCursor(self) -> QTextCursor: """Return a text cursor positioned at the end of the document.""" cursor = QTextCursor(self) cursor.movePosition(QTextCursor.End, QTextCursor.MoveAnchor) cursor.setCharFormat(self.__currentCharFormat) return cursor # ---------------------- # A file like interface. # ---------------------- @Slot(str) def write(self, string: str) -> None: assert QThread.currentThread() is self.thread() cursor = self.textCursor() cursor.insertText(string) @Slot(object) def writelines(self, lines: Iterable[str]) -> None: assert QThread.currentThread() is self.thread() self.write("".join(lines)) @Slot() def flush(self) -> None: assert QThread.currentThread() is self.thread() def writeWithFormat(self, string: str, charformat: QTextCharFormat) -> None: assert QThread.currentThread() is self.thread() cursor = self.textCursor() cursor.setCharFormat(charformat) cursor.insertText(string) def writelinesWithFormat(self, lines, charformat): # type: (List[str], QTextCharFormat) -> None self.writeWithFormat("".join(lines), charformat) def formatted(self, color=None, background=None, weight=None, italic=None, underline=None, font=None): # type: (...) -> Formatter """ Return a formatted file like object proxy. """ charformat = update_char_format( self.currentCharFormat(), color, background, weight, italic, underline, font ) return Formatter(self, charformat) __streams: List[Tuple['TextStream', Optional['Formatter']]] def connectedStreams(self) -> List['TextStream']: """Return all streams connected using `connectStream`.""" return [s for s, _ in self.__streams] def connectStream( self, stream: 'TextStream', charformat: Optional[QTextCharFormat] = None, **kwargs ) -> None: """ Connect a :class:`TextStream` instance to this document. The `stream` connection will be 'inherited' by `clone()` """ if kwargs and charformat is not None: raise TypeError("'charformat' and kwargs cannot be used together") if kwargs: charformat = update_char_format(QTextCharFormat(), **kwargs) writer: Optional[Formatter] = None if charformat is not None: writer = Formatter(self, charformat) self.__streams.append((stream, writer)) if writer is not None: stream.stream.connect(writer.write) else: stream.stream.connect(self.write) def disconnectStream(self, stream: 'TextStream'): """ Disconnect a :class:`TextStream` instance from this document. """ item = findf(self.__streams, lambda t: t[0] is stream) if item is not None: self.__streams.remove(item) _, writer = item if writer is not None: stream.stream.disconnect(writer.write) else: stream.stream.disconnect(self.write) def clone(self, parent=None) -> 'TerminalTextDocument': """Create a new TerminalTextDocument that is a copy of this document.""" clone = type(self)() clone.setParent(parent) clone.setDocumentLayout(QPlainTextDocumentLayout(clone)) cursor = QTextCursor(clone) cursor.insertFragment(QTextDocumentFragment(self)) clone.rootFrame().setFrameFormat(self.rootFrame().frameFormat()) clone.setDefaultStyleSheet(self.defaultStyleSheet()) clone.setDefaultFont(self.defaultFont()) clone.setDefaultTextOption(self.defaultTextOption()) clone.setCurrentCharFormat(self.currentCharFormat()) for s, w in self.__streams: clone.connectStream(s, w.charformat if w is not None else None) return clone class OutputView(QWidget): def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.__lines = 5000 self.setLayout(QVBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) self.__text = TerminalView() self.__text.setDocument(TerminalTextDocument(self.__text)) self.__text.setWordWrapMode(QTextOption.NoWrap) self.__text.setMaximumBlockCount(self.__lines) self.layout().addWidget(self.__text) def setMaximumLines(self, lines): # type: (int) -> None """ Set the maximum number of lines to keep displayed. """ if self.__lines != lines: self.__lines = lines self.__text.setMaximumBlockCount(lines) def maximumLines(self): # type: () -> int """ Return the maximum number of lines in the display. """ return self.__lines def clear(self): # type: () -> None """ Clear the displayed text. """ assert QThread.currentThread() is self.thread() self.__text.clear() def setCurrentCharFormat(self, charformat): # type: (QTextCharFormat) -> None """Set the QTextCharFormat to be used when writing. """ assert QThread.currentThread() is self.thread() self.document().setCurrentCharFormat(charformat) def currentCharFormat(self): # type: () -> QTextCharFormat return QTextCharFormat(self.document().currentCharFormat()) def toPlainText(self): # type: () -> str """ Return the full contents of the output view. """ return self.__text.toPlainText() # A file like interface. @Slot(str) def write(self, string): # type: (str) -> None assert QThread.currentThread() is self.thread() doc = self.document() doc.write(string) @Slot(object) def writelines(self, lines): # type: (List[str]) -> None assert QThread.currentThread() is self.thread() self.write("".join(lines)) @Slot() def flush(self): # type: () -> None assert QThread.currentThread() is self.thread() def writeWithFormat(self, string, charformat): # type: (str, QTextCharFormat) -> None assert QThread.currentThread() is self.thread() doc = self.document() doc.writeWithFormat(string, charformat) def writelinesWithFormat(self, lines, charformat): # type: (List[str], QTextCharFormat) -> None assert QThread.currentThread() is self.thread() self.writeWithFormat("".join(lines), charformat) def formatted(self, color=None, background=None, weight=None, italic=None, underline=None, font=None): # type: (...) -> Formatter """ Return a formatted file like object proxy. """ charformat = update_char_format( self.currentCharFormat(), color, background, weight, italic, underline, font ) return Formatter(self, charformat) def document(self) -> TerminalTextDocument: return self.__text.document() def setDocument(self, document: TerminalTextDocument) -> None: document.setMaximumBlockCount(self.__lines) document.setDefaultFont(self.__text.font()) self.__text.setDocument(document) def formated(self, *args, **kwargs): warnings.warn( "'Use 'formatted'", DeprecationWarning, stacklevel=2 ) return self.formatted(*args, **kwargs) class Formatter(QObject): def __init__(self, outputview, charformat): # type: (Union[TerminalTextDocument, OutputView], QTextCharFormat) -> None # Parent to the output view. Ensure the formatter does not outlive it. super().__init__(outputview) self.outputview = outputview self.charformat = charformat @Slot(str) def write(self, string): # type: (str) -> None self.outputview.writeWithFormat(string, self.charformat) @Slot(object) def writelines(self, lines): # type: (List[str]) -> None self.outputview.writelinesWithFormat(lines, self.charformat) @Slot() def flush(self): # type: () -> None self.outputview.flush() def formatted(self, color=None, background=None, weight=None, italic=None, underline=None, font=None): # type: (...) -> Formatter charformat = update_char_format(self.charformat, color, background, weight, italic, underline, font) return Formatter(self.outputview, charformat) def __enter__(self): return self def __exit__(self, *args): self.outputview = None self.charformat = None self.setParent(None) def formated(self, *args, **kwargs): warnings.warn( "Use 'formatted'", DeprecationWarning, stacklevel=2 ) return self.formatted(*args, **kwargs) class formater(Formatter): def __init__(self, *args, **kwargs): warnings.warn( "Deprecated: Renamed to Formatter.", DeprecationWarning, stacklevel=2 ) super().__init__(*args, **kwargs) class TextStream(QObject): stream = Signal(str) flushed = Signal() __closed = False def close(self): # type: () -> None self.__closed = True def closed(self): # type: () -> bool return self.__closed def isatty(self): # type: () -> bool return False def write(self, string): # type: (str) -> None if self.__closed: raise ValueError("write operation on a closed stream.") self.stream.emit(string) def writelines(self, lines): # type: (List[str]) -> None if self.__closed: raise ValueError("write operation on a closed stream.") self.stream.emit("".join(lines)) def flush(self): # type: () -> None if self.__closed: raise ValueError("write operation on a closed stream.") self.flushed.emit() def writeable(self): # type: () -> bool return True def readable(self): # type: () -> bool return False def seekable(self): # type: () -> bool return False encoding = None errors = None newlines = None buffer = None def detach(self): raise io.UnsupportedOperation("detach") def read(self, size=-1): raise io.UnsupportedOperation("read") def readline(self, size=-1): raise io.UnsupportedOperation("readline") def readlines(self): raise io.UnsupportedOperation("readlines") def fileno(self): raise io.UnsupportedOperation("fileno") def seek(self, offset, whence=io.SEEK_SET): raise io.UnsupportedOperation("seek") def tell(self): raise io.UnsupportedOperation("tell") class ExceptHook(QObject): # Signal emitted with the `sys.exc_info` tuple. handledException = Signal(tuple) def __init__(self, parent=None, stream=None, **kwargs): super().__init__(parent, **kwargs) self.stream = stream def __call__(self, exc_type, exc_value, tb): # type: (Type[BaseException], BaseException, TracebackType) -> None if self.stream is None: stream = sys.stderr else: stream = self.stream if stream is not None: header = exc_type.__name__ + ' Exception' if QThread.currentThread() != QCoreApplication.instance().thread(): header += " (in non-GUI thread)" text = traceback.format_exception(exc_type, exc_value, tb) text.insert(0, '{:-^79}\n'.format(' ' + header + ' ')) text.append('-' * 79 + '\n') try: stream.writelines(text) stream.flush() except Exception: pass self.handledException.emit((exc_type, exc_value, tb)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/schemeinfo.py0000644000175100002000000001347714730024325024404 0ustar00runnerdocker""" Scheme Info editor widget. """ import typing from typing import Optional from AnyQt.QtWidgets import ( QWidget, QDialog, QLabel, QTextEdit, QCheckBox, QFormLayout, QVBoxLayout, QHBoxLayout, QDialogButtonBox, QSizePolicy ) from AnyQt.QtCore import Qt from ..gui.lineedit import LineEdit from ..gui.utils import StyledWidget_paintEvent, StyledWidget if typing.TYPE_CHECKING: from ..scheme import Scheme class SchemeInfoEdit(QWidget): """Scheme info editor widget. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.scheme = None # type: Optional[Scheme] self.__schemeIsUntitled = True self.__setupUi() def __setupUi(self): layout = QFormLayout() layout.setRowWrapPolicy(QFormLayout.WrapAllRows) layout.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) self.name_edit = LineEdit(self) self.name_edit.setPlaceholderText(self.tr("untitled")) self.name_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.desc_edit = QTextEdit(self) self.desc_edit.setTabChangesFocus(True) layout.addRow(self.tr("Title"), self.name_edit) layout.addRow(self.tr("Description"), self.desc_edit) self.setLayout(layout) def setScheme(self, scheme): # type: (Scheme) -> None """Set the scheme to display/edit """ self.scheme = scheme if not scheme.title: self.name_edit.setText(self.tr("untitled")) self.name_edit.selectAll() self.__schemeIsUntitled = True else: self.name_edit.setText(scheme.title) self.__schemeIsUntitled = False self.desc_edit.setPlainText(scheme.description or "") def commit(self): # type: () -> None """ Commit the current contents of the editor widgets back to the scheme. """ if self.scheme is None: return if self.__schemeIsUntitled and \ self.name_edit.text() == self.tr("untitled"): # 'untitled' text was not changed name = "" else: name = self.name_edit.text().strip() description = self.desc_edit.toPlainText().strip() self.scheme.title = name self.scheme.description = description def paintEvent(self, event): return StyledWidget_paintEvent(self, event) def title(self): # type: () -> str return self.name_edit.text().strip() def description(self): # type: () -> str return self.desc_edit.toPlainText().strip() class SchemeInfoDialog(QDialog): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.scheme = None # type: Optional[Scheme] self.__autoCommit = True self.__setupUi() def __setupUi(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.editor = SchemeInfoEdit(self) self.editor.layout().setContentsMargins(20, 20, 20, 20) self.editor.layout().setSpacing(15) self.editor.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) heading = self.tr("Workflow Info") heading = "

              {0}

              ".format(heading) self.heading = QLabel(heading, self, objectName="heading") # Insert heading self.editor.layout().insertRow(0, self.heading) self.buttonbox = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal, self ) # Insert button box self.editor.layout().addRow(self.buttonbox) widget = StyledWidget(self, objectName="auto-show-container") check_layout = QHBoxLayout() check_layout.setContentsMargins(20, 10, 20, 10) self.__showAtNewSchemeCheck = \ QCheckBox(self.tr("Show when I make a New Workflow."), self, objectName="auto-show-check", checked=False, ) check_layout.addWidget(self.__showAtNewSchemeCheck) check_layout.addWidget( QLabel(self.tr("You can also edit Workflow Info later " "(File -> Workflow Info)."), self, objectName="auto-show-info"), alignment=Qt.AlignRight) widget.setLayout(check_layout) widget.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) if self.__autoCommit: self.buttonbox.accepted.connect(self.editor.commit) self.buttonbox.accepted.connect(self.accept) self.buttonbox.rejected.connect(self.reject) layout.addWidget(self.editor, stretch=10) layout.addWidget(widget) self.setLayout(layout) def setShowAtNewScheme(self, checked): # type: (bool) -> None """ Set the 'Show at new scheme' check state. """ self.__showAtNewSchemeCheck.setChecked(checked) def showAtNewScheme(self): # type: () -> bool """ Return the check state of the 'Show at new scheme' check box. """ return self.__showAtNewSchemeCheck.isChecked() def setAutoCommit(self, auto): # type: (bool) -> None if self.__autoCommit != auto: self.__autoCommit = auto if auto: self.buttonbox.accepted.connect(self.editor.commit) else: self.buttonbox.accepted.disconnect(self.editor.commit) def setScheme(self, scheme): # type: (Scheme) -> None """Set the scheme to display/edit. """ self.scheme = scheme self.editor.setScheme(scheme) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/settings.py0000644000175100002000000006130114730024325024111 0ustar00runnerdocker""" User settings/preference dialog =============================== """ import sys import logging import warnings from functools import cmp_to_key from collections import namedtuple from AnyQt.QtWidgets import ( QWidget, QMainWindow, QComboBox, QCheckBox, QListView, QTabWidget, QToolBar, QAction, QStackedWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QSizePolicy, QDialogButtonBox, QLineEdit, QLabel, QStyleFactory, QLayout) from AnyQt.QtGui import QStandardItemModel, QStandardItem from AnyQt.QtCore import ( Qt, QEventLoop, QAbstractItemModel, QModelIndex, QSettings, Property, Signal) from .. import config from ..localization import get_languages from ..utils.settings import SettingChangedEvent from ..utils.propertybindings import ( AbstractBoundProperty, PropertyBinding, BindingManager ) log = logging.getLogger(__name__) def refresh_proxies(): from orangecanvas.main import fix_set_proxy_env fix_set_proxy_env() class UserDefaultsPropertyBinding(AbstractBoundProperty): """ A Property binding for a setting in a :class:`orangecanvas.utility.settings.Settings` instance. """ def __init__(self, obj, propertyName, parent=None): super().__init__(obj, propertyName, parent) obj.installEventFilter(self) def get(self): return self.obj.get(self.propertyName) def set(self, value): self.obj[self.propertyName] = value def eventFilter(self, obj, event): if event.type() == SettingChangedEvent.SettingChanged and \ event.key() == self.propertyName: self.notifyChanged() return super().eventFilter(obj, event) class UserSettingsModel(QAbstractItemModel): """ An Item Model for user settings presenting a list of key, setting value entries along with it's status and type. """ def __init__(self, parent=None, settings=None): super().__init__(parent) self.__settings = settings self.__headers = ["Name", "Status", "Type", "Value"] def setSettings(self, settings): if self.__settings != settings: self.__settings = settings self.reset() def settings(self): return self.__settings def rowCount(self, parent=QModelIndex()): if parent.isValid(): return 0 elif self.__settings: return len(self.__settings) else: return 0 def columnCount(self, parent=QModelIndex()): if parent.isValid(): return 0 else: return len(self.__headers) def parent(self, index): return QModelIndex() def index(self, row, column=0, parent=QModelIndex()): if parent.isValid() or \ column < 0 or column >= self.columnCount() or \ row < 0 or row >= self.rowCount(): return QModelIndex() return self.createIndex(row, column, row) def headerData(self, section, orientation, role=Qt.DisplayRole): if section >= 0 and section < 4 and orientation == Qt.Horizontal: if role == Qt.DisplayRole: return self.__headers[section] return super().headerData(section, orientation, role) def data(self, index, role=Qt.DisplayRole): if self._valid(index): key = self._keyFromIndex(index) column = index.column() if role == Qt.DisplayRole: if column == 0: return key elif column == 1: default = self.__settings.isdefault(key) return "Default" if default else "User" elif column == 2: return type(self.__settings.get(key)).__name__ elif column == 3: return self.__settings.get(key) return self return None def flags(self, index): if self._valid(index): flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable if index.column() == 3: return Qt.ItemIsEditable | flags else: return flags return Qt.NoItemFlags def setData(self, index, value, role=Qt.EditRole): if self._valid(index) and index.column() == 3: key = self._keyFromIndex(index) try: self.__settings[key] = value except (TypeError, ValueError) as ex: log.error("Failed to set value (%r) for key %r", value, key, exc_info=True) else: self.dataChanged.emit(index, index) return True return False def _valid(self, index): row = index.row() return row >= 0 and row < self.rowCount() def _keyFromIndex(self, index): row = index.row() return list(self.__settings.keys())[row] def container_widget_helper(orientation=Qt.Vertical, spacing=None, margin=0): widget = QWidget() if orientation == Qt.Vertical: layout = QVBoxLayout() widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) else: layout = QHBoxLayout() if spacing is not None: layout.setSpacing(spacing) if margin is not None: layout.setContentsMargins(0, 0, 0, 0) widget.setLayout(layout) return widget _State = namedtuple("_State", ["visible", "position"]) class FormLayout(QFormLayout): """ When adding a row to a QFormLayout, wherein the field is a layout (or a widget with a layout), the label's height is too large to look pretty. This subclass sets the label a fixed height to match the first item in the layout. """ def addRow(self, *args): if len(args) != 2: return super().addRow(*args) label, field = args if not isinstance(field, QLayout) and field.layout() is None: return super().addRow(label, field) layout = field if isinstance(field, QLayout) else field.layout() widget = layout.itemAt(0).widget() height = widget.sizeHint().height() if isinstance(label, str): label = QLabel(label) label.setFixedHeight(height) return super().addRow(label, field) class UserSettingsDialog(QMainWindow): """ A User Settings/Defaults dialog. """ MAC_UNIFIED = True def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) self.setWindowFlags(Qt.Dialog) self.setWindowModality(Qt.ApplicationModal) self.layout().setSizeConstraint(QVBoxLayout.SetFixedSize) self.__macUnified = sys.platform == "darwin" and self.MAC_UNIFIED self._manager = BindingManager(self, submitPolicy=BindingManager.AutoSubmit) self.__loop = None self.__settings = config.settings() self.__setupUi() def __setupUi(self): """Set up the UI. """ if self.__macUnified: self.tab = QToolBar( floatable=False, movable=False, allowedAreas=Qt.TopToolBarArea, ) self.addToolBar(Qt.TopToolBarArea, self.tab) self.setUnifiedTitleAndToolBarOnMac(True) # This does not seem to work self.setWindowFlags(self.windowFlags() & \ ~Qt.MacWindowToolBarButtonHint) self.tab.actionTriggered[QAction].connect( self.__macOnToolBarAction ) central = QStackedWidget() central.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) else: self.tab = central = QTabWidget(self) # Add a close button to the bottom of the dialog # (to satisfy GNOME 3 which shows the dialog without a title bar). container = container_widget_helper() container.layout().addWidget(central) buttonbox = QDialogButtonBox(QDialogButtonBox.Close) buttonbox.rejected.connect(self.close) container.layout().addWidget(buttonbox) self.setCentralWidget(container) self.stack = central # General Tab tab = QWidget() self.addTab(tab, self.tr("General"), toolTip=self.tr("General Options")) form = FormLayout() tab.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) languages = get_languages() if languages: langlay = QHBoxLayout() label = QLabel( "Changes will take effect on next application startup.") label.setHidden(True) cm_lang = QComboBox( objectName="combo-language", toolTip=self.tr("Select the application language.") ) cm_lang.addItems(list(languages)) self.bind(cm_lang, "currentText", "application/language") cm_lang.currentTextChanged.connect(lambda: label.setHidden(False)) langlay.addWidget(cm_lang) langlay.addWidget(label) form.addRow(self.tr("Language"), langlay) nodes = QWidget(self, objectName="nodes") nodes.setLayout(QVBoxLayout()) nodes.layout().setContentsMargins(0, 0, 0, 0) cb_anim = QCheckBox( self.tr("Enable node animations"), objectName="enable-node-animations", toolTip=self.tr("Enable shadow and ping animations for nodes " "in the workflow.") ) cb_anchors = QCheckBox( self.tr("Open anchors on hover"), objectName="open-anchors-on-hover", toolTip=self.tr( "Open/expand node anchors on mouse hover (if unchecked the " "anchors are expanded when Shift key is pressed)." ), ) self.bind(cb_anim, "checked", "schemeedit/enable-node-animations") self.bind(cb_anchors, "checked", "schemeedit/open-anchors-on-hover") nodes.layout().addWidget(cb_anim) nodes.layout().addWidget(cb_anchors) form.addRow(self.tr("Nodes"), nodes) links = QWidget(self, objectName="links") links.setLayout(QVBoxLayout()) links.layout().setContentsMargins(0, 0, 0, 0) cb_show = QCheckBox( self.tr("Show channel names between widgets"), objectName="show-channel-names", toolTip=self.tr("Show source and sink channel names " "over the links.") ) self.bind(cb_show, "checked", "schemeedit/show-channel-names") links.layout().addWidget(cb_show) form.addRow(self.tr("Links"), links) quickmenu = QWidget(self, objectName="quickmenu-options") quickmenu.setLayout(QVBoxLayout()) quickmenu.layout().setContentsMargins(0, 0, 0, 0) cb1 = QCheckBox(self.tr("Open on double click"), toolTip=self.tr("Open quick menu on a double click " "on an empty spot in the canvas")) cb2 = QCheckBox(self.tr("Open on right click"), toolTip=self.tr("Open quick menu on a right click " "on an empty spot in the canvas")) cb3 = QCheckBox(self.tr("Open on space key press"), toolTip=self.tr("Open quick menu on Space key press " "while the mouse is hovering over the canvas.")) cb4 = QCheckBox(self.tr("Open on any key press"), toolTip=self.tr("Open quick menu on any key press " "while the mouse is hovering over the canvas.")) cb5 = QCheckBox(self.tr("Show categories"), toolTip=self.tr("In addition to searching, allow filtering " "by categories.")) self.bind(cb1, "checked", "quickmenu/trigger-on-double-click") self.bind(cb2, "checked", "quickmenu/trigger-on-right-click") self.bind(cb3, "checked", "quickmenu/trigger-on-space-key") self.bind(cb4, "checked", "quickmenu/trigger-on-any-key") self.bind(cb5, "checked", "quickmenu/show-categories") quickmenu.layout().addWidget(cb1) quickmenu.layout().addWidget(cb2) quickmenu.layout().addWidget(cb3) quickmenu.layout().addWidget(cb4) quickmenu.layout().addWidget(cb5) form.addRow(self.tr("Quick menu"), quickmenu) startup = QWidget(self, objectName="startup-group") startup.setLayout(QVBoxLayout()) startup.layout().setContentsMargins(0, 0, 0, 0) cb_splash = QCheckBox(self.tr("Show splash screen"), self, objectName="show-splash-screen") cb_welcome = QCheckBox(self.tr("Show welcome screen"), self, objectName="show-welcome-screen") cb_crash = QCheckBox(self.tr("Load crashed scratch workflows"), self, objectName="load-crashed-workflows") self.bind(cb_splash, "checked", "startup/show-splash-screen") self.bind(cb_welcome, "checked", "startup/show-welcome-screen") self.bind(cb_crash, "checked", "startup/load-crashed-workflows") startup.layout().addWidget(cb_splash) startup.layout().addWidget(cb_welcome) startup.layout().addWidget(cb_crash) form.addRow(self.tr("On startup"), startup) toolbox = QWidget(self, objectName="toolbox-group") toolbox.setLayout(QVBoxLayout()) toolbox.layout().setContentsMargins(0, 0, 0, 0) exclusive = QCheckBox(self.tr("Only one tab can be open at a time")) self.bind(exclusive, "checked", "mainwindow/toolbox-dock-exclusive") toolbox.layout().addWidget(exclusive) form.addRow(self.tr("Tool box"), toolbox) tab.setLayout(form) # Style tab tab = StyleConfigWidget() self.addTab(tab, self.tr("&Style"), toolTip="Application style") self.bind(tab, "selectedStyle_", "application-style/style-name") self.bind(tab, "selectedPalette_", "application-style/palette") # Output Tab tab = QWidget() self.addTab(tab, self.tr("Output"), toolTip="Output Redirection") form = FormLayout() combo = QComboBox() combo.addItems([self.tr("Critical"), self.tr("Error"), self.tr("Warn"), self.tr("Info"), self.tr("Debug")]) self.bind(combo, "currentIndex", "logging/level") form.addRow(self.tr("Logging"), combo) box = QWidget() layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) cb1 = QCheckBox(self.tr("Open in external browser"), objectName="open-in-external-browser") self.bind(cb1, "checked", "help/open-in-external-browser") layout.addWidget(cb1) box.setLayout(layout) form.addRow(self.tr("Help window"), box) tab.setLayout(form) # Categories Tab tab = QWidget() layout = QVBoxLayout() view = QListView( editTriggers=QListView.NoEditTriggers ) from .. import registry reg = registry.global_registry() model = QStandardItemModel() settings = QSettings() for cat in reg.categories(): item = QStandardItem() item.setText(cat.name) item.setCheckable(True) visible, _ = category_state(cat, settings) item.setCheckState(Qt.Checked if visible else Qt.Unchecked) model.appendRow([item]) view.setModel(model) layout.addWidget(view) tab.setLayout(layout) model.itemChanged.connect( lambda item: save_category_state( reg.category(str(item.text())), _State(item.checkState() == Qt.Checked, -1), settings ) ) self.addTab(tab, "Categories") # Add-ons Tab tab = QWidget() self.addTab(tab, self.tr("Add-ons"), toolTip="Settings related to add-on installation") form = FormLayout() conda = QWidget(self, objectName="conda-group") conda.setLayout(QVBoxLayout()) conda.layout().setContentsMargins(0, 0, 0, 0) cb_conda_install = QCheckBox(self.tr("Install add-ons with conda"), self, objectName="allow-conda") self.bind(cb_conda_install, "checked", "add-ons/allow-conda") conda.layout().addWidget(cb_conda_install) form.addRow(self.tr("Conda"), conda) form.addRow(self.tr("Pip"), QLabel("Pip install arguments:")) line_edit_pip = QLineEdit() self.bind(line_edit_pip, "text", "add-ons/pip-install-arguments") form.addRow("", line_edit_pip) tab.setLayout(form) # Network Tab tab = QWidget() self.addTab(tab, self.tr("Network"), toolTip="Settings related to networking") form = FormLayout() line_edit_http_proxy = QLineEdit() self.bind(line_edit_http_proxy, "text", "network/http-proxy") form.addRow("HTTP proxy:", line_edit_http_proxy) line_edit_https_proxy = QLineEdit() self.bind(line_edit_https_proxy, "text", "network/https-proxy") form.addRow("HTTPS proxy:", line_edit_https_proxy) tab.setLayout(form) if self.__macUnified: # Need some sensible size otherwise mac unified toolbar 'takes' # the space that should be used for layout of the contents self.adjustSize() def addTab(self, widget, text, toolTip=None, icon=None): if self.__macUnified: action = QAction(text, self) if toolTip: action.setToolTip(toolTip) if icon: action.setIcon(toolTip) action.setData(len(self.tab.actions())) self.tab.addAction(action) self.stack.addWidget(widget) else: i = self.tab.addTab(widget, text) if toolTip: self.tab.setTabToolTip(i, toolTip) if icon: self.tab.setTabIcon(i, icon) def setCurrentIndex(self, index: int): if self.__macUnified: self.stack.setCurrentIndex(index) else: self.tab.setCurrentIndex(index) def widget(self, index): if self.__macUnified: return self.stack.widget(index) else: return self.tab.widget(index) def keyPressEvent(self, event): if event.key() == Qt.Key_Escape: self.hide() self.deleteLater() def bind(self, source, source_property, key, transformer=None): target = UserDefaultsPropertyBinding(self.__settings, key) source = PropertyBinding(source, source_property) source.set(target.get()) self._manager.bind(target, source) def commit(self): self._manager.commit() def revert(self): self._manager.revert() def reset(self): for target, source in self._manager.bindings(): try: source.reset() except NotImplementedError: # Cannot reset. pass except Exception: log.error("Error reseting %r", source.propertyName, exc_info=True) def exec(self): self.__loop = QEventLoop() self.show() status = self.__loop.exec() self.__loop = None refresh_proxies() return status def exec_(self, *args, **kwargs): warnings.warn( "exec_ is deprecated, use exec", DeprecationWarning, stacklevel=2 ) return self.exec(*args, **kwargs) def hideEvent(self, event): super().hideEvent(event) if self.__loop is not None: self.__loop.exit(0) self.__loop = None def __macOnToolBarAction(self, action): index = action.data() self.stack.setCurrentIndex(index) class StyleConfigWidget(QWidget): DisplayNames = { "windowsvista": "Windows (default)", "macintosh": "macOS (default)", "windows": "MS Windows 9x", } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._current_palette = "" form = FormLayout() styles = QStyleFactory.keys() styles = sorted(styles, key=cmp_to_key( lambda a, b: 1 if a.lower() == "windows" and b.lower() == "fusion" else (-1 if a.lower() == "fusion" and b.lower() == "windows" else 0) )) styles = [ (self.DisplayNames.get(st.lower(), st.capitalize()), st) for st in styles ] # Default style with empty userData key so it cleared in # persistent settings, allowing for default style resolution # on application star. styles = [("Default", "")] + styles self.style_cb = style_cb = QComboBox(objectName="style-cb") for name, key in styles: self.style_cb.addItem(name, userData=key) style_cb.currentIndexChanged.connect(self._style_changed) self.colors_cb = colors_cb = QComboBox(objectName="palette-cb") colors_cb.addItem("Default", userData="") colors_cb.addItem("Breeze Light", userData="breeze-light") colors_cb.addItem("Breeze Dark", userData="breeze-dark") colors_cb.addItem("Zion Reversed", userData="zion-reversed") colors_cb.addItem("Dark", userData="dark") form.addRow("Style", style_cb) form.addRow("Color theme", colors_cb) label = QLabel( "Changes will be applied on next application startup.", enabled=False, ) label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) form.addRow(label) self.setLayout(form) self._update_colors_enabled_state() style_cb.currentIndexChanged.connect(self.selectedStyleChanged) colors_cb.currentIndexChanged.connect(self.selectedPaletteChanged) def _style_changed(self): self._update_colors_enabled_state() def _update_colors_enabled_state(self): current = self.style_cb.currentData(Qt.UserRole) enable = current is not None and current.lower() in ("fusion", "windows") self._set_palette_enabled(enable) def _set_palette_enabled(self, state: bool): cb = self.colors_cb if cb.isEnabled() != state: cb.setEnabled(state) if not state: current = cb.currentData(Qt.UserRole) self._current_palette = current cb.setCurrentIndex(-1) else: index = cb.findData(self._current_palette, Qt.UserRole) if index == -1: index = 0 cb.setCurrentIndex(index) def selectedStyle(self) -> str: """Return the current selected style key.""" key = self.style_cb.currentData() return key if key is not None else "" def setSelectedStyle(self, style: str) -> None: """Set the current selected style key.""" idx = self.style_cb.findData(style, Qt.DisplayRole, Qt.MatchFixedString) if idx == -1: idx = 0 # select the default style self.style_cb.setCurrentIndex(idx) selectedStyleChanged = Signal() selectedStyle_ = Property( str, selectedStyle, setSelectedStyle, notify=selectedStyleChanged ) def selectedPalette(self) -> str: """The current selected palette key.""" key = self.colors_cb.currentData(Qt.UserRole) return key if key is not None else "" def setSelectedPalette(self, key: str) -> None: """Set the current selected palette key.""" if not self.colors_cb.isEnabled(): self._current_palette = key return idx = self.colors_cb.findData(key, Qt.UserRole, Qt.MatchFixedString) if idx == -1: idx = 0 # select the default color theme self.colors_cb.setCurrentIndex(idx) selectedPaletteChanged = Signal() selectedPalette_ = Property( str, selectedPalette, setSelectedPalette, notify=selectedPaletteChanged ) def category_state(cat, settings): visible = settings.value( "mainwindow/categories/{0}/visible".format(cat.name), defaultValue=not cat.hidden, type=bool ) position = settings.value( "mainwindow/categories/{0}/position".format(cat.name), defaultValue=-1, type=int ) return (visible, position) def save_category_state(cat, state, settings): settings.setValue( "mainwindow/categories/{0}/visible".format(cat.name), state.visible ) settings.setValue( "mainwindow/categories/{0}/position".format(cat.name), state.position ) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.778081 orange_canvas_core-0.2.5/orangecanvas/application/tests/0000755000175100002000000000000014730024333023037 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/__init__.py0000644000175100002000000000000014730024325025137 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_addons.py0000644000175100002000000001555614730024325025735 0ustar00runnerdockerimport os import tempfile import unittest from contextlib import contextmanager from unittest.mock import patch from zipfile import ZipFile from AnyQt.QtCore import QEventLoop, QMimeData, QPointF, Qt, QUrl from AnyQt.QtGui import QDropEvent from AnyQt.QtTest import QTest from AnyQt.QtWidgets import QDialogButtonBox, QMessageBox, QTreeView, QStyle from orangecanvas.application import addons from orangecanvas.application.addons import AddonManagerDialog from orangecanvas.application.utils.addons import ( Available, CondaInstaller, Install, Installable, Installed, PipInstaller, Uninstall, Upgrade, _QueryResult, ) from orangecanvas.gui.test import QAppTestCase from orangecanvas.utils.qinvoke import qinvoke from orangecanvas.utils.pkgmeta import Distribution, EntryPoint @contextmanager def addon_archive(pkginfo): file = tempfile.NamedTemporaryFile("wb", delete=False, suffix=".zip") name = file.name file.close() with ZipFile(name, 'w') as myzip: myzip.writestr('PKG-INFO', pkginfo) try: yield name finally: os.remove(name) class FakeDistribution(Distribution): def locate_file(self, path): pass def read_text(self, filename): pass def __init__(self, name, version): super().__init__() self._name = name self._version = version @property def name(self): return self._name @property def version(self): return self._version class FakeEntryPoint(EntryPoint): def for_(self, dist): vars(self).update(dist=dist) return self class TestAddonManagerDialog(QAppTestCase): def test_widget(self): items = [ Installed( Installable("foo", "1.1", "", "", "", []), FakeDistribution(name="foo", version="1.0"), ), Available( Installable("q", "1.2", "", "", "", []) ), Installed( None, FakeDistribution(name="a", version="0.0") ), ] w = AddonManagerDialog() w.setItems(items) _ = w.items() state = w.itemState() self.assertSequenceEqual(state, []) state = [(Install, items[1])] w.setItemState(state) self.assertSequenceEqual(state, w.itemState()) state = state + [(Upgrade, items[0])] w.setItemState(state) self.assertSequenceEqual(state, w.itemState()[::-1]) state = [(Uninstall, items[0])] w.setItemState(state) self.assertSequenceEqual(state, w.itemState()) updateTopLayout = w._AddonManagerDialog__updateTopLayout updateTopLayout(False) updateTopLayout(True) w.setItemState([]) # toggle install state view = w.findChild(QTreeView, "add-ons-view") index = view.model().index(0, 0) delegate = view.itemDelegateForColumn(0) style = view.style() opt = view.viewOptions() opt.rect = view.visualRect(index) delegate.initStyleOption(opt, index) rect = style.subElementRect( QStyle.SE_ItemViewItemCheckIndicator, opt, view ) def check_state_equal(left, right): self.assertEqual(Qt.CheckState(left), Qt.CheckState(right)) check_state_equal(index.data(Qt.CheckStateRole), Qt.PartiallyChecked) QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=rect.center()) check_state_equal(index.data(Qt.CheckStateRole), Qt.Checked) QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=rect.center()) check_state_equal(index.data(Qt.CheckStateRole), Qt.Unchecked) @patch("orangecanvas.config.default.addon_entry_points", return_value=[ FakeEntryPoint( "a", "b", "g").for_(FakeDistribution(name="foo", version="1.0"))]) def test_drop(self, p1): items = [ Installed( Installable("foo", "1.1", "", "", "", []), FakeDistribution(name="foo", version="1.0"), ), ] w = AddonManagerDialog() w.setItems(items) # drop an addon already in the list pkginfo = "Metadata-Version: 1.0\nName: foo\nVersion: 0.9" with addon_archive(pkginfo) as fn: event = self._drop_event(QUrl.fromLocalFile(fn)) w.dropEvent(event) items = w.items() self.assertEqual(1, len(items)) self.assertEqual("0.9", items[0].installable.version) self.assertEqual(True, items[0].installable.force) state = [(Upgrade, items[0])] self.assertSequenceEqual(state, w.itemState()) # drop a new addon pkginfo = "Metadata-Version: 1.0\nName: foo2\nVersion: 0.8" with addon_archive(pkginfo) as fn: event = self._drop_event(QUrl.fromLocalFile(fn)) w.dropEvent(event) items = w.items() self.assertEqual(2, len(items)) self.assertEqual("0.8", items[1].installable.version) self.assertEqual(True, items[1].installable.force) state = state + [(Install, items[1])] self.assertSequenceEqual(state, w.itemState()) def _drop_event(self, url): # make sure data does not get garbage collected before it used # pylint: disable=attribute-defined-outside-init self.event_data = data = QMimeData() data.setUrls([QUrl(url)]) return QDropEvent( QPointF(0, 0), Qt.MoveAction, data, Qt.NoButton, Qt.NoModifier, QDropEvent.Drop) def test_run_query(self): w = AddonManagerDialog() query_res = [ _QueryResult("uber-pkg", None), _QueryResult("unter-pkg", Installable("unter-pkg", "0.0.0", "", "", "", [])) ] def query(names): return query_res with patch.object(QMessageBox, "exec", return_value=QMessageBox.Cancel), \ patch.object(addons, "query_pypi", query): f = w.runQueryAndAddResults( ["uber-pkg", "unter-pkg"], ) loop = QEventLoop() f.add_done_callback(qinvoke(lambda f: loop.quit(), loop)) loop.exec() items = w.items() self.assertEqual(items, [Available(query_res[1].installable)]) def test_install(self): w = AddonManagerDialog() foo = Available(Installable("foo", "1.1", "", "", "", [])) w.setItems([foo]) w.setItemState([(Install, foo)]) with patch.object(PipInstaller, "install", lambda self, pkg: None), \ patch.object(CondaInstaller, "install", lambda self, pkg: None), \ patch.object(QMessageBox, "exec", return_value=QMessageBox.Cancel): b = w.findChild(QDialogButtonBox) b.accepted.emit() QTest.qWait(1) w.reject() QTest.qWait(1) w.deleteLater() if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_addons_utils.py0000644000175100002000000001012214730024325027135 0ustar00runnerdockerimport os import stat import unittest from tempfile import mkdtemp from requests import Session from requests_cache import CachedSession from packaging.requirements import Requirement from orangecanvas.application.utils.addons import ( Available, Installable, Installed, installable_from_json_response, installable_items, is_updatable, prettify_name, _session, ) from orangecanvas.application.tests.test_addons import FakeDistribution class TestUtils(unittest.TestCase): def test_items_1(self): inst = Installable("foo", "1.0", "a foo", "", "", []) dist = FakeDistribution(name="foo", version="1.0") item = Available(inst) self.assertFalse(is_updatable(item)) self.assertEqual(item.name, "foo") self.assertEqual(item.normalized_name, "foo") item = Installed(None, dist) self.assertFalse(is_updatable(item)) self.assertEqual(item.name, dist.name) self.assertEqual(item.normalized_name, dist.name) item = Installed(inst, dist) self.assertFalse(is_updatable(item)) self.assertEqual(item.name, inst.name) item = Installed(inst._replace(version="0.9"), dist) self.assertFalse(is_updatable(item)) item = Installed(inst._replace(version="1.1"), dist) self.assertTrue(is_updatable(item)) item = Installed(inst._replace(version="2.0"), dist, constraint=Requirement("foo<1.99")) self.assertFalse(is_updatable(item)) item = Installed(inst._replace(version="2.0"), dist, constraint=Requirement("foo<2.99")) self.assertTrue(is_updatable(item)) def test_items_2(self): inst1 = Installable("foo", "1.0", "a foo", "", "", []) inst2 = Installable("bar", "1.0", "a bar", "", "", []) dist2 = FakeDistribution(name="bar", version="0.9") dist3 = FakeDistribution(name="quack", version="1.0") items = installable_items([inst1, inst2], [dist2, dist3]) self.assertIn(Available(inst1), items) self.assertIn(Installed(inst2, dist2), items) self.assertIn(Installed(None, dist3), items) def test_installable_from_json_response(self): inst = installable_from_json_response({ "info": { "name": "foo", "version": "1.0", }, "releases": { "1.0": [ { "filename": "aa.tar.gz", "url": "https://examples.com", "size": 100, "packagetype": "sdist", } ] }, }) self.assertTrue(inst.name, "foo") self.assertEqual(inst.version, "1.0") def test_prettify_name(self): names = [ 'AFooBar', 'FooBar', 'Foo-Bar', 'Foo-Bar-FOOBAR', 'Foo-bar-foobar', 'Foo', 'FOOBar', 'A4FooBar', '4Foo', 'Foo3Bar' ] pretty_names = [ 'A Foo Bar', 'Foo Bar', 'Foo Bar', 'Foo Bar FOOBAR', 'Foo bar foobar', 'Foo', 'FOO Bar', 'A4Foo Bar', '4Foo', 'Foo3Bar' ] for name, pretty_name in zip(names, pretty_names): self.assertEqual(pretty_name, prettify_name(name)) # test if orange prefix is handled self.assertEqual('Orange', prettify_name('Orange')) self.assertEqual('Orange3', prettify_name('Orange3')) self.assertEqual('Some Addon', prettify_name('Orange-SomeAddon')) self.assertEqual('Text', prettify_name('Orange3-Text')) self.assertEqual('Image Analytics', prettify_name('Orange3-ImageAnalytics')) self.assertEqual('Survival Analysis', prettify_name('Orange3-Survival-Analysis')) def test_session(self): # when permissions - use CachedSession self.assertIsInstance(_session(), CachedSession) # when no permissions - use request's Session temp_dir = mkdtemp() os.chmod(temp_dir, stat.S_IRUSR) self.assertIsInstance(_session(temp_dir), Session) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_application.py0000644000175100002000000000377714730024325026772 0ustar00runnerdockerimport os import sys import time import unittest from AnyQt.QtGui import QPalette from AnyQt.QtTest import QSignalSpy from orangecanvas.utils import shtools as sh from orangecanvas.application import application as appmod from orangecanvas.utils.shtools import temp_named_file def application_test_helper(): app = appmod.CanvasApplication([]) p = app.palette() spy = QSignalSpy(app.applicationPaletteChanged) p.setColor(QPalette.Base, p.color(QPalette.Text)) app.setPalette(p) assert list(spy) == [[]] app.quit() return class TestApplication(unittest.TestCase): def test_application(self): res = sh.python_run([ "-c", f"import {__name__} as m\n" f"m.application_test_helper()\n" ]) self.assertEqual(res.returncode, 0) def test_application_help(self): res = sh.python_run([ "-m", "orangecanvas", "--help" ]) self.assertEqual(res.returncode, 0) def remove_after_exit(fname): appmod.run_after_exit([ sys.executable, '-c', f'import os, sys; os.remove(sys.argv[1])', fname ]) def restart_command_test_helper(fname): cmd = [ sys.executable, '-c', f'import os, sys; os.remove(sys.argv[1])', fname ] appmod.set_restart_command(cmd) assert appmod.restart_command() == cmd appmod.restart_cancel() assert appmod.restart_command() is None appmod.set_restart_command(cmd) class TestApplicationRestart(unittest.TestCase): def test_restart_command(self): with temp_named_file('', delete=False) as fname: res = sh.python_run([ "-c", f"import sys, {__name__} as m\n" f"m.restart_command_test_helper(sys.argv[1])\n", fname ]) start = time.perf_counter() while os.path.exists(fname) and time.perf_counter() - start < 5: pass self.assertFalse(os.path.exists(fname)) self.assertEqual(res.returncode, 0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_canvastooldock.py0000644000175100002000000000647214730024325027474 0ustar00runnerdocker""" Test for canvas toolbox. """ from AnyQt.QtWidgets import ( QWidget, QToolBar, QTextEdit, QSplitter, QApplication ) from AnyQt.QtCore import Qt, QTimer, QPoint from ...registry import tests as registry_tests from ...registry.qt import QtWidgetRegistry from ...gui.dock import CollapsibleDockWidget from ..canvastooldock import ( WidgetToolBox, CanvasToolDock, SplitterResizer, QuickCategoryToolbar, CategoryPopupMenu, popup_position_from_source, widget_popup_geometry ) from ...gui import test class TestCanvasDockWidget(test.QAppTestCase): def test_dock(self): reg = registry_tests.small_testing_registry() reg = QtWidgetRegistry(reg, parent=self.app) toolbox = WidgetToolBox() toolbox.setObjectName("widgets-toolbox") toolbox.setModel(reg.model()) text = QTextEdit() splitter = QSplitter() splitter.setOrientation(Qt.Vertical) splitter.addWidget(toolbox) splitter.addWidget(text) dock = CollapsibleDockWidget() dock.setExpandedWidget(splitter) toolbar = QToolBar() toolbar.addAction("1") toolbar.setOrientation(Qt.Vertical) toolbar.setMovable(False) toolbar.setFloatable(False) dock.setCollapsedWidget(toolbar) dock.show() self.qWait() def test_canvas_tool_dock(self): reg = registry_tests.small_testing_registry() reg = QtWidgetRegistry(reg, parent=self.app) dock = CanvasToolDock() dock.toolbox.setModel(reg.model()) dock.show() self.qWait() def test_splitter_resizer(self): w = QSplitter(orientation=Qt.Vertical) w.addWidget(QWidget()) text = QTextEdit() w.addWidget(text) resizer = SplitterResizer(parent=None) resizer.setSplitterAndWidget(w, text) def toogle(): if resizer.size() == 0: resizer.open() else: resizer.close() w.show() timer = QTimer(resizer, interval=100) timer.timeout.connect(toogle) timer.start() toogle() self.qWait() timer.stop() def test_category_toolbar(self): reg = registry_tests.small_testing_registry() reg = QtWidgetRegistry(reg, parent=self.app) w = QuickCategoryToolbar() w.setModel(reg.model()) w.show() self.qWait() class TestPopupMenu(test.QAppTestCase): def test(self): reg = registry_tests.small_testing_registry() reg = QtWidgetRegistry(reg, parent=self.app) model = reg.model() w = CategoryPopupMenu() w.setModel(model) w.setRootIndex(model.index(0, 0)) w.popup() self.qWait() def test_popup_position(self): popup = CategoryPopupMenu() screen = popup.screen() screen_geom = screen.availableGeometry() popup.setMinimumHeight(screen_geom.height() + 20) w = QWidget() w.setGeometry( screen_geom.left() + 100, screen_geom.top() + 100, 20, 20 ) pos = popup_position_from_source(popup, w) self.assertTrue(screen_geom.contains(pos)) pos = QPoint(screen_geom.top() - 100, screen_geom.left() - 100) geom = widget_popup_geometry(pos, popup) self.assertEqual(screen_geom.intersected(geom), geom) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_main.py0000644000175100002000000001207214730024325025377 0ustar00runnerdockerimport logging import unittest from contextlib import contextmanager from functools import wraps from typing import Iterable from unittest.mock import patch, Mock from AnyQt.QtCore import Qt from AnyQt.QtGui import QPalette from orangecanvas import config from orangecanvas.application.canvasmain import CanvasMainWindow from orangecanvas.config import Config from orangecanvas.gui.test import QAppTestCase from orangecanvas.main import Main from orangecanvas.registry import WidgetDiscovery from orangecanvas.registry.tests import set_up_modules, tear_down_modules from orangecanvas.scheme import Scheme from orangecanvas.utils.shtools import temp_named_file from orangecanvas.utils.pkgmeta import EntryPoint class TestMain(unittest.TestCase): def test_params(self): m = Main() m.parse_arguments(["-", "--config", "foo.bar", "that"]) self.assertEqual(m.arguments, ["that"]) self.assertEqual(m.options.config, "foo.bar") m = Main() m.parse_arguments(["-", "-l3"]) self.assertEqual(m.options.log_level, logging.WARNING) m = Main() m.parse_arguments(["-", "-l", "warn"]) self.assertEqual(m.options.log_level, logging.WARNING) def test_style_param_compat(self): # test old '--style' parameter handling m = Main() m.parse_arguments(["-", "--style", "windows"]) self.assertEqual(m.arguments, ["-style", "windows"]) m = Main() m.parse_arguments(["-", "--qt", "-stylesheet path.qss"]) self.assertEqual(m.arguments, ["-stylesheet", "path.qss"]) def test_main_argument_parser(self): class Main2(Main): def argument_parser(self): p = super().argument_parser() p.add_argument("--foo", type=str, default=None) return p m = Main2() m.parse_arguments(["-", "-l", "warn", "--foo", "bar"]) self.assertEqual(m.options.foo, "bar") @contextmanager def patch_main_application(app): def setup_application(self: Main): self.application = app with patch.object(Main, "setup_application", setup_application): yield def with_patched_main_application(f): @wraps(f) def wrapped(self: QAppTestCase, *args, **kwargs): with patch_main_application(self.app): return f(self, *args, **kwargs) return wrapped class TestConfig(Config): def init(self): return def widget_discovery(self, *args, **kwargs): return WidgetDiscovery(*args, **kwargs) def widgets_entry_points(self): # type: () -> Iterable[EntryPoint] pkg = "orangecanvas.registry.tests" return ( EntryPoint("add", f"{pkg}.operators.add", "w"), EntryPoint("sub", f"{pkg}.operators.sub", "w") ) def workflow_constructor(self, *args, **kwargs): return Scheme(*args, **kwargs) def splash_screen(self): return config.Default.splash_screen() class TestMainGuiCase(QAppTestCase): def setUp(self): super().setUp() self.app.fileOpenRequest = Mock() self._config = config.default set_up_modules() def tearDown(self): tear_down_modules() config.default = self._config del self.app.fileOpenRequest del self._config super().tearDown() @with_patched_main_application def test_main_show_splash_screen(self): m = Main() m.parse_arguments(["-", "--config", f"{__name__}.TestConfig"]) m.activate_default_config() m.show_splash_message("aa") m.close_splash_screen() @with_patched_main_application def test_discovery(self): m = Main() m.parse_arguments(["-", "--config", f"{__name__}.TestConfig"]) m.activate_default_config() m.run_discovery() self.assertTrue(bool(m.registry.widgets())) self.assertTrue(bool(m.registry.categories())) @with_patched_main_application def test_run(self): m = Main() with patch.object(self.app, "exec", lambda: 42): res = m.run(["-", "--no-welcome", "--no-splash"]) self.assertEqual(res, 42) @with_patched_main_application def test_run_with_file(self): m = Main() with patch.object(self.app, "exec", lambda: 42), \ patch.object(CanvasMainWindow, "open_scheme_file", Mock()), \ temp_named_file('') as fname: res = m.run(["-", "--no-welcome", "--no-splash", fname]) CanvasMainWindow.open_scheme_file.assert_called_with(fname) self.assertEqual(res, 42) @with_patched_main_application def test_run_stylesheet_reconfigure(self): m = Main() m.parse_arguments(["-", "--config", f"{__name__}.TestConfig"]) m.window = m.create_main_window() m.application = self.app def setpalette(color): self.app.setPalette(QPalette(color)) m._Main__reconfigure_stylesheet() setpalette(Qt.white) sheet = m.window.styleSheet() setpalette(Qt.black) self.assertNotEqual(sheet, m.window.styleSheet()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_mainwindow.py0000644000175100002000000003223214730024325026627 0ustar00runnerdockerimport io import os import tempfile from unittest.mock import patch from AnyQt.QtGui import QWhatsThisClickedEvent from AnyQt.QtWidgets import ( QToolButton, QDialog, QMessageBox, QApplication, QFileDialog ) from .. import addons from ..outputview import TextStream from ..utils.addons import _QueryResult, Installable from ...scheme import SchemeTextAnnotation, SchemeLink from ...gui.quickhelp import QuickHelpTipEvent, QuickHelp from ...utils.shtools import temp_named_file from ...utils.pickle import swp_name from ...gui.test import QAppTestCase from ..canvasmain import CanvasMainWindow from ..widgettoolbox import WidgetToolBox from ...registry import tests as registry_tests class MainWindow(CanvasMainWindow): _instances = [] def create_new_window(self): # type: () -> CanvasMainWindow inst = super().create_new_window() MainWindow._instances.append(inst) return inst class TestMainWindowBase(QAppTestCase): def setUp(self): super().setUp() self.w = MainWindow() self.registry = registry_tests.small_testing_registry() self.w.set_widget_registry(self.registry) def tearDown(self): self.w.clear_swp() self.w.deleteLater() for w in MainWindow._instances: w.deleteLater() MainWindow._instances.clear() del self.w del self.registry self.qWait(1) super().tearDown() class TestMainWindow(TestMainWindowBase): def test_create_new_window(self): w = self.w new = w.create_new_window() self.assertIsInstance(new, MainWindow) r1 = new.widget_registry self.assertEqual(r1.widgets(), self.registry.widgets()) w.show() new.show() w.set_scheme_margins_enabled(True) new.deleteLater() stream = TextStream() w.connect_output_stream(stream) def test_connect_output_stream(self): w = self.w stream = TextStream() w.connect_output_stream(stream) stream.write("Hello") self.assertEqual(w.output_view().toPlainText(), "Hello") w.disconnect_output_stream(stream) stream.write("Bye") self.assertEqual(w.output_view().toPlainText(), "Hello") def test_create_new_window_streams(self): w = self.w stream = TextStream() w.connect_output_stream(stream) new = w.create_new_window() stream.write("Hello") self.assertEqual(w.output_view().toPlainText(), "Hello") self.assertEqual(new.output_view().toPlainText(), "Hello") def test_new_window(self): w = self.w with patch( "orangecanvas.application.schemeinfo.SchemeInfoDialog.exec", ): w.new_workflow_window() def test_examples_dialog(self): w = self.w with patch( "orangecanvas.preview.previewdialog.PreviewDialog.exec", return_value=QDialog.Rejected, ): w.examples_dialog() def test_create_toolbox(self): w = self.w toolbox = w.findChild(WidgetToolBox) assert isinstance(toolbox, WidgetToolBox) wf = w.current_document().scheme() grid = toolbox.widget(0) button = grid.findChild(QToolButton) # type: QToolButton self.assertEqual(len(wf.nodes), 0) button.click() self.assertEqual(len(wf.nodes), 1) def test_create_category_toolbar(self): w = self.w dock = w.dock_widget dock.setExpanded(False) a = w.quick_category.actions()[0] with patch( "orangecanvas.application.canvastooldock.CategoryPopupMenu.exec", return_value=None, ): w.on_quick_category_action(a) def test_recent_list(self): w = self.w w.clear_recent_schemes() w.add_recent_scheme("This one", __file__) new = w.create_new_window() self.assertEqual(len(new.recent_schemes), 1) w.clear_recent_schemes() def test_quick_help_events(self): w = self.w help: QuickHelp = w.dock_help html = "

              HELLO

              " ev = QuickHelpTipEvent("", html, priority=QuickHelpTipEvent.Normal) QApplication.sendEvent(w, ev) self.assertEqual(help.currentText(), "

              HELLO

              ") def test_help_requests(self): w = self.w ev = QWhatsThisClickedEvent('help://search?id=one') QApplication.sendEvent(w, ev) class TestMainWindowLoad(TestMainWindowBase): filename = "" def setUp(self): super().setUp() fd, filename = tempfile.mkstemp() self.file = os.fdopen(fd, "w+b") self.filename = filename def tearDown(self): self.file.close() os.remove(self.filename) super().tearDown() def test_open_example_scheme(self): self.file.write(TEST_OWS) self.file.flush() self.w.open_example_scheme(self.filename) def test_open_scheme_file(self): self.file.write(TEST_OWS) self.file.flush() self.w.open_scheme_file(self.filename) def test_save(self): w = self.w w.current_document().setPath(self.filename) with patch.object(w, "save_scheme_as") as f: w.save_scheme() f.assert_not_called() w.current_document().setPath("") def exec(myself): myself.setOption(QFileDialog.DontUseNativeDialog) myself.setOption(QFileDialog.DontConfirmOverwrite) myself.selectFile(self.filename) myself.accept() with patch("AnyQt.QtWidgets.QFileDialog.exec", exec): w.save_scheme() self.assertTrue(os.path.samefile(w.current_document().path(), self.filename)) def test_save_svg_image(self): w = self.w scheme = w.current_document().scheme() scheme.load_from(io.BytesIO(TEST_OWS), registry=w.widget_registry) with patch("AnyQt.QtWidgets.QFileDialog.exec"): w.save_as_svg() dialog = w.findChild(QFileDialog, "save-as-svg-filedialog") dialog.setOption(QFileDialog.DontUseNativeDialog) dialog.setOption(QFileDialog.DontConfirmOverwrite) dialog.selectFile(self.filename) dialog.accept() with open(self.filename, "rb") as f: contents = f.read() self.assertIn(b"') as fname, \ patch.object(QMessageBox, "open", lambda self: None): w.load_scheme(fname) self.assertIs(w.current_document().scheme(), workflow) dlg = w.findChild(QMessageBox) self.assertIsNotNone(dlg) self.assertIn("99.9", dlg.detailedText()) dlg.done(QMessageBox.Ok) TEST_OWS = b"""\ $$ """ TEST_OWS_REQ = b"""\ $$ """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_outputview.py0000644000175100002000000001133514730024325026707 0ustar00runnerdockerimport sys import multiprocessing.pool from datetime import datetime from threading import current_thread from AnyQt.QtCore import Qt, QThread, QTimer, QCoreApplication, QEvent from AnyQt.QtGui import QTextCharFormat, QColor from ...gui.test import QAppTestCase from ..outputview import OutputView, TextStream, ExceptHook, \ TerminalTextDocument class TestOutputView(QAppTestCase): def test_outputview(self): output = OutputView() output.show() line1 = "A line \n" line2 = "A different line\n" output.write(line1) self.assertEqual(output.toPlainText(), line1) output.write(line2) self.assertEqual(output.toPlainText(), line1 + line2) output.clear() self.assertEqual(output.toPlainText(), "") output.writelines([line1, line2]) self.assertEqual(output.toPlainText(), line1 + line2) output.setMaximumLines(5) def advance(): now = datetime.now().strftime("%c\n") output.write(now) text = output.toPlainText() self.assertLessEqual(len(text.splitlines()), 5) timer = QTimer(output, interval=25) timer.timeout.connect(advance) timer.start() self.qWait(100) timer.stop() def test_formatted(self): output = OutputView() output.show() output.write("A sword day, ") with output.formatted(color=Qt.red) as f: f.write("a red day...\n") with f.formatted(color=Qt.green) as f: f.write("Actually sir, orcs bleed green.\n") bold = output.formatted(weight=100, underline=True) bold.write("Shutup") self.qWait() def test_threadsafe(self): output = OutputView() output.resize(500, 300) output.show() blue_formater = output.formatted(color=Qt.blue) red_formater = output.formatted(color=Qt.red) correct = [] def check_thread(*args): correct.append(QThread.currentThread() == self.app.thread()) blue = TextStream() blue.stream.connect(blue_formater.write) blue.stream.connect(check_thread) red = TextStream() red.stream.connect(red_formater.write) red.stream.connect(check_thread) def printer(i): if i % 12 == 0: fizzbuz = "fizzbuz" elif i % 4 == 0: fizzbuz = "buz" elif i % 3 == 0: fizzbuz = "fizz" else: fizzbuz = str(i) if i % 2: writer = blue else: writer = red writer.write("Greetings from thread {0}. " "This is {1}\n".format(current_thread().name, fizzbuz)) pool = multiprocessing.pool.ThreadPool(100) res = pool.map_async(printer, range(10000)) self.qWait() res.wait() # force all pending enqueued emits QCoreApplication.sendPostedEvents(blue, QEvent.MetaCall) QCoreApplication.sendPostedEvents(red, QEvent.MetaCall) self.app.processEvents() self.assertTrue(all(correct)) self.assertEqual(len(correct), 10000) pool.close() def test_excepthook(self): output = OutputView() output.resize(500, 300) output.show() red_formater = output.formatted(color=Qt.red) red = TextStream() red.stream.connect(red_formater.write) hook = ExceptHook(stream=red) def raise_exception(i): try: if i % 2 == 0: raise ValueError("odd") else: raise ValueError("even") except Exception: # explicitly call hook (Thread class has it's own handler) hook(*sys.exc_info()) pool = multiprocessing.pool.ThreadPool(10) res = pool.map_async(raise_exception, range(100)) self.qWait(100) res.wait() pool.close() def test_clone(self): doc = TerminalTextDocument() writer = TextStream() doc.connectStream(writer) writer.write("A") doc_c = doc.clone() writer.write("B") self.assertEqual(doc.toPlainText(), "AB") self.assertEqual(doc_c.toPlainText(), "AB") writer_err = TextStream() cf = QTextCharFormat() cf.setForeground(QColor(Qt.red)) doc_c.connectStream(writer_err, cf) writer_err.write("C") self.assertEqual(doc_c.toPlainText(), "ABC") self.assertEqual(doc.toPlainText(), "AB") doc_c.disconnectStream(writer_err) writer_err.write("D") self.assertEqual(doc_c.toPlainText(), "ABC") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_schemeinfo.py0000644000175100002000000000117714730024325026577 0ustar00runnerdockerfrom ...scheme import Scheme from ..schemeinfo import SchemeInfoDialog from ...gui import test class TestSchemeInfo(test.QAppTestCase): def test_scheme_info(self): scheme = Scheme(title="A Scheme", description="A String\n") dialog = SchemeInfoDialog() dialog.setScheme(scheme) self.singleShot(10, dialog.close) status = dialog.exec() if status == dialog.Accepted: self.assertEqual(scheme.title, dialog.editor.name_edit.text()) self.assertEqual(scheme.description, dialog.editor.desc_edit.toPlainText()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_settings.py0000644000175100002000000000343514730024325026316 0ustar00runnerdockerimport logging from AnyQt.QtCore import QSettings from AnyQt.QtWidgets import QTreeView from orangecanvas import config from ...gui import test from ..settings import UserSettingsDialog, UserSettingsModel, \ UserDefaultsPropertyBinding from ...utils.settings import Settings, config_slot from ... import registry from ...registry import tests as registry_tests class TestUserSettings(test.QAppTestCase): def setUp(self): logging.basicConfig() super().setUp() def test(self): registry.set_global_registry(registry_tests.small_testing_registry()) settings = UserSettingsDialog() settings.show() self.qWait() registry.set_global_registry(None) def test_settings_model(self): store = QSettings(QSettings.IniFormat, QSettings.UserScope, "biolab.si", "Orange Canvas UnitTests") defaults = [config_slot("S1", bool, True, "Something"), config_slot("S2", str, "I an not a String", "Disregard the string.")] settings = Settings(defaults=defaults, store=store) model = UserSettingsModel(settings=settings) self.assertEqual(model.rowCount(), len(settings)) view = QTreeView() view.setHeaderHidden(False) view.setModel(model) view.show() self.qWait() def test_conda_checkbox(self): """ We want that orange is installed with conda by default, users can change this setting in settings if they need to. This test check whether the default setting for conda checkbox is True. """ settings = config.settings() setting = UserDefaultsPropertyBinding( settings, "add-ons/allow-conda") self.assertTrue(setting.get()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_welcomedialog.py0000644000175100002000000000216114730024325027264 0ustar00runnerdocker""" Test for welcome screen. """ from AnyQt.QtWidgets import QAction from ...resources import icon_loader from ..welcomedialog import WelcomeDialog, decorate_welcome_icon from ...gui.test import QAppTestCase class TestDialog(QAppTestCase): def test_dialog(self): d = WelcomeDialog() loader = icon_loader() icon = loader.get("icons/default-widget.svg") action1 = QAction(decorate_welcome_icon(icon, "light-green"), "one", self.app) action2 = QAction(decorate_welcome_icon(icon, "orange"), "two", self.app) d.addRow([action1, action2]) action3 = QAction(decorate_welcome_icon(icon, "light-green"), "three", self.app) d.addRow([action3]) self.assertTrue(d.buttonAt(1, 0).defaultAction() == action3) d.show() action = [None] def p(a): action[0] = a d.triggered.connect(p) self.singleShot(0, action1.trigger) self.qWait() self.assertIs(action[0], d.triggeredAction()) self.assertIs(action[0], action1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/tests/test_widgettoolbox.py0000644000175100002000000000602614730024325027347 0ustar00runnerdocker""" Tests for WidgetsToolBox. """ from typing import cast from AnyQt.QtWidgets import QWidget, QHBoxLayout from AnyQt.QtGui import QStandardItemModel from AnyQt.QtCore import QSize from AnyQt.QtTest import QTest from ...registry import tests as registry_tests from ...registry.qt import QtWidgetRegistry from ..widgettoolbox import WidgetToolBox, WidgetToolGrid, ToolGrid from ...gui import test class TestWidgetToolBox(test.QAppTestCase): def setUp(self): super().setUp() reg = registry_tests.small_testing_registry() self.reg = QtWidgetRegistry(reg) def tearDown(self): self.reg.model().clear() del self.reg super().tearDown() def test_widgettoolgrid(self): w = QWidget() layout = QHBoxLayout() triggered_actions1 = [] triggered_actions2 = [] model = self.reg.model() data_descriptions = self.reg.widgets("Constants") one_action = self.reg.action_for_widget("one") actions = list(map(self.reg.action_for_widget, data_descriptions)) grid = ToolGrid(w) grid.setActions(actions) grid.actionTriggered.connect(triggered_actions1.append) layout.addWidget(grid) grid = WidgetToolGrid(w) # First category ("Data") grid.setModel(model, rootIndex=model.index(0, 0)) self.assertIs(model, grid.model()) # Test order of buttons grid_layout = grid.layout() for i in range(len(actions)): button = grid_layout.itemAtPosition(i // 4, i % 4).widget() self.assertIs(button.defaultAction(), actions[i]) grid.actionTriggered.connect(triggered_actions2.append) layout.addWidget(grid) w.setLayout(layout) w.show() one_action.trigger() self.qWait() def test_toolbox(self): w = QWidget() layout = QHBoxLayout() triggered_actions = [] model = self.reg.model() one_action = self.reg.action_for_widget("one") box = WidgetToolBox() box.setModel(model) box.triggered.connect(triggered_actions.append) layout.addWidget(box) box.setButtonSize(QSize(50, 80)) w.setLayout(layout) w.show() one_action.trigger() box.setButtonSize(QSize(60, 80)) box.setIconSize(QSize(35, 35)) box.setTabButtonHeight(40) box.setTabIconSize(QSize(30, 30)) box.setModel(QStandardItemModel()) self.assertEqual(box.count(), 0) box.setModel(model) self.assertEqual(box.count(), model.rowCount()) self.qWait() def test_filter(self): w = WidgetToolBox() w.setModel(self.reg.model()) edit = w.filterLineEdit() g0 = cast(WidgetToolGrid, w.widget(0)) self.assertEqual(g0.model().rowCount(g0.rootIndex()), 3) QTest.keyClicks(edit, "zero") self.assertEqual(g0.model().rowCount(g0.rootIndex()), 1) QTest.keyClicks(edit, "\b\b\b\b") self.assertEqual(g0.model().rowCount(g0.rootIndex()), 3) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.778081 orange_canvas_core-0.2.5/orangecanvas/application/utils/0000755000175100002000000000000014730024333023035 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/utils/__init__.py0000644000175100002000000000000014730024325025135 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/utils/addons.py0000644000175100002000000004775114730024325024676 0ustar00runnerdockerimport itertools import json import logging import os import re import shlex import sys import sysconfig from collections import deque from datetime import timedelta from enum import Enum from sqlite3 import OperationalError from types import SimpleNamespace from typing import ( AnyStr, Callable, List, NamedTuple, Optional, Tuple, TypeVar, Union, Dict, Any, IO, Iterable ) import requests import requests_cache from packaging.requirements import Requirement from packaging.version import Version from AnyQt.QtCore import QObject, QSettings, QStandardPaths, QTimer, Signal, Slot from orangecanvas import config from orangecanvas.utils import unique from orangecanvas.utils.pkgmeta import ( parse_meta, normalize_name, Distribution, get_distribution ) from orangecanvas.utils.shtools import create_process, python_process log = logging.getLogger(__name__) PYPI_API_JSON = "https://pypi.org/pypi/{name}/json" A = TypeVar("A") B = TypeVar("B") def prettify_name(name): dash_split = name.split('-') # Orange3-ImageAnalytics => ImageAnalytics orange_prefix = len(dash_split) > 1 and dash_split[0].lower() in ['orange', 'orange3'] name = ' '.join(dash_split[1:] if orange_prefix else dash_split) # ImageAnalytics => Image Analytics # while keeping acronyms return re.sub(r"(? Installed return super().__new__(cls, installable, local, required, constraint) @property def name(self): if self.installable is not None: return self.installable.name else: return self.local.name @property def normalized_name(self): return normalize_name(self.name) #: An installable item/slot Item = Union[Available, Installed] def is_updatable(item: Item) -> bool: if isinstance(item, Available): return False elif item.installable is None: return False else: inst, dist = item.installable, item.local try: v1 = Version(dist.version) v2 = Version(inst.version) except ValueError: return False if inst.force: return True if item.constraint is not None \ and not item.constraint.specifier.contains(v2, prereleases=True): return False else: return v1 < v2 def get_meta_from_archive(path): """Return project metadata extracted from sdist or wheel archive, or None if metadata can't be found.""" def is_metadata(fname): return fname.endswith(('PKG-INFO', 'METADATA')) meta = None if path.endswith(('.zip', '.whl')): from zipfile import ZipFile with ZipFile(path) as archive: meta = next(filter(is_metadata, archive.namelist()), None) if meta: meta = archive.read(meta).decode('utf-8') elif path.endswith(('.tar.gz', '.tgz')): import tarfile with tarfile.open(path) as archive: meta = next(filter(is_metadata, archive.getnames()), None) if meta: meta = archive.extractfile(meta).read().decode('utf-8') if meta: return parse_meta(meta) def pypi_json_query_project_meta(projects, session=None): # type: (List[str], Optional[requests.Session]) -> List[Optional[dict]] """ Parameters ---------- projects : List[str] List of project names to query session : Optional[requests.Session] """ if session is None: session = _session() rval = [] # type: List[Optional[dict]] for name in projects: r = session.get(PYPI_API_JSON.format(name=name)) if r.status_code != 200: rval.append(None) else: try: meta = r.json() except json.JSONDecodeError: rval.append(None) else: try: # sanity check installable_from_json_response(meta) except (TypeError, KeyError): rval.append(None) else: rval.append(meta) return rval def installable_from_json_response(meta): # type: (dict) -> Installable """ Extract relevant project meta data from a PyPiJSONRPC response Parameters ---------- meta : dict JSON response decoded into python native dict. Returns ------- installable : Installable """ info = meta["info"] name = info["name"] version = info.get("version", "0") summary = info.get("summary", "") description = info.get("description", "") content_type = info.get("description_content_type", None) package_url = info.get("package_url", "") distributions = meta.get("releases", {}).get(version, []) release_urls = [ReleaseUrl(r["filename"], url=r["url"], size=r["size"], python_version=r.get("python_version", ""), package_type=r["packagetype"]) for r in distributions] requirements = info.get("requires_dist", []) return Installable(name, version, summary, description, package_url, release_urls, requirements, content_type) def _session(cachedir=None): # type: (...) -> requests.Session """ Return a requests.Session instance Parameters ---------- cachedir : Optional[str] HTTP cache location. Returns ------- session : requests.Session """ if cachedir is None: cachedir = QStandardPaths.writableLocation(QStandardPaths.CacheLocation) cachedir = os.path.join(cachedir, "networkcache") try: return requests_cache.CachedSession( os.path.join(cachedir, "requests.sqlite"), backend="sqlite", cache_control=True, expire_after=timedelta(days=1), stale_if_error=True, ) except OperationalError as ex: # if no permission to write in dir or read cache file return uncached session log.info( f"Cache file creation/opening failed with: '{str(ex)}'. " f"Using requests.Session instead of cached session." ) return requests.Session() def optional_map(func: Callable[[A], B]) -> Callable[[Optional[A]], Optional[B]]: def f(x: Optional[A]) -> Optional[B]: return func(x) if x is not None else None return f class _QueryResult(SimpleNamespace): def __init__( self, queryname: str, installable: Optional[Installable], **kwargs ) -> None: self.queryname = queryname self.installable = installable super().__init__(**kwargs) def query_pypi(names: List[str]) -> List[_QueryResult]: res = pypi_json_query_project_meta(names) installable_from_json_response_ = optional_map( installable_from_json_response ) return [ _QueryResult(name, installable_from_json_response_(r)) for name, r in zip(names, res) ] def list_available_versions( config: config.Config, session: Optional[requests.Session] = None ) -> Tuple[List[Installable], List[Exception]]: if session is None: session = _session() exceptions = [] try: defaults = config.addon_defaults_list() except requests.exceptions.RequestException as e: defaults = [] exceptions.append(e) def getname(item): # type: (Dict[str, Any]) -> str info = item.get("info", {}) if not isinstance(info, dict): return "" name = info.get("name", "") assert isinstance(name, str) return name defaults_names = {getname(a) for a in defaults} # query pypi.org for installed add-ons that are not in the defaults # list installed = [ep.dist for ep in config.addon_entry_points() if ep.dist is not None] missing = {dist.name.casefold() for dist in installed} - \ {name.casefold() for name in defaults_names} distributions = [] for p in missing: try: response = session.get(PYPI_API_JSON.format(name=p)) if response.status_code != 200: continue distributions.append(response.json()) except requests.exceptions.RequestException as e: exceptions.append(e) packages = [] for addon in distributions + defaults: try: packages.append(installable_from_json_response(addon)) except (TypeError, KeyError) as e: exceptions.append(e) return packages, exceptions def installable_items(pypipackages, installed=[]): # type: (Iterable[Installable], Iterable[Distribution]) -> List[Item] """ Return a list of installable items. Parameters ---------- pypipackages : List[Installable] installed : List[Distribution] """ dists = {dist.name: dist for dist in installed} packages = {pkg.name: pkg for pkg in pypipackages} # For every pypi available distribution not listed by # `installed`, check if it is actually already installed. for pkg_name in set(packages.keys()).difference(set(dists.keys())): d = get_distribution(pkg_name) if d is not None: dists[d.name] = d project_names = unique(itertools.chain(packages.keys(), dists.keys())) items = [] # type: List[Item] for name in project_names: if name in dists and name in packages: item = Installed(packages[name], dists[name]) elif name in dists: item = Installed(None, dists[name]) elif name in packages: item = Available(packages[name]) else: assert False items.append(item) return items def is_requirement_available(req: Union[Requirement, str]) -> bool: if not isinstance(req, Requirement): req = Requirement(req) try: d = get_distribution(req.name) except Exception: return False else: return d is not None def have_install_permissions(): """Check if we can create a file in the site-packages folder. This works on a Win7 miniconda install, where os.access did not. """ try: fn = os.path.join(sysconfig.get_path("purelib"), "test_write_" + str(os.getpid())) with open(fn, "w"): pass os.remove(fn) return True except PermissionError: return False except OSError: return False class Command(Enum): Install = "Install" Upgrade = "Upgrade" Uninstall = "Uninstall" Install = Command.Install Upgrade = Command.Upgrade Uninstall = Command.Uninstall Action = Tuple[Command, Item] class CommandFailed(Exception): def __init__(self, cmd, retcode, output): if not isinstance(cmd, str): cmd = " ".join(map(shlex.quote, cmd)) self.cmd = cmd self.retcode = retcode self.output = output class Installer(QObject): installStatusChanged = Signal(str) started = Signal() finished = Signal() error = Signal(str, object, int, list) def __init__(self, parent=None, steps=[]): super().__init__(parent) self.__interupt = False self.__queue = deque(steps) self.__statusMessage = "" self.pip = PipInstaller() self.conda = CondaInstaller() def start(self): QTimer.singleShot(0, self._next) def interupt(self): self.__interupt = True def setStatusMessage(self, message): if self.__statusMessage != message: self.__statusMessage = message self.installStatusChanged.emit(message) @Slot() def _next(self): command, pkg = self.__queue.popleft() try: if command == Install \ or (command == Upgrade and pkg.installable.force): self.setStatusMessage( "Installing {}".format(pkg.installable.name)) if self.conda: try: self.conda.install(pkg.installable) except CommandFailed: self.pip.install(pkg.installable) else: self.pip.install(pkg.installable) elif command == Upgrade: self.setStatusMessage( "Upgrading {}".format(pkg.installable.name)) if self.conda: try: self.conda.upgrade(pkg.installable) except CommandFailed: self.pip.upgrade(pkg.installable) else: self.pip.upgrade(pkg.installable) elif command == Uninstall: self.setStatusMessage( "Uninstalling {}".format(pkg.local.name)) if self.conda: try: self.conda.uninstall(pkg.local) except CommandFailed: self.pip.uninstall(pkg.local) else: self.pip.uninstall(pkg.local) except CommandFailed as ex: self.error.emit( "Command failed: python {}".format(ex.cmd), pkg, ex.retcode, ex.output ) return if self.__queue: QTimer.singleShot(0, self._next) else: self.finished.emit() class PipInstaller: def __init__(self): arguments = QSettings().value('add-ons/pip-install-arguments', '', type=str) self.arguments = shlex.split(arguments) def install(self, pkg): # type: (Installable) -> None cmd = [ "python", "-m", "pip", "install", "--upgrade", "--upgrade-strategy=only-if-needed", ] + self.arguments if pkg.package_url.startswith(("http://", "https://")): version = "=={}".format(pkg.version) if pkg.version is not None else "" cmd.append(pkg.name + version) else: # Package url is path to the (local) wheel cmd.append(pkg.package_url) run_command(cmd) def upgrade(self, package): cmd = [ "python", "-m", "pip", "install", "--upgrade", "--upgrade-strategy=only-if-needed", ] + self.arguments if package.package_url.startswith(("http://", "https://")): version = ( "=={}".format(package.version) if package.version is not None else "" ) cmd.append(package.name + version) else: cmd.append(package.package_url) run_command(cmd) def uninstall(self, dist): cmd = ["python", "-m", "pip", "uninstall", "--yes", dist.name] run_command(cmd) class CondaInstaller: def __init__(self): enabled = QSettings().value('add-ons/allow-conda', True, type=bool) if enabled: self.conda = self._find_conda() else: self.conda = None def _find_conda(self): executable = sys.executable bin = os.path.dirname(executable) # posix conda = os.path.join(bin, "conda") if os.path.exists(conda): return conda # windows conda = os.path.join(bin, "Scripts", "conda.bat") if os.path.exists(conda): # "activate" conda environment orange is running in os.environ["CONDA_PREFIX"] = bin os.environ["CONDA_DEFAULT_ENV"] = bin return conda def install(self, pkg): version = "={}".format(pkg.version) if pkg.version is not None else "" cmd = [self.conda, "install", "--yes", "--quiet", self._normalize(pkg.name) + version] return run_command(cmd) def upgrade(self, pkg): version = "={}".format(pkg.version) if pkg.version is not None else "" cmd = [self.conda, "install", "--yes", "--quiet", self._normalize(pkg.name) + version] return run_command(cmd) def uninstall(self, dist): cmd = [self.conda, "uninstall", "--yes", self._normalize(dist.name)] return run_command(cmd) def _normalize(self, name): # Conda 4.3.30 is inconsistent, upgrade command is case sensitive # while install and uninstall are not. We assume that all conda # package names are lowercase which fixes the problems (for now) return name.lower() def __bool__(self): return bool(self.conda) def run_command(command, raise_on_fail=True, **kwargs): # type: (List[str], bool, Any) -> Tuple[int, List[AnyStr]] """ Run command in a subprocess. Return `process` return code and output once it completes. """ log.info("Running %s", " ".join(command)) if command[0] == "python": process = python_process(command[1:], **kwargs) else: process = create_process(command, **kwargs) rcode, output = run_process(process, file=sys.stdout) if rcode != 0 and raise_on_fail: raise CommandFailed(command, rcode, output) else: return rcode, output def run_process(process: 'subprocess.Popen', **kwargs) -> Tuple[int, List[AnyStr]]: file = kwargs.pop("file", sys.stdout) # type: Optional[IO] if file is ...: file = sys.stdout output = [] while process.poll() is None: line = process.stdout.readline() output.append(line) print(line, end="", file=file) # Read remaining output if any line = process.stdout.read() if line: output.append(line) print(line, end="", file=file) return process.returncode, output ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/welcomedialog.py0000644000175100002000000002257714730024325025100 0ustar00runnerdocker""" Orange Canvas Welcome Dialog """ from typing import Optional, Union, Iterable from xml.sax.saxutils import escape from AnyQt.QtWidgets import ( QDialog, QWidget, QToolButton, QCheckBox, QAction, QHBoxLayout, QVBoxLayout, QSizePolicy, QLabel, QApplication ) from AnyQt.QtGui import ( QFont, QIcon, QPixmap, QPainter, QColor, QBrush, QActionEvent, QIconEngine, ) from AnyQt.QtCore import Qt, QRect, QSize, QPoint from AnyQt.QtCore import pyqtSignal as Signal from ..canvas.items.utils import radial_gradient from ..registry import NAMED_COLORS from ..gui.svgiconengine import StyledSvgIconEngine from .. import styles class DecoratedIconEngine(QIconEngine): def __init__(self, base: QIcon, background: QColor): super().__init__() self.__base = base self.__background = background self.__gradient = radial_gradient(background) def paint( self, painter: 'QPainter', rect: QRect, mode: QIcon.Mode, state: QIcon.State ) -> None: size = rect.size() dpr = painter.device().devicePixelRatioF() size = size * dpr pm = self.pixmap(size, mode, state) painter.drawPixmap(rect, pm) return def pixmap( self, size: QSize, mode: QIcon.Mode, state: QIcon.State ) -> QPixmap: pixmap = QPixmap(size) pixmap.fill(Qt.transparent) p = QPainter(pixmap) p.setRenderHint(QPainter.Antialiasing, True) p.setBrush(QBrush(self.__gradient)) p.setPen(Qt.NoPen) icon_size = QSize(5 * size.width() // 8, 5 * size.height() // 8) icon_rect = QRect(QPoint(0, 0), icon_size) ellipse_rect = QRect(QPoint(0, 0), size) p.drawEllipse(ellipse_rect) icon_rect.moveCenter(ellipse_rect.center()) palette = styles.breeze_light() # Special case for StyledSvgIconEngine. This is drawn on a # light-ish color background and should not render with a dark palette # (this is bad, and I feel bad). with StyledSvgIconEngine.setOverridePalette(palette): self.__base.paint(p, icon_rect, Qt.AlignCenter) p.end() return pixmap def clone(self) -> 'QIconEngine': return DecoratedIconEngine( self.__base, self.__background ) def decorate_welcome_icon(icon, background_color): # type: (QIcon, Union[QColor, str]) -> QIcon """Return a `QIcon` with a circle shaped background. """ background_color = NAMED_COLORS.get(background_color, background_color) return QIcon(DecoratedIconEngine(icon, QColor(background_color))) WELCOME_WIDGET_BUTTON_STYLE = """ WelcomeActionButton { border: 1px solid transparent; border-radius: 10px; font-size: 13px; icon-size: 75px; } WelcomeActionButton:pressed { background-color: palette(highlight); color: palette(highlighted-text); } WelcomeActionButton:focus { border: 1px solid palette(highlight); } """ class WelcomeActionButton(QToolButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setFont(QApplication.font("QAbstractButton")) def actionEvent(self, event): # type: (QActionEvent) -> None super().actionEvent(event) if event.type() == QActionEvent.ActionChanged \ and event.action() is self.defaultAction(): # The base does not update self visibility for defaultAction. self.setVisible(event.action().isVisible()) class WelcomeDialog(QDialog): """ A welcome widget shown at startup presenting a series of buttons (actions) for a beginner to choose from. """ triggered = Signal(QAction) def __init__(self, *args, **kwargs): showAtStartup = kwargs.pop("showAtStartup", True) feedbackUrl = kwargs.pop("feedbackUrl", "") super().__init__(*args, **kwargs) self.__triggeredAction = None # type: Optional[QAction] self.__showAtStartupCheck = None self.__mainLayout = None self.__feedbackUrl = None self.__feedbackLabel = None self.setupUi() self.setFeedbackUrl(feedbackUrl) self.setShowAtStartup(showAtStartup) def setupUi(self): self.setLayout(QVBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) self.layout().setSpacing(0) self.__mainLayout = QVBoxLayout() self.__mainLayout.setContentsMargins(0, 40, 0, 40) self.__mainLayout.setSpacing(65) self.layout().addLayout(self.__mainLayout) self.setStyleSheet(WELCOME_WIDGET_BUTTON_STYLE) bottom_bar = QWidget(objectName="bottom-bar") bottom_bar_layout = QHBoxLayout() bottom_bar_layout.setContentsMargins(20, 10, 20, 10) bottom_bar.setLayout(bottom_bar_layout) bottom_bar.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Maximum) self.__showAtStartupCheck = QCheckBox( self.tr("Show at startup"), bottom_bar, checked=False ) self.__feedbackLabel = QLabel( textInteractionFlags=Qt.TextBrowserInteraction, openExternalLinks=True, visible=False, ) bottom_bar_layout.addWidget( self.__showAtStartupCheck, alignment=Qt.AlignVCenter | Qt.AlignLeft ) bottom_bar_layout.addWidget( self.__feedbackLabel, alignment=Qt.AlignVCenter | Qt.AlignRight ) self.layout().addWidget(bottom_bar, alignment=Qt.AlignBottom, stretch=1) self.setSizeGripEnabled(False) self.setFixedSize(620, 390) def setShowAtStartup(self, show): # type: (bool) -> None """ Set the 'Show at startup' check box state. """ if self.__showAtStartupCheck.isChecked() != show: self.__showAtStartupCheck.setChecked(show) def showAtStartup(self): # type: () -> bool """ Return the 'Show at startup' check box state. """ return self.__showAtStartupCheck.isChecked() def setFeedbackUrl(self, url): # type: (str) -> None """ Set an 'feedback' url. When set a link is displayed in the bottom row. """ self.__feedbackUrl = url if url: text = self.tr("Help us improve!") self.__feedbackLabel.setText( '{text}'.format(url=url, text=escape(text)) ) else: self.__feedbackLabel.setText("") self.__feedbackLabel.setVisible(bool(url)) def addRow(self, actions, background="light-orange"): """Add a row with `actions`. """ count = self.__mainLayout.count() self.insertRow(count, actions, background) def insertRow(self, index, actions, background="light-orange"): # type: (int, Iterable[QAction], Union[QColor, str]) -> None """Insert a row with `actions` at `index`. """ widget = QWidget(objectName="icon-row") layout = QHBoxLayout() layout.setContentsMargins(40, 0, 40, 0) layout.setSpacing(65) widget.setLayout(layout) self.__mainLayout.insertWidget(index, widget, stretch=10, alignment=Qt.AlignCenter) for i, action in enumerate(actions): self.insertAction(index, i, action, background) def insertAction(self, row, index, action, background="light-orange"): """Insert `action` in `row` in position `index`. """ button = self.createButton(action, background) self.insertButton(row, index, button) def insertButton(self, row, index, button): # type: (int, int, QToolButton) -> None """Insert `button` in `row` in position `index`. """ item = self.__mainLayout.itemAt(row) layout = item.widget().layout() layout.insertWidget(index, button) button.triggered.connect(self.__on_actionTriggered) def createButton(self, action, background="light-orange"): # type: (QAction, Union[QColor, str]) -> QToolButton """Create a tool button for action. """ button = WelcomeActionButton(self) button.setDefaultAction(action) button.setText(action.iconText()) button.setIcon(decorate_welcome_icon(action.icon(), background)) button.setToolTip(action.toolTip()) button.setFixedSize(100, 100) button.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) button.setVisible(action.isVisible()) font = QFont(button.font()) font.setPointSize(13) button.setFont(font) return button def buttonAt(self, i, j): # type: (int, int) -> QToolButton """Return the button at i-t row and j-th column. """ item = self.__mainLayout.itemAt(i) row = item.widget() item = row.layout().itemAt(j) return item.widget() def triggeredAction(self): # type: () -> Optional[QAction] """Return the action that was triggered by the user. """ return self.__triggeredAction def showEvent(self, event): # Clear the triggered action before show. self.__triggeredAction = None super().showEvent(event) def __on_actionTriggered(self, action): # type: (QAction) -> None """Called when the button action is triggered. """ self.triggered.emit(action) self.__triggeredAction = action ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/application/widgettoolbox.py0000644000175100002000000004666714730024325025165 0ustar00runnerdocker""" Widget Tool Box =============== A tool box with a tool grid for each category. """ from typing import Optional, Iterable, Any from AnyQt.QtWidgets import ( QAbstractButton, QSizePolicy, QAction, QApplication, QToolButton, QWidget, QLineEdit ) from AnyQt.QtGui import ( QDrag, QPalette, QBrush, QIcon, QColor, QGradient, QActionEvent, QMouseEvent ) from AnyQt.QtCore import ( Qt, QObject, QAbstractItemModel, QModelIndex, QSize, QEvent, QMimeData, QByteArray, QDataStream, QIODevice, QPoint, QPersistentModelIndex ) from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from ..gui.itemmodels import FilterProxyModel from ..gui.toolbox import ToolBox from ..gui.toolgrid import ToolGrid from ..gui.quickhelp import StatusTipPromoter from ..gui.utils import create_gradient_brush from ..registry import WidgetDescription from ..registry.qt import QtWidgetRegistry from ..registry.utils import search_filter_query_helper from ..resources import load_styled_svg_icon def iter_index(model, index): # type: (QAbstractItemModel, QModelIndex) -> Iterable[QModelIndex] """ Iterate over child indexes of a `QModelIndex` in a `model`. """ for row in range(model.rowCount(index)): yield model.index(row, 0, index) def item_text(index): # type: (QModelIndex) -> str value = index.data(Qt.DisplayRole) if value is None: return "" else: return str(value) def item_icon(index): # type: (QModelIndex) -> QIcon value = index.data(Qt.DecorationRole) if isinstance(value, QIcon): return value else: return QIcon() def item_tooltip(index): # type: (QModelIndex) -> str value = index.data(Qt.ToolTipRole) if isinstance(value, str): return value return item_text(index) def item_background(index): # type: (QModelIndex) -> Optional[QBrush] value = index.data(Qt.BackgroundRole) if isinstance(value, QBrush): return value elif isinstance(value, (QColor, Qt.GlobalColor, QGradient)): return QBrush(value) else: return None class WidgetToolGrid(ToolGrid): """ A Tool Grid with widget buttons. Populates the widget buttons from an item model. Also adds support for drag operations. """ def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) self.__model = None # type: Optional[QAbstractItemModel] self.__rootIndex = QPersistentModelIndex() # type: QPersistentModelIndex self.__actionRole = QtWidgetRegistry.WIDGET_ACTION_ROLE # type: int self.__dragListener = DragStartEventListener(self) self.__dragListener.dragStartOperationRequested.connect( self.__startDrag ) self.__statusTipPromoter = StatusTipPromoter(self) def setModel(self, model, rootIndex=QModelIndex()): # type: (QAbstractItemModel, QModelIndex) -> None """ Set a model (`QStandardItemModel`) for the tool grid. The widget actions are children of the rootIndex. .. warning:: The model should not be deleted before the `WidgetToolGrid` instance. """ if self.__model is not None: self.__model.rowsInserted.disconnect(self.__on_rowsInserted) self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) self.__model.modelReset.disconnect(self.__on_modelReset) self.__model = None self.__model = model self.__rootIndex = QPersistentModelIndex(rootIndex) if self.__model is not None: self.__model.rowsInserted.connect(self.__on_rowsInserted) self.__model.rowsRemoved.connect(self.__on_rowsRemoved) self.__model.modelReset.connect(self.__on_modelReset) self.__initFromModel(model, rootIndex) def model(self): # type: () -> Optional[QAbstractItemModel] """ Return the model for the tool grid. """ return self.__model def rootIndex(self): # type: () -> QModelIndex """ Return the root index of the model. """ return QModelIndex(self.__rootIndex) def setActionRole(self, role): # type: (int) -> None """ Set the action role. This is the model role containing a `QAction` instance. """ if self.__actionRole != role: self.__actionRole = role if self.__model: self.__update() def actionRole(self): # type: () -> int """ Return the action role. """ return self.__actionRole def actionEvent(self, event): # type: (QActionEvent) -> None if event.type() == QEvent.ActionAdded: # Creates and inserts the button instance. super().actionEvent(event) button = self.buttonForAction(event.action()) button.installEventFilter(self.__dragListener) button.installEventFilter(self.__statusTipPromoter) return elif event.type() == QEvent.ActionRemoved: button = self.buttonForAction(event.action()) button.removeEventFilter(self.__dragListener) button.removeEventFilter(self.__statusTipPromoter) # Removes the button super().actionEvent(event) return else: super().actionEvent(event) def __initFromModel(self, model, rootIndex): # type: (QAbstractItemModel, QModelIndex) -> None """ Initialize the grid from the model with rootIndex as the root. """ for i, index in enumerate(iter_index(model, rootIndex)): self.__insertItem(i, index) def __insertItem(self, index, item): # type: (int, QModelIndex) -> None """ Insert a widget action from `item` (`QModelIndex`) at `index`. """ value = item.data(self.__actionRole) if isinstance(value, QAction): action = value else: action = QAction(item_text(item), self) action.setIcon(item_icon(item)) action.setToolTip(item_tooltip(item)) self.insertAction(index, action) def __update(self): # type: () -> None self.clear() if self.__model is not None: self.__initFromModel(self.__model, QModelIndex(self.__rootIndex)) def __on_rowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None """ Insert items from range start:end into the grid. """ if parent == QModelIndex(self.__rootIndex): for i in range(start, end + 1): item = self.__model.index(i, 0, parent) self.__insertItem(i, item) def __on_rowsRemoved(self, parent, start, end): # type: (QModelIndex, int, int) -> None """ Remove items from range start:end from the grid. """ if parent == QModelIndex(self.__rootIndex): actions = self.actions() actions = actions[start: end + 1] self.__removeActions(actions) def __on_modelReset(self): self.__removeActions(self.actions()) def __removeActions(self, actions: Iterable[QAction]): for action in actions: self.removeAction(action) def __startDrag(self, button): # type: (QToolButton) -> None """ Start a drag from button """ action = button.defaultAction() desc = action.data() # Widget Description icon = action.icon() drag_data = QMimeData() drag_data.setData( "application/vnd.orange-canvas.registry.qualified-name", desc.qualified_name.encode("utf-8") ) drag = QDrag(button) drag.setPixmap(icon.pixmap(self.iconSize())) drag.setMimeData(drag_data) drag.exec(Qt.CopyAction) class DragStartEventListener(QObject): """ An event filter object that can be used to detect drag start operation on buttons which otherwise do not support it. """ dragStartOperationRequested = Signal(QAbstractButton) """A drag operation started on a button.""" def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.button = None # type: Optional[Qt.MouseButton] self.buttonDownObj = None # type: Optional[QAbstractButton] self.buttonDownPos = None # type: Optional[QPoint] def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.MouseButtonPress: assert isinstance(event, QMouseEvent) self.buttonDownPos = event.pos() self.buttonDownObj = obj self.button = event.button() elif event.type() == QEvent.MouseMove and obj is self.buttonDownObj: assert self.buttonDownObj is not None if (self.buttonDownPos - event.pos()).manhattanLength() > \ QApplication.startDragDistance() and \ not self.buttonDownObj.hitButton(event.pos()): # Process the widget's mouse event, before starting the # drag operation, so the widget can update its state. obj.mouseMoveEvent(event) self.dragStartOperationRequested.emit(obj) obj.setDown(False) self.button = None self.buttonDownPos = None self.buttonDownObj = None return True # Already handled return super().eventFilter(obj, event) class WidgetToolBox(ToolBox): """ `WidgetToolBox` widget shows a tool box containing button grids of actions for a :class:`QtWidgetRegistry` item model. """ triggered = Signal(QAction) hovered = Signal(QAction) def __init__(self, parent=None): # type: (Optional[QWidget]) -> None super().__init__(parent) self.__model = None # type: Optional[QAbstractItemModel] self.__proxyModel = FilterProxyModel() self.__proxyModel.dataChanged.connect(self.__on_dataChanged) self.__proxyModel.rowsInserted.connect(self.__on_rowsInserted) self.__proxyModel.rowsRemoved.connect(self.__on_rowsRemoved) self.__proxyModel.modelReset.connect(self.__on_modelReset) self.__iconSize = QSize(25, 25) self.__buttonSize = QSize(50, 50) self.__filterText = "" self.__filteredSavedState = {} self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) action = QAction( load_styled_svg_icon("Search.svg"), self.tr("Search"), self ) self.__filterEdit = QLineEdit( objectName="filter-edit-line", placeholderText=self.tr("Filter..."), toolTip=self.tr("Filter/search the list of available widgets."), clearButtonEnabled=True, ) self.__filterEdit.setAttribute(Qt.WA_MacShowFocusRect, False) self.__filterEdit.addAction(action, QLineEdit.LeadingPosition) self.__filterEdit.textChanged.connect(self.__on_filterTextChanged) layout = self.layout() layout.setSpacing(1) layout.insertWidget(0, self.__filterEdit) open_all = QAction(self.tr("Open all"), self) open_all.triggered.connect(self.openAllTabs) close_all = QAction(self.tr("Close all"), self) close_all.triggered.connect(self.closeAllTabs) self.addActions([open_all, close_all]) self.setContextMenuPolicy(Qt.ActionsContextMenu) def filterLineEdit(self) -> QLineEdit: return self.__filterEdit def setIconSize(self, size): # type: (QSize) -> None """ Set the widget icon size (icons in the button grid). """ if self.__iconSize != size: self.__iconSize = QSize(size) for widget in map(self.widget, range(self.count())): widget.setIconSize(size) def iconSize(self): # type: () -> QSize """ Return the widget buttons icon size. """ return QSize(self.__iconSize) iconSize_ = Property(QSize, fget=iconSize, fset=setIconSize, designable=True) def setButtonSize(self, size): # type: (QSize) -> None """ Set fixed widget button size. """ if self.__buttonSize != size: self.__buttonSize = QSize(size) for widget in map(self.widget, range(self.count())): widget.setButtonSize(size) def buttonSize(self): # type: () -> QSize """Return the widget button size """ return QSize(self.__buttonSize) buttonSize_ = Property(QSize, fget=buttonSize, fset=setButtonSize, designable=True) def saveState(self): # type: () -> QByteArray """ Return the toolbox state (as a `QByteArray`). .. note:: Individual tabs are stored by their action's text. """ version = 2 actions = map(self.tabAction, range(self.count())) expanded = [action for action in actions if action.isChecked()] expanded = [action.text() for action in expanded] byte_array = QByteArray() stream = QDataStream(byte_array, QIODevice.WriteOnly) stream.writeInt(version) stream.writeQStringList(expanded) return byte_array def restoreState(self, state): # type: (QByteArray) -> bool """ Restore the toolbox from a :class:`QByteArray` `state`. .. note:: The toolbox should already be populated for the state changes to take effect. """ stream = QDataStream(state, QIODevice.ReadOnly) version = stream.readInt() if version == 2: expanded = stream.readQStringList() for action in map(self.tabAction, range(self.count())): if (action.text() in expanded) != action.isChecked(): action.trigger() return True return False def setModel(self, model): # type: (QAbstractItemModel) -> None """ Set the widget registry model (:class:`QAbstractItemModel`) for this toolbox. """ self.__model = model rows = self.__proxyModel.rowCount() if rows: self.__on_rowsRemoved(QModelIndex(), 0, rows - 1) self.__proxyModel.setSourceModel(model) self.__initFromModel(self.__proxyModel) def __initFromModel(self, model): # type: (QAbstractItemModel) -> None for row in range(model.rowCount()): self.__insertItem(model.index(row, 0), self.count()) def __insertItem(self, item, index): # type: (QModelIndex, int) -> None """ Insert category item (`QModelIndex`) at index. """ grid = WidgetToolGrid() grid.setModel(item.model(), item) grid.actionTriggered.connect(self.triggered) grid.actionHovered.connect(self.hovered) grid.setIconSize(self.__iconSize) grid.setButtonSize(self.__buttonSize) grid.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) text = item_text(item) icon = item_icon(item) tooltip = item_tooltip(item) # Set the 'tab-title' property to text. grid.setProperty("tab-title", text) grid.setObjectName("widgets-toolbox-grid") self.insertItem(index, grid, text, icon, tooltip) button = self.tabButton(index) # Set the 'highlight' color if applicable highlight_foreground = None highlight = item_background(item) if highlight is None \ and item.data(QtWidgetRegistry.BACKGROUND_ROLE) is not None: highlight = item.data(QtWidgetRegistry.BACKGROUND_ROLE) if isinstance(highlight, QBrush) and highlight.style() != Qt.NoBrush: if not highlight.gradient(): value = highlight.color().value() highlight = create_gradient_brush(highlight.color()) highlight_foreground = Qt.black if value > 128 else Qt.white palette = button.palette() if highlight is not None: palette.setBrush(QPalette.Highlight, highlight) if highlight_foreground is not None: palette.setBrush(QPalette.HighlightedText, highlight_foreground) button.setPalette(palette) def __on_dataChanged(self, topLeft, bottomRight): # type: (QModelIndex, QModelIndex) -> None parent = topLeft.parent() if not parent.isValid(): for row in range(topLeft.row(), bottomRight.row() + 1): item = topLeft.sibling(row, topLeft.column()) button = self.tabButton(row) button.setIcon(item_icon(item)) button.setText(item_text(item)) button.setToolTip(item_tooltip(item)) def __on_rowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None """ Items have been inserted in the model. """ # Only the top level items (categories) are handled here. assert self.__model is not None if not parent.isValid(): for i in range(start, end + 1): item = self.__proxyModel.index(i, 0) self.__insertItem(item, i) def __on_rowsRemoved(self, parent, start, end): # type: (QModelIndex, int, int) -> None """ Rows have been removed from the model. """ # Only the top level items (categories) are handled here. if not parent.isValid(): for i in reversed(range(start, end + 1)): self.removeItem(i) def __on_modelReset(self): for i in reversed(range(self.count())): self.removeItem(i) def __on_filterTextChanged(self, text: str) -> None: def acceptable(desc: Optional[WidgetDescription]) -> bool: if desc is not None: return search_filter_query_helper(desc, text.strip().lower()) else: return True # accept other (category, ...) self.__proxyModel.setFilters([ FilterProxyModel.Filter(0, QtWidgetRegistry.WIDGET_DESC_ROLE, acceptable), ]) if not self.__filterText and text: self.__filterText = text self.__openAllTabsForFilter() elif self.__filterText and not text: self.__filterText = "" self.__restoreAllTabsForFilter() def __openAllTabsForFilter(self): """Open all tabs for displaying filter/search results.""" self.__filteredSavedState = {"!__exclusive": self.exclusive()} self.setExclusive(False) for i in range(self.count()): b = self.tabButton(i) self.__filteredSavedState[b.text()] = b.isChecked() b.defaultAction().setChecked(True) def __restoreAllTabsForFilter(self): """Restore open tabs after filter/search.""" self.setExclusive(self.__filteredSavedState.get("!__exclusive", False)) for i in range(self.count()): b = self.tabButton(i) b.defaultAction().setChecked(self.__filteredSavedState.get(b.text(), b.isChecked())) def openAllTabs(self): """Open all tabs.""" self.setExclusive(False) for i in range(self.count()): self.tabButton(i).defaultAction().setChecked(True) def closeAllTabs(self): """Close all tabs.""" self.setExclusive(False) for i in range(self.count()): self.tabButton(i).defaultAction().setChecked(False)././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.778081 orange_canvas_core-0.2.5/orangecanvas/canvas/0000755000175100002000000000000014730024333020645 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/__init__.py0000644000175100002000000000047114730024325022761 0ustar00runnerdocker""" ====== Canvas ====== The :mod:`.canvas` package contains classes for visualizing the contents of a :class:`~.scheme.Scheme`, utilizing the Qt's `Graphics View Framework`_. .. _`Graphics View Framework`: http://qt-project.org/doc/qt-4.8/graphicsview.html """ __all__ = ["scene", "layout", "view", "items"] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.782081 orange_canvas_core-0.2.5/orangecanvas/canvas/items/0000755000175100002000000000000014730024333021766 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/__init__.py0000644000175100002000000000044014730024325024076 0ustar00runnerdocker""" Orange Canvas Graphics Items """ from .nodeitem import NodeItem, NodeAnchorItem, NodeBodyItem, SHADOW_COLOR from .nodeitem import SourceAnchorItem, SinkAnchorItem, AnchorPoint from .linkitem import LinkItem, LinkCurveItem from .annotationitem import TextAnnotation, ArrowAnnotation ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/annotationitem.py0000644000175100002000000006317514730024325025406 0ustar00runnerdockerfrom typing import Optional, Union, Any, Tuple from AnyQt.QtWidgets import ( QGraphicsItem, QGraphicsPathItem, QGraphicsWidget, QGraphicsDropShadowEffect, QMenu, QAction, QActionGroup, QStyleOptionGraphicsItem, QWidget, QGraphicsSceneMouseEvent, QGraphicsSceneResizeEvent, QGraphicsSceneContextMenuEvent ) from AnyQt.QtGui import ( QPainterPath, QPainterPathStroker, QPolygonF, QColor, QPen, QBrush, QPalette, QPainter, QTextDocument, QTextCursor, QFontMetricsF ) from AnyQt.QtCore import ( Qt, QPointF, QSizeF, QRectF, QLineF, QEvent, QMetaObject, QObject ) from AnyQt.QtCore import ( pyqtSignal as Signal, pyqtProperty as Property, pyqtSlot as Slot ) from orangecanvas.utils import markup from .graphicspathobject import GraphicsPathObject from .graphicstextitem import GraphicsTextEdit class Annotation(QGraphicsWidget): """ Base class for annotations in the canvas scheme. """ class GraphicsTextEdit(GraphicsTextEdit): """ QGraphicsTextItem subclass defining an additional placeholderText property (text displayed when no text is set). """ def __init__(self, *args, placeholderText="", **kwargs): # type: (Any, str, Any) -> None kwargs.setdefault( "editTriggers", GraphicsTextEdit.DoubleClicked | GraphicsTextEdit.EditKeyPressed ) super().__init__(*args, **kwargs) self.setAcceptHoverEvents(True) self.__placeholderText = placeholderText def setPlaceholderText(self, text): # type: (str) -> None """ Set the placeholder text. This is shown when the item has no text, i.e when `toPlainText()` returns an empty string. """ if self.__placeholderText != text: self.__placeholderText = text if not self.toPlainText(): self.update() def placeholderText(self): # type: () -> str """ Return the placeholder text. """ return self.__placeholderText placeholderText_ = Property(str, placeholderText, setPlaceholderText, doc="Placeholder text") def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None super().paint(painter, option, widget) # Draw placeholder text if necessary if not (self.toPlainText() and self.toHtml()) and \ self.__placeholderText and \ not (self.hasFocus() and self.textInteractionFlags() & Qt.TextEditable): brect = self.boundingRect() font = self.font() painter.setFont(font) metrics = QFontMetricsF(font) text = metrics.elidedText( self.__placeholderText, Qt.ElideRight, brect.width() ) color = self.defaultTextColor() color.setAlpha(min(color.alpha(), 150)) painter.setPen(QPen(color)) painter.drawText(brect, Qt.AlignTop | Qt.AlignLeft, text) class TextAnnotation(Annotation): """ Text annotation item for the canvas scheme. Text interaction (if enabled) is started by double clicking the item. """ #: Emitted when the editing is finished (i.e. the item loses edit focus). editingFinished = Signal() #: Emitted when the text content changes on user interaction. textEdited = Signal() #: Emitted when the text annotation's contents change #: (`content` or `contentType` changed) contentChanged = Signal() def __init__(self, parent=None, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None super().__init__(None, **kwargs) self.setFlag(QGraphicsItem.ItemIsMovable) self.setFlag(QGraphicsItem.ItemIsSelectable) self.setFocusPolicy(Qt.ClickFocus) self.__contentType = "text/plain" self.__content = "" self.__textMargins = (2, 2, 2, 2) self.__textInteractionFlags = Qt.NoTextInteraction self.__defaultInteractionFlags = ( Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard ) rect = self.geometry().translated(-self.pos()) self.__framePen = QPen(Qt.NoPen) self.__framePathItem = QGraphicsPathItem(self) self.__framePathItem.setPen(self.__framePen) self.__textItem = GraphicsTextEdit( self, editTriggers=GraphicsTextEdit.NoEditTriggers ) self.__textItem.setOpenExternalLinks(True) self.__textItem.setPlaceholderText(self.tr("Enter text here")) self.__textItem.setPos(2, 2) self.__textItem.setTextWidth(rect.width() - 4) self.__textItem.setTabChangesFocus(True) self.__textItem.setTextInteractionFlags(self.__defaultInteractionFlags) self.__textItem.setFont(self.font()) self.__textItem.editingFinished.connect(self.__textEditingFinished) self.__textItem.setDefaultTextColor( self.palette().color(QPalette.Text) ) if self.__textItem.scene() is not None: self.__textItem.installSceneEventFilter(self) layout = self.__textItem.document().documentLayout() layout.documentSizeChanged.connect(self.__onDocumentSizeChanged) self.__updateFrame() # set parent item at the end in order to ensure # QGraphicsItem.ItemSceneHasChanged is delivered after initialization if parent is not None: self.setParentItem(parent) def itemChange(self, change, value): # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any if change == QGraphicsItem.ItemSceneHasChanged: if self.__textItem.scene() is not None: self.__textItem.installSceneEventFilter(self) if change == QGraphicsItem.ItemSelectedHasChanged: self.__updateFrameStyle() return super().itemChange(change, value) def adjustSize(self): # type: () -> None """Resize to a reasonable size. """ self.__textItem.setTextWidth(-1) self.__textItem.adjustSize() size = self.__textItem.boundingRect().size() left, top, right, bottom = self.textMargins() geom = QRectF(self.pos(), size + QSizeF(left + right, top + bottom)) self.setGeometry(geom) def setFramePen(self, pen): # type: (QPen) -> None """Set the frame pen. By default Qt.NoPen is used (i.e. the frame is not shown). """ if pen != self.__framePen: self.__framePen = QPen(pen) self.__updateFrameStyle() def framePen(self): # type: () -> QPen """Return the frame pen. """ return QPen(self.__framePen) def setFrameBrush(self, brush): # type: (QBrush) -> None """Set the frame brush. """ self.__framePathItem.setBrush(brush) def frameBrush(self): # type: () -> QBrush """Return the frame brush. """ return self.__framePathItem.brush() def __updateFrameStyle(self): # type: () -> None if self.isSelected(): pen = QPen(QColor(96, 158, 215), 1.25, Qt.DashDotLine) else: pen = self.__framePen self.__framePathItem.setPen(pen) def contentType(self): # type: () -> str return self.__contentType def setContent(self, content, contentType="text/plain"): # type: (str, str) -> None if self.__content != content or self.__contentType != contentType: self.__contentType = contentType self.__content = content self.__updateRenderedContent() self.contentChanged.emit() def content(self): # type: () -> str return self.__content def setPlainText(self, text): # type: (str) -> None """Set the annotation text as plain text. """ self.setContent(text, "text/plain") def toPlainText(self): # type: () -> str return self.__textItem.toPlainText() def setHtml(self, text): # type: (str) -> None """Set the annotation text as html. """ self.setContent(text, "text/html") def toHtml(self): # type: () -> str return self.__textItem.toHtml() def setDefaultTextColor(self, color): # type: (QColor) -> None """Set the default text color. """ self.__textItem.setDefaultTextColor(color) def defaultTextColor(self): # type: () -> QColor return self.__textItem.defaultTextColor() def setTextMargins(self, left, top, right, bottom): # type: (int, int, int, int) -> None """Set the text margins. """ margins = (left, top, right, bottom) if self.__textMargins != margins: self.__textMargins = margins self.__textItem.setPos(left, top) self.__textItem.setTextWidth( max(self.geometry().width() - left - right, 0) ) def textMargins(self): # type: () -> Tuple[int, int, int, int] """Return the text margins. """ return self.__textMargins def document(self): # type: () -> QTextDocument """Return the QTextDocument instance used internally. """ return self.__textItem.document() def setTextCursor(self, cursor): # type: (QTextCursor) -> None self.__textItem.setTextCursor(cursor) def textCursor(self): # type: () -> QTextCursor return self.__textItem.textCursor() def setTextInteractionFlags(self, flags): # type: (Qt.TextInteractionFlag) -> None self.__textInteractionFlags = Qt.TextInteractionFlag(flags) def textInteractionFlags(self): # type: () -> Qt.TextInteractionFlag return self.__textInteractionFlags def setDefaultStyleSheet(self, stylesheet): # type: (str) -> None self.document().setDefaultStyleSheet(stylesheet) def mouseDoubleClickEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> None super().mouseDoubleClickEvent(event) if event.buttons() == Qt.LeftButton and \ self.__textInteractionFlags & Qt.TextEditable: self.startEdit() def startEdit(self): # type: () -> None """Start the annotation text edit process. """ self.__textItem.setPlainText(self.__content) self.__textItem.setTextInteractionFlags(self.__textInteractionFlags) self.__textItem.setFocus(Qt.MouseFocusReason) self.__textItem.edit() self.__textItem.document().contentsChanged.connect( self.textEdited ) def endEdit(self): # type: () -> None """End the annotation edit. """ content = self.__textItem.toPlainText() self.__textItem.setTextInteractionFlags(self.__defaultInteractionFlags) self.__textItem.document().contentsChanged.disconnect( self.textEdited ) cursor = self.__textItem.textCursor() cursor.clearSelection() self.__textItem.setTextCursor(cursor) self.__content = content self.editingFinished.emit() # Cannot change the textItem's html immediately, this method is # invoked from it. # TODO: Separate the editor from the view. QMetaObject.invokeMethod( self, "__updateRenderedContent", Qt.QueuedConnection) def __onDocumentSizeChanged(self, size): # type: (QSizeF) -> None # The size of the text document has changed. Expand the text # control rect's height if the text no longer fits inside. rect = self.geometry() _, top, _, bottom = self.textMargins() if rect.height() < (size.height() + bottom + top): rect.setHeight(size.height() + bottom + top) self.setGeometry(rect) def __updateFrame(self): # type: () -> None rect = self.geometry() rect.moveTo(0, 0) path = QPainterPath() path.addRect(rect) self.__framePathItem.setPath(path) def resizeEvent(self, event): # type: (QGraphicsSceneResizeEvent) -> None width = event.newSize().width() left, _, right, _ = self.textMargins() self.__textItem.setTextWidth(max(width - left - right, 0)) self.__updateFrame() super().resizeEvent(event) def __textEditingFinished(self): # type: () -> None self.endEdit() def sceneEventFilter(self, obj, event): # type: (QGraphicsItem, QEvent) -> bool if obj is self.__textItem and \ not (self.__textItem.hasFocus() and self.__textItem.textInteractionFlags() & Qt.TextEditable) and \ event.type() == QEvent.GraphicsSceneContextMenu: # Handle context menu events here self.contextMenuEvent(event) event.accept() return True return super().sceneEventFilter(obj, event) def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.FontChange: self.__textItem.setFont(self.font()) elif event.type() == QEvent.PaletteChange: self.__textItem.setDefaultTextColor( self.palette().color(QPalette.Text) ) super().changeEvent(event) @Slot() def __updateRenderedContent(self): # type: () -> None self.__textItem.setHtml( markup.render_as_rich_text(self.__content, self.__contentType) ) def contextMenuEvent(self, event): # type: (QGraphicsSceneContextMenuEvent) -> None menu = QMenu(event.widget()) menu.setAttribute(Qt.WA_DeleteOnClose) formatmenu = menu.addMenu("Render as") group = QActionGroup(self) def makeaction(text, parent, data=None, **kwargs): # type: (str, QObject, Any, Any) -> QAction action = QAction(text, parent, **kwargs) if data is not None: action.setData(data) return action formatactions = [ makeaction("Plain Text", group, checkable=True, toolTip=self.tr("Render contents as plain text"), data="text/plain"), makeaction("HTML", group, checkable=True, toolTip=self.tr("Render contents as HTML"), data="text/html"), makeaction("RST", group, checkable=True, toolTip=self.tr("Render contents as RST " "(reStructuredText)"), data="text/rst"), makeaction("Markdown", group, checkable=True, toolTip=self.tr("Render contents as Markdown"), data="text/markdown") ] for action in formatactions: action.setChecked(action.data() == self.__contentType.lower()) formatmenu.addAction(action) def ontriggered(action): # type: (QAction) -> None mimetype = action.data() content = self.content() self.setContent(content, mimetype) self.editingFinished.emit() menu.triggered.connect(ontriggered) menu.popup(event.screenPos()) event.accept() class ArrowItem(GraphicsPathObject): #: Arrow Style Plain, Concave = 1, 2 def __init__(self, parent=None, line=None, lineWidth=4., **kwargs): # type: (Optional[QGraphicsItem], Optional[QLineF], float, Any) -> None super().__init__(parent, **kwargs) if line is None: line = QLineF(0, 0, 10, 0) self.__line = line self.__lineWidth = lineWidth self.__arrowStyle = ArrowItem.Plain self.__updateArrowPath() def setLine(self, line): # type: (QLineF) -> None """Set the baseline of the arrow (:class:`QLineF`). """ if self.__line != line: self.__line = QLineF(line) self.__updateArrowPath() def line(self): # type: () -> QLineF """Return the baseline of the arrow. """ return QLineF(self.__line) def setLineWidth(self, lineWidth): # type: (float) -> None """Set the width of the arrow. """ if self.__lineWidth != lineWidth: self.__lineWidth = lineWidth self.__updateArrowPath() def lineWidth(self): # type: () -> float """Return the width of the arrow. """ return self.__lineWidth def setArrowStyle(self, style): # type: (int) -> None """Set the arrow style (`ArrowItem.Plain` or `ArrowItem.Concave`) """ if self.__arrowStyle != style: self.__arrowStyle = style self.__updateArrowPath() def arrowStyle(self): # type: () -> int """Return the arrow style """ return self.__arrowStyle def __updateArrowPath(self): # type: () -> None if self.__arrowStyle == ArrowItem.Plain: path = arrow_path_plain(self.__line, self.__lineWidth) else: path = arrow_path_concave(self.__line, self.__lineWidth) self.setPath(path) def arrow_path_plain(line, width): # type: (QLineF, float) -> QPainterPath """ Return an :class:`QPainterPath` of a plain looking arrow. """ path = QPainterPath() p1, p2 = line.p1(), line.p2() if p1 == p2: return path baseline = QLineF(line) # Require some minimum length. baseline.setLength(max(line.length() - width * 3, width * 3)) path.moveTo(baseline.p1()) path.lineTo(baseline.p2()) stroker = QPainterPathStroker() stroker.setWidth(width) path = stroker.createStroke(path) arrow_head_len = width * 4 arrow_head_angle = 50 line_angle = line.angle() - 180 angle_1 = line_angle - arrow_head_angle / 2.0 angle_2 = line_angle + arrow_head_angle / 2.0 points = [p2, p2 + QLineF.fromPolar(arrow_head_len, angle_1).p2(), p2 + QLineF.fromPolar(arrow_head_len, angle_2).p2(), p2] poly = QPolygonF(points) path_head = QPainterPath() path_head.addPolygon(poly) path = path.united(path_head) return path def arrow_path_concave(line, width): # type: (QLineF, float) -> QPainterPath """ Return a :class:`QPainterPath` of a pretty looking arrow. """ path = QPainterPath() p1, p2 = line.p1(), line.p2() if p1 == p2: return path baseline = QLineF(line) # Require some minimum length. baseline.setLength(max(line.length() - width * 3, width * 3)) start, end = baseline.p1(), baseline.p2() mid = (start + end) / 2.0 normal = QLineF.fromPolar(1.0, baseline.angle() + 90).p2() path.moveTo(start) path.lineTo(start + (normal * width / 4.0)) path.quadTo(mid + (normal * width / 4.0), end + (normal * width / 1.5)) path.lineTo(end - (normal * width / 1.5)) path.quadTo(mid - (normal * width / 4.0), start - (normal * width / 4.0)) path.closeSubpath() arrow_head_len = width * 4 arrow_head_angle = 50 line_angle = line.angle() - 180 angle_1 = line_angle - arrow_head_angle / 2.0 angle_2 = line_angle + arrow_head_angle / 2.0 points = [p2, p2 + QLineF.fromPolar(arrow_head_len, angle_1).p2(), baseline.p2(), p2 + QLineF.fromPolar(arrow_head_len, angle_2).p2(), p2] poly = QPolygonF(points) path_head = QPainterPath() path_head.addPolygon(poly) path = path.united(path_head) return path class ArrowAnnotation(Annotation): def __init__(self, parent=None, line=None, **kwargs): # type: (Optional[QGraphicsItem], Optional[QLineF], Any) -> None super().__init__(parent, **kwargs) self.setFlag(QGraphicsItem.ItemIsMovable) self.setFlag(QGraphicsItem.ItemIsSelectable) self.setFocusPolicy(Qt.ClickFocus) if line is None: line = QLineF(0, 0, 20, 0) self.__line = QLineF(line) self.__color = QColor(Qt.red) # An item with the same shape as this arrow, stacked behind this # item as a source for QGraphicsDropShadowEffect. Cannot attach # the effect to this item directly as QGraphicsEffect makes the item # non devicePixelRatio aware. self.__arrowShadowBase = ArrowItem(self, line=line) self.__arrowShadowBase.setPen(Qt.NoPen) # no pen -> slightly thinner self.__arrowShadowBase.setBrush(QBrush(self.__color)) self.__arrowShadowBase.setArrowStyle(ArrowItem.Concave) self.__arrowShadowBase.setLineWidth(5) self.__shadow = QGraphicsDropShadowEffect( blurRadius=5, offset=QPointF(1.0, 2.0), ) self.__arrowShadowBase.setGraphicsEffect(self.__shadow) self.__shadow.setEnabled(True) # The 'real' shape item self.__arrowItem = ArrowItem(self, line=line) self.__arrowItem.setBrush(self.__color) self.__arrowItem.setPen(QPen(self.__color)) self.__arrowItem.setArrowStyle(ArrowItem.Concave) self.__arrowItem.setLineWidth(5) self.__autoAdjustGeometry = True def setAutoAdjustGeometry(self, autoAdjust): # type: (bool) -> None """ If set to `True` then the geometry will be adjusted whenever the arrow is changed with `setLine`. Otherwise the geometry of the item is only updated so the `line` lies within the `geometry()` rect (i.e. it only grows). True by default """ self.__autoAdjustGeometry = autoAdjust if autoAdjust: self.adjustGeometry() def autoAdjustGeometry(self): # type: () -> bool """ Should the geometry of the item be adjusted automatically when `setLine` is called. """ return self.__autoAdjustGeometry def setLine(self, line): # type: (QLineF) -> None """ Set the arrow base line (a `QLineF` in object coordinates). """ if self.__line != line: self.__line = QLineF(line) # local item coordinate system geom = self.geometry().translated(-self.pos()) if geom.isNull() and not line.isNull(): geom = QRectF(0, 0, 1, 1) arrow_shape = arrow_path_concave(line, self.lineWidth()) arrow_rect = arrow_shape.boundingRect() if not (geom.contains(arrow_rect)): geom = geom.united(arrow_rect) if self.__autoAdjustGeometry: # Shrink the geometry if required. geom = geom.intersected(arrow_rect) # topLeft can move changing the local coordinates. diff = geom.topLeft() line = QLineF(line.p1() - diff, line.p2() - diff) self.__arrowItem.setLine(line) self.__arrowShadowBase.setLine(line) self.__line = line # parent item coordinate system geom.translate(self.pos()) self.setGeometry(geom) def line(self): # type: () -> QLineF """ Return the arrow base line (`QLineF` in object coordinates). """ return QLineF(self.__line) def setColor(self, color): # type: (QColor) -> None """ Set arrow brush color. """ if self.__color != color: self.__color = QColor(color) self.__updateStyleState() def color(self): # type: () -> QColor """ Return the arrow brush color. """ return QColor(self.__color) def setLineWidth(self, lineWidth): # type: (float) -> None """ Set the arrow line width. """ self.__arrowItem.setLineWidth(lineWidth) self.__arrowShadowBase.setLineWidth(lineWidth) def lineWidth(self): # type: () -> float """ Return the arrow line width. """ return self.__arrowItem.lineWidth() def adjustGeometry(self): # type: () -> None """ Adjust the widget geometry to exactly fit the arrow inside while preserving the arrow path scene geometry. """ # local system coordinate geom = self.geometry().translated(-self.pos()) line = self.__line arrow_rect = self.__arrowItem.shape().boundingRect() if geom.isNull() and not line.isNull(): geom = QRectF(0, 0, 1, 1) if not (geom.contains(arrow_rect)): geom = geom.united(arrow_rect) geom = geom.intersected(arrow_rect) diff = geom.topLeft() line = QLineF(line.p1() - diff, line.p2() - diff) geom.translate(self.pos()) self.setGeometry(geom) self.setLine(line) def shape(self): # type: () -> QPainterPath arrow_shape = self.__arrowItem.shape() return self.mapFromItem(self.__arrowItem, arrow_shape) def itemChange(self, change, value): # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any if change == QGraphicsItem.ItemSelectedHasChanged: self.__updateStyleState() return super().itemChange(change, value) def __updateStyleState(self): # type: () -> None """ Update the arrows' brush, pen, ... based on it's state """ if self.isSelected(): color = self.__color.darker(150) pen = QPen(QColor(96, 158, 215), 1.25, Qt.DashDotLine) pen.setWidthF(1.25) pen.setCosmetic(True) shadow = pen.color().darker(150) else: color = self.__color pen = QPen(color) shadow = QColor(63, 63, 63, 180) self.__arrowShadowBase.setBrush(color) self.__shadow.setColor(shadow) self.__arrowItem.setBrush(color) self.__arrowItem.setPen(pen) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/controlpoints.py0000644000175100002000000004000614730024325025256 0ustar00runnerdockerimport enum import typing from typing import Optional, Any, Union, Tuple from AnyQt.QtWidgets import QGraphicsItem, QGraphicsObject from AnyQt.QtGui import QBrush, QPainterPath from AnyQt.QtCore import Qt, QPointF, QLineF, QRectF, QMargins, QEvent from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from .graphicspathobject import GraphicsPathObject from .utils import toGraphicsObjectIfPossible if typing.TYPE_CHECKING: ConstraintFunc = typing.Callable[[QPointF], QPointF] class ControlPoint(GraphicsPathObject): """A control point for annotations in the canvas. """ class Anchor(enum.IntEnum): Free = 0 Left, Top, Right, Bottom, Center = 1, 2, 4, 8, 16 TopLeft = Top | Left TopRight = Top | Right BottomRight = Bottom | Right BottomLeft = Bottom | Left Free = Anchor.Free Left = Anchor.Left Right = Anchor.Right Top = Anchor.Top Bottom = Anchor.Bottom TopLeft = Anchor.TopLeft TopRight = Anchor.TopRight BottomRight = Anchor.BottomRight BottomLeft = Anchor.BottomLeft def __init__( self, parent: Optional[QGraphicsItem] = None, anchor=Free, constraint=Qt.Orientation(0), cursor=Qt.ArrowCursor, **kwargs ) -> None: super().__init__(parent, **kwargs) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, False) self.setAcceptedMouseButtons(Qt.LeftButton) self.__constraint = constraint # type: Qt.Orientation self.__constraintFunc = None # type: Optional[ConstraintFunc] self.__anchor = ControlPoint.Free self.__initialPosition = None # type: Optional[QPointF] self.setAnchor(anchor) self.setCursor(cursor) path = QPainterPath() path.addEllipse(QRectF(-4, -4, 8, 8)) self.setPath(path) self.setBrush(QBrush(Qt.lightGray, Qt.SolidPattern)) def setAnchor(self, anchor): # type: (Anchor) -> None """Set anchor position """ self.__anchor = anchor def anchor(self): # type: () -> Anchor return self.__anchor def mousePressEvent(self, event): if event.button() == Qt.LeftButton: # Enable ItemPositionChange (and pos constraint) only when # this is the mouse grabber item self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) event.accept() else: super().mousePressEvent(event) def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton: self.__initialPosition = None self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, False) event.accept() else: super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton: if self.__initialPosition is None: self.__initialPosition = self.pos() current = self.mapToParent(self.mapFromScene(event.scenePos())) down = self.mapToParent( self.mapFromScene(event.buttonDownScenePos(Qt.LeftButton))) self.setPos(self.__initialPosition + current - down) event.accept() else: super().mouseMoveEvent(event) def itemChange(self, change, value): if change == QGraphicsItem.ItemPositionChange: return self.constrain(value) return super().itemChange(change, value) def hasConstraint(self): # type: () -> bool return self.__constraintFunc is not None or self.__constraint != 0 def setConstraint(self, constraint): # type: (Qt.Orientation) -> None """Set the constraint for the point (Qt.Vertical Qt.Horizontal or 0) .. note:: Clears the constraintFunc if it was previously set """ if self.__constraint != constraint: self.__constraint = constraint self.__constraintFunc = None def constrain(self, pos): # type: (QPointF) -> QPointF """Constrain the pos. """ if self.__constraintFunc: return self.__constraintFunc(pos) elif self.__constraint == Qt.Vertical: return QPointF(self.pos().x(), pos.y()) elif self.__constraint == Qt.Horizontal: return QPointF(pos.x(), self.pos().y()) else: return QPointF(pos) def setConstraintFunc(self, func): # type: (Optional[ConstraintFunc]) -> None if self.__constraintFunc != func: self.__constraintFunc = func class ControlPointRect(QGraphicsObject): class Constraint(enum.IntEnum): Free = 0 KeepAspectRatio = 1 KeepCenter = 2 Free = Constraint.Free KeepAspectRatio = Constraint.KeepAspectRatio KeepCenter = Constraint.KeepCenter rectChanged = Signal(QRectF) rectEdited = Signal(QRectF) def __init__(self, parent=None, rect=QRectF(), constraints=Free, **kwargs): # type: (Optional[QGraphicsItem], QRectF, Constraint, Any) -> None super().__init__(parent, **kwargs) self.setFlag(QGraphicsItem.ItemHasNoContents) self.setFlag(QGraphicsItem.ItemIsFocusable) self.__rect = QRectF(rect) if rect is not None else QRectF() self.__margins = QMargins() points = [ ControlPoint(self, ControlPoint.Left, constraint=Qt.Horizontal, cursor=Qt.SizeHorCursor), ControlPoint(self, ControlPoint.Top, constraint=Qt.Vertical, cursor=Qt.SizeVerCursor), ControlPoint(self, ControlPoint.TopLeft, cursor=Qt.SizeFDiagCursor), ControlPoint(self, ControlPoint.Right, constraint=Qt.Horizontal, cursor=Qt.SizeHorCursor), ControlPoint(self, ControlPoint.TopRight, cursor=Qt.SizeBDiagCursor), ControlPoint(self, ControlPoint.Bottom, constraint=Qt.Vertical, cursor=Qt.SizeVerCursor), ControlPoint(self, ControlPoint.BottomLeft, cursor=Qt.SizeBDiagCursor), ControlPoint(self, ControlPoint.BottomRight, cursor=Qt.SizeFDiagCursor) ] assert(points == sorted(points, key=lambda p: p.anchor())) self.__points = dict((p.anchor(), p) for p in points) if self.scene(): self.__installFilter() for p in points: p.setFlag(QGraphicsItem.ItemIsFocusable) p.setFocusProxy(self) self.__constraints = constraints self.__activeControl = None # type: Optional[ControlPoint] self.__pointsLayout() def controlPoint(self, anchor): # type: (ControlPoint.Anchor) -> ControlPoint """ Return the anchor point (:class:`ControlPoint`) for anchor position. """ return self.__points[anchor] def setRect(self, rect): # type: (QRectF) -> None """ Set the control point rectangle (:class:`QRectF`) """ if self.__rect != rect: self.__rect = QRectF(rect) self.__pointsLayout() self.prepareGeometryChange() self.rectChanged.emit(rect.normalized()) def rect(self): # type: () -> QRectF """ Return the control point rectangle. """ # Return the rect normalized. During the control point move the # rect can change to an invalid size, but the layout must still # know to which point does an unnormalized rect side belong, # so __rect is left unnormalized. # NOTE: This means all signal emits (rectChanged/Edited) must # also emit normalized rects return self.__rect.normalized() rect_ = Property(QRectF, fget=rect, fset=setRect, user=True) def setControlMargins(self, *margins): # type: (int) -> None """Set the controls points on the margins around `rect` """ if len(margins) > 1: margins = QMargins(*margins) elif len(margins) == 1: margin = margins[0] margins = QMargins(margin, margin, margin, margin) else: raise TypeError if self.__margins != margins: self.__margins = margins self.__pointsLayout() def controlMargins(self): # type: () -> QMargins return QMargins(self.__margins) def setConstraints(self, constraints): raise NotImplementedError def isControlActive(self): # type: () -> bool """Return the state of the control. True if the control is active (user is dragging one of the points) False otherwise. """ return self.__activeControl is not None def itemChange(self, change, value): # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any if change == QGraphicsItem.ItemSceneHasChanged and self.scene(): self.__installFilter() return super().itemChange(change, value) def sceneEventFilter(self, obj, event): # type: (QGraphicsItem, QEvent) -> bool obj = toGraphicsObjectIfPossible(obj) if isinstance(obj, ControlPoint): etype = event.type() if etype in (QEvent.GraphicsSceneMousePress, QEvent.GraphicsSceneMouseDoubleClick) and \ event.button() == Qt.LeftButton: self.__setActiveControl(obj) elif etype == QEvent.GraphicsSceneMouseRelease and \ event.button() == Qt.LeftButton: self.__setActiveControl(None) return super().sceneEventFilter(obj, event) def __installFilter(self): # type: () -> None # Install filters on the control points. for p in self.__points.values(): p.installSceneEventFilter(self) def __pointsLayout(self): # type: () -> None """Layout the control points """ rect = self.__rect margins = self.__margins rect = rect.adjusted(-margins.left(), -margins.top(), margins.right(), margins.bottom()) center = rect.center() cx, cy = center.x(), center.y() left, top, right, bottom = \ rect.left(), rect.top(), rect.right(), rect.bottom() self.controlPoint(ControlPoint.Left).setPos(left, cy) self.controlPoint(ControlPoint.Right).setPos(right, cy) self.controlPoint(ControlPoint.Top).setPos(cx, top) self.controlPoint(ControlPoint.Bottom).setPos(cx, bottom) self.controlPoint(ControlPoint.TopLeft).setPos(left, top) self.controlPoint(ControlPoint.TopRight).setPos(right, top) self.controlPoint(ControlPoint.BottomLeft).setPos(left, bottom) self.controlPoint(ControlPoint.BottomRight).setPos(right, bottom) def __setActiveControl(self, control): # type: (Optional[ControlPoint]) -> None if self.__activeControl != control: if self.__activeControl is not None: self.__activeControl.positionChanged[QPointF].disconnect( self.__activeControlMoved ) self.__activeControl = control if control is not None: control.positionChanged[QPointF].connect( self.__activeControlMoved ) def __activeControlMoved(self, pos): # type: (QPointF) -> None # The active control point has moved, update the control # rectangle control = self.__activeControl assert control is not None pos = control.pos() rect = QRectF(self.__rect) margins = self.__margins # TODO: keyboard modifiers and constraints. anchor = control.anchor() if anchor & ControlPoint.Top: rect.setTop(pos.y() + margins.top()) elif anchor & ControlPoint.Bottom: rect.setBottom(pos.y() - margins.bottom()) if anchor & ControlPoint.Left: rect.setLeft(pos.x() + margins.left()) elif anchor & ControlPoint.Right: rect.setRight(pos.x() - margins.right()) changed = self.__rect != rect self.blockSignals(True) self.setRect(rect) self.blockSignals(False) if changed: self.rectEdited.emit(rect.normalized()) def boundingRect(self): # type: () -> QRectF return QRectF() class ControlPointLine(QGraphicsObject): lineChanged = Signal(QLineF) lineEdited = Signal(QLineF) def __init__(self, parent=None, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None super().__init__(parent, **kwargs) self.setFlag(QGraphicsItem.ItemHasNoContents) self.setFlag(QGraphicsItem.ItemIsFocusable) self.__line = QLineF() self.__points = [ ControlPoint(self, ControlPoint.TopLeft, cursor=Qt.DragMoveCursor), # TopLeft is line start ControlPoint(self, ControlPoint.BottomRight, cursor=Qt.DragMoveCursor) # line end ] self.__activeControl = None # type: Optional[ControlPoint] if self.scene(): self.__installFilter() for p in self.__points: p.setFlag(QGraphicsItem.ItemIsFocusable) p.setFocusProxy(self) def setLine(self, line): # type: (QLineF) -> None if not isinstance(line, QLineF): raise TypeError() if line != self.__line: self.__line = QLineF(line) self.__pointsLayout() self.lineChanged.emit(line) def line(self): # type: () -> QLineF return QLineF(self.__line) def isControlActive(self): # type: () -> bool """Return the state of the control. True if the control is active (user is dragging one of the points) False otherwise. """ return self.__activeControl is not None def __installFilter(self): # type: () -> None for p in self.__points: p.installSceneEventFilter(self) def itemChange(self, change, value): # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any if change == QGraphicsItem.ItemSceneHasChanged: if self.scene(): self.__installFilter() return super().itemChange(change, value) def sceneEventFilter(self, obj, event): # type: (QGraphicsItem, QEvent) -> bool obj = toGraphicsObjectIfPossible(obj) if isinstance(obj, ControlPoint): etype = event.type() if etype in (QEvent.GraphicsSceneMousePress, QEvent.GraphicsSceneMouseDoubleClick): self.__setActiveControl(obj) elif etype == QEvent.GraphicsSceneMouseRelease: self.__setActiveControl(None) return super().sceneEventFilter(obj, event) def __pointsLayout(self): # type: () -> None self.__points[0].setPos(self.__line.p1()) self.__points[1].setPos(self.__line.p2()) def __setActiveControl(self, control): # type: (Optional[ControlPoint]) -> None if self.__activeControl != control: if self.__activeControl is not None: self.__activeControl.positionChanged[QPointF].disconnect( self.__activeControlMoved ) self.__activeControl = control if control is not None: control.positionChanged[QPointF].connect( self.__activeControlMoved ) def __activeControlMoved(self, pos): # type: (QPointF) -> None line = QLineF(self.__line) control = self.__activeControl assert control is not None if control.anchor() == ControlPoint.TopLeft: line.setP1(pos) elif control.anchor() == ControlPoint.BottomRight: line.setP2(pos) if self.__line != line: self.blockSignals(True) self.setLine(line) self.blockSignals(False) self.lineEdited.emit(line) def boundingRect(self): # type: () -> QRectF return QRectF() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/graphicspathobject.py0000644000175100002000000000774114730024325026216 0ustar00runnerdockerfrom typing import Any, Optional, Union from AnyQt.QtWidgets import ( QGraphicsItem, QGraphicsObject, QStyleOptionGraphicsItem, QWidget ) from AnyQt.QtGui import ( QPainterPath, QPainterPathStroker, QBrush, QPen, QPainter, QColor ) from AnyQt.QtCore import Qt, QPointF, QRectF from AnyQt.QtCore import pyqtSignal as Signal class GraphicsPathObject(QGraphicsObject): """A QGraphicsObject subclass implementing an interface similar to QGraphicsPathItem, and also adding a positionChanged() signal """ positionChanged = Signal([], ["QPointF"]) def __init__(self, parent=None, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None super().__init__(parent, **kwargs) self.setFlag(QGraphicsObject.ItemSendsGeometryChanges) self.__path = QPainterPath() self.__brush = QBrush(Qt.NoBrush) self.__pen = QPen() self.__boundingRect = None # type: Optional[QRectF] def setPath(self, path): # type: (QPainterPath) -> None """Set the items `path` (:class:`QPainterPath`). """ if self.__path != path: self.prepareGeometryChange() # Need to store a copy of object so the shape can't be mutated # without properly updating the geometry. self.__path = QPainterPath(path) self.__boundingRect = None self.update() def path(self): # type: () -> QPainterPath """Return the items path. """ return QPainterPath(self.__path) def setBrush(self, brush): # type: (Union[QBrush, QColor, Qt.GlobalColor, Qt.BrushStyle]) -> None """Set the items `brush` (:class:`QBrush`) """ if not isinstance(brush, QBrush): brush = QBrush(brush) if self.__brush != brush: self.__brush = QBrush(brush) self.update() def brush(self): # type: () -> QBrush """Return the items brush. """ return QBrush(self.__brush) def setPen(self, pen): # type: (Union[QPen, QBrush, Qt.PenStyle]) -> None """Set the items outline `pen` (:class:`QPen`). """ if not isinstance(pen, QPen): pen = QPen(pen) if self.__pen != pen: self.prepareGeometryChange() self.__pen = QPen(pen) self.__boundingRect = None self.update() def pen(self): # type: () -> QPen """Return the items pen. """ return QPen(self.__pen) def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None if self.__path.isEmpty(): return painter.save() painter.setPen(self.__pen) painter.setBrush(self.__brush) painter.drawPath(self.__path) painter.restore() def boundingRect(self): # type: () -> QRectF if self.__boundingRect is None: br = self.__path.controlPointRect() pen_w = self.__pen.widthF() self.__boundingRect = br.adjusted(-pen_w, -pen_w, pen_w, pen_w) return QRectF(self.__boundingRect) def shape(self): # type: () -> QPainterPath return shapeFromPath(self.__path, self.__pen) def itemChange(self, change, value): # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any if change == QGraphicsObject.ItemPositionHasChanged: self.positionChanged.emit() self.positionChanged[QPointF].emit(value) return super().itemChange(change, value) def shapeFromPath(path, pen): # type: (QPainterPath, QPen) -> QPainterPath """Create a QPainterPath shape from the `path` drawn with `pen`. """ stroker = QPainterPathStroker() stroker.setCapStyle(pen.capStyle()) stroker.setJoinStyle(pen.joinStyle()) stroker.setMiterLimit(pen.miterLimit()) stroker.setWidth(max(pen.widthF(), 1e-9)) shape = stroker.createStroke(path) shape.addPath(path) return shape ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/graphicstextitem.py0000644000175100002000000004567514730024325025746 0ustar00runnerdockerimport enum import sys from typing import Optional, Iterable, Union, Callable, Any from AnyQt.QtCore import ( Qt, QEvent, Signal, QSize, QRect, QPointF, QMimeData, QT_VERSION_INFO ) from AnyQt.QtGui import ( QTextDocument, QTextBlock, QTextLine, QPalette, QPainter, QPen, QPainterPath, QFocusEvent, QKeyEvent, QTextBlockFormat, QTextCursor, QImage, QKeySequence, QIcon, QTextDocumentFragment ) from AnyQt.QtWidgets import ( QGraphicsTextItem, QStyleOptionGraphicsItem, QStyle, QWidget, QApplication, QGraphicsSceneHoverEvent, QGraphicsSceneMouseEvent, QStyleOptionButton, QGraphicsItem, QGraphicsSceneContextMenuEvent, QMenu, QAction, ) from orangecanvas.utils import set_flag class GraphicsTextItem(QGraphicsTextItem): """ A graphics text item displaying the text highlighted when selected. """ def __init__(self, *args, **kwargs): self.__selected = False self.__palette = QPalette() self.__content = "" #: The cached text background shape when this item is selected self.__cachedBackgroundPath = None # type: Optional[QPainterPath] self.__styleState = QStyle.State(0) super().__init__(*args, **kwargs) layout = self.document().documentLayout() layout.update.connect(self.__onLayoutChanged) def __onLayoutChanged(self): self.__cachedBackgroundPath = None self.update() def setStyleState(self, flags): if self.__styleState != flags: self.__styleState = flags self.__updateDefaultTextColor() self.update() def styleState(self): return self.__styleState def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None state = option.state | self.__styleState if state & (QStyle.State_Selected | QStyle.State_HasFocus) \ and not state & QStyle.State_Editing: path = self.__textBackgroundPath() palette = self.palette() if state & QStyle.State_Enabled: cg = QPalette.Active else: cg = QPalette.Inactive if widget is not None: window = widget.window() if not window.isActiveWindow(): cg = QPalette.Inactive color = palette.color( cg, QPalette.Highlight if state & QStyle.State_Selected else QPalette.Light ) painter.save() painter.setPen(QPen(Qt.NoPen)) painter.setBrush(color) painter.drawPath(path) painter.restore() super().paint(painter, option, widget) def __textBackgroundPath(self) -> QPainterPath: # return a path outlining all the text lines. if self.__cachedBackgroundPath is None: self.__cachedBackgroundPath = text_outline_path(self.document()) return self.__cachedBackgroundPath def setSelectionState(self, state): # type: (bool) -> None state = set_flag(self.__styleState, QStyle.State_Selected, state) if self.__styleState != state: self.__styleState = state self.__updateDefaultTextColor() self.update() def setPalette(self, palette): # type: (QPalette) -> None if self.__palette != palette: self.__palette = QPalette(palette) QApplication.sendEvent(self, QEvent(QEvent.PaletteChange)) def palette(self): # type: () -> QPalette palette = QPalette(self.__palette) parent = self.parentWidget() scene = self.scene() if parent is not None: return parent.palette().resolve(palette) elif scene is not None: return scene.palette().resolve(palette) else: return palette def __updateDefaultTextColor(self): # type: () -> None if self.__styleState & QStyle.State_Selected \ and not self.__styleState & QStyle.State_Editing: role = QPalette.HighlightedText else: role = QPalette.WindowText self.setDefaultTextColor(self.palette().color(role)) def setHtml(self, contents): # type: (str) -> None if contents != self.__content: self.__content = contents self.__cachedBackgroundPath = None super().setHtml(contents) def event(self, event) -> bool: if event.type() == QEvent.PaletteChange: self.__updateDefaultTextColor() self.update() return super().event(event) if (5, 15, 1) <= QT_VERSION_INFO <= (6, 0, 0): # QTBUG-88309 def contextMenuEvent(self, event: QGraphicsSceneContextMenuEvent) -> None: QGraphicsTextItem_contextMenuEvent(self, event) def QGraphicsTextItem_contextMenuEvent( self: QGraphicsTextItem, event: QGraphicsSceneContextMenuEvent ) -> None: menu = createStandardContextMenu(self, event.pos(), event.widget()) if menu is not None: menu.popup(event.screenPos()) def createStandardContextMenu( item: QGraphicsTextItem, pos: QPointF, parent: Optional[QWidget] = None, acceptRichText=False ) -> Optional[QMenu]: """ Like the private QWidgetTextControl::createStandardContextMenu """ def setActionIcon(action: QAction, name: str): icon = QIcon.fromTheme(name) if not icon.isNull(): action.setIcon(icon) def createMimeDataFromSelection(fragment: QTextDocumentFragment) -> QMimeData: mime = QMimeData() mime.setText(fragment.toPlainText()) mime.setHtml(fragment.toHtml(b"utf-8")) # missing here is odf return mime def copy(): cursor = item.textCursor() if cursor.hasSelection(): mime = createMimeDataFromSelection(QTextDocumentFragment(cursor)) QApplication.clipboard().setMimeData(mime) def cut(): copy() item.textCursor().removeSelectedText() def copyLinkLocation(): mime = QMimeData() mime.setText(link) QApplication.clipboard().setMimeData(mime) def canPaste(): mime = QApplication.clipboard().mimeData() return mime.hasFormat("text/plain") or mime.hasFormat("text/html") def paste(): mime = QApplication.clipboard().mimeData() if mime is not None: insertFromMimeData(mime) def insertFromMimeData(mime: QMimeData): fragment: Optional[QTextDocumentFragment] = None if mime.hasHtml() and acceptRichText: fragment = QTextDocumentFragment.fromHtml(mime.html()) elif mime.hasText(): fragment = QTextDocumentFragment.fromPlainText(mime.text()) if fragment is not None: item.textCursor().insertFragment(fragment) def deleteSelected(): cursor = item.textCursor() cursor.removeSelectedText() def selectAll(): cursor = item.textCursor() cursor.select(QTextCursor.Document) item.setTextCursor(cursor) def addAction( menu: QMenu, text: str, slot: Callable[[], Any], shortcut: Optional[QKeySequence.StandardKey] = None, enabled=True, objectName="", icon="" ) -> QAction: ac = menu.addAction(text) ac.triggered.connect(slot) ac.setEnabled(enabled) if shortcut: ac.setShortcut(shortcut) if objectName: ac.setObjectName(objectName) if icon: setActionIcon(ac, icon) return ac flags = item.textInteractionFlags() showTextSelectionActions = flags & ( Qt.TextEditable | Qt.TextSelectableByKeyboard | Qt.TextSelectableByMouse ) doc = item.document() cursor = item.textCursor() assert doc is not None layout = doc.documentLayout() link = layout.anchorAt(pos) if not link and not showTextSelectionActions: return None menu = QMenu(parent) menu.setAttribute(Qt.WA_DeleteOnClose) if flags & Qt.TextEditable: addAction( menu, "&Undo", doc.undo, shortcut=QKeySequence.Undo, enabled=doc.isUndoAvailable(), objectName="edit-undo", icon="edit-undo", ) addAction( menu, "&Redo", doc.redo, shortcut=QKeySequence.Redo, enabled=doc.isRedoAvailable(), objectName="edit-redo", icon="edit-redo", ) menu.addSeparator() addAction( menu, "Cu&t", cut, shortcut=QKeySequence.Cut, enabled=cursor.hasSelection(), objectName="edit-cut", icon="edit-cut", ) if showTextSelectionActions: addAction( menu, "&Copy", copy, shortcut=QKeySequence.Copy, enabled=cursor.hasSelection(), objectName="edit-copy", icon="edit-copy" ) if flags & (Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard): addAction( menu, "Copy &Link Location", copyLinkLocation, enabled=bool(link), objectName="link-copy", ) if flags & Qt.TextEditable: addAction( menu, "&Paste", paste, shortcut=QKeySequence.Paste, enabled=canPaste(), objectName="edit-paste", icon="edit-paste", ) addAction( menu, "Delete", deleteSelected, enabled=cursor.hasSelection(), objectName="edit-delete", icon="edit-delete", ) if showTextSelectionActions: addAction( menu, "Select All", selectAll, shortcut=QKeySequence.SelectAll, enabled=not doc.isEmpty(), objectName="select-all", ) return menu def iter_blocks(doc): # type: (QTextDocument) -> Iterable[QTextBlock] block = doc.begin() while block != doc.end(): yield block block = block.next() def iter_lines(doc): # type: (QTextDocument) -> Iterable[QTextLine] for block in iter_blocks(doc): blocklayout = block.layout() for i in range(blocklayout.lineCount()): yield blocklayout.lineAt(i) def text_outline_path(doc: QTextDocument) -> QPainterPath: # return a path outlining all the text lines. margin = doc.documentMargin() path = QPainterPath() offset = min(margin, 2) for line in iter_lines(doc): rect = line.naturalTextRect() rect.translate(margin, margin) rect = rect.adjusted(-offset, -offset, offset, offset) p = QPainterPath() p.addRoundedRect(rect, 3, 3) path = path.united(p) return path class EditTriggers(enum.IntEnum): NoEditTriggers = 0 CurrentChanged = 1 DoubleClicked = 2 SelectedClicked = 4 EditKeyPressed = 8 AnyKeyPressed = 16 class GraphicsTextEdit(GraphicsTextItem): EditTriggers = EditTriggers NoEditTriggers = EditTriggers.NoEditTriggers CurrentChanged = EditTriggers.CurrentChanged DoubleClicked = EditTriggers.DoubleClicked SelectedClicked = EditTriggers.SelectedClicked EditKeyPressed = EditTriggers.EditKeyPressed AnyKeyPressed = EditTriggers.AnyKeyPressed #: Signal emitted when editing operation starts (the item receives edit #: focus) editingStarted = Signal() #: Signal emitted when editing operation ends (the item loses edit focus) editingFinished = Signal() documentSizeChanged = Signal() def __init__(self, *args, **kwargs): self.__editTriggers = kwargs.pop( "editTriggers", GraphicsTextEdit.DoubleClicked ) alignment = kwargs.pop("alignment", None) self.__returnKeyEndsEditing = kwargs.pop("returnKeyEndsEditing", False) super().__init__(*args, **kwargs) self.__editing = False self.__textInteractionFlags = self.textInteractionFlags() if sys.platform == "darwin": self.__editKeys = (Qt.Key_Enter, Qt.Key_Return) else: self.__editKeys = (Qt.Key_F2,) self.document().documentLayout().documentSizeChanged.connect( self.documentSizeChanged ) if alignment is not None: self.setAlignment(alignment) def setAlignment(self, alignment: Qt.AlignmentFlag) -> None: """Set alignment for the current text block.""" block = QTextBlockFormat() block.setAlignment(alignment) cursor = self.textCursor() cursor.mergeBlockFormat(block) self.setTextCursor(cursor) def alignment(self) -> Qt.AlignmentFlag: return self.textCursor().blockFormat().alignment() def selectAll(self) -> None: """Select all text.""" cursor = self.textCursor() cursor.select(QTextCursor.Document) self.setTextCursor(cursor) def clearSelection(self) -> None: """Clear current selection.""" cursor = self.textCursor() cursor.clearSelection() self.setTextCursor(cursor) def hoverMoveEvent(self, event: QGraphicsSceneHoverEvent) -> None: layout = self.document().documentLayout() if layout.anchorAt(event.pos()): self.setCursor(Qt.PointingHandCursor) else: self.unsetCursor() super().hoverMoveEvent(event) def mousePressEvent(self, event: QGraphicsSceneMouseEvent) -> None: flags = self.textInteractionFlags() if flags & Qt.LinksAccessibleByMouse \ and not flags & Qt.TextSelectableByMouse \ and self.document().documentLayout().anchorAt(event.pos()): # QGraphicsTextItem ignores the press event without # Qt.TextSelectableByMouse flag set. This causes the # corresponding mouse release to never get to this item # and therefore no linkActivated/openUrl ... super().mousePressEvent(event) if not event.isAccepted(): event.accept() else: super().mousePressEvent(event) def keyPressEvent(self, event: QKeyEvent) -> None: editing = self.__editing if self.__editTriggers & EditTriggers.EditKeyPressed \ and not editing: if event.key() in self.__editKeys: self.__startEdit(Qt.ShortcutFocusReason) event.accept() return elif self.__editTriggers & EditTriggers.AnyKeyPressed \ and not editing: self.__startEdit(Qt.OtherFocusReason) event.accept() return if editing and self.__returnKeyEndsEditing \ and event.key() in (Qt.Key_Enter, Qt.Key_Return): self.__endEdit() event.accept() return super().keyPressEvent(event) def setTextInteractionFlags( self, flags: Union['Qt.TextInteractionFlag', 'Qt.TextInteractionFlags'] ) -> None: super().setTextInteractionFlags(flags) if self.hasFocus() and flags & Qt.TextEditable and not self.__editing: self.__startEdit() def isEditing(self) -> bool: """Is editing currently active.""" return self.__editing def edit(self) -> None: """Start editing""" if not self.__editing: self.__startEdit(Qt.OtherFocusReason) def mouseDoubleClickEvent(self, event: QGraphicsSceneMouseEvent) -> None: super().mouseDoubleClickEvent(event) if self.__editTriggers & GraphicsTextEdit.DoubleClicked: self.__startEdit(Qt.MouseFocusReason) def focusInEvent(self, event: QFocusEvent) -> None: super().focusInEvent(event) if self.textInteractionFlags() & Qt.TextEditable \ and not self.__editing \ and self.__editTriggers & EditTriggers.CurrentChanged: self.__startEdit(event.reason()) def focusOutEvent(self, event: QFocusEvent) -> None: super().focusOutEvent(event) if self.__editing and event.reason() not in { Qt.ActiveWindowFocusReason, Qt.PopupFocusReason }: self.__endEdit() def paint(self, painter, option, widget=None): if self.__editing: option.state |= QStyle.State_Editing # Disable base QGraphicsItem selected/focused outline state = option.state option = QStyleOptionGraphicsItem(option) option.palette = self.palette().resolve(option.palette) option.state &= ~(QStyle.State_Selected | QStyle.State_HasFocus) super().paint(painter, option, widget) if state & QStyle.State_Editing: brect = self.boundingRect() width = 3. color = qgraphicsitem_accent_color(self, option.palette) color.setAlpha(230) pen = QPen(color, width, Qt.SolidLine) painter.setPen(pen) adjust = width / 2. pen.setJoinStyle(Qt.RoundJoin) painter.drawRect( brect.adjusted(adjust, adjust, -adjust, -adjust), ) def __startEdit(self, focusReason=Qt.OtherFocusReason) -> None: if self.__editing: return self.__editing = True self.__textInteractionFlags = self.textInteractionFlags() self.setTextInteractionFlags(Qt.TextEditorInteraction) self.setStyleState(self.styleState() | QStyle.State_Editing) self.setFocus(focusReason) self.editingStarted.emit() def __endEdit(self) -> None: self.__editing = False self.clearSelection() self.setTextInteractionFlags(self.__textInteractionFlags) self.setStyleState(self.styleState() & ~QStyle.State_Editing) self.editingFinished.emit() def qgraphicsitem_style(item: QGraphicsItem) -> QStyle: if item.isWidget(): return item.style() parent = item.parentWidget() if parent is not None: return parent.style() scene = item.scene() if scene is not None: return scene.style() return QApplication.style() def qmacstyle_accent_color(style: QStyle): option = QStyleOptionButton() option.state |= (QStyle.State_Active | QStyle.State_Enabled | QStyle.State_Raised) option.features |= QStyleOptionButton.DefaultButton option.text = "" size = style.sizeFromContents( QStyle.CT_PushButton, option, QSize(20, 10), None ) option.rect = QRect(0, 0, size.width(), size.height()) img = QImage( size.width(), size.height(), QImage.Format_ARGB32_Premultiplied ) img.fill(Qt.transparent) painter = QPainter(img) try: style.drawControl(QStyle.CE_PushButton, option, painter, None) finally: painter.end() color = img.pixelColor(size.width() // 2, size.height() // 2) return color def qgraphicsitem_accent_color(item: 'QGraphicsItem', palette: QPalette): style = qgraphicsitem_style(item) mo = style.metaObject() if mo.className() == 'QMacStyle': return qmacstyle_accent_color(style) else: return palette.highlight().color() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/linkitem.py0000644000175100002000000006464514730024325024174 0ustar00runnerdocker""" ========= Link Item ========= """ import math from xml.sax.saxutils import escape import typing from typing import Optional, Any from AnyQt.QtWidgets import ( QGraphicsItem, QGraphicsPathItem, QGraphicsWidget, QGraphicsDropShadowEffect, QGraphicsSceneHoverEvent, QStyle, QGraphicsSceneMouseEvent ) from AnyQt.QtGui import ( QPen, QBrush, QColor, QPainterPath, QTransform, QPalette, QFont, ) from AnyQt.QtCore import Qt, QPointF, QRectF, QLineF, QEvent, QPropertyAnimation, Signal, QTimer from .nodeitem import AnchorPoint, SHADOW_COLOR from .graphicstextitem import GraphicsTextItem from .utils import stroke_path, qpainterpath_sub_path from ...registry import InputSignal, OutputSignal from ...scheme import SchemeLink if typing.TYPE_CHECKING: from . import NodeItem, AnchorPoint class LinkCurveItem(QGraphicsPathItem): """ Link curve item. The main component of a :class:`LinkItem`. """ def __init__(self, parent): # type: (QGraphicsItem) -> None super().__init__(parent) self.setAcceptedMouseButtons(Qt.NoButton) self.setAcceptHoverEvents(True) self.__animationEnabled = False self.__hover = False self.__enabled = True self.__selected = False self.__shape = None # type: Optional[QPainterPath] self.__curvepath = QPainterPath() self.__curvepath_disabled = None # type: Optional[QPainterPath] self.__pen = self.pen() self.setPen(QPen(QBrush(QColor("#9CACB4")), 2.0)) self.shadow = QGraphicsDropShadowEffect( blurRadius=5, color=QColor(SHADOW_COLOR), offset=QPointF(0, 0) ) self.setGraphicsEffect(self.shadow) self.shadow.setEnabled(False) self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius") self.__blurAnimation.setDuration(50) self.__blurAnimation.finished.connect(self.__on_finished) def setCurvePath(self, path): # type: (QPainterPath) -> None if path != self.__curvepath: self.prepareGeometryChange() self.__curvepath = QPainterPath(path) self.__curvepath_disabled = None self.__shape = None self.__update() def curvePath(self): # type: () -> QPainterPath return QPainterPath(self.__curvepath) def setHoverState(self, state): # type: (bool) -> None if self.__hover != state: self.prepareGeometryChange() self.__hover = state self.__update() def setSelectionState(self, state): # type: (bool) -> None if self.__selected != state: self.prepareGeometryChange() self.__selected = state self.__update() def setLinkEnabled(self, state): # type: (bool) -> None self.prepareGeometryChange() self.__enabled = state self.__update() def isLinkEnabled(self): # type: () -> bool return self.__enabled def setPen(self, pen): # type: (QPen) -> None if self.__pen != pen: self.prepareGeometryChange() self.__pen = QPen(pen) self.__shape = None super().setPen(self.__pen) def shape(self): # type: () -> QPainterPath if self.__shape is None: path = self.curvePath() pen = QPen(self.pen()) pen.setWidthF(max(pen.widthF(), 25.0)) pen.setStyle(Qt.SolidLine) self.__shape = stroke_path(path, pen) return self.__shape def setPath(self, path): # type: (QPainterPath) -> None self.__shape = None super().setPath(path) def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the link item animation enabled. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled def __update(self): # type: () -> None radius = 5 if self.__hover or self.__selected else 0 if radius != 0 and not self.shadow.isEnabled(): self.shadow.setEnabled(True) if self.__animationEnabled: if self.__blurAnimation.state() == QPropertyAnimation.Running: self.__blurAnimation.stop() self.__blurAnimation.setStartValue(self.shadow.blurRadius()) self.__blurAnimation.setEndValue(radius) self.__blurAnimation.start() else: self.shadow.setBlurRadius(radius) basecurve = self.__curvepath link_enabled = self.__enabled if link_enabled: path = basecurve else: if self.__curvepath_disabled is None: self.__curvepath_disabled = path_link_disabled(basecurve) path = self.__curvepath_disabled self.setPath(path) def __on_finished(self): if self.shadow.blurRadius() == 0: self.shadow.setEnabled(False) def path_link_disabled(basepath): # type: (QPainterPath) -> QPainterPath """ Return a QPainterPath 'styled' to indicate a 'disabled' link. A disabled link is displayed with a single disconnection symbol in the middle (--||--) Parameters ---------- basepath : QPainterPath The base path (a simple curve spine). Returns ------- path : QPainterPath A 'styled' link path """ segmentlen = basepath.length() px = 5 if segmentlen < 10: return QPainterPath(basepath) t = (px / 2) / segmentlen p1 = qpainterpath_sub_path(basepath, 0.0, 0.50 - t) p2 = qpainterpath_sub_path(basepath, 0.50 + t, 1.0) angle = -basepath.angleAtPercent(0.5) + 90 angler = math.radians(angle) normal = QPointF(math.cos(angler), math.sin(angler)) end1 = p1.currentPosition() start2 = QPointF(p2.elementAt(0).x, p2.elementAt(0).y) p1.moveTo(start2.x(), start2.y()) p1.addPath(p2) def QPainterPath_addLine(path, line): # type: (QPainterPath, QLineF) -> None path.moveTo(line.p1()) path.lineTo(line.p2()) QPainterPath_addLine(p1, QLineF(end1 - normal * 3, end1 + normal * 3)) QPainterPath_addLine(p1, QLineF(start2 - normal * 3, start2 + normal * 3)) return p1 _State = SchemeLink.State class LinkItem(QGraphicsWidget): """ A Link item in the canvas that connects two :class:`.NodeItem`\\s in the canvas. The link curve connects two `Anchor` items (see :func:`setSourceItem` and :func:`setSinkItem`). Once the anchors are set the curve automatically adjusts its end points whenever the anchors move. An optional source/sink text item can be displayed above the curve's central point (:func:`setSourceName`, :func:`setSinkName`) """ #: Signal emitted when the item has been activated (double-click) activated = Signal() #: Signal emitted the the item's selection state changes. selectedChanged = Signal(bool) #: Z value of the item Z_VALUE = 0 #: Runtime link state value #: These are pulled from SchemeLink.State for ease of binding to it's #: state State = SchemeLink.State #: The link has no associated state. NoState = SchemeLink.NoState #: Link is empty; the source node does not have any value on output Empty = SchemeLink.Empty #: Link is active; the source node has a valid value on output Active = SchemeLink.Active #: The link is pending; the sink node is scheduled for update Pending = SchemeLink.Pending #: The link's input is marked as invalidated (not yet available). Invalidated = SchemeLink.Invalidated def __init__(self, parent=None, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None self.__boundingRect = None # type: Optional[QRectF] super().__init__(parent, **kwargs) self.setAcceptedMouseButtons(Qt.RightButton | Qt.LeftButton) self.setAcceptHoverEvents(True) self.__animationEnabled = False self.setZValue(self.Z_VALUE) self.sourceItem = None # type: Optional[NodeItem] self.sourceAnchor = None # type: Optional[AnchorPoint] self.sinkItem = None # type: Optional[NodeItem] self.sinkAnchor = None # type: Optional[AnchorPoint] self.curveItem = LinkCurveItem(self) self.linkTextItem = GraphicsTextItem(self) self.linkTextItem.setAcceptedMouseButtons(Qt.NoButton) self.linkTextItem.setAcceptHoverEvents(False) self.__sourceName = "" self.__sinkName = "" self.__dynamic = False self.__dynamicEnabled = False self.__state = LinkItem.NoState self.__channelNamesVisible = True self.hover = False self.channelNameAnim = QPropertyAnimation(self.linkTextItem, b'opacity', self) self.channelNameAnim.setDuration(50) self.prepareGeometryChange() self.__updatePen() self.__updatePalette() self.__updateFont() def setSourceItem(self, item, signal=None, anchor=None): # type: (Optional[NodeItem], Optional[OutputSignal], Optional[AnchorPoint]) -> None """ Set the source `item` (:class:`.NodeItem`). Use `anchor` (:class:`.AnchorPoint`) as the curve start point (if ``None`` a new output anchor will be created using ``item.newOutputAnchor()``). Setting item to ``None`` and a valid anchor is a valid operation (for instance while mouse dragging one end of the link). """ if item is not None and anchor is not None: if anchor not in item.outputAnchors(): raise ValueError("Anchor must be belong to the item") if self.sourceItem != item: if self.sourceAnchor: # Remove a previous source item and the corresponding anchor self.sourceAnchor.scenePositionChanged.disconnect( self._sourcePosChanged ) if self.sourceItem is not None: self.sourceItem.removeOutputAnchor(self.sourceAnchor) self.sourceItem.selectedChanged.disconnect( self.__updateSelectedState) self.sourceItem = self.sourceAnchor = None self.sourceItem = item if item is not None and anchor is None: # Create a new output anchor for the item if none is provided. anchor = item.newOutputAnchor(signal) if item is not None: item.selectedChanged.connect(self.__updateSelectedState) if anchor != self.sourceAnchor: if self.sourceAnchor is not None: self.sourceAnchor.scenePositionChanged.disconnect( self._sourcePosChanged ) self.sourceAnchor = anchor if self.sourceAnchor is not None: self.sourceAnchor.scenePositionChanged.connect( self._sourcePosChanged ) self.__updateCurve() def setSinkItem(self, item, signal=None, anchor=None): # type: (Optional[NodeItem], Optional[InputSignal], Optional[AnchorPoint]) -> None """ Set the sink `item` (:class:`.NodeItem`). Use `anchor` (:class:`.AnchorPoint`) as the curve end point (if ``None`` a new input anchor will be created using ``item.newInputAnchor()``). Setting item to ``None`` and a valid anchor is a valid operation (for instance while mouse dragging one and of the link). """ if item is not None and anchor is not None: if anchor not in item.inputAnchors(): raise ValueError("Anchor must be belong to the item") if self.sinkItem != item: if self.sinkAnchor: # Remove a previous source item and the corresponding anchor self.sinkAnchor.scenePositionChanged.disconnect( self._sinkPosChanged ) if self.sinkItem is not None: self.sinkItem.removeInputAnchor(self.sinkAnchor) self.sinkItem.selectedChanged.disconnect( self.__updateSelectedState) self.sinkItem = self.sinkAnchor = None self.sinkItem = item if item is not None and anchor is None: # Create a new input anchor for the item if none is provided. anchor = item.newInputAnchor(signal) if item is not None: item.selectedChanged.connect(self.__updateSelectedState) if self.sinkAnchor != anchor: if self.sinkAnchor is not None: self.sinkAnchor.scenePositionChanged.disconnect( self._sinkPosChanged ) self.sinkAnchor = anchor if self.sinkAnchor is not None: self.sinkAnchor.scenePositionChanged.connect( self._sinkPosChanged ) self.__updateCurve() def setChannelNamesVisible(self, visible): # type: (bool) -> None """ Set the visibility of the channel name text. """ if self.__channelNamesVisible != visible: self.__channelNamesVisible = visible self.__initChannelNameOpacity() def setSourceName(self, name): # type: (str) -> None """ Set the name of the source (used in channel name text). """ if self.__sourceName != name: self.__sourceName = name self.__updateText() def sourceName(self): # type: () -> str """ Return the source name. """ return self.__sourceName def setSinkName(self, name): # type: (str) -> None """ Set the name of the sink (used in channel name text). """ if self.__sinkName != name: self.__sinkName = name self.__updateText() def sinkName(self): # type: () -> str """ Return the sink name. """ return self.__sinkName def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the link item animation enabled state. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled self.curveItem.setAnimationEnabled(enabled) def _sinkPosChanged(self, *arg): self.__updateCurve() def _sourcePosChanged(self, *arg): self.__updateCurve() def __updateCurve(self): # type: () -> None self.prepareGeometryChange() self.__boundingRect = None if self.sourceAnchor and self.sinkAnchor: source_pos = self.sourceAnchor.anchorScenePos() sink_pos = self.sinkAnchor.anchorScenePos() source_pos = self.curveItem.mapFromScene(source_pos) sink_pos = self.curveItem.mapFromScene(sink_pos) # Adaptive offset for the curve control points to avoid a # cusp when the two points have the same y coordinate # and are close together delta = source_pos - sink_pos dist = math.sqrt(delta.x() ** 2 + delta.y() ** 2) cp_offset = min(dist / 2.0, 60.0) # TODO: make the curve tangent orthogonal to the anchors path. path = QPainterPath() path.moveTo(source_pos) path.cubicTo(source_pos + QPointF(cp_offset, 0), sink_pos - QPointF(cp_offset, 0), sink_pos) self.curveItem.setCurvePath(path) self.__updateText() else: self.setHoverState(False) self.curveItem.setPath(QPainterPath()) def __updateText(self): # type: () -> None self.prepareGeometryChange() self.__boundingRect = None if self.__sourceName or self.__sinkName: if self.__sourceName != self.__sinkName: text = ("{0} \u2192 {1}" .format(escape(self.__sourceName), escape(self.__sinkName))) else: # If the names are the same show only one. # Is this right? If the sink has two input channels of the # same type having the name on the link help elucidate # the scheme. text = escape(self.__sourceName) else: text = "" self.linkTextItem.setHtml( '
              {0}
              ' .format(text)) path = self.curveItem.curvePath() # Constrain the text width if it is too long to fit on a single line # between the two ends if not path.isEmpty(): # Use the distance between the start/end points as a measure of # available space diff = path.pointAtPercent(0.0) - path.pointAtPercent(1.0) available_width = math.sqrt(diff.x() ** 2 + diff.y() ** 2) # Get the ideal text width if it was unconstrained doc = self.linkTextItem.document().clone(self) doc.setTextWidth(-1) idealwidth = doc.idealWidth() doc.deleteLater() # Constrain the text width but not below a certain min width minwidth = 100 textwidth = max(minwidth, min(available_width, idealwidth)) self.linkTextItem.setTextWidth(textwidth) else: # Reset the fixed width self.linkTextItem.setTextWidth(-1) if not path.isEmpty(): center = path.pointAtPercent(0.5) angle = path.angleAtPercent(0.5) brect = self.linkTextItem.boundingRect() transform = QTransform() transform.translate(center.x(), center.y()) # Rotate text to be on top of link if 90 <= angle < 270: transform.rotate(180 - angle) else: transform.rotate(-angle) # Center and move above the curve path. transform.translate(-brect.width() / 2, -brect.height()) self.linkTextItem.setTransform(transform) def removeLink(self): # type: () -> None self.setSinkItem(None) self.setSourceItem(None) self.__updateCurve() def setHoverState(self, state): # type: (bool) -> None if self.hover != state: self.prepareGeometryChange() self.__boundingRect = None self.hover = state if self.sinkAnchor: self.sinkAnchor.setHoverState(state) if self.sourceAnchor: self.sourceAnchor.setHoverState(state) self.curveItem.setHoverState(state) self.__updatePen() self.__updateChannelNameVisibility() self.__updateZValue() def __updateZValue(self): text_ss = self.linkTextItem.styleState() if self.hover: text_ss |= QStyle.State_HasFocus z = 9999 self.linkTextItem.setParentItem(None) else: text_ss &= ~QStyle.State_HasFocus z = self.Z_VALUE self.linkTextItem.setParentItem(self) self.linkTextItem.setZValue(z) self.linkTextItem.setStyleState(text_ss) def mouseDoubleClickEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> None super().mouseDoubleClickEvent(event) QTimer.singleShot(0, self.activated.emit) def hoverEnterEvent(self, event): # type: (QGraphicsSceneHoverEvent) -> None # Hover enter event happens when the mouse enters any child object # but we only want to show the 'hovered' shadow when the mouse # is over the 'curveItem', so we install self as an event filter # on the LinkCurveItem and listen to its hover events. self.curveItem.installSceneEventFilter(self) return super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): # type: (QGraphicsSceneHoverEvent) -> None # Remove the event filter to prevent unnecessary work in # scene event filter when not needed self.curveItem.removeSceneEventFilter(self) return super().hoverLeaveEvent(event) def __initChannelNameOpacity(self): if self.__channelNamesVisible: self.linkTextItem.setOpacity(1) else: self.linkTextItem.setOpacity(0) def __updateChannelNameVisibility(self): if self.__channelNamesVisible: return enabled = self.hover or self.isSelected() or self.__isSelectedImplicit() targetOpacity = 1 if enabled else 0 if not self.__animationEnabled: self.linkTextItem.setOpacity(targetOpacity) else: if self.channelNameAnim.state() == QPropertyAnimation.Running: self.channelNameAnim.stop() self.channelNameAnim.setStartValue(self.linkTextItem.opacity()) self.channelNameAnim.setEndValue(targetOpacity) self.channelNameAnim.start() def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.PaletteChange: self.__updatePalette() elif event.type() == QEvent.FontChange: self.__updateFont() super().changeEvent(event) def sceneEventFilter(self, obj, event): # type: (QGraphicsItem, QEvent) -> bool if obj is self.curveItem: if event.type() == QEvent.GraphicsSceneHoverEnter: self.setHoverState(True) elif event.type() == QEvent.GraphicsSceneHoverLeave: self.setHoverState(False) return super().sceneEventFilter(obj, event) def boundingRect(self): # type: () -> QRectF if self.__boundingRect is None: self.__boundingRect = self.childrenBoundingRect() return self.__boundingRect def shape(self): # type: () -> QPainterPath return self.curveItem.shape() def setEnabled(self, enabled): # type: (bool) -> None """ Reimplemented from :class:`QGraphicWidget` Set link enabled state. When disabled the link is rendered with a dashed line. """ # This getter/setter pair override a property from the base class. # They should be renamed to e.g. setLinkEnabled/linkEnabled self.curveItem.setLinkEnabled(enabled) def isEnabled(self): # type: () -> bool return self.curveItem.isLinkEnabled() def setDynamicEnabled(self, enabled): # type: (bool) -> None """ Set the link's dynamic enabled state. If the link is `dynamic` it will be rendered in red/green color respectively depending on the state of the dynamic enabled state. """ if self.__dynamicEnabled != enabled: self.__dynamicEnabled = enabled if self.__dynamic: self.__updatePen() def isDynamicEnabled(self): # type: () -> bool """ Is the link dynamic enabled. """ return self.__dynamicEnabled def setDynamic(self, dynamic): # type: (bool) -> None """ Mark the link as dynamic (i.e. it responds to :func:`setDynamicEnabled`). """ if self.__dynamic != dynamic: self.__dynamic = dynamic self.__updatePen() def isDynamic(self): # type: () -> bool """ Is the link dynamic. """ return self.__dynamic def setRuntimeState(self, state): # type: (_State) -> None """ Style the link appropriate to the LinkItem.State Parameters ---------- state : LinkItem.State """ if self.__state != state: self.__state = state self.__updateAnchors() self.__updatePen() def runtimeState(self): # type: () -> _State return self.__state def __updatePen(self): # type: () -> None self.prepareGeometryChange() self.__boundingRect = None if self.__dynamic: if self.__dynamicEnabled: color = QColor(0, 150, 0, 150) else: color = QColor(150, 0, 0, 150) normal = QPen(QBrush(color), 2.0) hover = QPen(QBrush(color.darker(120)), 2.0) else: normal = QPen(QBrush(QColor("#9CACB4")), 2.0) hover = QPen(QBrush(QColor("#959595")), 2.0) if self.__state & LinkItem.Empty: pen_style = Qt.DashLine else: pen_style = Qt.SolidLine normal.setStyle(pen_style) hover.setStyle(pen_style) if self.hover or self.isSelected(): pen = hover else: pen = normal self.curveItem.setPen(pen) def __updatePalette(self): # type: () -> None self.linkTextItem.setDefaultTextColor( self.palette().color(QPalette.Text)) def __updateFont(self): # type: () -> None font = self.font() # linkTextItem will be rotated. Hinting causes bad positioning under # rotation so we prefer to disable it. This is only a hint, on windows # (DirectWrite engine) vertical hinting is still performed. font.setHintingPreference(QFont.PreferNoHinting) self.linkTextItem.setFont(font) def __updateAnchors(self): state = QStyle.State(0) if self.hover: state |= QStyle.State_MouseOver if self.isSelected() or self.__isSelectedImplicit(): state |= QStyle.State_Selected if self.sinkAnchor is not None: self.sinkAnchor.indicator.setStyleState(state) self.sinkAnchor.indicator.setLinkState(self.__state) if self.sourceAnchor is not None: self.sourceAnchor.indicator.setStyleState(state) self.sourceAnchor.indicator.setLinkState(self.__state) def __updateSelectedState(self): selected = self.isSelected() or self.__isSelectedImplicit() self.linkTextItem.setSelectionState(selected) self.__updatePen() self.__updateAnchors() self.__updateChannelNameVisibility() self.curveItem.setSelectionState(selected) def __isSelectedImplicit(self): source, sink = self.sourceItem, self.sinkItem return (source is not None and source.isSelected() and sink is not None and sink.isSelected()) def itemChange(self, change: QGraphicsItem.GraphicsItemChange, value: Any) -> Any: if change == QGraphicsItem.ItemSelectedHasChanged: self.__updateSelectedState() self.selectedChanged.emit(value) return super().itemChange(change, value) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/nodeitem.py0000644000175100002000000017006414730024325024155 0ustar00runnerdocker""" ========= Node Item ========= """ import typing import string from operator import attrgetter from itertools import groupby from functools import reduce from xml.sax.saxutils import escape from typing import Dict, Any, Optional, List, Iterable, Tuple, Union from AnyQt.QtWidgets import ( QGraphicsItem, QGraphicsObject, QGraphicsWidget, QGraphicsDropShadowEffect, QStyle, QApplication, QGraphicsSceneMouseEvent, QGraphicsSceneContextMenuEvent, QStyleOptionGraphicsItem, QWidget, QGraphicsEllipseItem ) from AnyQt.QtGui import ( QPen, QBrush, QColor, QPalette, QIcon, QPainter, QPainterPath, QPainterPathStroker, QConicalGradient, QTransform) from AnyQt.QtCore import ( Qt, QEvent, QPointF, QRectF, QRect, QSize, QElapsedTimer, QTimer, QPropertyAnimation, QEasingCurve, QObject, QVariantAnimation, QParallelAnimationGroup, Slot ) from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from .graphicspathobject import GraphicsPathObject from .graphicstextitem import GraphicsTextItem, GraphicsTextEdit from .utils import ( saturated, radial_gradient, linspace, qpainterpath_sub_path, clip ) from ... import styles from ...gui.iconengine import StyledIconEngine from ...gui.utils import disconnected from ...scheme.node import UserMessage from ...registry import NAMED_COLORS, WidgetDescription, CategoryDescription, \ InputSignal, OutputSignal from ...resources import icon_loader from .utils import uniform_linear_layout_trunc from ...utils import set_flag if typing.TYPE_CHECKING: from ...registry import WidgetDescription def create_palette(light_color, color): # type: (QColor, QColor) -> QPalette """ Return a new :class:`QPalette` from for the :class:`NodeBodyItem`. """ palette = QPalette() palette.setColor(QPalette.Inactive, QPalette.Light, saturated(light_color, 50)) palette.setColor(QPalette.Inactive, QPalette.Midlight, saturated(light_color, 90)) palette.setColor(QPalette.Inactive, QPalette.Button, light_color) palette.setColor(QPalette.Active, QPalette.Light, saturated(color, 50)) palette.setColor(QPalette.Active, QPalette.Midlight, saturated(color, 90)) palette.setColor(QPalette.Active, QPalette.Button, color) palette.setColor(QPalette.ButtonText, QColor("#515151")) return palette def default_palette(): # type: () -> QPalette """ Create and return a default palette for a node. """ return create_palette(QColor(NAMED_COLORS["light-yellow"]), QColor(NAMED_COLORS["yellow"])) def animation_restart(animation): # type: (QPropertyAnimation) -> None if animation.state() == QPropertyAnimation.Running: animation.pause() animation.start() SHADOW_COLOR = "#9CACB4" SELECTED_SHADOW_COLOR = "#609ED7" class NodeBodyItem(GraphicsPathObject): """ The central part (body) of the `NodeItem`. """ def __init__(self, parent=None): # type: (NodeItem) -> None super().__init__(parent) assert isinstance(parent, NodeItem) self.__processingState = 0 self.__progress = -1. self.__spinnerValue = 0 self.__animationEnabled = False self.__isSelected = False self.__hover = False self.__shapeRect = QRectF(-10, -10, 20, 20) self.palette = QPalette() self.setAcceptHoverEvents(True) self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) self.setPen(QPen(Qt.NoPen)) self.setPalette(default_palette()) self.shadow = QGraphicsDropShadowEffect( blurRadius=0, color=QColor(SHADOW_COLOR), offset=QPointF(0, 0), ) self.shadow.setEnabled(False) # An item with the same shape as this object, stacked behind this # item as a source for QGraphicsDropShadowEffect. Cannot attach # the effect to this item directly as QGraphicsEffect makes the item # non devicePixelRatio aware. shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item") shadowitem.setPen(Qt.NoPen) shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR).lighter())) shadowitem.setGraphicsEffect(self.shadow) shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent) self.__shadow = shadowitem self.__blurAnimation = QPropertyAnimation( self.shadow, b"blurRadius", self, duration=100 ) self.__blurAnimation.finished.connect(self.__on_finished) self.__pingAnimation = QPropertyAnimation( self, b"scale", self, duration=250 ) self.__pingAnimation.setKeyValues([(0.0, 1.0), (0.5, 1.1), (1.0, 1.0)]) self.__spinnerAnimation = QVariantAnimation( self, startValue=0, endValue=360, duration=2000, loopCount=-1, ) self.__spinnerAnimation.valueChanged.connect(self.update) self.__spinnerStartTimer = QTimer( self, interval=3000, singleShot=True, timeout=self.__progressTimeout ) # TODO: The body item should allow the setting of arbitrary painter # paths (for instance rounded rect, ...) def setShapeRect(self, rect): # type: (QRectF) -> None """ Set the item's shape `rect`. The item should be confined within this rect. """ path = QPainterPath() path.addEllipse(rect) self.setPath(path) self.__shadow.setPath(path) self.__shapeRect = rect def setPalette(self, palette): # type: (QPalette) -> None """ Set the body color palette (:class:`QPalette`). """ self.palette = QPalette(palette) self.__updateBrush() def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the node animation enabled. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled def setProcessingState(self, state): # type: (int) -> None """ Set the processing state of the node. """ if self.__processingState != state: self.__processingState = state self.stopSpinner() if not state and self.__animationEnabled: self.ping() if state: self.__spinnerStartTimer.start() else: self.__spinnerStartTimer.stop() def setProgress(self, progress): # type: (float) -> None """ Set the progress indicator state of the node. `progress` should be a number between 0 and 100. """ if self.__progress != progress: self.__progress = progress if self.__progress >= 0: self.stopSpinner() self.update() self.__spinnerStartTimer.start() def ping(self): # type: () -> None """ Trigger a 'ping' animation. """ animation_restart(self.__pingAnimation) def startSpinner(self): self.__spinnerAnimation.start() self.__spinnerStartTimer.stop() self.update() def stopSpinner(self): self.__spinnerAnimation.stop() self.__spinnerStartTimer.stop() self.update() def __progressTimeout(self): if self.__processingState: self.startSpinner() def hoverEnterEvent(self, event): self.__hover = True self.__updateShadowState() return super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.__hover = False self.__updateShadowState() return super().hoverLeaveEvent(event) def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None """ Paint the shape and a progress meter. """ # Let the default implementation draw the shape if option.state & QStyle.State_Selected: # Prevent the default bounding rect selection indicator. option.state = QStyle.State(option.state ^ QStyle.State_Selected) super().paint(painter, option, widget) if self.__progress >= 0 or self.__processingState \ or self.__spinnerAnimation.state() == QVariantAnimation.Running: # Draw the progress meter over the shape. # Set the clip to shape so the meter does not overflow the shape. rect = self.__shapeRect painter.save() painter.setClipPath(self.shape(), Qt.ReplaceClip) color = self.palette.color(QPalette.ButtonText) pen = QPen(color, 5) painter.setPen(pen) spinner = self.__spinnerAnimation indeterminate = spinner.state() != QVariantAnimation.Stopped if indeterminate: draw_spinner(painter, rect, 5, color, self.__spinnerAnimation.currentValue()) else: span = max(1, int(360 * self.__progress / 100)) draw_progress(painter, rect, 5, color, span) painter.restore() def __updateShadowState(self): # type: () -> None if self.__isSelected or self.__hover: enabled = True radius = 17 else: enabled = False radius = 0 if enabled and not self.shadow.isEnabled(): self.shadow.setEnabled(enabled) if self.__isSelected: color = QColor(SELECTED_SHADOW_COLOR) else: color = QColor(SHADOW_COLOR) self.shadow.setColor(color) if self.__animationEnabled: if self.__blurAnimation.state() == QPropertyAnimation.Running: self.__blurAnimation.stop() self.__blurAnimation.setStartValue(self.shadow.blurRadius()) self.__blurAnimation.setEndValue(radius) self.__blurAnimation.start() else: self.shadow.setBlurRadius(radius) def __updateBrush(self): # type: () -> None palette = self.palette if self.__isSelected: cg = QPalette.Active else: cg = QPalette.Inactive palette.setCurrentColorGroup(cg) c1 = palette.color(QPalette.Light) c2 = palette.color(QPalette.Button) grad = radial_gradient(c2, c1) self.setBrush(QBrush(grad)) # TODO: The selected state should be set using the # QStyle flags (State_Selected. State_HasFocus) def setSelected(self, selected): # type: (bool) -> None """ Set the `selected` state. .. note:: The item does not have `QGraphicsItem.ItemIsSelectable` flag. This property is instead controlled by the parent NodeItem. """ self.__isSelected = selected self.__updateShadowState() self.__updateBrush() def __on_finished(self): # type: () -> None if self.shadow.blurRadius() == 0: self.shadow.setEnabled(False) class LinkAnchorIndicator(QGraphicsEllipseItem): """ A visual indicator of the link anchor point at both ends of the :class:`LinkItem`. """ def __init__(self, parent=None): # type: (Optional[QGraphicsItem]) -> None self.__styleState = QStyle.State(0) self.__linkState = LinkItem.NoState super().__init__(parent) self.setAcceptedMouseButtons(Qt.NoButton) self.setRect(-3.5, -3.5, 7., 7.) self.setPen(QPen(Qt.NoPen)) self.setBrush(QBrush(QColor("#9CACB4"))) self.hoverBrush = QBrush(QColor("#959595")) self.__hover = False def setHoverState(self, state): # type: (bool) -> None """ The hover state is set by the LinkItem. """ state = set_flag(self.__styleState, QStyle.State_MouseOver, state) self.setStyleState(state) def setStyleState(self, state: QStyle.State): if self.__styleState != state: self.__styleState = state self.update() def setLinkState(self, state: 'LinkItem.State'): if self.__linkState != state: self.__linkState = state self.update() def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None hover = self.__styleState & (QStyle.State_Selected | QStyle.State_MouseOver) brush = self.hoverBrush if hover else self.brush() if self.__linkState & (LinkItem.Pending | LinkItem.Invalidated): brush = QBrush(Qt.red) painter.setBrush(brush) painter.setPen(self.pen()) painter.drawEllipse(self.rect()) def draw_spinner(painter, rect, penwidth, color, angle): # type: (QPainter, QRectF, int, QColor, int) -> None gradient = QConicalGradient() color2 = QColor(color) color2.setAlpha(0) stops = [ (0.0, color), (1.0, color2), ] gradient.setStops(stops) gradient.setCoordinateMode(QConicalGradient.ObjectBoundingMode) gradient.setCenter(0.5, 0.5) gradient.setAngle(-angle) pen = QPen() pen.setCapStyle(Qt.RoundCap) pen.setWidthF(penwidth) pen.setBrush(gradient) painter.setPen(pen) painter.drawEllipse(rect) def draw_progress(painter, rect, penwidth, color, angle): # type: (QPainter, QRectF, int, QColor, int) -> None painter.setPen(QPen(color, penwidth)) painter.drawArc(rect, 90 * 16, -angle * 16) class AnchorPoint(QGraphicsObject): """ A anchor indicator on the :class:`NodeAnchorItem`. """ #: Signal emitted when the item's scene position changes. scenePositionChanged = Signal(QPointF) #: Signal emitted when the item's `anchorDirection` changes. anchorDirectionChanged = Signal(QPointF) #: Signal emitted when anchor's Input/Output channel changes. signalChanged = Signal(QGraphicsObject) def __init__( self, parent: Optional[QGraphicsItem] = None, signal: Union[InputSignal, OutputSignal, None] = None, **kwargs ) -> None: super().__init__(parent, **kwargs) self.setFlag(QGraphicsItem.ItemIsFocusable) self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True) self.setFlag(QGraphicsItem.ItemHasNoContents, True) self.indicator = LinkAnchorIndicator(self) self.signal = signal self.__direction = QPointF() self.anim = QPropertyAnimation(self, b'pos', self) self.anim.setDuration(50) def setSignal(self, signal): if self.signal != signal: self.signal = signal self.signalChanged.emit(self) def anchorScenePos(self): # type: () -> QPointF """ Return anchor position in scene coordinates. """ return self.mapToScene(QPointF(0, 0)) def setAnchorDirection(self, direction): # type: (QPointF) -> None """ Set the preferred direction (QPointF) in item coordinates. """ if self.__direction != direction: self.__direction = QPointF(direction) self.anchorDirectionChanged.emit(direction) def anchorDirection(self): # type: () -> QPointF """ Return the preferred anchor direction. """ return QPointF(self.__direction) def itemChange(self, change, value): # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any if change == QGraphicsItem.ItemScenePositionHasChanged: self.scenePositionChanged.emit(value) return super().itemChange(change, value) def boundingRect(self,): # type: () -> QRectF return QRectF() def setHoverState(self, enabled): self.indicator.setHoverState(enabled) def setLinkState(self, state: 'LinkItem.State'): self.indicator.setLinkState(state) ANCHOR_TEXT_MARGIN = 4 def make_channel_anchors_path( path: QPainterPath, #: The full uninterrupted anchor path anchors: int, #: Number of anchors spacing=2. #: Spacing ) -> QPainterPath: """Create a subdivided channel anchors path.""" if path.isEmpty() or anchors <= 1: return QPainterPath(path) pathlen = path.length() spacing = min(spacing, pathlen / anchors) delta = (spacing / 2) / pathlen # half of inner spacing splits = list(linspace(anchors + 1)) # adjust linspace splits to give all sub paths equal length (inner # paths get `delta` subtracted twice while edge paths only once) splits = [(1 + 2 * delta) * x - delta for x in splits] splits = splits[1:-1] start = 0.0 subpaths = [] for p in splits: subpaths.append(qpainterpath_sub_path(path, start, p - delta)) start = p + delta subpaths.append(qpainterpath_sub_path(path, start, 1.0)) return reduce(QPainterPath.united, subpaths, QPainterPath()) class NodeAnchorItem(GraphicsPathObject): """ The left/right widget input/output anchors. """ def __init__(self, parent, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None super().__init__(parent, **kwargs) self.__parentNodeItem = None # type: Optional[NodeItem] self.setAcceptHoverEvents(True) self.setPen(QPen(Qt.NoPen)) self.normalBrush = QBrush(QColor("#CDD5D9")) self.normalHoverBrush = QBrush(QColor("#9CACB4")) self.connectedBrush = self.normalHoverBrush self.connectedHoverBrush = QBrush(QColor("#959595")) self.setBrush(self.normalBrush) self.__animationEnabled = False self.__hover = False self.__anchorOpen = False self.__compatibleSignals = None self.__keepSignalsOpen = [] # Does this item have any anchored links. self.anchored = False if isinstance(parent, NodeItem): self.__parentNodeItem = parent else: self.__parentNodeItem = None self.__anchorPath = QPainterPath() self.__points = [] # type: List[AnchorPoint] self.__uniformPointPositions = [] # type: List[float] self.__channelPointPositions = [] # type: List[float] self.__incompatible = False # type: bool self.__signals = [] # type: List[Union[InputSignal, OutputSignal]] self.__signalLabels = [] # type: List[GraphicsTextItem] self.__signalLabelAnims = [] # type: List[QPropertyAnimation] self.__fullStroke = QPainterPath() self.__dottedStroke = QPainterPath() self.__channelStroke = QPainterPath() self.__shape = None # type: Optional[QPainterPath] self.shadow = QGraphicsDropShadowEffect( blurRadius=0, color=QColor(SHADOW_COLOR), offset=QPointF(0, 0), ) # self.setGraphicsEffect(self.shadow) self.shadow.setEnabled(False) shadowitem = GraphicsPathObject(self, objectName="shadow-shape-item") shadowitem.setPen(Qt.NoPen) shadowitem.setBrush(QBrush(QColor(SHADOW_COLOR))) shadowitem.setGraphicsEffect(self.shadow) shadowitem.setFlag(QGraphicsItem.ItemStacksBehindParent) self.__shadow = shadowitem self.__blurAnimation = QPropertyAnimation(self.shadow, b"blurRadius", self) self.__blurAnimation.setDuration(50) self.__blurAnimation.finished.connect(self.__on_finished) self.animGroup = QParallelAnimationGroup() def setSignals(self, signals): self.__signals = signals self.setAnchorPath(self.__anchorPath) # (re)instantiate anchor paths # TODO this is ugly alignLeft = isinstance(self, SourceAnchorItem) for s in signals: lbl = GraphicsTextItem(self) lbl.setAcceptedMouseButtons(Qt.NoButton) lbl.setAcceptHoverEvents(False) text = s.name lbl.setHtml('
              {0}
              ' .format(text)) cperc = self.__getChannelPercent(s) sigPos = self.__anchorPath.pointAtPercent(cperc) lblrect = lbl.boundingRect() transform = QTransform() transform.translate(sigPos.x(), sigPos.y()) transform.translate(0, -lblrect.height() / 2) if not alignLeft: transform.translate(-lblrect.width() - ANCHOR_TEXT_MARGIN, 0) else: transform.translate(ANCHOR_TEXT_MARGIN, 0) lbl.setTransform(transform) lbl.setOpacity(0) self.__signalLabels.append(lbl) lblAnim = QPropertyAnimation(lbl, b'opacity', self) lblAnim.setDuration(50) self.animGroup.addAnimation(lblAnim) self.__signalLabelAnims.append(lblAnim) def setIncompatible(self, enabled): if self.__incompatible != enabled: self.__incompatible = enabled self.__updatePositions() def setKeepAnchorOpen(self, signal): if signal is None: self.__keepSignalsOpen = [] elif not isinstance(signal, list): self.__keepSignalsOpen = [signal] else: self.__keepSignalsOpen = signal self.__updateLabels(self.__keepSignalsOpen) self.__updatePositions() def parentNodeItem(self): # type: () -> Optional['NodeItem'] """ Return a parent :class:`NodeItem` or ``None`` if this anchor's parent is not a :class:`NodeItem` instance. """ return self.__parentNodeItem def setAnchorPath(self, path): # type: (QPainterPath) -> None """ Set the anchor's curve path as a :class:`QPainterPath`. """ self.__anchorPath = QPainterPath(path) # Create a stroke of the path. stroke_path = QPainterPathStroker() stroke_path.setCapStyle(Qt.RoundCap) # Shape is wider (bigger mouse hit area - should be settable) stroke_path.setWidth(25) self.prepareGeometryChange() self.__shape = stroke_path.createStroke(path) stroke_width = 3 stroke_path.setWidth(stroke_width) # The full stroke self.__fullStroke = stroke_path.createStroke(path) # The dotted stroke (when not connected to anything) self.__dottedStroke = stroke_path.createStroke( make_channel_anchors_path(path, 6, spacing=stroke_width + 4) ) # The channel stroke (when channels are open) self.__channelStroke = stroke_path.createStroke( make_channel_anchors_path( path, len(self.__signals), spacing=stroke_width + 4 )) if self.anchored: self.setPath(self.__fullStroke) self.__shadow.setPath(self.__fullStroke) brush = self.connectedHoverBrush if self.__hover else self.connectedBrush self.setBrush(brush) else: self.setPath(self.__dottedStroke) self.__shadow.setPath(self.__dottedStroke) brush = self.normalHoverBrush if self.__hover else self.normalBrush self.setBrush(brush) def anchorPath(self): # type: () -> QPainterPath """ Return the anchor path (:class:`QPainterPath`). This is a curve on which the anchor points lie. """ return QPainterPath(self.__anchorPath) def setAnchored(self, anchored): # type: (bool) -> None """ Set the items anchored state. When ``False`` the item draws it self with a dotted stroke. """ self.anchored = anchored if anchored: self.shadow.setEnabled(False) self.setBrush(self.connectedBrush) else: brush = self.normalHoverBrush if self.__hover else self.normalBrush self.setBrush(brush) self.__updatePositions() def setConnectionHint(self, hint=None): """ Set the connection hint. This can be used to indicate if a connection can be made or not. """ raise NotImplementedError def count(self): # type: () -> int """ Return the number of anchor points. """ return len(self.__points) def addAnchor(self, anchor): # type: (AnchorPoint) -> int """ Add a new :class:`AnchorPoint` to this item and return it's index. The `position` specifies where along the `anchorPath` is the new point inserted. """ return self.insertAnchor(self.count(), anchor) def __updateAnchorSignalPosition(self, anchor): cperc = self.__getChannelPercent(anchor.signal) i = self.__points.index(anchor) self.__channelPointPositions[i] = cperc self.__updatePositions() def insertAnchor(self, index, anchor): # type: (int, AnchorPoint) -> int """ Insert a new :class:`AnchorPoint` at `index`. See also -------- NodeAnchorItem.addAnchor """ if anchor in self.__points: raise ValueError("%s already added." % anchor) self.__points.insert(index, anchor) self.__uniformPointPositions.insert(index, 0) cperc = self.__getChannelPercent(anchor.signal) self.__channelPointPositions.insert(index, cperc) self.animGroup.addAnimation(anchor.anim) anchor.setParentItem(self) anchor.destroyed.connect(self.__onAnchorDestroyed) anchor.signalChanged.connect(self.__updateAnchorSignalPosition) positions = self.anchorPositions() positions = uniform_linear_layout_trunc(positions) if anchor.signal in self.__keepSignalsOpen or \ self.__anchorOpen and self.__hover: perc = cperc else: perc = positions[index] pos = self.__anchorPath.pointAtPercent(perc) anchor.setPos(pos) self.setAnchorPositions(positions) self.setAnchored(bool(self.__points)) hover_for_color = self.__hover and len(self.__points) > 1 # a stylistic choice anchor.setHoverState(hover_for_color) return index def removeAnchor(self, anchor): # type: (AnchorPoint) -> None """ Remove and delete the anchor point. """ anchor = self.takeAnchor(anchor) self.animGroup.removeAnimation(anchor.anim) anchor.hide() anchor.setParentItem(None) anchor.deleteLater() positions = self.anchorPositions() positions = uniform_linear_layout_trunc(positions) self.setAnchorPositions(positions) def takeAnchor(self, anchor): # type: (AnchorPoint) -> AnchorPoint """ Remove the anchor but don't delete it. """ index = self.__points.index(anchor) del self.__points[index] del self.__uniformPointPositions[index] del self.__channelPointPositions[index] anchor.destroyed.disconnect(self.__onAnchorDestroyed) self.__updatePositions() self.setAnchored(bool(self.__points)) return anchor def __onAnchorDestroyed(self, anchor): # type: (QObject) -> None try: index = self.__points.index(anchor) except ValueError: return del self.__points[index] del self.__uniformPointPositions[index] del self.__channelPointPositions[index] def anchorPoints(self): # type: () -> List[AnchorPoint] """ Return a list of anchor points. """ return list(self.__points) def anchorPoint(self, index): # type: (int) -> AnchorPoint """ Return the anchor point at `index`. """ return self.__points[index] def setAnchorPositions(self, positions): # type: (Iterable[float]) -> None """ Set the anchor positions in percentages (0..1) along the path curve. """ if self.__uniformPointPositions != positions: self.__uniformPointPositions = list(positions) self.__updatePositions() def anchorPositions(self): # type: () -> List[float] """ Return the positions of anchor points as a list of floats where each float is between 0 and 1 and specifies where along the anchor path does the point lie (0 is at start 1 is at the end). """ return list(self.__uniformPointPositions) def shape(self): # type: () -> QPainterPath if self.__shape is not None: return QPainterPath(self.__shape) else: return super().shape() def boundingRect(self): if self.__shape is not None: return self.__shape.controlPointRect() else: return GraphicsPathObject.boundingRect(self) def setHovered(self, enabled): self.__hover = enabled if enabled: brush = self.connectedHoverBrush if self.anchored else self.normalHoverBrush else: brush = self.connectedBrush if self.anchored else self.normalBrush self.setBrush(brush) self.__updateHoverState() def hoverEnterEvent(self, event): self.setHovered(True) return super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.setHovered(False) return super().hoverLeaveEvent(event) def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the anchor animation enabled. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled def signalAtPos(self, scenePos, signalsToFind=None): if signalsToFind is None: signalsToFind = self.__signals pos = self.mapFromScene(scenePos) def signalLengthToPos(s): perc = self.__getChannelPercent(s) p = self.__anchorPath.pointAtPercent(perc) return (p - pos).manhattanLength() return min(signalsToFind, key=signalLengthToPos) def __updateHoverState(self): self.__updateShadowState() self.__updatePositions() for indicator in self.anchorPoints(): indicator.setHoverState(self.__hover) def __getChannelPercent(self, signal): if signal is None: return 0.5 signals = self.__signals ci = signals.index(signal) gap_perc = 1 / 8 seg_perc = (1 - (gap_perc * (len(signals) - 1))) / len(signals) return clip((ci * (gap_perc + seg_perc)) + seg_perc / 2, 0.0, 1.0) def __updateShadowState(self): # type: () -> None radius = 5 if self.__hover else 0 if radius != 0 and not self.shadow.isEnabled(): self.shadow.setEnabled(True) if self.__animationEnabled: if self.__blurAnimation.state() == QPropertyAnimation.Running: self.__blurAnimation.stop() self.__blurAnimation.setStartValue(self.shadow.blurRadius()) self.__blurAnimation.setEndValue(radius) self.__blurAnimation.start() else: self.shadow.setBlurRadius(radius) def setAnchorOpen(self, anchorOpen: bool): """ Should the anchors expand to expose individual channel connections. """ self.__anchorOpen = anchorOpen self.__updatePositions() def anchorOpen(self) -> bool: return self.__anchorOpen anchorOpen_ = Property(bool, anchorOpen, setAnchorOpen) def setCompatibleSignals(self, compatibleSignals): self.__compatibleSignals = compatibleSignals self.__updatePositions() def __updateLabels(self, showSignals): for signal, label in zip(self.__signals, self.__signalLabels): if signal not in showSignals: opacity = 0 elif self.__compatibleSignals is not None \ and signal not in self.__compatibleSignals: opacity = 0.65 else: opacity = 1 label.setOpacity(opacity) def __initializeAnimation(self, targetPoss, showSignals): # TODO if animation currently running, set start value/time accordingly for a, t in zip(self.__points, targetPoss): currPos = a.pos() a.anim.setStartValue(currPos) pos = self.__anchorPath.pointAtPercent(t) a.anim.setEndValue(pos) for sig, lbl, lblAnim in zip(self.__signals, self.__signalLabels, self.__signalLabelAnims): lblAnim.setStartValue(lbl.opacity()) lblAnim.setEndValue(1 if sig in showSignals else 0) def __updatePositions(self): # type: () -> None """Update anchor points positions. """ if self.__keepSignalsOpen or self.__anchorOpen and self.__hover: stroke = self.__channelStroke targetPoss = self.__channelPointPositions showSignals = self.__keepSignalsOpen or self.__signals elif self.anchored: stroke = self.__fullStroke targetPoss = self.__uniformPointPositions showSignals = self.__signals if self.__incompatible else [] else: stroke = self.__dottedStroke targetPoss = self.__uniformPointPositions showSignals = self.__signals if self.__incompatible else [] if self.animGroup.state() == QPropertyAnimation.Running: self.animGroup.stop() if self.__animationEnabled: self.__initializeAnimation(targetPoss, showSignals) self.animGroup.start() self.setPath(stroke) self.__shadow.setPath(stroke) else: for point, t in zip(self.__points, targetPoss): pos = self.__anchorPath.pointAtPercent(t) point.setPos(pos) self.__updateLabels(showSignals) self.setPath(stroke) self.__shadow.setPath(stroke) def __on_finished(self): # type: () -> None if self.shadow.blurRadius() == 0: self.shadow.setEnabled(False) class SourceAnchorItem(NodeAnchorItem): """ A source anchor item """ pass class SinkAnchorItem(NodeAnchorItem): """ A sink anchor item. """ pass def standard_icon(standard_pixmap): # type: (QStyle.StandardPixmap) -> QIcon """ Return return the application style's standard icon for a `QStyle.StandardPixmap`. """ style = QApplication.instance().style() return style.standardIcon(standard_pixmap) class GraphicsIconItem(QGraphicsWidget): """ A graphics item displaying an :class:`QIcon`. """ def __init__(self, parent=None, icon=QIcon(), iconSize=QSize(), **kwargs): # type: (Optional[QGraphicsItem], QIcon, QSize, Any) -> None super().__init__(parent, **kwargs) self.setFlag(QGraphicsItem.ItemUsesExtendedStyleOption, True) if icon is None: icon = QIcon() if iconSize is None or iconSize.isNull(): style = QApplication.instance().style() size = style.pixelMetric(style.PM_LargeIconSize) iconSize = QSize(size, size) self.__transformationMode = Qt.SmoothTransformation self.__iconSize = QSize(iconSize) self.__icon = QIcon(icon) self.anim = QPropertyAnimation(self, b"opacity") self.anim.setDuration(350) self.anim.setStartValue(1) self.anim.setKeyValueAt(0.5, 0) self.anim.setEndValue(1) self.anim.setEasingCurve(QEasingCurve.OutQuad) self.anim.setLoopCount(5) def setIcon(self, icon): # type: (QIcon) -> None """ Set the icon (:class:`QIcon`). """ if self.__icon != icon: self.__icon = QIcon(icon) self.update() def icon(self): # type: () -> QIcon """ Return the icon (:class:`QIcon`). """ return QIcon(self.__icon) def setIconSize(self, size): # type: (QSize) -> None """ Set the icon (and this item's) size (:class:`QSize`). """ if self.__iconSize != size: self.prepareGeometryChange() self.__iconSize = QSize(size) self.update() def iconSize(self): # type: () -> QSize """ Return the icon size (:class:`QSize`). """ return QSize(self.__iconSize) def setTransformationMode(self, mode): # type: (Qt.TransformationMode) -> None """ Set pixmap transformation mode. (`Qt.SmoothTransformation` or `Qt.FastTransformation`). """ if self.__transformationMode != mode: self.__transformationMode = mode self.update() def transformationMode(self): # type: () -> Qt.TransformationMode """ Return the pixmap transformation mode. """ return self.__transformationMode def boundingRect(self): # type: () -> QRectF return QRectF(0, 0, self.__iconSize.width(), self.__iconSize.height()) def paint(self, painter, option, widget=None): # type: (QPainter, QStyleOptionGraphicsItem, Optional[QWidget]) -> None if not self.__icon.isNull(): if option.state & QStyle.State_Selected: mode = QIcon.Selected elif option.state & QStyle.State_Enabled: mode = QIcon.Normal elif option.state & QStyle.State_Active: mode = QIcon.Active else: mode = QIcon.Disabled w, h = self.__iconSize.width(), self.__iconSize.height() target = QRect(0, 0, w, h) painter.setRenderHint( QPainter.SmoothPixmapTransform, self.__transformationMode == Qt.SmoothTransformation ) palette = self.palette() # assuming light-ish background color with StyledIconEngine.setOverridePalette(palette): self.__icon.paint(painter, target, Qt.AlignCenter, mode) class NodeItem(QGraphicsWidget): """ An widget node item in the canvas. """ #: Signal emitted when the scene position of the node has changed. positionChanged = Signal() #: Signal emitted when the geometry of the channel anchors changes. anchorGeometryChanged = Signal() #: Signal emitted when the item has been activated (by a mouse double #: click or a keyboard) activated = Signal() #: The item is under the mouse. hovered = Signal() #: Signal emitted the the item's selection state changes. selectedChanged = Signal(bool) #: Span of the anchor in degrees ANCHOR_SPAN_ANGLE = 90 #: Z value of the item Z_VALUE = 100 def __init__(self, widget_description=None, parent=None, **kwargs): # type: (WidgetDescription, QGraphicsItem, Any) -> None self.__boundingRect = None # type: Optional[QRectF] super().__init__(parent, **kwargs) self.setFocusPolicy(Qt.ClickFocus) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges, True) self.setFlag(QGraphicsItem.ItemIsSelectable, True) self.setFlag(QGraphicsItem.ItemIsMovable, True) self.setFlag(QGraphicsItem.ItemIsFocusable, True) self.mousePressTime = QElapsedTimer() self.mousePressTime.start() self.__title = "" self.__processingState = 0 self.__progress = -1. self.__statusMessage = "" self.__renderedText = "" self.__error = None # type: Optional[str] self.__warning = None # type: Optional[str] self.__info = None # type: Optional[str] self.__messages = {} # type: Dict[Any, UserMessage] self.__anchorLayout = None self.__animationEnabled = False self.setZValue(self.Z_VALUE) shape_rect = QRectF(-24, -24, 48, 48) self.shapeItem = NodeBodyItem(self) self.shapeItem.setShapeRect(shape_rect) self.shapeItem.setAnimationEnabled(self.__animationEnabled) # Rect for widget's 'ears'. anchor_rect = QRectF(-31, -31, 62, 62) self.inputAnchorItem = SinkAnchorItem(self) input_path = QPainterPath() start_angle = 180 - self.ANCHOR_SPAN_ANGLE / 2 input_path.arcMoveTo(anchor_rect, start_angle) input_path.arcTo(anchor_rect, start_angle, self.ANCHOR_SPAN_ANGLE) self.inputAnchorItem.setAnchorPath(input_path) self.inputAnchorItem.setAnimationEnabled(self.__animationEnabled) self.outputAnchorItem = SourceAnchorItem(self) output_path = QPainterPath() start_angle = self.ANCHOR_SPAN_ANGLE / 2 output_path.arcMoveTo(anchor_rect, start_angle) output_path.arcTo(anchor_rect, start_angle, - self.ANCHOR_SPAN_ANGLE) self.outputAnchorItem.setAnchorPath(output_path) self.outputAnchorItem.setAnimationEnabled(self.__animationEnabled) self.inputAnchorItem.hide() self.outputAnchorItem.hide() # Title caption item self.captionTextItem = GraphicsTextEdit( self, editTriggers=GraphicsTextEdit.NoEditTriggers, returnKeyEndsEditing=True, ) self.captionTextItem.setTabChangesFocus(True) self.captionTextItem.setPlainText("") self.captionTextItem.setPos(0, 33) def iconItem(standard_pixmap): # type: (QStyle.StandardPixmap) -> GraphicsIconItem item = GraphicsIconItem( self, icon=standard_icon(standard_pixmap), iconSize=QSize(16, 16) ) item.hide() return item self.errorItem = iconItem(QStyle.SP_MessageBoxCritical) self.warningItem = iconItem(QStyle.SP_MessageBoxWarning) self.infoItem = iconItem(QStyle.SP_MessageBoxInformation) self.prepareGeometryChange() self.__boundingRect = None if widget_description is not None: self.setWidgetDescription(widget_description) @classmethod def from_node(cls, node): """ Create an :class:`NodeItem` instance and initialize it from a :class:`SchemeNode` instance. """ self = cls() self.setWidgetDescription(node.description) # self.setCategoryDescription(node.category) return self @classmethod def from_node_meta(cls, meta_description): """ Create an `NodeItem` instance from a node meta description. """ self = cls() self.setWidgetDescription(meta_description) return self # TODO: Remove the set[Widget|Category]Description. The user should # handle setting of icons, title, ... def setWidgetDescription(self, desc): # type: (WidgetDescription) -> None """ Set widget description. """ self.widget_description = desc if desc is None: return icon = icon_loader.from_description(desc).get(desc.icon) if icon: self.setIcon(icon) if not self.title(): self.setTitle(desc.name) if desc.inputs: self.inputAnchorItem.setSignals(desc.inputs) self.inputAnchorItem.show() if desc.outputs: self.outputAnchorItem.setSignals(desc.outputs) self.outputAnchorItem.show() tooltip = NodeItem_toolTipHelper(self) self.setToolTip(tooltip) def setWidgetCategory(self, desc): # type: (CategoryDescription) -> None """ Set the widget category. """ self.category_description = desc if desc and desc.background: background = NAMED_COLORS.get(desc.background, desc.background) color = QColor(background) if color.isValid(): self.setColor(color) def setIcon(self, icon): # type: (QIcon) -> None """ Set the node item's icon (:class:`QIcon`). """ self.icon_item = GraphicsIconItem( self.shapeItem, icon=icon, iconSize=QSize(36, 36) ) # assuming light-ish color background self.icon_item.setPalette(styles.breeze_light()) self.icon_item.setPos(-18, -18) def setColor(self, color, selectedColor=None): # type: (QColor, Optional[QColor]) -> None """ Set the widget color. """ if selectedColor is None: selectedColor = saturated(color, 150) palette = create_palette(color, selectedColor) self.shapeItem.setPalette(palette) def setTitle(self, title): # type: (str) -> None """ Set the node title. The title text is displayed at the bottom of the node. """ if self.__title != title: self.__title = title if self.captionTextItem.isEditing(): self.captionTextItem.setPlainText(title) else: self.__updateTitleText() def title(self): # type: () -> str """ Return the node title. """ return self.__title title_ = Property(str, fget=title, fset=setTitle, doc="Node title text.") #: Title editing has started titleEditingStarted = Signal() #: Title editing has finished titleEditingFinished = Signal() def editTitle(self): """ Start the inline title text edit process. """ if self.captionTextItem.isEditing(): return self.captionTextItem.setPlainText(self.__title) self.captionTextItem.selectAll() self.captionTextItem.setAlignment(Qt.AlignCenter) self.captionTextItem.document().clearUndoRedoStacks() self.captionTextItem.editingFinished.connect(self.__editTitleFinish) self.captionTextItem.edit() doc = self.captionTextItem.document() doc.documentLayout().documentSizeChanged.connect( self.__autoLayoutTitleText, Qt.UniqueConnection ) self.titleEditingStarted.emit() def __editTitleFinish(self): # called when title editing has finished self.captionTextItem.editingFinished.disconnect(self.__editTitleFinish) doc = self.captionTextItem.document() doc.documentLayout().documentSizeChanged.disconnect( self.__autoLayoutTitleText ) name = self.captionTextItem.toPlainText() if name != self.__title: self.setTitle(name) self.__updateTitleText() self.titleEditingFinished.emit() @Slot() def __autoLayoutTitleText(self): # auto layout the title during editing doc = self.captionTextItem.document() doc_copy = doc.clone() doc_copy.adjustSize() width = doc_copy.textWidth() doc_copy.deleteLater() if width == doc.textWidth(): return self.prepareGeometryChange() self.__boundingRect = None with disconnected( doc.documentLayout().documentSizeChanged, self.__autoLayoutTitleText ): doc.adjustSize() width = self.captionTextItem.textWidth() self.captionTextItem.setPos(-width / 2.0, 33) def setAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the node animation enabled state. """ if self.__animationEnabled != enabled: self.__animationEnabled = enabled self.shapeItem.setAnimationEnabled(enabled) self.outputAnchorItem.setAnimationEnabled(self.__animationEnabled) self.inputAnchorItem.setAnimationEnabled(self.__animationEnabled) def animationEnabled(self): # type: () -> bool """ Are node animations enabled. """ return self.__animationEnabled def setProcessingState(self, state): # type: (int) -> None """ Set the node processing state i.e. the node is processing (is busy) or is idle. """ if self.__processingState != state: self.__processingState = state self.shapeItem.setProcessingState(state) if not state: # Clear the progress meter. self.setProgress(-1) if self.__animationEnabled: self.shapeItem.ping() def processingState(self): # type: () -> int """ The node processing state. """ return self.__processingState processingState_ = Property(int, fget=processingState, fset=setProcessingState) def setProgress(self, progress): # type: (float) -> None """ Set the node work progress state (number between 0 and 100). """ if progress is None or progress < 0 or not self.__processingState: progress = -1. progress = clip(progress, -1, 100.) if self.__progress != progress: self.__progress = progress self.shapeItem.setProgress(progress) self.__updateTitleText() def progress(self): # type: () -> float """ Return the node work progress state. """ return self.__progress progress_ = Property(float, fget=progress, fset=setProgress, doc="Node progress state.") def setStatusMessage(self, message): # type: (str) -> None """ Set the node status message text. This text is displayed below the node's title. """ if self.__statusMessage != message: self.__statusMessage = message self.__updateTitleText() def statusMessage(self): # type: () -> str return self.__statusMessage def setStateMessage(self, message): # type: (UserMessage) -> None """ Set a state message to display over the item. Parameters ---------- message : UserMessage Message to display. `message.severity` is used to determine the icon and `message.contents` is used as a tool tip. """ self.__messages[message.message_id] = message self.__updateMessages() def setErrorMessage(self, message): if self.__error != message: self.__error = message self.__updateMessages() def setWarningMessage(self, message): if self.__warning != message: self.__warning = message self.__updateMessages() def setInfoMessage(self, message): if self.__info != message: self.__info = message self.__updateMessages() def newInputAnchor(self, signal=None): # type: (Optional[InputSignal]) -> AnchorPoint """ Create and return a new input :class:`AnchorPoint`. """ if not (self.widget_description and self.widget_description.inputs): raise ValueError("Widget has no inputs.") anchor = AnchorPoint(self, signal=signal) self.inputAnchorItem.addAnchor(anchor) return anchor def removeInputAnchor(self, anchor): # type: (AnchorPoint) -> None """ Remove input anchor. """ self.inputAnchorItem.removeAnchor(anchor) def newOutputAnchor(self, signal=None): # type: (Optional[OutputSignal]) -> AnchorPoint """ Create and return a new output :class:`AnchorPoint`. """ if not (self.widget_description and self.widget_description.outputs): raise ValueError("Widget has no outputs.") anchor = AnchorPoint(self, signal=signal) self.outputAnchorItem.addAnchor(anchor) return anchor def removeOutputAnchor(self, anchor): # type: (AnchorPoint) -> None """ Remove output anchor. """ self.outputAnchorItem.removeAnchor(anchor) def inputAnchors(self): # type: () -> List[AnchorPoint] """ Return a list of all input anchor points. """ return self.inputAnchorItem.anchorPoints() def outputAnchors(self): # type: () -> List[AnchorPoint] """ Return a list of all output anchor points. """ return self.outputAnchorItem.anchorPoints() def setAnchorRotation(self, angle): # type: (float) -> None """ Set the anchor rotation. """ self.inputAnchorItem.setRotation(angle) self.outputAnchorItem.setRotation(angle) self.anchorGeometryChanged.emit() def anchorRotation(self): # type: () -> float """ Return the anchor rotation. """ return self.inputAnchorItem.rotation() def boundingRect(self): # type: () -> QRectF # TODO: Important because of this any time the child # items change geometry the self.prepareGeometryChange() # needs to be called. if self.__boundingRect is None: self.__boundingRect = self.childrenBoundingRect() return QRectF(self.__boundingRect) def shape(self): # type: () -> QPainterPath # Shape for mouse hit detection. # TODO: Should this return the union of all child items? return self.shapeItem.shape() def __updateTitleText(self): # type: () -> None """ Update the title text item. """ if self.captionTextItem.isEditing(): return text = ['
              %s' % escape(self.title())] status_text = [] progress_included = False if self.__statusMessage: msg = escape(self.__statusMessage) format_fields = dict(parse_format_fields(msg)) if "progress" in format_fields and len(format_fields) == 1: # Insert progress into the status text format string. spec, _ = format_fields["progress"] if spec is not None: progress_included = True progress_str = "{0:.0f}%".format(self.progress()) status_text.append(msg.format(progress=progress_str)) else: status_text.append(msg) if self.progress() >= 0 and not progress_included: status_text.append("%i%%" % int(self.progress())) if status_text: text += ["
              ", '', "
              ".join(status_text), "
              "] text += ["
              "] text = "".join(text) if self.__renderedText != text: self.__renderedText = text # The NodeItems boundingRect could change. self.prepareGeometryChange() self.__boundingRect = None self.captionTextItem.setHtml(text) self.__layoutCaptionTextItem() def __layoutCaptionTextItem(self): self.prepareGeometryChange() self.__boundingRect = None self.captionTextItem.document().adjustSize() width = self.captionTextItem.textWidth() self.captionTextItem.setPos(-width / 2.0, 33) def __updateMessages(self): # type: () -> None """ Update message items (position, visibility and tool tips). """ items = [self.errorItem, self.warningItem, self.infoItem] messages = list(self.__messages.values()) + [ UserMessage(self.__error or "", UserMessage.Error, message_id="_error"), UserMessage(self.__warning or "", UserMessage.Warning, message_id="_warn"), UserMessage(self.__info or "", UserMessage.Info, message_id="_info"), ] key = attrgetter("severity") messages = groupby(sorted(messages, key=key, reverse=True), key=key) for (_, message_g), item in zip(messages, items): message = "
              ".join(m.contents for m in message_g if m.contents) item.setVisible(bool(message)) if bool(message): item.anim.start(QPropertyAnimation.KeepWhenStopped) item.setToolTip(message or "") shown = [item for item in items if item.isVisible()] count = len(shown) if count: spacing = 3 rects = [item.boundingRect() for item in shown] width = sum(rect.width() for rect in rects) width += spacing * max(0, count - 1) height = max(rect.height() for rect in rects) origin = self.shapeItem.boundingRect().top() - spacing - height origin = QPointF(-width / 2, origin) for item, rect in zip(shown, rects): item.setPos(origin) origin = origin + QPointF(rect.width() + spacing, 0) def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> None if self.mousePressTime.elapsed() < QApplication.doubleClickInterval(): # Double-click triggers two mouse press events and a double-click event. # Ignore the second mouse press event (causes widget's node relocation with # Logitech's Smart Move). event.ignore() else: self.mousePressTime.restart() if self.shapeItem.path().contains(event.pos()): super().mousePressEvent(event) else: event.ignore() def mouseDoubleClickEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> None if self.shapeItem.path().contains(event.pos()): super().mouseDoubleClickEvent(event) QTimer.singleShot(0, self.activated.emit) else: event.ignore() def contextMenuEvent(self, event): # type: (QGraphicsSceneContextMenuEvent) -> None if self.shapeItem.path().contains(event.pos()): super().contextMenuEvent(event) else: event.ignore() def changeEvent(self, event): if event.type() == QEvent.PaletteChange: self.__updatePalette() elif event.type() == QEvent.FontChange: self.__updateFont() super().changeEvent(event) def itemChange(self, change, value): # type: (QGraphicsItem.GraphicsItemChange, Any) -> Any if change == QGraphicsItem.ItemSelectedHasChanged: self.shapeItem.setSelected(value) self.captionTextItem.setSelectionState(value) self.selectedChanged.emit(value) elif change == QGraphicsItem.ItemPositionHasChanged: self.positionChanged.emit() return super().itemChange(change, value) def __updatePalette(self): # type: () -> None palette = self.palette() self.captionTextItem.setPalette(palette) def __updateFont(self): # type: () -> None self.prepareGeometryChange() self.captionTextItem.setFont(self.font()) self.__layoutCaptionTextItem() TOOLTIP_TEMPLATE = """\ {tooltip} """ def NodeItem_toolTipHelper(node, links_in=[], links_out=[]): # type: (NodeItem, List[LinkItem], List[LinkItem]) -> str """ A helper function for constructing a standard tooltip for the node in on the canvas. Parameters: =========== node : NodeItem The node item instance. links_in : list of LinkItem instances A list of input links for the node. links_out : list of LinkItem instances A list of output links for the node. """ desc = node.widget_description channel_fmt = "
            • {0}
            • " title_fmt = "{title}
              " title = title_fmt.format(title=escape(node.title())) inputs_list_fmt = "Inputs:
                {inputs}

              " outputs_list_fmt = "Outputs:
                {outputs}
              " if desc.inputs: inputs = [channel_fmt.format(inp.name) for inp in desc.inputs] inputs = inputs_list_fmt.format(inputs="".join(inputs)) else: inputs = "No inputs
              " if desc.outputs: outputs = [channel_fmt.format(out.name) for out in desc.outputs] outputs = outputs_list_fmt.format(outputs="".join(outputs)) else: outputs = "No outputs" tooltip = title + inputs + outputs style = "ul { margin-top: 1px; margin-bottom: 1px; }" return TOOLTIP_TEMPLATE.format(style=style, tooltip=tooltip) def parse_format_fields(format_str): # type: (str) -> List[Tuple[str, Tuple[Optional[str], Optional[str]]]] formatter = string.Formatter() format_fields = [(field, (spec, conv)) for _, field, spec, conv in formatter.parse(format_str) if field is not None] return format_fields from .linkitem import LinkItem ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.782081 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/0000755000175100002000000000000014730024333023130 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/__init__.py0000644000175100002000000000134214730024325025242 0ustar00runnerdocker""" Tests for items """ from AnyQt.QtWidgets import QGraphicsScene, QGraphicsView from AnyQt.QtGui import QPainter from orangecanvas.gui.test import QAppTestCase class TestItems(QAppTestCase): def setUp(self): super().setUp() self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) self.view.setRenderHints( QPainter.Antialiasing | QPainter.SmoothPixmapTransform | QPainter.TextAntialiasing ) self.view.resize(500, 300) self.view.show() def tearDown(self): self.scene.clear() self.scene.deleteLater() self.view.deleteLater() del self.scene del self.view super().tearDown() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/test_annotationitem.py0000644000175100002000000000403514730024325027575 0ustar00runnerdockerimport math import time from AnyQt.QtGui import QColor from AnyQt.QtCore import Qt, QRectF, QLineF, QTimer, QPointF from ..annotationitem import TextAnnotation, ArrowAnnotation, ArrowItem from . import TestItems class TestAnnotationItem(TestItems): def test_textannotation(self): text = "Annotation" annot = TextAnnotation() annot.setPlainText(text) self.assertEqual(annot.toPlainText(), text) annot2 = TextAnnotation() self.assertEqual(annot2.toPlainText(), "") text = "This is an annotation" annot2.setPlainText(text) self.assertEqual(annot2.toPlainText(), text) annot2.setDefaultTextColor(Qt.red) control_rect = QRectF(0, 0, 100, 200) annot2.setGeometry(control_rect) self.assertEqual(annot2.geometry(), control_rect) annot.setTextInteractionFlags(Qt.TextEditorInteraction) annot.setPos(400, 100) annot.adjustSize() annot._TextAnnotation__textItem.setFocus() annot3 = TextAnnotation(pos=QPointF(100, 100)) self.scene.addItem(annot) self.scene.addItem(annot2) self.scene.addItem(annot3) self.qWait() def test_arrowannotation(self): item = ArrowItem() self.scene.addItem(item) item.setLine(QLineF(100, 100, 100, 200)) item.setLineWidth(5) item = ArrowItem() item.setLine(QLineF(150, 100, 150, 200)) item.setLineWidth(10) item.setArrowStyle(ArrowItem.Concave) self.scene.addItem(item) item = ArrowAnnotation() item.setPos(10, 10) item.setLine(QLineF(10, 10, 200, 200)) self.scene.addItem(item) item.setLineWidth(5) def advance(): clock = time.process_time() * 10 item.setLineWidth(5 + math.sin(clock) * 5) item.setColor(QColor(Qt.red).lighter(100 + int(30 * math.cos(clock)))) timer = QTimer(item, interval=10) timer.timeout.connect(advance) timer.start() self.qWait() timer.stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/test_controlpoints.py0000644000175100002000000000346314730024325027465 0ustar00runnerdockerfrom AnyQt.QtWidgets import QGraphicsRectItem, QGraphicsLineItem from AnyQt.QtCore import QRectF, QMargins, QLineF from . import TestItems from ..controlpoints import ControlPoint, ControlPointRect, ControlPointLine class TestControlPoints(TestItems): def test_controlpoint(self): point = ControlPoint() self.scene.addItem(point) point.setAnchor(ControlPoint.Left) self.assertEqual(point.anchor(), ControlPoint.Left) def test_controlpointrect(self): control = ControlPointRect() rect = QGraphicsRectItem(QRectF(10, 10, 100, 200)) self.scene.addItem(rect) self.scene.addItem(control) control.setRect(rect.rect()) control.setFocus() control.rectChanged.connect(rect.setRect) control.setRect(QRectF(20, 20, 100, 200)) self.assertEqual(control.rect(), rect.rect()) self.assertEqual(control.rect(), QRectF(20, 20, 100, 200)) control.setControlMargins(5) self.assertEqual(control.controlMargins(), QMargins(5, 5, 5, 5)) control.rectEdited.connect(rect.setRect) self.view.show() self.qWait() self.assertEqual(rect.rect(), control.rect()) def test_controlpointline(self): control = ControlPointLine() line = QGraphicsLineItem(10, 10, 200, 200) self.scene.addItem(line) self.scene.addItem(control) control.setLine(line.line()) control.setFocus() control.lineChanged.connect(line.setLine) control.setLine(QLineF(30, 30, 180, 180)) self.assertEqual(control.line(), line.line()) self.assertEqual(line.line(), QLineF(30, 30, 180, 180)) control.lineEdited.connect(line.setLine) self.view.show() self.qWait() self.assertEqual(control.line(), line.line()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/test_graphicspathobject.py0000644000175100002000000000375514730024325030420 0ustar00runnerdockerfrom AnyQt.QtGui import QPainterPath, QBrush, QPen, QColor from AnyQt.QtCore import QPointF from . import TestItems from ..graphicspathobject import GraphicsPathObject, shapeFromPath def area(rect): return rect.width() * rect.height() class TestGraphicsPathObject(TestItems): def test_graphicspathobject(self): obj = GraphicsPathObject() path = QPainterPath() obj.setFlag(GraphicsPathObject.ItemIsMovable) path.addEllipse(20, 20, 50, 50) obj.setPath(path) self.assertEqual(obj.path(), path) self.assertTrue(obj.path() is not path, msg="setPath stores the path not a copy") brect = obj.boundingRect() self.assertTrue(brect.contains(path.boundingRect())) with self.assertRaises(TypeError): obj.setPath("This is not a path") brush = QBrush(QColor("#ffbb11")) obj.setBrush(brush) self.assertEqual(obj.brush(), brush) self.assertTrue(obj.brush() is not brush, "setBrush stores the brush not a copy") pen = QPen(QColor("#FFFFFF"), 1.4) obj.setPen(pen) self.assertEqual(obj.pen(), pen) self.assertTrue(obj.pen() is not pen, "setPen stores the pen not a copy") brect = obj.boundingRect() self.assertGreaterEqual(area(brect), (50 + 1.4 * 2) ** 2) self.assertIsInstance(obj.shape(), QPainterPath) positions = [] obj.positionChanged[QPointF].connect(positions.append) pos = QPointF(10, 10) obj.setPos(pos) self.assertEqual(positions, [pos]) self.scene.addItem(obj) self.view.show() self.qWait() def test_shapeFromPath(self): path = QPainterPath() path.addRect(10, 10, 20, 20) pen = QPen(QColor("#FFF"), 2.0) path = shapeFromPath(path, pen) self.assertGreaterEqual(area(path.controlPointRect()), (20 + 2.0) ** 2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/test_graphicstextitem.py0000644000175100002000000000621614730024325030133 0ustar00runnerdockerfrom typing import Optional from unittest import skipUnless from AnyQt.QtCore import QPointF, QPoint, Qt, QT_VERSION_INFO from AnyQt.QtTest import QSignalSpy from AnyQt.QtWidgets import ( QGraphicsScene, QGraphicsView, QGraphicsItem, QMenu, QAction, QApplication, QWidget ) from orangecanvas.gui.test import QAppTestCase, contextMenu from orangecanvas.canvas.items.graphicstextitem import GraphicsTextItem from orangecanvas.utils import findf @skipUnless((5, 15, 1) <= QT_VERSION_INFO < (6, 0, 0), "contextMenuEvent is not reimplemented") class TestGraphicsTextItem(QAppTestCase): def setUp(self): super().setUp() self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) self.item = GraphicsTextItem() self.item.setPlainText("AAA") self.item.setTextInteractionFlags(Qt.TextEditable) self.scene.addItem(self.item) self.view.setFocus() def tearDown(self): self.scene.clear() self.view.deleteLater() del self.scene del self.view super().tearDown() def test_item_context_menu(self): item = self.item menu = self._context_menu() self.assertFalse(item.textCursor().hasSelection()) ac = find_action(menu, "select-all") self.assertTrue(ac.isEnabled()) ac.trigger() self.assertTrue(item.textCursor().hasSelection()) def test_copy_cut_paste(self): item = self.item cb = QApplication.clipboard() c = item.textCursor() c.select(c.Document) item.setTextCursor(c) menu = self._context_menu() ac = find_action(menu, "edit-copy") spy = QSignalSpy(cb.dataChanged) ac.trigger() self.assertTrue(len(spy) or spy.wait()) ac = find_action(menu, "edit-cut") spy = QSignalSpy(cb.dataChanged) ac.trigger() self.assertTrue(len(spy) or spy.wait()) self.assertEqual(item.toPlainText(), "") ac = find_action(menu, "edit-paste") ac.trigger() self.assertEqual(item.toPlainText(), "AAA") def test_context_menu_delete(self): item = self.item c = item.textCursor() c.select(c.Document) item.setTextCursor(c) menu = self._context_menu() ac = find_action(menu, "edit-delete") ac.trigger() self.assertEqual(self.item.toPlainText(), "") def _context_menu(self): point = map_to_viewport(self.view, self.item, self.item.boundingRect().center()) contextMenu(self.view.viewport(), point) return self._get_menu() def _get_menu(self) -> QMenu: menu = findf( self.app.topLevelWidgets(), lambda w: isinstance(w, QMenu) and w.parent() is self.view.viewport() ) assert menu is not None return menu def map_to_viewport(view: QGraphicsView, item: QGraphicsItem, point: QPointF) -> QPoint: point = item.mapToScene(point) return view.mapFromScene(point) def find_action(widget, name): # type: (QWidget, str) -> Optional[QAction] for a in widget.actions(): if a.objectName() == name: return a return None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/test_linkitem.py0000644000175100002000000000770114730024325026363 0ustar00runnerdockerimport time from AnyQt.QtCore import QTimer from ..linkitem import LinkItem from .. import NodeItem, AnchorPoint from ....registry.tests import small_testing_registry from . import TestItems class TestLinkItem(TestItems): def test_linkitem(self): reg = small_testing_registry() const_desc = reg.category("Constants") one_desc = reg.widget("one") one_item = NodeItem() one_item.setWidgetDescription(one_desc) one_item.setWidgetCategory(const_desc) one_item.setPos(0, 100) negate_desc = reg.widget("negate") negate_item = NodeItem() negate_item.setWidgetDescription(negate_desc) negate_item.setWidgetCategory(const_desc) negate_item.setPos(200, 100) operator_desc = reg.category("Operators") add_desc = reg.widget("add") nb_item = NodeItem() nb_item.setWidgetDescription(add_desc) nb_item.setWidgetCategory(operator_desc) nb_item.setPos(400, 100) self.scene.addItem(one_item) self.scene.addItem(negate_item) self.scene.addItem(nb_item) link = LinkItem() anchor1 = one_item.newOutputAnchor() anchor2 = negate_item.newInputAnchor() self.assertSequenceEqual(one_item.outputAnchors(), [anchor1]) self.assertSequenceEqual(negate_item.inputAnchors(), [anchor2]) link.setSourceItem(one_item, anchor=anchor1) link.setSinkItem(negate_item, anchor=anchor2) # Setting an item and an anchor not in the item's anchors raises # an error. with self.assertRaises(ValueError): link.setSourceItem(one_item, anchor=AnchorPoint()) self.assertSequenceEqual(one_item.outputAnchors(), [anchor1]) anchor2 = one_item.newOutputAnchor() link.setSourceItem(one_item, anchor=anchor2) self.assertSequenceEqual(one_item.outputAnchors(), [anchor1, anchor2]) self.assertIs(link.sourceAnchor, anchor2) one_item.removeOutputAnchor(anchor1) self.scene.addItem(link) link = LinkItem() link.setSourceItem(negate_item) link.setSinkItem(nb_item) self.scene.addItem(link) self.assertTrue(len(nb_item.inputAnchors()) == 1) self.assertTrue(len(negate_item.outputAnchors()) == 1) self.assertTrue(len(negate_item.inputAnchors()) == 1) self.assertTrue(len(one_item.outputAnchors()) == 1) link.removeLink() self.assertTrue(len(nb_item.inputAnchors()) == 0) self.assertTrue(len(negate_item.outputAnchors()) == 0) self.assertTrue(len(negate_item.inputAnchors()) == 1) self.assertTrue(len(one_item.outputAnchors()) == 1) self.qWait() def test_dynamic_link(self): link = LinkItem() anchor1 = AnchorPoint() anchor2 = AnchorPoint() self.scene.addItem(link) self.scene.addItem(anchor1) self.scene.addItem(anchor2) link.setSourceItem(None, anchor=anchor1) link.setSinkItem(None, anchor=anchor2) anchor2.setPos(100, 100) link.setSourceName("1") link.setSinkName("2") link.setDynamic(True) self.assertTrue(link.isDynamic()) link.setDynamicEnabled(True) self.assertTrue(link.isDynamicEnabled()) def advance(): clock = time.process_time() link.setDynamic(clock > 1) link.setDynamicEnabled(int(clock) % 2 == 0) timer = QTimer(link, interval=0) timer.timeout.connect(advance) timer.start() self.qWait() timer.stop() def test_link_enabled(self): link = LinkItem() anchor1 = AnchorPoint() anchor2 = AnchorPoint() anchor2.setPos(100, 100) link.setSourceItem(None, anchor=anchor1) link.setSinkItem(None, anchor=anchor2) link.setEnabled(False) self.assertFalse(link.isEnabled()) link.setEnabled(True) self.assertTrue(link.isEnabled()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/test_nodeitem.py0000644000175100002000000001560414730024325026354 0ustar00runnerdockerfrom AnyQt.QtCore import QTimer, Qt from AnyQt.QtWidgets import QGraphicsEllipseItem from AnyQt.QtGui import QPainterPath from AnyQt.QtTest import QSignalSpy, QTest from .. import NodeItem, AnchorPoint, NodeAnchorItem from . import TestItems from ....registry import InputSignal from ....registry.tests import small_testing_registry class TestNodeItem(TestItems): def setUp(self): super().setUp() self.reg = small_testing_registry() self.const_desc = self.reg.category("Constants") self.operator_desc = self.reg.category("Operators") self.one_desc = self.reg.widget("one") self.negate_desc = self.reg.widget("negate") self.add_desc = self.reg.widget("add") def test_nodeitem(self): one_item = NodeItem() one_item.setWidgetDescription(self.one_desc) one_item.setWidgetCategory(self.const_desc) one_item.setTitle("Neo") self.assertEqual(one_item.title(), "Neo") one_item.setProcessingState(True) self.assertEqual(one_item.processingState(), True) one_item.setProgress(50) self.assertEqual(one_item.progress(), 50) one_item.setProgress(100) self.assertEqual(one_item.progress(), 100) one_item.setProgress(101) self.assertEqual(one_item.progress(), 100, "Progress overshots") one_item.setProcessingState(False) self.assertEqual(one_item.processingState(), False) self.assertEqual(one_item.progress(), -1, "setProcessingState does not clear the progress.") self.scene.addItem(one_item) one_item.setPos(100, 100) negate_item = NodeItem() negate_item.setWidgetDescription(self.negate_desc) negate_item.setWidgetCategory(self.const_desc) self.scene.addItem(negate_item) negate_item.setPos(300, 100) nb_item = NodeItem() nb_item.setWidgetDescription(self.add_desc) nb_item.setWidgetCategory(self.operator_desc) self.scene.addItem(nb_item) nb_item.setPos(500, 100) positions = [] anchor = one_item.newOutputAnchor() anchor.scenePositionChanged.connect(positions.append) one_item.setPos(110, 100) self.assertTrue(len(positions) > 0) one_item.setErrorMessage("message") one_item.setWarningMessage("message") one_item.setInfoMessage("I am alive") one_item.setErrorMessage(None) one_item.setWarningMessage(None) one_item.setInfoMessage(None) one_item.setInfoMessage("I am back.") nb_item.setProcessingState(1) negate_item.setProcessingState(1) negate_item.shapeItem.startSpinner() def progress(): p = (nb_item.progress() + 25) % 100 nb_item.setProgress(p) if p > 50: nb_item.setInfoMessage("Over 50%") one_item.setWarningMessage("Second") else: nb_item.setInfoMessage(None) one_item.setWarningMessage(None) negate_item.setAnchorRotation(50 - p) timer = QTimer(nb_item, interval=5) timer.start() timer.timeout.connect(progress) self.qWait() timer.stop() def test_nodeanchors(self): one_item = NodeItem() one_item.setWidgetDescription(self.one_desc) one_item.setWidgetCategory(self.const_desc) one_item.setTitle("File Node") self.scene.addItem(one_item) one_item.setPos(100, 100) negate_item = NodeItem() negate_item.setWidgetDescription(self.negate_desc) negate_item.setWidgetCategory(self.const_desc) self.scene.addItem(negate_item) negate_item.setPos(300, 100) nb_item = NodeItem() nb_item.setWidgetDescription(self.add_desc) nb_item.setWidgetCategory(self.operator_desc) with self.assertRaises(ValueError): one_item.newInputAnchor() anchor = one_item.newOutputAnchor() self.assertIsInstance(anchor, AnchorPoint) self.qWait() def test_anchoritem(self): anchoritem = NodeAnchorItem(None) anchoritem.setAnimationEnabled(False) self.scene.addItem(anchoritem) path = QPainterPath() path.addEllipse(0, 0, 100, 100) anchoritem.setAnchorPath(path) anchor = AnchorPoint() anchoritem.addAnchor(anchor) ellipse1 = QGraphicsEllipseItem(-3, -3, 6, 6) ellipse2 = QGraphicsEllipseItem(-3, -3, 6, 6) self.scene.addItem(ellipse1) self.scene.addItem(ellipse2) anchor.scenePositionChanged.connect(ellipse1.setPos) with self.assertRaises(ValueError): anchoritem.addAnchor(anchor) anchor1 = AnchorPoint() anchoritem.addAnchor(anchor1) anchor1.scenePositionChanged.connect(ellipse2.setPos) self.assertSequenceEqual(anchoritem.anchorPoints(), [anchor, anchor1]) self.assertSequenceEqual(anchoritem.anchorPositions(), [2/3, 1/3]) anchoritem.setAnchorPositions([0.5, 0.0]) self.assertSequenceEqual(anchoritem.anchorPositions(), [0.5, 0.0]) def advance(): t = anchoritem.anchorPositions() t = [(t + 0.05) % 1.0 for t in t] anchoritem.setAnchorPositions(t) timer = QTimer(anchoritem, interval=10) timer.start() timer.timeout.connect(advance) self.qWait() timer.stop() anchoritem.setAnchorOpen(True) anchoritem.setHovered(True) self.assertEqual(*[ p.scenePos() for p in anchoritem.anchorPoints() ]) anchoritem.setAnchorOpen(False) self.assertNotEqual(*[ p.scenePos() for p in anchoritem.anchorPoints() ]) anchoritem.setAnchorOpen(False) anchoritem.setHovered(True) self.assertNotEqual(*[ p.scenePos() for p in anchoritem.anchorPoints() ]) path = anchoritem.anchorPath() anchoritem.setAnchored(True) anchoritem.setAnchorPath(path) self.assertEqual(path, anchoritem.anchorPath()) anchoritem.setAnchored(False) anchoritem.setAnchorPath(path) self.assertEqual(path, anchoritem.anchorPath()) def test_title_edit(self): item = NodeItem() item.setWidgetDescription(self.one_desc) self.scene.addItem(item) item.setTitle("AA") item.setStatusMessage("BB") self.assertIn("BB", item.captionTextItem.toPlainText()) spy = QSignalSpy(item.titleEditingFinished) item.editTitle() self.assertEqual(len(spy), 0) self.assertEqual("AA", item.captionTextItem.toPlainText()) QTest.keyClicks(self.view.viewport(), "CCCC") QTest.keyClick(self.view.viewport(), Qt.Key_Enter) self.assertEqual(len(spy), 1) self.assertIn("BB", item.captionTextItem.toPlainText()) self.assertIn("CCCC", item.captionTextItem.toPlainText()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/tests/test_utils.py0000644000175100002000000000733014730024325025705 0ustar00runnerdockerimport math import unittest from AnyQt.QtGui import QPainterPath from ..utils import ( linspace, linspace_trunc, argsort, composition, qpainterpath_sub_path ) class TestUtils(unittest.TestCase): def test_linspace(self): cases = [ (0, []), (1, [0.0]), (2, [0.0, 1.0]), (3, [0.0, 0.5, 1.0]), (4, [0.0, 1./3, 2./3, 1.0]), (5, [0.0, 0.25, 0.5, 0.75, 1.0]), ] for n, expected in cases: self.assertSequenceEqual( list(linspace(n)), expected ) def test_linspace_trunc(self): cases = [ (0, []), (1, [0.5]), (2, [1./3, 2./3]), (3, [0.25, 0.5, 0.75]), ] for n, expected in cases: self.assertSequenceEqual( list(linspace_trunc(n)), expected ) def test_argsort(self): cases = [ ([], []), ([1], [0]), ([1, 2, 3], [0, 1, 2]), (['c', 'b', 'a'], [2, 1, 0]), ([(2, 'b'), (3, 'c'), (1, 'a')], [2, 0, 1]) ] for seq, expected in cases: self.assertSequenceEqual( argsort(seq), expected ) self.assertSequenceEqual( argsort(seq, reverse=True), expected[::-1] ) cases = [ ([(2, 2), (3,), (5,)], [1, 0, 2]), ] for seq, expected in cases: self.assertSequenceEqual(argsort(seq, key=sum), expected) self.assertSequenceEqual(argsort(seq, key=sum, reverse=True), expected[::-1]) def test_composition(self): idt = composition(ord, chr) self.assertEqual(idt("a"), "a") next = composition(composition(ord, lambda a: a + 1), chr) self.assertEqual(next("a"), "b") def test_qpainterpath_sub_path(self): path = QPainterPath() p = qpainterpath_sub_path(path, 0, 0.5) self.assertTrue(p.isEmpty()) path = QPainterPath() path.moveTo(0., 0.) path.quadTo(0.5, 0.0, 1.0, 0.0) p = qpainterpath_sub_path(path, 0, 0.5) els = p.elementAt(0) ele = p.elementAt(p.elementCount() - 1) self.assertEqual((els.x, els.y), (0.0, 0.0)) self.assertTrue(math.isclose(ele.x, 0.5)) self.assertEqual(ele.y, 0.0) p = qpainterpath_sub_path(path, 0.5, 1.0) els = p.elementAt(0) ele = p.elementAt(p.elementCount() - 1) self.assertTrue(math.isclose(els.x, 0.5)) self.assertEqual(els.y, 0.0) self.assertEqual((ele.x, ele.y), (1.0, 0.0)) path = QPainterPath() path.moveTo(0., 0.) path.lineTo(0.5, 0.0) path.lineTo(1.0, 0.0) p = qpainterpath_sub_path(path, 0.25, 0.75) els = p.elementAt(0) ele = p.elementAt(p.elementCount() - 1) self.assertTrue(math.isclose(els.x, 0.25)) self.assertEqual(els.y, 0.0) self.assertTrue(math.isclose(ele.x, 0.75)) self.assertEqual(ele.y, 0.0) path = QPainterPath() path.moveTo(0., 0.) path.lineTo(0.25, 0.) path.moveTo(0.75, 0.) path.lineTo(1.0, 0.) p = qpainterpath_sub_path(path, 0.0, 0.5) els = p.elementAt(0) ele = p.elementAt(p.elementCount() - 1) self.assertEqual((els.x, els.y), (0.0, 0.0)) self.assertTrue(math.isclose(ele.x, 0.25)) self.assertEqual(ele.y, 0.0) p = qpainterpath_sub_path(path, 0.5, 1.0) els = p.elementAt(0) ele = p.elementAt(p.elementCount() - 1) self.assertTrue(math.isclose(els.x, 0.75)) self.assertEqual(els.y, 0.0) self.assertEqual((ele.x, ele.y), (1.0, 0.0)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/items/utils.py0000644000175100002000000002464614730024325023515 0ustar00runnerdockerimport sys import math from itertools import islice, count from operator import itemgetter import typing from typing import List, Iterable, Optional, Callable, Any, Union, Tuple from AnyQt.QtCore import QPointF, QLineF from AnyQt.QtGui import ( QColor, QRadialGradient, QPainterPathStroker, QPainterPath, QPen ) from AnyQt.QtWidgets import QGraphicsItem if typing.TYPE_CHECKING: T = typing.TypeVar("T") A = typing.TypeVar("A") B = typing.TypeVar("B") C = typing.TypeVar("C") def composition(f, g): # type: (Callable[[A], B], Callable[[B], C]) -> Callable[[A], C] """ Return a composition of two functions. """ def fg(arg): # type: (A) -> C return g(f(arg)) return fg def argsort(iterable, key=None, reverse=False): # type: (Iterable[T], Optional[Callable[[T], Any]], bool) -> List[int] """ Return indices that sort elements of iterable in ascending order. A custom key function can be supplied to customize the sort order, and the reverse flag can be set to request the result in descending order. Parameters ---------- iterable : Iterable[T] key : Callable[[T], Any] reverse : bool Returns ------- indices : List[int] """ if key is None: key_ = itemgetter(0) else: key_ = composition(itemgetter(0), key) ordered = sorted(zip(iterable, count(0)), key=key_, reverse=reverse) return list(map(itemgetter(1), ordered)) def linspace(count): # type: (int) -> Iterable[float] """ Return `count` evenly spaced points from 0..1 interval. >>> list(linspace(3))) [0.0, 0.5, 1.0] """ if count > 1: return (i / (count - 1) for i in range(count)) elif count == 1: return (_ for _ in (0.0,)) elif count == 0: return (_ for _ in ()) else: raise ValueError("Count must be non-negative") def linspace_trunc(count): # type: (int) -> Iterable[float] """ Return `count` evenly spaced points from 0..1 interval *excluding* both end points. >>> list(linspace_trunc(3)) [0.25, 0.5, 0.75] """ return islice(linspace(count + 2), 1, count + 1) def sample_path(path, num=10): # type: (QPainterPath, int) -> List[QPointF] """ Sample `num` equidistant points from the `path` (`QPainterPath`). """ return [path.pointAtPercent(p) for p in linspace(num)] def clip(a, amin, amax): """Clip (limit) the value `a` between `amin` and `amax`""" return max(min(a, amax), amin) def saturated(color, factor=150): # type: (QColor, int) -> QColor """Return a saturated color. """ h = color.hsvHueF() s = color.hsvSaturationF() v = color.valueF() a = color.alphaF() s = factor * s / 100.0 s = clip(s, 0.0, 1.0) return QColor.fromHsvF(h, s, v, a).convertTo(color.spec()) def radial_gradient(color, color_light=50): # type: (QColor, Union[int, QColor]) -> QRadialGradient """ radial_gradient(QColor, QColor) radial_gradient(QColor, int) Return a radial gradient. `color_light` can be a QColor or an int. In the later case the light color is derived from `color` using `saturated(color, color_light)`. """ if not isinstance(color_light, QColor): color_light = saturated(color, color_light) gradient = QRadialGradient(0.5, 0.5, 0.5) gradient.setColorAt(0.0, color_light) gradient.setColorAt(0.5, color_light) gradient.setColorAt(1.0, color) gradient.setCoordinateMode(QRadialGradient.ObjectBoundingMode) return gradient def toGraphicsObjectIfPossible(item): """Return the item as a QGraphicsObject if possible. This function is intended as a workaround for a problem with older versions of PyQt (< 4.9), where methods returning 'QGraphicsItem *' lose the type of the QGraphicsObject subclasses and instead return generic QGraphicsItem wrappers. """ if item is None: return None obj = item.toGraphicsObject() return item if obj is None else obj def uniform_linear_layout_trunc(points): # type: (List[float]) -> List[float] """ Layout the points (a list of floats in 0..1 range) in a uniform linear space (truncated) while preserving the existing sorting order. """ indices = argsort(points) indices = invert_permutation_indices(indices) space = list(linspace_trunc(len(indices))) return [space[i] for i in indices] def invert_permutation_indices(indices): # type: (List[int]) -> List[int] """ Invert the permutation given by indices. """ inverted = [sys.maxsize] * len(indices) for i, index in enumerate(indices): inverted[index] = i return inverted def stroke_path(path, pen): # type: (QPainterPath, QPen) -> QPainterPath """Create a QPainterPath stroke from the `path` drawn with `pen`. """ stroker = QPainterPathStroker() stroker.setCapStyle(pen.capStyle()) stroker.setJoinStyle(pen.joinStyle()) stroker.setMiterLimit(pen.miterLimit()) stroker.setWidth(max(pen.widthF(), 1e-9)) return stroker.createStroke(path) def bezier_subdivide(cp, t): # type: (List[QPointF], float) -> Tuple[List[QPointF], List[QPointF]] """ Subdivide a cubic bezier curve defined by the control points `cp`. Parameters ---------- cp : List[QPointF] The control points for a cubic bezier curve. t : float The cut point; a value between 0 and 1. Returns ------- cp : Tuple[List[QPointF], List[QPointF]] Two lists of new control points for the new left and right part respectively. """ # http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-sub.html c00, c01, c02, c03 = cp c10 = c00 * (1 - t) + c01 * t c11 = c01 * (1 - t) + c02 * t c12 = c02 * (1 - t) + c03 * t c20 = c10 * (1 - t) + c11 * t c21 = c11 * (1 - t) + c12 * t c30 = c20 * (1 - t) + c21 * t first = [c00, c10, c20, c30] second = [c30, c21, c12, c03] return first, second class _Section(typing.NamedTuple): #: Single section path (single cubicTo, lineTo, moveTo path) p: QPainterPath #: The path section type type: QPainterPath.ElementType #: The approximate original start distance start: float #: The approximate original end distance end: float def _qpainterpath_sections(path: QPainterPath) -> Iterable[_Section]: """ Return `path` elementary *sections* (single line, single bezier or move elements). """ if path.isEmpty(): return el0 = path.elementAt(0) assert el0.type == QPainterPath.MoveToElement i = 1 section_start = section_end = 0 while i < path.elementCount(): el1 = path.elementAt(i) if el1.type == QPainterPath.LineToElement: p = QPainterPath() p.moveTo(el0.x, el0.y) p.lineTo(el1.x, el1.y) section_end += p.length() yield _Section(p, QPainterPath.LineToElement, section_start, section_end) i += 1 el0 = el1 section_start = section_end elif el1.type == QPainterPath.CurveToElement: c0, c1, c2, c3 = el0, el1, path.elementAt(i + 1), path.elementAt(i + 2) assert all(el.type == QPainterPath.CurveToDataElement for el in [c2, c3]) p0, p1, p2, p3 = [QPointF(el.x, el.y) for el in [c0, c1, c2, c3]] p = QPainterPath() p.moveTo(p0) p.cubicTo(p1, p2, p3) section_end += p.length() yield _Section(p, QPainterPath.CurveToElement, section_start, section_end) i += 3 el0 = c3 section_start = section_end elif el1.type == QPainterPath.MoveToElement: p = QPainterPath() p.moveTo(el1.x, el1.y) i += 1 el0 = el1 yield _Section(p, QPainterPath.MoveToElement, section_start, section_end) section_start = section_end def _qpainterpath_simple_cut(path: QPainterPath, start: float, end: float): """ Cut a sub path from a simple `path` (single lineTo or cubicTo). """ assert 0. <= start <= end <= 1.0 if path.elementCount() == 0: return QPainterPath() el0 = path.elementAt(0) assert el0.type == QPainterPath.MoveToElement if path.elementCount() == 1: return QPainterPath(path) el1 = path.elementAt(1) if el1.type == QPainterPath.LineToElement: segment = QLineF(el0.x, el0.y, el1.x, el1.y) p1 = segment.pointAt(start) p2 = segment.pointAt(end) p = QPainterPath() p.moveTo(p1) p.lineTo(p2) return p elif el1.type == QPainterPath.CurveToElement: c0, c1, c2, c3 = el0, el1, path.elementAt(2), path.elementAt(3) assert all(el.type == QPainterPath.CurveToDataElement for el in [c2, c3]) cp = [QPointF(el.x, el.y) for el in [c0, c1, c2, c3]] # adjust the end # |---------+---------+-----| # |--------start-----end----| end_ = (end - start) / (1 - start) assert 0 <= end_ <= 1.0 _, cp = bezier_subdivide(cp, start) cp, _ = bezier_subdivide(cp, end_) p = QPainterPath() p.moveTo(cp[0]) p.cubicTo(*cp[1:]) return p else: assert False def qpainterpath_sub_path( path: QPainterPath, start: float, end: float ) -> QPainterPath: """ Cut and return a sub path from `path`. Parameters ---------- path: QPainterPath The source path. start: float The starting position for the cut as a number between `0.0` and `1.0` end: float The end position for the cut as a number between `0.0` and `1.0` """ assert 0.0 <= start <= 1.0 and 0.0 <= end <= 1.0 length = path.length() startlen = length * start endlen = length * end res = QPainterPath() for section in list(_qpainterpath_sections(path)): if startlen <= section.start <= endlen \ or startlen <= section.end <= endlen \ or (section.start <= startlen and section.end >= endlen): if math.isclose(section.p.length(), 0): res.addPath(section.p) else: start_ = (startlen - section.start) / section.p.length() end_ = (endlen - section.start) / section.p.length() p = _qpainterpath_simple_cut( section.p, max(start_, 0.), min(end_, 1.0) ) res.addPath(p) return res ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/layout.py0000644000175100002000000001324014730024325022535 0ustar00runnerdocker""" Node/Link layout. """ from operator import attrgetter import typing from typing import Optional, Any, List from AnyQt.QtWidgets import QGraphicsObject, QApplication, QGraphicsItem from AnyQt.QtCore import QRectF, QLineF, QEvent, QPointF from AnyQt import sip from .items import ( NodeItem, LinkItem, NodeAnchorItem, SourceAnchorItem, SinkAnchorItem ) from .items.utils import ( invert_permutation_indices, argsort, composition, linspace_trunc ) if typing.TYPE_CHECKING: from .scene import CanvasScene class AnchorLayout(QGraphicsObject): def __init__(self, parent=None, **kwargs): # type: (Optional[QGraphicsItem], Any) -> None super().__init__(parent, **kwargs) self.setFlag(QGraphicsObject.ItemHasNoContents) self.__layoutPending = False self.__isActive = False self.__invalidatedAnchors = [] # type: List[NodeAnchorItem] self.__enabled = True def boundingRect(self): # type: () -> QRectF return QRectF() def activate(self): # type: () -> None """ Immediately layout all anchors. """ if self.isEnabled() and not self.__isActive: self.__isActive = True try: self._doLayout() finally: self.__isActive = False self.__layoutPending = False def isActivated(self): # type: () -> bool """ Is the layout currently activated (in :func:`activate()`) """ return self.__isActive def _doLayout(self): # type: () -> None if not self.isEnabled(): return scene = self.scene() items = scene.items() links = [item for item in items if isinstance(item, LinkItem)] point_pairs = [(link.sourceAnchor, link.sinkAnchor) for link in links if link.sourceAnchor is not None and link.sinkAnchor is not None] point_pairs += [(a, b) for b, a in point_pairs] to_other = dict(point_pairs) anchors = set(self.__invalidatedAnchors) for anchor_item in anchors: if sip.isdeleted(anchor_item): continue points = anchor_item.anchorPoints() anchor_pos = anchor_item.mapToScene(anchor_item.pos()) others = [to_other[point] for point in points] if isinstance(anchor_item, SourceAnchorItem): others_angle = [-angle(anchor_pos, other.anchorScenePos()) for other in others] else: others_angle = [angle(other.anchorScenePos(), anchor_pos) for other in others] indices = argsort(others_angle) # Invert the indices. indices = invert_permutation_indices(indices) positions = list(linspace_trunc(len(points))) positions = [positions[i] for i in indices] anchor_item.setAnchorPositions(positions) self.__invalidatedAnchors = [] def invalidateLink(self, link): # type: (LinkItem) -> None """ Invalidate the anchors on `link` and schedule an update. Parameters ---------- link : LinkItem """ if link.sourceItem is not None: self.invalidateAnchorItem(link.sourceItem.outputAnchorItem) if link.sinkItem is not None: self.invalidateAnchorItem(link.sinkItem.inputAnchorItem) def invalidateNode(self, node): # type: (NodeItem) -> None """ Invalidate the anchors on `node` and schedule an update. Parameters ---------- node : NodeItem """ self.invalidateAnchorItem(node.inputAnchorItem) self.invalidateAnchorItem(node.outputAnchorItem) self.scheduleDelayedActivate() def invalidateAnchorItem(self, anchor): # type: (NodeAnchorItem) -> None """ Invalidate the all links on `anchor`. Parameters ---------- anchor : NodeAnchorItem """ self.__invalidatedAnchors.append(anchor) scene = self.scene() # type: CanvasScene node = anchor.parentNodeItem() if node is None: return if isinstance(anchor, SourceAnchorItem): links = scene.node_output_links(node) getter = composition(attrgetter("sinkItem"), attrgetter("inputAnchorItem")) elif isinstance(anchor, SinkAnchorItem): links = scene.node_input_links(node) getter = composition(attrgetter("sourceItem"), attrgetter("outputAnchorItem")) else: raise TypeError(type(anchor)) self.__invalidatedAnchors.extend(map(getter, links)) self.scheduleDelayedActivate() def scheduleDelayedActivate(self): # type: () -> None """ Schedule an layout pass """ if self.isEnabled() and not self.__layoutPending: self.__layoutPending = True QApplication.postEvent(self, QEvent(QEvent.LayoutRequest)) def __delayedActivate(self): # type: () -> None if self.__layoutPending: self.activate() def event(self, event): # type: (QEvent)->bool if event.type() == QEvent.LayoutRequest: self.activate() return True return super().event(event) def angle(point1, point2): # type: (QPointF, QPointF) -> float """ Return the angle between the two points in range from -180 to 180. """ angle = QLineF(point1, point2).angle() if angle > 180: return angle - 360 else: return angle ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/scene.py0000644000175100002000000010071414730024325022320 0ustar00runnerdocker""" ===================== Canvas Graphics Scene ===================== """ import typing from typing import Dict, List, Optional, Any, Type, Tuple, Union import logging import itertools from operator import attrgetter from xml.sax.saxutils import escape from AnyQt.QtWidgets import QGraphicsScene, QGraphicsItem from AnyQt.QtGui import QPainter, QColor, QFont from AnyQt.QtCore import ( Qt, QPointF, QRectF, QSizeF, QLineF, QBuffer, QObject, QSignalMapper, QParallelAnimationGroup, QT_VERSION ) from AnyQt.QtSvg import QSvgGenerator from AnyQt.QtCore import pyqtSignal as Signal from ..registry import ( WidgetRegistry, WidgetDescription, CategoryDescription, InputSignal, OutputSignal ) from .. import scheme from ..scheme import Scheme, SchemeNode, SchemeLink, BaseSchemeAnnotation from . import items from .items import NodeItem, LinkItem from .items.annotationitem import Annotation from .layout import AnchorLayout if typing.TYPE_CHECKING: from ..document.interactions import UserInteraction T = typing.TypeVar("T", bound=QGraphicsItem) __all__ = [ "CanvasScene", "grab_svg" ] log = logging.getLogger(__name__) class CanvasScene(QGraphicsScene): """ A Graphics Scene for displaying an :class:`~.scheme.Scheme` instance. """ #: Signal emitted when a :class:`NodeItem` has been added to the scene. node_item_added = Signal(object) #: Signal emitted when a :class:`NodeItem` has been removed from the #: scene. node_item_removed = Signal(object) #: Signal emitted when a new :class:`LinkItem` has been added to the #: scene. link_item_added = Signal(object) #: Signal emitted when a :class:`LinkItem` has been removed. link_item_removed = Signal(object) #: Signal emitted when a :class:`Annotation` item has been added. annotation_added = Signal(object) #: Signal emitted when a :class:`Annotation` item has been removed. annotation_removed = Signal(object) #: Signal emitted when the position of a :class:`NodeItem` has changed. node_item_position_changed = Signal(object, QPointF) #: Signal emitted when an :class:`NodeItem` has been double clicked. node_item_double_clicked = Signal(object) #: An node item has been activated (double-clicked) node_item_activated = Signal(object) #: An node item has been hovered node_item_hovered = Signal(object) #: Link item has been activated (double-clicked) link_item_activated = Signal(object) #: Link item has been hovered link_item_hovered = Signal(object) def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) self.scheme = None # type: Optional[Scheme] self.registry = None # type: Optional[WidgetRegistry] # All node items self.__node_items = [] # type: List[NodeItem] # Mapping from SchemeNodes to canvas items self.__item_for_node = {} # type: Dict[SchemeNode, NodeItem] # All link items self.__link_items = [] # type: List[LinkItem] # Mapping from SchemeLinks to canvas items. self.__item_for_link = {} # type: Dict[SchemeLink, LinkItem] # All annotation items self.__annotation_items = [] # type: List[Annotation] # Mapping from SchemeAnnotations to canvas items. self.__item_for_annotation = {} # type: Dict[BaseSchemeAnnotation, Annotation] # Is the scene editable self.editable = True # Anchor Layout self.__anchor_layout = AnchorLayout() self.addItem(self.__anchor_layout) self.__channel_names_visible = True self.__node_animation_enabled = True self.__animations_temporarily_disabled = False self.user_interaction_handler = None # type: Optional[UserInteraction] self.activated_mapper = QSignalMapper(self) self.activated_mapper.mappedObject.connect( lambda node: self.node_item_activated.emit(node) ) self.hovered_mapper = QSignalMapper(self) self.hovered_mapper.mappedObject.connect( lambda node: self.node_item_hovered.emit(node) ) self.position_change_mapper = QSignalMapper(self) self.position_change_mapper.mappedObject.connect( self._on_position_change ) self.link_activated_mapper = QSignalMapper(self) self.link_activated_mapper.mappedObject.connect( lambda node: self.link_item_activated.emit(node) ) self.__anchors_opened = False def clear_scene(self): # type: () -> None """ Clear (reset) the scene. """ if self.scheme is not None: self.scheme.node_added.disconnect(self.add_node) self.scheme.node_removed.disconnect(self.remove_node) self.scheme.link_added.disconnect(self.add_link) self.scheme.link_removed.disconnect(self.remove_link) self.scheme.annotation_added.disconnect(self.add_annotation) self.scheme.annotation_removed.disconnect(self.remove_annotation) # Remove all items to make sure all signals from scheme items # to canvas items are disconnected. for annot in self.scheme.annotations: if annot in self.__item_for_annotation: self.remove_annotation(annot) for link in self.scheme.links: if link in self.__item_for_link: self.remove_link(link) for node in self.scheme.nodes: if node in self.__item_for_node: self.remove_node(node) self.scheme = None self.__node_items = [] self.__item_for_node = {} self.__link_items = [] self.__item_for_link = {} self.__annotation_items = [] self.__item_for_annotation = {} self.__anchor_layout.deleteLater() self.user_interaction_handler = None self.clear() def set_scheme(self, scheme): # type: (Scheme) -> None """ Set the scheme to display. Populates the scene with nodes and links already in the scheme. Any further change to the scheme will be reflected in the scene. Parameters ---------- scheme : :class:`~.scheme.Scheme` """ if self.scheme is not None: # Clear the old scheme self.clear_scene() self.scheme = scheme if self.scheme is not None: self.scheme.node_added.connect(self.add_node) self.scheme.node_removed.connect(self.remove_node) self.scheme.link_added.connect(self.add_link) self.scheme.link_removed.connect(self.remove_link) self.scheme.annotation_added.connect(self.add_annotation) self.scheme.annotation_removed.connect(self.remove_annotation) for node in scheme.nodes: self.add_node(node) for link in scheme.links: self.add_link(link) for annot in scheme.annotations: self.add_annotation(annot) self.__anchor_layout.activate() def set_registry(self, registry): # type: (WidgetRegistry) -> None """ Set the widget registry. """ # TODO: Remove/Deprecate. Is used only to get the category/background # color. That should be part of the SchemeNode/WidgetDescription. self.registry = registry def set_anchor_layout(self, layout): """ Set an :class:`~.layout.AnchorLayout` """ if self.__anchor_layout != layout: if self.__anchor_layout: self.__anchor_layout.deleteLater() self.__anchor_layout = None self.__anchor_layout = layout def anchor_layout(self): """ Return the anchor layout instance. """ return self.__anchor_layout def set_channel_names_visible(self, visible): # type: (bool) -> None """ Set the channel names visibility. """ self.__channel_names_visible = visible for link in self.__link_items: link.setChannelNamesVisible(visible) def channel_names_visible(self): # type: () -> bool """ Return the channel names visibility state. """ return self.__channel_names_visible def set_node_animation_enabled(self, enabled): # type: (bool) -> None """ Set node animation enabled state. """ if self.__node_animation_enabled != enabled: self.__node_animation_enabled = enabled for node in self.__node_items: node.setAnimationEnabled(enabled) for link in self.__link_items: link.setAnimationEnabled(enabled) def add_node_item(self, item): # type: (NodeItem) -> NodeItem """ Add a :class:`.NodeItem` instance to the scene. """ if item in self.__node_items: raise ValueError("%r is already in the scene." % item) if item.pos().isNull(): if self.__node_items: pos = self.__node_items[-1].pos() + QPointF(150, 0) else: pos = QPointF(150, 150) item.setPos(pos) item.setFont(self.font()) # Set signal mappings self.activated_mapper.setMapping(item, item) item.activated.connect(self.activated_mapper.map) self.hovered_mapper.setMapping(item, item) item.hovered.connect(self.hovered_mapper.map) self.position_change_mapper.setMapping(item, item) item.positionChanged.connect(self.position_change_mapper.map) self.addItem(item) self.__node_items.append(item) self.clearSelection() item.setSelected(True) self.node_item_added.emit(item) return item def add_node(self, node): # type: (SchemeNode) -> NodeItem """ Add and return a default constructed :class:`.NodeItem` for a :class:`SchemeNode` instance `node`. If the `node` is already in the scene do nothing and just return its item. """ if node in self.__item_for_node: # Already added return self.__item_for_node[node] item = self.new_node_item(node.description) if node.position: pos = QPointF(*node.position) item.setPos(pos) item.setTitle(node.title) item.setProcessingState(node.processing_state) item.setProgress(node.progress) item.inputAnchorItem.setAnchorOpen(self.__anchors_opened) item.outputAnchorItem.setAnchorOpen(self.__anchors_opened) for message in node.state_messages(): item.setStateMessage(message) item.setStatusMessage(node.status_message()) self.__item_for_node[node] = item node.position_changed.connect(self.__on_node_pos_changed) node.title_changed.connect(item.setTitle) node.progress_changed.connect(item.setProgress) node.processing_state_changed.connect(item.setProcessingState) node.state_message_changed.connect(item.setStateMessage) node.status_message_changed.connect(item.setStatusMessage) return self.add_node_item(item) def new_node_item(self, widget_desc, category_desc=None): # type: (WidgetDescription, Optional[CategoryDescription]) -> NodeItem """ Construct an new :class:`.NodeItem` from a `WidgetDescription`. Optionally also set `CategoryDescription`. """ item = items.NodeItem() item.setWidgetDescription(widget_desc) if category_desc is None and self.registry and widget_desc.category: category_desc = self.registry.category(widget_desc.category) if category_desc is None and self.registry is not None: try: category_desc = self.registry.category(widget_desc.category) except KeyError: pass if category_desc is not None: item.setWidgetCategory(category_desc) item.setAnimationEnabled(self.__node_animation_enabled) return item def remove_node_item(self, item): # type: (NodeItem) -> None """ Remove `item` (:class:`.NodeItem`) from the scene. """ desc = item.widget_description self.activated_mapper.removeMappings(item) self.hovered_mapper.removeMappings(item) self.position_change_mapper.removeMappings(item) self.link_activated_mapper.removeMappings(item) item.hide() self.removeItem(item) self.__node_items.remove(item) self.node_item_removed.emit(item) def remove_node(self, node): # type: (SchemeNode) -> None """ Remove the :class:`.NodeItem` instance that was previously constructed for a :class:`SchemeNode` `node` using the `add_node` method. """ item = self.__item_for_node.pop(node) node.position_changed.disconnect(self.__on_node_pos_changed) node.title_changed.disconnect(item.setTitle) node.progress_changed.disconnect(item.setProgress) node.processing_state_changed.disconnect(item.setProcessingState) node.state_message_changed.disconnect(item.setStateMessage) self.remove_node_item(item) def node_items(self): # type: () -> List[NodeItem] """ Return all :class:`.NodeItem` instances in the scene. """ return list(self.__node_items) def add_link_item(self, item): # type: (LinkItem) -> LinkItem """ Add a link (:class:`.LinkItem`) to the scene. """ self.link_activated_mapper.setMapping(item, item) item.activated.connect(self.link_activated_mapper.map) if item.scene() is not self: self.addItem(item) item.setFont(self.font()) self.__link_items.append(item) self.link_item_added.emit(item) self.__anchor_layout.invalidateLink(item) return item def add_link(self, scheme_link): # type: (SchemeLink) -> LinkItem """ Create and add a :class:`.LinkItem` instance for a :class:`SchemeLink` instance. If the link is already in the scene do nothing and just return its :class:`.LinkItem`. """ if scheme_link in self.__item_for_link: return self.__item_for_link[scheme_link] source = self.__item_for_node[scheme_link.source_node] sink = self.__item_for_node[scheme_link.sink_node] item = self.new_link_item(source, scheme_link.source_channel, sink, scheme_link.sink_channel) item.setEnabled(scheme_link.is_enabled()) scheme_link.enabled_changed.connect(item.setEnabled) if scheme_link.is_dynamic(): item.setDynamic(True) item.setDynamicEnabled(scheme_link.is_dynamic_enabled()) scheme_link.dynamic_enabled_changed.connect(item.setDynamicEnabled) item.setRuntimeState(scheme_link.runtime_state()) scheme_link.state_changed.connect(item.setRuntimeState) self.add_link_item(item) self.__item_for_link[scheme_link] = item return item def new_link_item(self, source_item, source_channel, sink_item, sink_channel): # type: (NodeItem, OutputSignal, NodeItem, InputSignal) -> LinkItem """ Construct and return a new :class:`.LinkItem` """ item = items.LinkItem() item.setSourceItem(source_item, source_channel) item.setSinkItem(sink_item, sink_channel) def channel_name(channel): # type: (Union[OutputSignal, InputSignal, str]) -> str if isinstance(channel, str): return channel else: return channel.name source_name = channel_name(source_channel) sink_name = channel_name(sink_channel) fmt = "{0}  \u2192  {1}" item.setSourceName(source_name) item.setSinkName(sink_name) item.setChannelNamesVisible(self.__channel_names_visible) item.setAnimationEnabled(self.__node_animation_enabled) return item def remove_link_item(self, item): # type: (LinkItem) -> LinkItem """ Remove a link (:class:`.LinkItem`) from the scene. """ # Invalidate the anchor layout. self.__anchor_layout.invalidateLink(item) self.__link_items.remove(item) # Remove the anchor points. item.removeLink() self.removeItem(item) self.link_item_removed.emit(item) return item def remove_link(self, scheme_link): # type: (SchemeLink) -> None """ Remove a :class:`.LinkItem` instance that was previously constructed for a :class:`SchemeLink` instance `link` using the `add_link` method. """ item = self.__item_for_link.pop(scheme_link) scheme_link.enabled_changed.disconnect(item.setEnabled) if scheme_link.is_dynamic(): scheme_link.dynamic_enabled_changed.disconnect( item.setDynamicEnabled ) scheme_link.state_changed.disconnect(item.setRuntimeState) self.remove_link_item(item) def link_items(self): # type: () -> List[LinkItem] """ Return all :class:`.LinkItem` s in the scene. """ return list(self.__link_items) def add_annotation_item(self, annotation): # type: (Annotation) -> Annotation """ Add an :class:`.Annotation` item to the scene. """ self.__annotation_items.append(annotation) self.addItem(annotation) self.annotation_added.emit(annotation) return annotation def add_annotation(self, scheme_annot): # type: (BaseSchemeAnnotation) -> Annotation """ Create a new item for :class:`SchemeAnnotation` and add it to the scene. If the `scheme_annot` is already in the scene do nothing and just return its item. """ if scheme_annot in self.__item_for_annotation: # Already added return self.__item_for_annotation[scheme_annot] if isinstance(scheme_annot, scheme.SchemeTextAnnotation): item = items.TextAnnotation() x, y, w, h = scheme_annot.rect item.setPos(x, y) item.resize(w, h) item.setTextInteractionFlags(Qt.TextEditorInteraction) font = font_from_dict(scheme_annot.font, item.font()) item.setFont(font) item.setContent(scheme_annot.content, scheme_annot.content_type) scheme_annot.content_changed.connect(item.setContent) elif isinstance(scheme_annot, scheme.SchemeArrowAnnotation): item = items.ArrowAnnotation() start, end = scheme_annot.start_pos, scheme_annot.end_pos item.setLine(QLineF(QPointF(*start), QPointF(*end))) item.setColor(QColor(scheme_annot.color)) scheme_annot.geometry_changed.connect( self.__on_scheme_annot_geometry_change ) self.add_annotation_item(item) self.__item_for_annotation[scheme_annot] = item return item def remove_annotation_item(self, annotation): # type: (Annotation) -> None """ Remove an :class:`.Annotation` instance from the scene. """ self.__annotation_items.remove(annotation) self.removeItem(annotation) self.annotation_removed.emit(annotation) def remove_annotation(self, scheme_annotation): # type: (BaseSchemeAnnotation) -> None """ Remove an :class:`.Annotation` instance that was previously added using :func:`add_anotation`. """ item = self.__item_for_annotation.pop(scheme_annotation) scheme_annotation.geometry_changed.disconnect( self.__on_scheme_annot_geometry_change ) if isinstance(scheme_annotation, scheme.SchemeTextAnnotation): scheme_annotation.content_changed.disconnect(item.setContent) self.remove_annotation_item(item) def annotation_items(self): # type: () -> List[Annotation] """ Return all :class:`.Annotation` items in the scene. """ return self.__annotation_items.copy() def item_for_annotation(self, scheme_annotation): # type: (BaseSchemeAnnotation) -> Annotation return self.__item_for_annotation[scheme_annotation] def annotation_for_item(self, item): # type: (Annotation) -> BaseSchemeAnnotation rev = {v: k for k, v in self.__item_for_annotation.items()} return rev[item] def commit_scheme_node(self, node): """ Commit the `node` into the scheme. """ if not self.editable: raise Exception("Scheme not editable.") if node not in self.__item_for_node: raise ValueError("No 'NodeItem' for node.") item = self.__item_for_node[node] try: self.scheme.add_node(node) except Exception: log.error("An error occurred while committing node '%s'", node, exc_info=True) # Cleanup (remove the node item) self.remove_node_item(item) raise log.debug("Commited node '%s' from '%s' to '%s'" % \ (node, self, self.scheme)) def commit_scheme_link(self, link): """ Commit a scheme link. """ if not self.editable: raise Exception("Scheme not editable") if link not in self.__item_for_link: raise ValueError("No 'LinkItem' for link.") self.scheme.add_link(link) log.debug("Commited link '%s' from '%s' to '%s'" % \ (link, self, self.scheme)) def node_for_item(self, item): # type: (NodeItem) -> SchemeNode """ Return the `SchemeNode` for the `item`. """ rev = dict([(v, k) for k, v in self.__item_for_node.items()]) return rev[item] def item_for_node(self, node): # type: (SchemeNode) -> NodeItem """ Return the :class:`NodeItem` instance for a :class:`SchemeNode`. """ return self.__item_for_node[node] def link_for_item(self, item): # type: (LinkItem) -> SchemeLink """ Return the `SchemeLink for `item` (:class:`LinkItem`). """ rev = dict([(v, k) for k, v in self.__item_for_link.items()]) return rev[item] def item_for_link(self, link): # type: (SchemeLink) -> LinkItem """ Return the :class:`LinkItem` for a :class:`SchemeLink` """ return self.__item_for_link[link] def selected_node_items(self): # type: () -> List[NodeItem] """ Return the selected :class:`NodeItem`'s. """ return [item for item in self.__node_items if item.isSelected()] def selected_link_items(self): # type: () -> List[LinkItem] return [item for item in self.__link_items if item.isSelected()] def selected_annotation_items(self): # type: () -> List[Annotation] """ Return the selected :class:`Annotation`'s """ return [item for item in self.__annotation_items if item.isSelected()] def node_links(self, node_item): # type: (NodeItem) -> List[LinkItem] """ Return all links from the `node_item` (:class:`NodeItem`). """ return self.node_output_links(node_item) + \ self.node_input_links(node_item) def node_output_links(self, node_item): # type: (NodeItem) -> List[LinkItem] """ Return a list of all output links from `node_item`. """ return [link for link in self.__link_items if link.sourceItem == node_item] def node_input_links(self, node_item): # type: (NodeItem) -> List[LinkItem] """ Return a list of all input links for `node_item`. """ return [link for link in self.__link_items if link.sinkItem == node_item] def neighbor_nodes(self, node_item): # type: (NodeItem) -> List[NodeItem] """ Return a list of `node_item`'s (class:`NodeItem`) neighbor nodes. """ neighbors = list(map(attrgetter("sourceItem"), self.node_input_links(node_item))) neighbors.extend(map(attrgetter("sinkItem"), self.node_output_links(node_item))) return neighbors def set_widget_anchors_open(self, enabled: bool): if self.__anchors_opened == enabled: return self.__anchors_opened = enabled for item in self.node_items(): item.inputAnchorItem.setAnchorOpen(enabled) item.outputAnchorItem.setAnchorOpen(enabled) def _on_position_change(self, item): # type: (NodeItem) -> None # Invalidate the anchor point layout for the node and schedule a layout. self.__anchor_layout.invalidateNode(item) self.node_item_position_changed.emit(item, item.pos()) def __on_node_pos_changed(self, pos): # type: (Tuple[float, float]) -> None node = self.sender() item = self.__item_for_node[node] item.setPos(*pos) def __on_scheme_annot_geometry_change(self): # type: () -> None annot = self.sender() item = self.__item_for_annotation[annot] if isinstance(annot, scheme.SchemeTextAnnotation): item.setGeometry(QRectF(*annot.rect)) elif isinstance(annot, scheme.SchemeArrowAnnotation): p1 = item.mapFromScene(QPointF(*annot.start_pos)) p2 = item.mapFromScene(QPointF(*annot.end_pos)) item.setLine(QLineF(p1, p2)) else: pass def item_at(self, pos, type_or_tuple=None, buttons=Qt.NoButton): # type: (QPointF, Optional[Type[T]], Qt.MouseButtons) -> Optional[T] """Return the item at `pos` that is an instance of the specified type (`type_or_tuple`). If `buttons` (`Qt.MouseButtons`) is given only return the item if it is the top level item that would accept any of the buttons (`QGraphicsItem.acceptedMouseButtons`). """ rect = QRectF(pos, QSizeF(1, 1)) items = self.items(rect) if buttons: items_iter = itertools.dropwhile( lambda item: not item.acceptedMouseButtons() & buttons, items ) items = list(items_iter)[:1] if type_or_tuple: items = [i for i in items if isinstance(i, type_or_tuple)] return items[0] if items else None def mousePressEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mousePressEvent(event): return # Right (context) click on the node item. If the widget is not # in the current selection then select the widget (only the widget). # Else simply return and let customContextMenuRequested signal # handle it shape_item = self.item_at(event.scenePos(), items.NodeItem) if shape_item and event.button() == Qt.RightButton and \ shape_item.flags() & QGraphicsItem.ItemIsSelectable: if not shape_item.isSelected(): self.clearSelection() shape_item.setSelected(True) return super().mousePressEvent(event) def mouseMoveEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseMoveEvent(event): return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseReleaseEvent(event): return super().mouseReleaseEvent(event) def mouseDoubleClickEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.mouseDoubleClickEvent(event): return super().mouseDoubleClickEvent(event) def keyPressEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.keyPressEvent(event): return super().keyPressEvent(event) def keyReleaseEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.keyReleaseEvent(event): return super().keyReleaseEvent(event) def contextMenuEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.contextMenuEvent(event): return super().contextMenuEvent(event) def dragEnterEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.dragEnterEvent(event): return super().dragEnterEvent(event) def dragMoveEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.dragMoveEvent(event): return super().dragMoveEvent(event) def dragLeaveEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.dragLeaveEvent(event): return super().dragLeaveEvent(event) def dropEvent(self, event): if self.user_interaction_handler and \ self.user_interaction_handler.dropEvent(event): return super().dropEvent(event) def set_user_interaction_handler(self, handler): # type: (UserInteraction) -> None if self.user_interaction_handler and \ not self.user_interaction_handler.isFinished(): self.user_interaction_handler.cancel() log.debug("Setting interaction '%s' to '%s'" % (handler, self)) self.user_interaction_handler = handler if handler: if self.__node_animation_enabled: self.__animations_temporarily_disabled = True self.set_node_animation_enabled(False) handler.start() elif self.__animations_temporarily_disabled: self.__animations_temporarily_disabled = False self.set_node_animation_enabled(True) def __str__(self): return "%s(objectName=%r, ...)" % \ (type(self).__name__, str(self.objectName())) def font_from_dict(font_dict, font=None): # type: (dict, Optional[QFont]) -> QFont if font is None: font = QFont() else: font = QFont(font) if "family" in font_dict: font.setFamily(font_dict["family"]) if "size" in font_dict: font.setPixelSize(font_dict["size"]) return font if QT_VERSION >= 0x50900 and \ QSvgGenerator().metric(QSvgGenerator.PdmDevicePixelRatioScaled) == 1: # QTBUG-63159 class _QSvgGenerator(QSvgGenerator): # type: ignore def metric(self, metric): if metric == QSvgGenerator.PdmDevicePixelRatioScaled: return int(1 * QSvgGenerator.devicePixelRatioFScale()) else: return super().metric(metric) else: _QSvgGenerator = QSvgGenerator # type: ignore def grab_svg(scene: QGraphicsScene) -> str: """ Return a SVG rendering of the scene contents. Parameters ---------- scene : :class:`CanvasScene` """ svg_buffer = QBuffer() gen = _QSvgGenerator() views = scene.views() if views: screen = views[0].screen() if screen is not None: res = screen.physicalDotsPerInch() gen.setResolution(int(res)) gen.setOutputDevice(svg_buffer) items_rect = scene.itemsBoundingRect().adjusted(-10, -10, 10, 10) if items_rect.isNull(): items_rect = QRectF(0, 0, 10, 10) width, height = items_rect.width(), items_rect.height() rect_ratio = float(width) / height # Keep a fixed aspect ratio. aspect_ratio = 1.618 if rect_ratio > aspect_ratio: height = int(height * rect_ratio / aspect_ratio) else: width = int(width * aspect_ratio / rect_ratio) target_rect = QRectF(0, 0, width, height) source_rect = QRectF(0, 0, width, height) source_rect.moveCenter(items_rect.center()) gen.setSize(target_rect.size().toSize()) gen.setViewBox(target_rect) painter = QPainter(gen) # Draw background. painter.setPen(Qt.NoPen) painter.setBrush(scene.palette().base()) painter.drawRect(target_rect) # Render the scene scene.render(painter, target_rect, source_rect) painter.end() buffer_str = bytes(svg_buffer.buffer()) return buffer_str.decode("utf-8") ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.782081 orange_canvas_core-0.2.5/orangecanvas/canvas/tests/0000755000175100002000000000000014730024333022007 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/tests/__init__.py0000644000175100002000000000000014730024325024107 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/tests/test_layout.py0000644000175100002000000000513514730024325024742 0ustar00runnerdockerimport time from AnyQt.QtCore import QTimer from AnyQt.QtWidgets import QGraphicsView from AnyQt.QtGui import QPainter, QPainterPath from ...gui.test import QAppTestCase from ..layout import AnchorLayout from ..scene import CanvasScene from ..items import NodeItem, LinkItem from ...registry.tests import small_testing_registry class TestAnchorLayout(QAppTestCase): def setUp(self): super().setUp() self.scene = CanvasScene() self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.Antialiasing) self.view.show() self.view.resize(600, 400) def tearDown(self): self.scene.clear() self.view.deleteLater() self.scene.deleteLater() del self.scene del self.view super().tearDown() def test_layout(self): one_desc, negate_desc, cons_desc = self.widget_desc() one_item = NodeItem() one_item.setWidgetDescription(one_desc) one_item.setPos(0, 150) self.scene.add_node_item(one_item) cons_item = NodeItem() cons_item.setWidgetDescription(cons_desc) cons_item.setPos(200, 0) self.scene.add_node_item(cons_item) negate_item = NodeItem() negate_item.setWidgetDescription(negate_desc) negate_item.setPos(200, 300) self.scene.add_node_item(negate_item) link = LinkItem() link.setSourceItem(one_item) link.setSinkItem(negate_item) self.scene.add_link_item(link) link = LinkItem() link.setSourceItem(one_item) link.setSinkItem(cons_item) self.scene.add_link_item(link) layout = AnchorLayout() self.scene.addItem(layout) self.scene.set_anchor_layout(layout) layout.invalidateNode(one_item) layout.activate() p1, p2 = one_item.outputAnchorItem.anchorPositions() self.assertTrue(p1 > p2) self.scene.node_item_position_changed.connect(layout.invalidateNode) path = QPainterPath() path.addEllipse(125, 0, 50, 300) def advance(): t = time.process_time() cons_item.setPos(path.pointAtPercent(t % 1.0)) negate_item.setPos(path.pointAtPercent((t + 0.5) % 1.0)) timer = QTimer(negate_item, interval=5) timer.start() timer.timeout.connect(advance) self.qWait() timer.stop() def widget_desc(self): reg = small_testing_registry() one_desc = reg.widget("one") negate_desc = reg.widget("negate") cons_desc = reg.widget("cons") return one_desc, negate_desc, cons_desc ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/tests/test_scene.py0000644000175100002000000002150214730024325024516 0ustar00runnerdockerfrom AnyQt.QtWidgets import QGraphicsView from AnyQt.QtGui import QPainter from ..scene import CanvasScene from .. import items from ... import scheme from ...registry.tests import small_testing_registry from ...gui.test import QAppTestCase class TestScene(QAppTestCase): def setUp(self): super().setUp() self.scene = CanvasScene() self.view = QGraphicsView(self.scene) self.view.setRenderHints(QPainter.Antialiasing | QPainter.TextAntialiasing) self.view.show() self.view.resize(400, 300) def tearDown(self): self.scene.clear() self.view.deleteLater() self.scene.deleteLater() del self.view del self.scene super().tearDown() def test_scene(self): """Test basic scene functionality. """ one_desc, negate_desc, cons_desc = self.widget_desc() one_item = items.NodeItem(one_desc) negate_item = items.NodeItem(negate_desc) cons_item = items.NodeItem(cons_desc) one_item = self.scene.add_node_item(one_item) negate_item = self.scene.add_node_item(negate_item) cons_item = self.scene.add_node_item(cons_item) # Remove a node self.scene.remove_node_item(cons_item) self.assertSequenceEqual(self.scene.node_items(), [one_item, negate_item]) # And add it again self.scene.add_node_item(cons_item) self.assertSequenceEqual(self.scene.node_items(), [one_item, negate_item, cons_item]) # Adding the same item again should raise an exception with self.assertRaises(ValueError): self.scene.add_node_item(cons_item) a1 = one_desc.outputs[0] a2 = negate_desc.inputs[0] a3 = negate_desc.outputs[0] a4 = cons_desc.inputs[0] # Add links link1 = self.scene.new_link_item( one_item, a1, negate_item, a2) link2 = self.scene.new_link_item( negate_item, a3, cons_item, a4) link1a = self.scene.add_link_item(link1) link2a = self.scene.add_link_item(link2) self.assertEqual(link1, link1a) self.assertEqual(link2, link2a) self.assertSequenceEqual(self.scene.link_items(), [link1, link2]) # Remove links self.scene.remove_link_item(link2) self.scene.remove_link_item(link1) self.assertSequenceEqual(self.scene.link_items(), []) self.assertTrue(link1.sourceItem is None and link1.sinkItem is None) self.assertTrue(link2.sourceItem is None and link2.sinkItem is None) self.assertSequenceEqual(one_item.outputAnchors(), []) self.assertSequenceEqual(negate_item.inputAnchors(), []) self.assertSequenceEqual(negate_item.outputAnchors(), []) self.assertSequenceEqual(cons_item.outputAnchors(), []) # And add one link again link1 = self.scene.new_link_item( one_item, a1, negate_item, a2) link1 = self.scene.add_link_item(link1) self.assertSequenceEqual(self.scene.link_items(), [link1]) self.assertTrue(one_item.outputAnchors()) self.assertTrue(negate_item.inputAnchors()) self.qWait() def test_scene_with_scheme(self): """Test scene through modifying the scheme. """ test_scheme = scheme.Scheme() self.scene.set_scheme(test_scheme) node_items = [] link_items = [] self.scene.node_item_added.connect(node_items.append) self.scene.node_item_removed.connect(node_items.remove) self.scene.link_item_added.connect(link_items.append) self.scene.link_item_removed.connect(link_items.remove) one_desc, negate_desc, cons_desc = self.widget_desc() one_node = scheme.SchemeNode(one_desc) negate_node = scheme.SchemeNode(negate_desc) cons_node = scheme.SchemeNode(cons_desc) nodes = [one_node, negate_node, cons_node] test_scheme.add_node(one_node) test_scheme.add_node(negate_node) test_scheme.add_node(cons_node) self.assertTrue(len(self.scene.node_items()) == 3) self.assertSequenceEqual(self.scene.node_items(), node_items) for node, item in zip(nodes, node_items): self.assertIs(item, self.scene.item_for_node(node)) # Remove a widget test_scheme.remove_node(cons_node) self.assertTrue(len(self.scene.node_items()) == 2) self.assertSequenceEqual(self.scene.node_items(), node_items) # And add it again test_scheme.add_node(cons_node) self.assertTrue(len(self.scene.node_items()) == 3) self.assertSequenceEqual(self.scene.node_items(), node_items) # Add links link1 = test_scheme.new_link(one_node, "value", negate_node, "value") link2 = test_scheme.new_link(negate_node, "result", cons_node, "first") self.assertTrue(len(self.scene.link_items()) == 2) self.assertSequenceEqual(self.scene.link_items(), link_items) # Remove links test_scheme.remove_link(link1) test_scheme.remove_link(link2) self.assertTrue(len(self.scene.link_items()) == 0) self.assertSequenceEqual(self.scene.link_items(), link_items) # And add one link again test_scheme.add_link(link1) self.assertTrue(len(self.scene.link_items()) == 1) self.assertSequenceEqual(self.scene.link_items(), link_items) self.qWait() def test_scheme_construction(self): """Test construction (editing) of the scheme through the scene. """ test_scheme = scheme.Scheme() self.scene.set_scheme(test_scheme) node_items = [] link_items = [] self.scene.node_item_added.connect(node_items.append) self.scene.node_item_removed.connect(node_items.remove) self.scene.link_item_added.connect(link_items.append) self.scene.link_item_removed.connect(link_items.remove) one_desc, negate_desc, cons_desc = self.widget_desc() one_node = scheme.SchemeNode(one_desc) one_item = self.scene.add_node(one_node) self.scene.commit_scheme_node(one_node) self.assertSequenceEqual(self.scene.node_items(), [one_item]) self.assertSequenceEqual(node_items, [one_item]) self.assertSequenceEqual(test_scheme.nodes, [one_node]) negate_node = scheme.SchemeNode(negate_desc) cons_node = scheme.SchemeNode(cons_desc) negate_item = self.scene.add_node(negate_node) cons_item = self.scene.add_node(cons_node) self.assertSequenceEqual(self.scene.node_items(), [one_item, negate_item, cons_item]) self.assertSequenceEqual(self.scene.node_items(), node_items) # The scheme is still the same. self.assertSequenceEqual(test_scheme.nodes, [one_node]) # Remove items self.scene.remove_node(negate_node) self.scene.remove_node(cons_node) self.assertSequenceEqual(self.scene.node_items(), [one_item]) self.assertSequenceEqual(node_items, [one_item]) self.assertSequenceEqual(test_scheme.nodes, [one_node]) # Add them again this time also in the scheme. negate_item = self.scene.add_node(negate_node) cons_item = self.scene.add_node(cons_node) self.scene.commit_scheme_node(negate_node) self.scene.commit_scheme_node(cons_node) self.assertSequenceEqual(self.scene.node_items(), [one_item, negate_item, cons_item]) self.assertSequenceEqual(self.scene.node_items(), node_items) self.assertSequenceEqual(test_scheme.nodes, [one_node, negate_node, cons_node]) link1 = scheme.SchemeLink(one_node, "value", negate_node, "value") link2 = scheme.SchemeLink(negate_node, "result", cons_node, "first") link_item1 = self.scene.add_link(link1) link_item2 = self.scene.add_link(link2) self.assertSequenceEqual(self.scene.link_items(), [link_item1, link_item2]) self.assertSequenceEqual(self.scene.link_items(), link_items) self.assertSequenceEqual(test_scheme.links, []) # Commit the links self.scene.commit_scheme_link(link1) self.scene.commit_scheme_link(link2) self.assertSequenceEqual(self.scene.link_items(), [link_item1, link_item2]) self.assertSequenceEqual(self.scene.link_items(), link_items) self.assertSequenceEqual(test_scheme.links, [link1, link2]) self.qWait() def widget_desc(self): reg = small_testing_registry() one_desc = reg.widget("one") negate_desc = reg.widget("negate") cons_desc = reg.widget("cons") return one_desc, negate_desc, cons_desc ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/tests/test_view.py0000644000175100002000000000154714730024325024402 0ustar00runnerdockerfrom AnyQt.QtCore import QPointF, QRect from orangecanvas.gui.test import QAppTestCase from orangecanvas.canvas import view, scene class TestView(QAppTestCase): def setUp(self): super().setUp() self.scene = scene.CanvasScene() self.view = view.CanvasView(self.scene) self.view.resize(420, 420) self.scene.setSceneRect(0, 0, 400, 400) def tearDown(self): self.scene.clear() del self.view del self.scene super().tearDown() def test_view_pinch_zoom(self): # Missing QTest.touchEvent in PyQt; cannot properly simulate touch # events so test this the ugly way. anchor = QPointF(350, 350) self.view._CanvasView__setZoomLevel(5.0, anchor) mapped = self.view.mapFromScene(anchor) self.assertTrue(self.view.viewport().rect().contains(mapped)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/canvas/view.py0000644000175100002000000002403614730024325022177 0ustar00runnerdocker""" Canvas Graphics View """ import logging import sys from typing import cast, Optional from AnyQt.QtWidgets import QGraphicsView, QAction, QGestureEvent, QPinchGesture from AnyQt.QtGui import QCursor, QIcon, QKeySequence, QTransform, QWheelEvent from AnyQt.QtCore import ( Qt, QRect, QSize, QRectF, QPoint, QTimer, QEvent, QPointF ) from AnyQt.QtCore import Property, pyqtSignal as Signal from orangecanvas.utils import is_event_source_mouse log = logging.getLogger(__name__) class CanvasView(QGraphicsView): """Canvas View handles the zooming. """ def __init__(self, *args): super().__init__(*args) self.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.grabGesture(Qt.PinchGesture) self.__backgroundIcon = QIcon() self.__autoScroll = False self.__autoScrollMargin = 16 self.__autoScrollTimer = QTimer(self) self.__autoScrollTimer.timeout.connect(self.__autoScrollAdvance) # scale factor accumulating partial increments from wheel events self.__zoomLevel = 100 # effective scale level(rounded to whole integers) self.__effectiveZoomLevel = 100 self.__zoomInAction = QAction( self.tr("Zoom in"), self, objectName="action-zoom-in", triggered=self.zoomIn, ) ctrl = "Cmd" if sys.platform == "darwin" else "Ctrl" self.__zoomInAction.setShortcuts([ QKeySequence.ZoomIn, QKeySequence(ctrl + "+=")] # if users forget to press Shift on us keyboards ) self.__zoomOutAction = QAction( self.tr("Zoom out"), self, objectName="action-zoom-out", shortcut=QKeySequence.ZoomOut, triggered=self.zoomOut ) self.__zoomResetAction = QAction( self.tr("Reset Zoom"), self, objectName="action-zoom-reset", triggered=self.zoomReset, shortcut=QKeySequence("Ctrl+0") ) def setScene(self, scene): super().setScene(scene) self._ensureSceneRect(scene) def _ensureSceneRect(self, scene): r = scene.addRect(QRectF(0, 0, 400, 400)) scene.sceneRect() scene.removeItem(r) def setAutoScrollMargin(self, margin): self.__autoScrollMargin = margin def autoScrollMargin(self): return self.__autoScrollMargin def setAutoScroll(self, enable): self.__autoScroll = enable def autoScroll(self): return self.__autoScroll def mousePressEvent(self, event): super().mousePressEvent(event) def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton: if not self.__autoScrollTimer.isActive() and \ self.__shouldAutoScroll(event.pos()): self.__startAutoScroll() super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if event.button() & Qt.LeftButton: self.__stopAutoScroll() return super().mouseReleaseEvent(event) def __should_scroll_horizontally(self, event: QWheelEvent): if not is_event_source_mouse(event): return False if (event.modifiers() & Qt.ShiftModifier and sys.platform == 'darwin' or event.modifiers() & Qt.AltModifier and sys.platform != 'darwin'): return True if event.angleDelta().x() == 0: vBar = self.verticalScrollBar() yDelta = event.angleDelta().y() direction = yDelta >= 0 edgeVBarValue = vBar.minimum() if direction else vBar.maximum() return vBar.value() == edgeVBarValue return False def wheelEvent(self, event: QWheelEvent): # Zoom if event.modifiers() & Qt.ControlModifier \ and event.buttons() == Qt.NoButton: delta = event.angleDelta().y() # use mouse position as anchor while zooming anchor = self.transformationAnchor() self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) self.__setZoomLevel(self.__zoomLevel + 10 * delta / 120) self.setTransformationAnchor(anchor) event.accept() # Scroll horizontally elif self.__should_scroll_horizontally(event): x, y = event.angleDelta().x(), event.angleDelta().y() sign_value = x if x != 0 else y sign = 1 if sign_value >= 0 else -1 new_angle_delta = QPoint(sign * max(abs(x), abs(y), sign_value), 0) new_pixel_delta = QPoint(0, 0) new_modifiers = event.modifiers() & ~(Qt.ShiftModifier | Qt.AltModifier) new_event = QWheelEvent( event.position(), event.globalPosition(), new_pixel_delta, new_angle_delta, event.buttons(), new_modifiers, event.phase(), event.inverted() ) event.accept() super().wheelEvent(new_event) else: super().wheelEvent(event) def gestureEvent(self, event: QGestureEvent): gesture = event.gesture(Qt.PinchGesture) if gesture is None: return if gesture.state() == Qt.GestureStarted: event.accept(gesture) elif gesture.changeFlags() & QPinchGesture.ScaleFactorChanged: anchor = gesture.centerPoint().toPoint() anchor = self.mapToScene(anchor) self.__setZoomLevel(self.__zoomLevel * gesture.scaleFactor(), anchor=anchor) event.accept() elif gesture.state() == Qt.GestureFinished: event.accept() def event(self, event: QEvent) -> bool: if event.type() == QEvent.Gesture: self.gestureEvent(cast(QGestureEvent, event)) return super().event(event) def zoomIn(self): self.__setZoomLevel(self.__zoomLevel + 10) def zoomOut(self): self.__setZoomLevel(self.__zoomLevel - 10) def zoomReset(self): """ Reset the zoom level. """ self.__setZoomLevel(100) def zoomLevel(self): # type: () -> float """ Return the current zoom level. Level is expressed in percentages; 100 is unscaled, 50 is half size, ... """ return self.__effectiveZoomLevel def setZoomLevel(self, level): self.__setZoomLevel(level) def __setZoomLevel(self, scale, anchor=None): # type: (float, Optional[QPointF]) -> None self.__zoomLevel = max(30, min(scale, 300)) scale = round(self.__zoomLevel) self.__zoomOutAction.setEnabled(scale != 30) self.__zoomInAction.setEnabled(scale != 300) if self.__effectiveZoomLevel != scale: self.__effectiveZoomLevel = scale transform = QTransform() transform.scale(scale / 100, scale / 100) if anchor is not None: anchor = self.mapFromScene(anchor) self.setTransform(transform) if anchor is not None: center = self.viewport().rect().center() diff = self.mapToScene(center) - self.mapToScene(anchor) self.centerOn(QPointF(anchor) + diff) self.zoomLevelChanged.emit(scale) zoomLevelChanged = Signal(float) zoomLevel_ = Property( float, zoomLevel, setZoomLevel, notify=zoomLevelChanged ) def __shouldAutoScroll(self, pos): if self.__autoScroll: margin = self.__autoScrollMargin viewrect = self.contentsRect() rect = viewrect.adjusted(margin, margin, -margin, -margin) # only do auto scroll when on the viewport's margins return not rect.contains(pos) and viewrect.contains(pos) else: return False def __startAutoScroll(self): self.__autoScrollTimer.start(10) log.debug("Auto scroll timer started") def __stopAutoScroll(self): if self.__autoScrollTimer.isActive(): self.__autoScrollTimer.stop() log.debug("Auto scroll timer stopped") def __autoScrollAdvance(self): """Advance the auto scroll """ pos = QCursor.pos() pos = self.mapFromGlobal(pos) margin = self.__autoScrollMargin vvalue = self.verticalScrollBar().value() hvalue = self.horizontalScrollBar().value() vrect = QRect(0, 0, self.width(), self.height()) # What should be the speed advance = 10 # We only do auto scroll if the mouse is inside the view. if vrect.contains(pos): if pos.x() < vrect.left() + margin: self.horizontalScrollBar().setValue(hvalue - advance) if pos.y() < vrect.top() + margin: self.verticalScrollBar().setValue(vvalue - advance) if pos.x() > vrect.right() - margin: self.horizontalScrollBar().setValue(hvalue + advance) if pos.y() > vrect.bottom() - margin: self.verticalScrollBar().setValue(vvalue + advance) if self.verticalScrollBar().value() == vvalue and \ self.horizontalScrollBar().value() == hvalue: self.__stopAutoScroll() else: self.__stopAutoScroll() log.debug("Auto scroll advance") def setBackgroundIcon(self, icon): if not isinstance(icon, QIcon): raise TypeError("A QIcon expected.") if self.__backgroundIcon != icon: self.__backgroundIcon = icon self.viewport().update() def backgroundIcon(self): return QIcon(self.__backgroundIcon) def drawBackground(self, painter, rect): super().drawBackground(painter, rect) if not self.__backgroundIcon.isNull(): painter.setClipRect(rect) vrect = QRect(QPoint(0, 0), self.viewport().size()) vrect = self.mapToScene(vrect).boundingRect() pm = self.__backgroundIcon.pixmap( vrect.size().toSize().boundedTo(QSize(200, 200)) ) pmrect = QRect(QPoint(0, 0), pm.size()) pmrect.moveCenter(vrect.center().toPoint()) if rect.toRect().intersects(pmrect): painter.drawPixmap(pmrect, pm) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/config.py0000644000175100002000000004140214730024325021213 0ustar00runnerdocker""" Orange Canvas Configuration """ import os import sys import logging import warnings import typing import pkgutil from typing import Dict, Optional, Tuple, List, Union, Iterable, Any import packaging.version from AnyQt.QtGui import ( QPainter, QFont, QFontMetrics, QColor, QPixmap, QImage, QIcon ) from AnyQt.QtCore import ( Qt, QCoreApplication, QPoint, QRect, QSettings, QStandardPaths, QEvent ) from .gui.utils import windows_set_current_process_app_user_model_id from .gui.svgiconengine import SvgIconEngine from .utils.settings import Settings, config_slot from .utils.pkgmeta import EntryPoint, Distribution, entry_points if typing.TYPE_CHECKING: import requests from .scheme import Scheme T = typing.TypeVar("T") log = logging.getLogger(__name__) __version__ = "0.0" #: Entry point by which widgets are registered. WIDGETS_ENTRY = "orangecanvas.widgets" #: Entry point by which add-ons register with importlib.metadata ADDONS_ENTRY = "orangecanvas.addon" #: Parameters for searching add-on packages in PyPi using xmlrpc api. ADDON_PYPI_SEARCH_SPEC = {"keywords": ["orange", "add-on"]} EXAMPLE_WORKFLOWS_ENTRY = "orangecanvas.examples" def standard_location(type): warnings.warn( "Use QStandardPaths.writableLocation", DeprecationWarning, stacklevel=2 ) return QStandardPaths.writableLocation(type) standard_location.DesktopLocation = QStandardPaths.DesktopLocation # type: ignore standard_location.DataLocation = QStandardPaths.AppLocalDataLocation # type: ignore standard_location.CacheLocation = QStandardPaths.CacheLocation # type: ignore standard_location.DocumentsLocation = QStandardPaths.DocumentsLocation # type: ignore class Config: """ Application configuration. """ #: Organization domain OrganizationDomain = "" # type: str #: The application name ApplicationName = "" # type: str #: Version ApplicationVersion = "" # type: str #: AppUserModelID as used on windows for grouping in the task bar #: (https://docs.microsoft.com/en-us/windows/win32/shell/appids). #: This ensures the program does not group with other Python programs #: and gets its own task icon. AppUserModelID = None # type: Optional[str] def init(self): """ Initialize the QCoreApplication.organizationDomain, applicationName, applicationVersion and the default settings format. Should only be run once at application startup. """ QCoreApplication.setOrganizationDomain(self.OrganizationDomain) QCoreApplication.setApplicationName(self.ApplicationName) QCoreApplication.setApplicationVersion(self.ApplicationVersion) QSettings.setDefaultFormat(QSettings.IniFormat) app = QCoreApplication.instance() if self.AppUserModelID: windows_set_current_process_app_user_model_id(self.AppUserModelID) if app is not None: QCoreApplication.sendEvent(app, QEvent(QEvent.PolishRequest)) def application_icon(self): # type: () -> QIcon """ Return the main application icon. """ return QIcon() def splash_screen(self): # type: () -> Tuple[QPixmap, QRect] """ Return a splash screen pixmap and an text area within it. The text area is used for displaying text messages during application startup. The default implementation returns a bland rectangle splash screen. Returns ------- t : Tuple[QPixmap, QRect] A QPixmap and a rect area within it. """ return QPixmap(), QRect() def widgets_entry_points(self): # type: () -> Iterable[EntryPoint] """ Return an iterator over entry points defining the set of 'nodes/widgets' available to the workflow model. """ return iter(()) def addon_entry_points(self): # type: () -> Iterable[EntryPoint] return iter(()) def addon_pypi_search_spec(self): return {} def addon_defaults_list( self, session=None # type: Optional[requests.Session] ): # type: (...) -> List[Dict[str, Union[str, list, dict, int, float]]] """ Return a list of default add-ons. The return value must be a list with meta description following the `PyPI JSON api`_ specification. At the minimum 'info.name' and 'info.version' must be supplied. e.g. `[{'info': {'name': 'Super Pkg', 'version': '4.2'}}] .. _`PyPI JSON api`: https://warehouse.readthedocs.io/api-reference/json/ """ return [] def core_packages(self): # type: () -> List[str] """ Return a list of core packages. List of packages that are core of the application. Most importantly, if they themselves define add-on/plugin entry points they must not be 'uninstalled' via a package manager, they can only be updated. Return ------ packages : List[str] A list of package names (can also contain PEP-440 version specifiers). """ return ["orange-canvas-core >= 0.1a, < 0.2a"] def examples_entry_points(self): # type: () -> Iterable[EntryPoint] """ Return an iterator over entry points defining example/preset workflows. """ return iter(()) def widget_discovery(self, *args, **kwargs): raise NotImplementedError def workflow_constructor(self, *args, **kwargs): # type: (Any, Any) -> Scheme """ The default workflow constructor. """ raise NotImplementedError #: Standard application urls. If defined to a valid url appropriate actions #: are defined in various contexts APPLICATION_URLS = { #: Submit a bug report action in the Help menu "Bug Report": None, #: A url quick tour/getting started url "Quick Start": None, #: An url to the full documentation "Documentation": None, #: Video screencast/tutorials "Screencasts": None, #: Used for 'Submit Feedback' action in the help menu "Feedback": None, } # type: Dict[str, Optional[str]] class Default(Config): OrganizationDomain = "biolab.si" ApplicationName = "Orange Canvas Core" ApplicationVersion = __version__ @staticmethod def application_icon(): """ Return the main application icon. """ data = pkgutil.get_data(__name__, "icons/orange-canvas.svg") return QIcon(SvgIconEngine(data)) @staticmethod def splash_screen(): # type: () -> Tuple[QPixmap, QRect] """ Return a splash screen pixmap and an text area within it. The text area is used for displaying text messages during application startup. The default implementation returns a bland rectangle splash screen. Returns ------- t : Tuple[QPixmap, QRect] A QPixmap and a rect area within it. """ contents = pkgutil.get_data(__name__, "icons/orange-canvas-core-splash.svg") img = QImage.fromData(contents, "svg") pm = QPixmap.fromImage(img) version = QCoreApplication.applicationVersion() if version: version_parsed = packaging.version.Version(version) version_comp = version_parsed.release version = ".".join(map(str, version_comp[:2])) size = 21 if len(version) < 5 else 16 font = QFont() font.setPixelSize(size) font.setBold(True) font.setItalic(True) font.setLetterSpacing(QFont.AbsoluteSpacing, 2) metrics = QFontMetrics(font) br = metrics.boundingRect(version).adjusted(-5, 0, 5, 0) br.moveBottomRight(QPoint(pm.width() - 15, pm.height() - 15)) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.TextAntialiasing) p.setFont(font) p.setPen(QColor("#231F20")) p.drawText(br, Qt.AlignCenter, version) p.end() textarea = QRect(15, 15, 170, 20) return pm, textarea @staticmethod def widgets_entry_points() -> Iterable[EntryPoint]: """ Return an iterator over entry points defining the set of 'nodes/widgets' available to the workflow model. """ return iter(entry_points(group=WIDGETS_ENTRY)) @staticmethod def addon_entry_points() -> Iterable[EntryPoint]: return iter(entry_points(group=ADDONS_ENTRY)) @staticmethod def addon_pypi_search_spec(): return dict(ADDON_PYPI_SEARCH_SPEC) @staticmethod def addon_defaults_list(session=None): """ Return a list of default add-ons. The return value must be a list with meta description following the `PyPI JSON api`_ specification. At the minimum 'info.name' and 'info.version' must be supplied. e.g. `[{'info': {'name': 'Super Pkg', 'version': '4.2'}}] .. _`PyPI JSON api`: https://warehouse.readthedocs.io/api-reference/json/ """ return [] @staticmethod def core_packages(): # type: () -> List[str] """ Return a list of core packages. List of packages that are core of the product. Most importantly, if they themselves define add-on/plugin entry points they must not be 'uninstalled' via a package manager, they can only be updated. Return ------ packages : List[str] A list of package names (can also contain PEP-440 version specifiers). """ return ["orange-canvas-core >= 0.0, < 0.1a"] @staticmethod def examples_entry_points(): return iter(entry_points(group=EXAMPLE_WORKFLOWS_ENTRY)) @staticmethod def widget_discovery(*args, **kwargs): from . import registry return registry.WidgetDiscovery(*args, **kwargs) @staticmethod def workflow_constructor(*args, **kwargs): from . import scheme return scheme.Scheme(*args, **kwargs) default = Default() def init(): """ Initialize the QCoreApplication.organizationDomain, applicationName, applicationVersion and the default settings format. Will only run once. .. note:: This should not be run before QApplication has been initialized. Otherwise it can break Qt's plugin search paths. """ default.init() # Make consecutive calls a null op. global init log.debug("Activating configuration for {}".format(default)) init = lambda: None rc = {} # type: ignore spec = \ [("startup/show-splash-screen", bool, True, "Show splash screen on startup"), ("startup/show-welcome-screen", bool, True, "Show Welcome screen on startup"), ("startup/load-crashed-workflows", bool, True, "Load crashed scratch workflows on startup"), ("application/language", str, "English", "Application language"), ("application/last-used-language", str, "English", "If different from application/language, widget discovery is forced"), ("stylesheet", str, "orange", "QSS stylesheet to use"), ("schemeinfo/show-at-new-scheme", bool, False, "Show Workflow Properties when creating a new Workflow"), ("mainwindow/scheme-margins-enabled", bool, False, "Show margins around the workflow view"), ("mainwindow/show-scheme-shadow", bool, True, "Show shadow around the workflow view"), ("mainwindow/toolbox-dock-exclusive", bool, False, "Should the toolbox show only one expanded category at the time"), ("mainwindow/toolbox-dock-floatable", bool, False, "Is the canvas toolbox floatable (detachable from the main window)"), ("mainwindow/toolbox-dock-movable", bool, True, "Is the canvas toolbox movable (between left and right edge)"), ("mainwindow/toolbox-dock-use-popover-menu", bool, True, "Use a popover menu to select a widget when clicking on a category " "button"), ("mainwindow/widgets-float-on-top", bool, False, "Float widgets on top of other windows"), ("mainwindow/number-of-recent-schemes", int, 15, "Number of recent workflows to keep in history"), ("schemeedit/show-channel-names", bool, True, "Show channel names"), ("schemeedit/show-link-state", bool, True, "Show link state hints."), ("schemeedit/enable-node-animations", bool, True, "Enable node animations."), ("schemeedit/freeze-on-load", bool, False, "Freeze signal propagation when loading a workflow."), ("quickmenu/trigger-on-double-click", bool, True, "Show quick menu on double click."), ("quickmenu/trigger-on-right-click", bool, True, "Show quick menu on right click."), ("quickmenu/trigger-on-space-key", bool, True, "Show quick menu on space key press."), ("quickmenu/trigger-on-any-key", bool, False, "Show quick menu on double click."), ("quickmenu/show-categories", bool, False, "Show categories in quick menu."), ("logging/level", int, 1, "Logging level"), ("logging/show-on-error", bool, True, "Show log window on error"), ("logging/dockable", bool, True, "Allow log window to be docked"), ("help/open-in-external-browser", bool, False, "Open help in an external browser"), ("add-ons/allow-conda", bool, True, "Install add-ons with conda"), ("add-ons/pip-install-arguments", str, '', 'Arguments to pass to "pip install" when installing add-ons.'), ("network/http-proxy", str, '', 'HTTP proxy.'), ("network/https-proxy", str, '', 'HTTPS proxy.'), ] spec = [config_slot(*t) for t in spec] def register_setting(key, type, default, doc=""): # type: (str, typing.Type[T], T, str) -> None """ Register an application setting. This only affects the `Settings` instance as returned by `settings`. Parameters ---------- key : str The setting key path type : Type[T] Type of the setting. One of `str`, `bool` or `int` default : T Default value for setting. doc : str Setting description string. """ spec.append(config_slot(key, type, default, doc)) def settings(): init() store = QSettings() settings = Settings(defaults=spec, store=store) return settings def data_dir(): """ Return the application data directory. If the directory path does not yet exists then create it. """ init() datadir = QStandardPaths.writableLocation(QStandardPaths.AppLocalDataLocation) version = QCoreApplication.applicationVersion() datadir = os.path.join(datadir, version) if not os.path.isdir(datadir): try: os.makedirs(datadir, exist_ok=True) except OSError: pass return datadir def cache_dir(): """ Return the application cache directory. If the directory path does not yet exists then create it. """ init() cachedir = QStandardPaths.writableLocation(QStandardPaths.CacheLocation) version = QCoreApplication.applicationVersion() cachedir = os.path.join(cachedir, version) if not os.path.exists(cachedir): os.makedirs(cachedir) return cachedir def log_dir(): """ Return the application log directory. """ init() if sys.platform == "darwin": name = str(QCoreApplication.applicationName()) logdir = os.path.join(os.path.expanduser("~/Library/Logs"), name) else: logdir = data_dir() if not os.path.exists(logdir): os.makedirs(logdir) return logdir def widget_settings_dir(): """ Return the widget settings directory. """ warnings.warn( "'widget_settings_dir' is deprecated.", DeprecationWarning, stacklevel=2 ) return os.path.join(data_dir(), 'widgets') def open_config(): warnings.warn( "open_config was never used and will be removed in the future", DeprecationWarning, stacklevel=2 ) return def save_config(): warnings.warn( "save_config was never used and will be removed in the future", DeprecationWarning, stacklevel=2 ) def widgets_entry_points(): """ Return an `EntryPoint` iterator for all 'orange.widget' entry points plus the default Orange Widgets. """ return default.widgets_entry_points() def splash_screen(): """ """ return default.splash_screen() def application_icon(): """ Return the main application icon. """ return default.application_icon() def widget_discovery(*args, **kwargs): return default.widget_discovery(*args, **kwargs) def workflow_constructor(*args, **kwargs): # type: (Any, Any) -> Scheme return default.workflow_constructor(*args, **kwargs) def set_default(conf): global default default = conf ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.786081 orange_canvas_core-0.2.5/orangecanvas/document/0000755000175100002000000000000014730024333021210 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/__init__.py0000644000175100002000000000062014730024325023320 0ustar00runnerdocker""" ======== Document ======== The :mod:`document` package contains classes for visual interactive editing of a :class:`Scheme` instance. The :class:`.SchemeEditWidget` is the main widget used for editing. It uses classes defined in :mod:`canvas` to display the scheme. It also supports undo/redo functionality. """ __all__ = ["quickmenu", "schemeedit"] from .schemeedit import SchemeEditWidget ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/commands.py0000644000175100002000000002746714730024325023404 0ustar00runnerdocker""" Undo/Redo Commands """ import typing from typing import Callable, Optional, Tuple, List, Any from AnyQt.QtWidgets import QUndoCommand if typing.TYPE_CHECKING: from ..scheme import ( Scheme, SchemeNode, SchemeLink, BaseSchemeAnnotation, SchemeTextAnnotation, SchemeArrowAnnotation ) Pos = Tuple[float, float] Rect = Tuple[float, float, float, float] Line = Tuple[Pos, Pos] class UndoCommand(QUndoCommand): """ For pickling """ def __init__(self, text, parent=None): QUndoCommand.__init__(self, text, parent) self.__parent = parent self.__initialized = True # defined and initialized in __setstate__ # self.__child_states = {} # self.__children = [] def __getstate__(self): return { **{k: v for k, v in self.__dict__.items()}, '_UndoCommand__initialized': False, '_UndoCommand__text': self.text(), '_UndoCommand__children': [self.child(i) for i in range(self.childCount())] } def __setstate__(self, state): if hasattr(self, '_UndoCommand__initialized') and \ self.__initialized: return text = state['_UndoCommand__text'] parent = state['_UndoCommand__parent'] # type: UndoCommand if parent is not None and \ (not hasattr(parent, '_UndoCommand__initialized') or not parent.__initialized): # will be initialized in parent's __setstate__ if not hasattr(parent, '_UndoCommand__child_states'): setattr(parent, '_UndoCommand__child_states', {}) parent.__child_states[self] = state return # init must be called on unpickle-time to recreate Qt object UndoCommand.__init__(self, text, parent) if hasattr(self, '_UndoCommand__child_states'): for child, s in self.__child_states.items(): child.__setstate__(s) self.__dict__ = {k: v for k, v in state.items()} self.__initialized = True @staticmethod def from_QUndoCommand(qc: QUndoCommand, parent=None): if type(qc) == QUndoCommand: qc.__class__ = UndoCommand qc.__parent = parent children = [qc.child(i) for i in range(qc.childCount())] for child in children: UndoCommand.from_QUndoCommand(child, parent=qc) return qc class AddNodeCommand(UndoCommand): def __init__(self, scheme, node, parent=None): # type: (Scheme, SchemeNode, Optional[UndoCommand]) -> None super().__init__("Add %s" % node.title, parent) self.scheme = scheme self.node = node def redo(self): self.scheme.add_node(self.node) def undo(self): self.scheme.remove_node(self.node) class RemoveNodeCommand(UndoCommand): def __init__(self, scheme, node, parent=None): # type: (Scheme, SchemeNode, Optional[UndoCommand]) -> None super().__init__("Remove %s" % node.title, parent) self.scheme = scheme self.node = node self._index = -1 links = scheme.input_links(self.node) + \ scheme.output_links(self.node) for link in links: RemoveLinkCommand(scheme, link, parent=self) def redo(self): # redo child commands super().redo() self._index = self.scheme.nodes.index(self.node) self.scheme.remove_node(self.node) def undo(self): assert self._index != -1 self.scheme.insert_node(self._index, self.node) # Undo child commands super().undo() class AddLinkCommand(UndoCommand): def __init__(self, scheme, link, parent=None): # type: (Scheme, SchemeLink, Optional[UndoCommand]) -> None super().__init__("Add link", parent) self.scheme = scheme self.link = link def redo(self): self.scheme.add_link(self.link) def undo(self): self.scheme.remove_link(self.link) class RemoveLinkCommand(UndoCommand): def __init__(self, scheme, link, parent=None): # type: (Scheme, SchemeLink, Optional[UndoCommand]) -> None super().__init__("Remove link", parent) self.scheme = scheme self.link = link self._index = -1 def redo(self): self._index = self.scheme.links.index(self.link) self.scheme.remove_link(self.link) def undo(self): assert self._index != -1 self.scheme.insert_link(self._index, self.link) self._index = -1 class InsertNodeCommand(UndoCommand): def __init__( self, scheme, # type: Scheme new_node, # type: SchemeNode old_link, # type: SchemeLink new_links, # type: Tuple[SchemeLink, SchemeLink] parent=None # type: Optional[UndoCommand] ): # type: (...) -> None super().__init__("Insert widget into link", parent) AddNodeCommand(scheme, new_node, parent=self) RemoveLinkCommand(scheme, old_link, parent=self) for link in new_links: AddLinkCommand(scheme, link, parent=self) class AddAnnotationCommand(UndoCommand): def __init__(self, scheme, annotation, parent=None): # type: (Scheme, BaseSchemeAnnotation, Optional[UndoCommand]) -> None super().__init__("Add annotation", parent) self.scheme = scheme self.annotation = annotation def redo(self): self.scheme.add_annotation(self.annotation) def undo(self): self.scheme.remove_annotation(self.annotation) class RemoveAnnotationCommand(UndoCommand): def __init__(self, scheme, annotation, parent=None): # type: (Scheme, BaseSchemeAnnotation, Optional[UndoCommand]) -> None super().__init__("Remove annotation", parent) self.scheme = scheme self.annotation = annotation self._index = -1 def redo(self): self._index = self.scheme.annotations.index(self.annotation) self.scheme.remove_annotation(self.annotation) def undo(self): assert self._index != -1 self.scheme.insert_annotation(self._index, self.annotation) self._index = -1 class MoveNodeCommand(UndoCommand): def __init__(self, scheme, node, old, new, parent=None): # type: (Scheme, SchemeNode, Pos, Pos, Optional[UndoCommand]) -> None super().__init__("Move", parent) self.scheme = scheme self.node = node self.old = old self.new = new def redo(self): self.node.position = self.new def undo(self): self.node.position = self.old class ResizeCommand(UndoCommand): def __init__(self, scheme, item, new_geom, parent=None): # type: (Scheme, SchemeTextAnnotation, Rect, Optional[UndoCommand]) -> None super().__init__("Resize", parent) self.scheme = scheme self.item = item self.new_geom = new_geom self.old_geom = item.rect def redo(self): self.item.rect = self.new_geom def undo(self): self.item.rect = self.old_geom class ArrowChangeCommand(UndoCommand): def __init__(self, scheme, item, new_line, parent=None): # type: (Scheme, SchemeArrowAnnotation, Line, Optional[UndoCommand]) -> None super().__init__("Move arrow", parent) self.scheme = scheme self.item = item self.new_line = new_line self.old_line = (item.start_pos, item.end_pos) def redo(self): self.item.set_line(*self.new_line) def undo(self): self.item.set_line(*self.old_line) class AnnotationGeometryChange(UndoCommand): def __init__( self, scheme, # type: Scheme annotation, # type: BaseSchemeAnnotation old, # type: Any new, # type: Any parent=None # type: Optional[UndoCommand] ): # type: (...) -> None super().__init__("Change Annotation Geometry", parent) self.scheme = scheme self.annotation = annotation self.old = old self.new = new def redo(self): self.annotation.geometry = self.new # type: ignore def undo(self): self.annotation.geometry = self.old # type: ignore class RenameNodeCommand(UndoCommand): def __init__(self, scheme, node, old_name, new_name, parent=None): # type: (Scheme, SchemeNode, str, str, Optional[UndoCommand]) -> None super().__init__("Rename", parent) self.scheme = scheme self.node = node self.old_name = old_name self.new_name = new_name def redo(self): self.node.set_title(self.new_name) def undo(self): self.node.set_title(self.old_name) class TextChangeCommand(UndoCommand): def __init__( self, scheme, # type: Scheme annotation, # type: SchemeTextAnnotation old_content, # type: str old_content_type, # type: str new_content, # type: str new_content_type, # type: str parent=None # type: Optional[UndoCommand] ): # type: (...) -> None super().__init__("Change text", parent) self.scheme = scheme self.annotation = annotation self.old_content = old_content self.old_content_type = old_content_type self.new_content = new_content self.new_content_type = new_content_type def redo(self): self.annotation.set_content(self.new_content, self.new_content_type) def undo(self): self.annotation.set_content(self.old_content, self.old_content_type) class SetAttrCommand(UndoCommand): def __init__( self, obj, # type: Any attrname, # type: str newvalue, # type: Any name=None, # type: Optional[str] parent=None # type: Optional[UndoCommand] ): # type: (...) -> None if name is None: name = "Set %r" % attrname super().__init__(name, parent) self.obj = obj self.attrname = attrname self.newvalue = newvalue self.oldvalue = getattr(obj, attrname) def redo(self): setattr(self.obj, self.attrname, self.newvalue) def undo(self): setattr(self.obj, self.attrname, self.oldvalue) class SetWindowGroupPresets(UndoCommand): def __init__( self, scheme: 'Scheme', presets: List['Scheme.WindowGroup'], parent: Optional[UndoCommand] = None, **kwargs ) -> None: text = kwargs.pop("text", "Set Window Presets") super().__init__(text, parent, **kwargs) self.scheme = scheme self.presets = presets self.__undo_presets = None def redo(self): presets = self.scheme.window_group_presets() self.scheme.set_window_group_presets(self.presets) self.__undo_presets = presets def undo(self): self.scheme.set_window_group_presets(self.__undo_presets) self.__undo_presets = None class SimpleUndoCommand(UndoCommand): """ Simple undo/redo command specified by callable function pair. Parameters ---------- redo: Callable[[], None] A function expressing a redo action. undo : Callable[[], None] A function expressing a undo action. text : str The command's text (see `UndoCommand.setText`) parent : Optional[UndoCommand] """ def __init__( self, redo, # type: Callable[[], None] undo, # type: Callable[[], None] text, # type: str parent=None # type: Optional[UndoCommand] ): # type: (...) -> None super().__init__(text, parent) self._redo = redo self._undo = undo def undo(self): # type: () -> None """Reimplemented.""" self._undo() def redo(self): # type: () -> None """Reimplemented.""" self._redo() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/editlinksdialog.py0000644000175100002000000010207014730024325024731 0ustar00runnerdocker""" =========== Link Editor =========== An Dialog to edit links between two nodes in the scheme. """ import typing from typing import cast, List, Tuple, Optional, Any, Union from collections import namedtuple from xml.sax.saxutils import escape from AnyQt.QtWidgets import ( QApplication, QDialog, QVBoxLayout, QDialogButtonBox, QGraphicsScene, QGraphicsView, QGraphicsWidget, QGraphicsRectItem, QGraphicsLineItem, QGraphicsTextItem, QGraphicsLayoutItem, QGraphicsLinearLayout, QGraphicsGridLayout, QGraphicsPixmapItem, QGraphicsDropShadowEffect, QSizePolicy, QGraphicsItem, QWidget, QWIDGETSIZE_MAX, QStyle ) from AnyQt.QtGui import ( QPalette, QPen, QPainter, QIcon, QPainterPathStroker ) from AnyQt.QtCore import ( Qt, QObject, QSize, QSizeF, QPointF, QRectF, QEvent ) from ..scheme import compatible_channels from ..registry import InputSignal, OutputSignal from ..resources import icon_loader from ..utils import type_str if typing.TYPE_CHECKING: from ..scheme import SchemeNode IOPair = Tuple[OutputSignal, InputSignal] class EditLinksDialog(QDialog): """ A dialog for editing links. >>> dlg = EditLinksDialog() >>> dlg.setNodes(source_node, sink_node) >>> dlg.setLinks([(source_node.output_channel("Data"), ... sink_node.input_channel("Data"))]) >>> if dlg.exec() == EditLinksDialog.Accepted: ... new_links = dlg.links() ... """ def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.setModal(True) self.__setupUi() def __setupUi(self): layout = QVBoxLayout() # Scene with the link editor. self.scene = LinksEditScene() self.view = QGraphicsView(self.scene) self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.view.setRenderHint(QPainter.Antialiasing) self.scene.editWidget.geometryChanged.connect(self.__onGeometryChanged) # Ok/Cancel/Clear All buttons. buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Reset, Qt.Horizontal) clear_button = buttons.button(QDialogButtonBox.Reset) clear_button.setText(self.tr("Clear All")) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) clear_button.clicked.connect(self.scene.editWidget.clearLinks) layout.addWidget(self.view) layout.addWidget(buttons) self.setLayout(layout) layout.setSizeConstraint(QVBoxLayout.SetFixedSize) self.setSizeGripEnabled(False) def setNodes(self, source_node, sink_node): # type: (SchemeNode, SchemeNode) -> None """ Set the source/sink nodes (:class:`.SchemeNode` instances) between which to edit the links. .. note:: This should be called before :func:`setLinks`. """ self.scene.editWidget.setNodes(source_node, sink_node) def setLinks(self, links): # type: (List[IOPair]) -> None """ Set a list of links to display between the source and sink nodes. The `links` is a list of (`OutputSignal`, `InputSignal`) tuples where the first element is an output signal of the source node and the second an input signal of the sink node. """ self.scene.editWidget.setLinks(links) def links(self): # type: () -> List[IOPair] """ Return the links between the source and sink node. """ return self.scene.editWidget.links() def __onGeometryChanged(self): size = self.scene.editWidget.size() m = self.contentsMargins() self.view.setFixedSize( size.toSize() + QSize(m.left() + m.right() + 4, m.top() + m.bottom() + 4) ) self.view.setSceneRect(self.scene.editWidget.geometry()) def find_item_at( scene, # type: QGraphicsScene pos, # type: QPointF order=Qt.DescendingOrder, # type: Qt.SortOrder type=None, # type: Optional[type] name=None, # type: Optional[str] ): # type: (...) -> Optional[QGraphicsItem] """ Find an object in a :class:`QGraphicsScene` `scene` at `pos`. If `type` is not `None` the it must specify the type of the item. I `name` is not `None` it must be a name of the object (`QObject.objectName()`). """ items = scene.items(pos, Qt.IntersectsItemShape, order) for item in items: if type is not None and \ not isinstance(item, type): continue if name is not None and isinstance(item, QObject) and \ item.objectName() != name: continue return item return None class LinksEditScene(QGraphicsScene): """ A :class:`QGraphicsScene` used by the :class:`LinkEditWidget`. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.editWidget = LinksEditWidget() self.addItem(self.editWidget) findItemAt = find_item_at _Link = namedtuple( "_Link", ["output", # OutputSignal "input", # InputSignal "lineItem", # QGraphicsLineItem connecting the input to output ]) class LinksEditWidget(QGraphicsWidget): """ A Graphics Widget for editing the links between two nodes. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton) self.source = None self.sink = None # QGraphicsWidget/Items in the scene. self.sourceNodeWidget = None self.sourceNodeTitle = None self.sinkNodeWidget = None self.sinkNodeTitle = None self.__links = [] # type: List[IOPair] self.__textItems = [] self.__iconItems = [] self.__tmpLine = None self.__dragStartItem = None self.setLayout(QGraphicsLinearLayout(Qt.Vertical)) self.layout().setContentsMargins(0, 0, 0, 0) def removeItems(self, items): """ Remove child items from the widget and scene. """ scene = self.scene() for item in items: item.setParentItem(None) if scene is not None: scene.removeItem(item) def clear(self): """ Clear the editor state (source and sink nodes, channels ...). """ if self.layout().count(): widget = self.layout().takeAt(0).graphicsItem() self.removeItems([widget]) self.source = None self.sink = None def setNodes(self, source, sink): """ Set the source/sink nodes (:class:`SchemeNode` instances) between which to edit the links. .. note:: Call this before :func:`setLinks`. """ self.clear() self.source = source self.sink = sink self.__updateState() def setLinks(self, links): """ Set a list of links to display between the source and sink nodes. `links` must be a list of (`OutputSignal`, `InputSignal`) tuples where the first element refers to the source node and the second to the sink node (as set by `setNodes`). """ self.clearLinks() for output, input in links: self.addLink(output, input) def links(self): """ Return the links between the source and sink node. """ return [(link.output, link.input) for link in self.__links] def mousePressEvent(self, event): if event.button() == Qt.LeftButton: startItem = find_item_at(self.scene(), event.pos(), type=ChannelAnchor) if startItem is not None and startItem.isEnabled(): # Start a connection line drag. self.__dragStartItem = startItem self.__tmpLine = None event.accept() return lineItem = find_item_at(self.scene(), event.scenePos(), type=QGraphicsLineItem) if lineItem is not None: # Remove a connection under the mouse for link in self.__links: if link.lineItem == lineItem: self.removeLink(link.output, link.input) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton: downPos = event.buttonDownPos(Qt.LeftButton) if not self.__tmpLine and self.__dragStartItem and \ (downPos - event.pos()).manhattanLength() > \ QApplication.instance().startDragDistance(): # Start a line drag line = LinkLineItem(self) start = self.__dragStartItem.boundingRect().center() start = self.mapFromItem(self.__dragStartItem, start) eventPos = event.pos() line.setLine(start.x(), start.y(), eventPos.x(), eventPos.y()) self.__tmpLine = line if self.__dragStartItem in self.sourceNodeWidget.channelAnchors: for anchor in self.sinkNodeWidget.channelAnchors: self.__updateAnchorState(anchor, [self.__dragStartItem]) else: for anchor in self.sourceNodeWidget.channelAnchors: self.__updateAnchorState(anchor, [self.__dragStartItem]) if self.__tmpLine: # Update the temp line line = self.__tmpLine.line() maybe_anchor = find_item_at(self.scene(), event.scenePos(), type=ChannelAnchor) # If hovering over anchor if maybe_anchor is not None and maybe_anchor.isEnabled(): target_pos = maybe_anchor.boundingRect().center() target_pos = self.mapFromItem(maybe_anchor, target_pos) line.setP2(target_pos) else: target_pos = event.pos() line.setP2(target_pos) self.__tmpLine.setLine(line) super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton and self.__tmpLine: self.__resetAnchorStates() endItem = find_item_at(self.scene(), event.scenePos(), type=ChannelAnchor) if endItem is not None: startItem = self.__dragStartItem startChannel = startItem.channel() endChannel = endItem.channel() possible = False # Make sure the drag was from input to output (or reversed) and # not between input -> input or output -> output # pylint: disable=unidiomatic-typecheck if type(startChannel) != type(endChannel): if isinstance(startChannel, InputSignal): startChannel, endChannel = endChannel, startChannel possible = compatible_channels(startChannel, endChannel) if possible: self.addLink(startChannel, endChannel) self.scene().removeItem(self.__tmpLine) self.__tmpLine = None self.__dragStartItem = None super().mouseReleaseEvent(event) def addLink(self, output, input): """ Add a link between `output` (:class:`OutputSignal`) and `input` (:class:`InputSignal`). """ if not compatible_channels(output, input): return if output not in self.source.output_channels(): raise ValueError("%r is not an output channel of %r" % \ (output, self.source)) if input not in self.sink.input_channels(): raise ValueError("%r is not an input channel of %r" % \ (input, self.sink)) if input.single: # Remove existing link if it exists. for s1, s2, _ in self.__links: if s2 == input: self.removeLink(s1, s2) line = LinkLineItem(self) line.setToolTip(self.tr("Click to remove the link.")) source_anchor = self.sourceNodeWidget.anchor(output) sink_anchor = self.sinkNodeWidget.anchor(input) source_pos = source_anchor.boundingRect().center() source_pos = self.mapFromItem(source_anchor, source_pos) sink_pos = sink_anchor.boundingRect().center() sink_pos = self.mapFromItem(sink_anchor, sink_pos) line.setLine(source_pos.x(), source_pos.y(), sink_pos.x(), sink_pos.y()) self.__links.append(_Link(output, input, line)) def removeLink(self, output, input): """ Remove a link between the `output` and `input` channels. """ for link in list(self.__links): if link.output == output and link.input == input: self.scene().removeItem(link.lineItem) self.__links.remove(link) break else: raise ValueError("No such link {0.name!r} -> {1.name!r}." \ .format(output, input)) def clearLinks(self): """ Clear (remove) all the links. """ for output, input, _ in list(self.__links): self.removeLink(output, input) def __updateState(self): """ Update the widget with the new source/sink node signal descriptions. """ widget = QGraphicsWidget() widget.setLayout(QGraphicsGridLayout()) # Space between left and right anchors widget.layout().setHorizontalSpacing(50) left_node = EditLinksNode(self, direction=Qt.LeftToRight, node=self.source) left_node.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) right_node = EditLinksNode(self, direction=Qt.RightToLeft, node=self.sink) right_node.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) left_node.setMinimumWidth(150) right_node.setMinimumWidth(150) widget.layout().addItem(left_node, 0, 0,) widget.layout().addItem(right_node, 0, 1,) title_template = "
              {0}
              " left_title = GraphicsTextWidget(self) left_title.setHtml(title_template.format(escape(self.source.title))) left_title.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) right_title = GraphicsTextWidget(self) right_title.setHtml(title_template.format(escape(self.sink.title))) right_title.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) widget.layout().addItem(left_title, 1, 0, alignment=Qt.AlignHCenter | Qt.AlignTop) widget.layout().addItem(right_title, 1, 1, alignment=Qt.AlignHCenter | Qt.AlignTop) widget.setParentItem(self) max_w = max(left_node.sizeHint(Qt.PreferredSize).width(), right_node.sizeHint(Qt.PreferredSize).width()) # fix same size left_node.setMinimumWidth(max_w) right_node.setMinimumWidth(max_w) left_title.setMinimumWidth(max_w) right_title.setMinimumWidth(max_w) self.layout().addItem(widget) self.layout().activate() self.sourceNodeWidget = left_node self.sinkNodeWidget = right_node self.sourceNodeTitle = left_title self.sinkNodeTitle = right_title # AnchorHover hover over anchor before hovering over line class AnchorHover(QGraphicsRectItem): def __init__(self, anchor, parent=None): super().__init__(parent=parent) self.setAcceptHoverEvents(True) self.anchor = anchor self.setRect(anchor.boundingRect()) self.setPos(self.mapFromScene(anchor.scenePos())) self.setFlag(QGraphicsItem.ItemHasNoContents, True) def hoverEnterEvent(self, event): if self.anchor.isEnabled(): self.anchor.hoverEnterEvent(event) else: event.ignore() def hoverLeaveEvent(self, event): if self.anchor.isEnabled(): self.anchor.hoverLeaveEvent(event) else: event.ignore() for anchor in left_node.channelAnchors + right_node.channelAnchors: anchor.overlay = AnchorHover(anchor, parent=self) anchor.overlay.setZValue(2.0) self.__resetAnchorStates() def __resetAnchorStates(self): source_anchors = self.sourceNodeWidget.channelAnchors sink_anchors = self.sinkNodeWidget.channelAnchors for anchor in source_anchors: self.__updateAnchorState(anchor, sink_anchors) for anchor in sink_anchors: self.__updateAnchorState(anchor, source_anchors) def __updateAnchorState(self, anchor, opposite_anchors): first_channel = anchor.channel() for opposite_anchor in opposite_anchors: second_channel = opposite_anchor.channel() if isinstance(first_channel, OutputSignal) and \ compatible_channels(first_channel, second_channel) or \ isinstance(first_channel, InputSignal) and \ compatible_channels(second_channel, first_channel): anchor.setEnabled(True) anchor.setToolTip("Click and drag to connect widgets!") return if isinstance(first_channel, OutputSignal): anchor.setToolTip("No compatible input channel.") else: anchor.setToolTip("No compatible output channel.") anchor.setEnabled(False) def changeEvent(self, event: QEvent) -> None: if event.type() == QEvent.PaletteChange: palette = self.palette() for _, _, link in self.__links: link.setPalette(palette) super().changeEvent(event) class EditLinksNode(QGraphicsWidget): """ A Node representation with channel anchors. `direction` specifies the layout (default `Qt.LeftToRight` will have icon on the left and channels on the right). """ def __init__(self, parent=None, direction=Qt.LeftToRight, node=None, icon=None, iconSize=None, **args): super().__init__(parent, **args) self.setAcceptedMouseButtons(Qt.NoButton) self.__direction = direction self.setLayout(QGraphicsLinearLayout(Qt.Horizontal)) # Set the maximum size, otherwise the layout can't grow beyond its # sizeHint (and we need it to grow so the widget can grow and keep the # contents centered vertically. self.layout().setMaximumSize(QSizeF(QWIDGETSIZE_MAX, QWIDGETSIZE_MAX)) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) self.__iconSize = iconSize or QSize(64, 64) self.__icon = icon self.__iconItem = QGraphicsPixmapItem(self) self.__iconLayoutItem = GraphicsItemLayoutItem(item=self.__iconItem) self.__channelLayout = QGraphicsGridLayout() self.channelAnchors: List[ChannelAnchor] = [] if self.__direction == Qt.LeftToRight: self.layout().addItem(self.__iconLayoutItem) self.layout().addItem(self.__channelLayout) channel_alignemnt = Qt.AlignRight else: self.layout().addItem(self.__channelLayout) self.layout().addItem(self.__iconLayoutItem) channel_alignemnt = Qt.AlignLeft self.layout().setAlignment(self.__iconLayoutItem, Qt.AlignCenter) self.layout().setAlignment(self.__channelLayout, Qt.AlignVCenter | channel_alignemnt) self.node: Optional[SchemeNode] = None self.channels: Union[List[InputSignal], List[OutputSignal]] = [] if node is not None: self.setSchemeNode(node) def setIconSize(self, size): """ Set the icon size for the node. """ if size != self.__iconSize: self.__iconSize = QSize(size) if self.__icon: self.__iconItem.setPixmap(self.__icon.pixmap(size)) self.__iconLayoutItem.updateGeometry() def iconSize(self): """ Return the icon size. """ return QSize(self.__iconSize) def setIcon(self, icon): """ Set the icon to display. """ if icon != self.__icon: self.__icon = QIcon(icon) self.__iconItem.setPixmap(icon.pixmap(self.iconSize())) self.__iconLayoutItem.updateGeometry() def icon(self): """ Return the icon. """ return QIcon(self.__icon) def setSchemeNode(self, node): # type: (SchemeNode) -> None """ Set an instance of `SchemeNode`. The widget will be initialized with its icon and channels. """ self.node = node channels: Union[List[InputSignal], List[OutputSignal]] if self.__direction == Qt.LeftToRight: channels = node.output_channels() else: channels = node.input_channels() self.channels = channels loader = icon_loader.from_description(node.description) icon = loader.get(node.description.icon) self.setIcon(icon) label_template = ('
              ' '{name}' '
              ') if self.__direction == Qt.LeftToRight: align = "right" label_alignment = Qt.AlignVCenter | Qt.AlignRight anchor_alignment = Qt.AlignVCenter | Qt.AlignLeft label_row = 0 anchor_row = 1 else: align = "left" label_alignment = Qt.AlignVCenter | Qt.AlignLeft anchor_alignment = Qt.AlignVCenter | Qt.AlignLeft label_row = 1 anchor_row = 0 self.channelAnchors = [] grid = self.__channelLayout for i, channel in enumerate(channels): channel = cast(Union[InputSignal, OutputSignal], channel) text = label_template.format(align=align, name=escape(channel.name)) text_item = GraphicsTextWidget(self) text_item.setHtml(text) text_item.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) text_item.setToolTip( escape(getattr(channel, 'description', type_str(channel.types))) ) grid.addItem(text_item, i, label_row, alignment=label_alignment) anchor = ChannelAnchor(self, channel=channel, rect=QRectF(0, 0, 20, 20)) layout_item = GraphicsItemLayoutItem(grid, item=anchor) grid.addItem(layout_item, i, anchor_row, alignment=anchor_alignment) self.channelAnchors.append(anchor) def anchor(self, channel): """ Return the anchor item for the `channel` name. """ for anchor in self.channelAnchors: if anchor.channel() == channel: return anchor raise ValueError(channel.name) def paint(self, painter, option, widget=None): painter.save() palette = self.palette() border = palette.brush(QPalette.Mid) pen = QPen(border, 1) pen.setCosmetic(True) painter.setPen(pen) painter.setBrush(palette.brush(QPalette.Window)) brect = self.boundingRect() painter.drawRoundedRect(brect, 4, 4) painter.restore() def changeEvent(self, event: QEvent) -> None: if event.type() == QEvent.PaletteChange: palette = self.palette() for anc in self.channelAnchors: anc.setPalette(palette) super().changeEvent(event) class GraphicsItemLayoutItem(QGraphicsLayoutItem): """ A graphics layout that handles the position of a general QGraphicsItem in a QGraphicsLayout. The items boundingRect is used as this items fixed sizeHint and the item is positioned at the top left corner of the this items geometry. """ def __init__(self, parent=None, item=None, ): self.__item = None super().__init__(parent, isLayout=False) self.setOwnedByLayout(True) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) if item is not None: self.setItem(item) def setItem(self, item): self.__item = item self.setGraphicsItem(item) def setGeometry(self, rect): # TODO: specifiy if the geometry should be set relative to the # bounding rect top left corner if self.__item: self.__item.setPos(rect.topLeft()) super().setGeometry(rect) def sizeHint(self, which, constraint): if self.__item: return self.__item.boundingRect().size() else: return super().sizeHint(which, constraint) class ChannelAnchor(QGraphicsRectItem): """ A rectangular Channel Anchor indicator. """ #: Used/filled by EditLinksWidget to track overlays overlay: QGraphicsRectItem = None def __init__(self, parent=None, channel=None, rect=None, **kwargs): super().__init__(parent, **kwargs) self.setAcceptedMouseButtons(Qt.NoButton) self.__channel = None if isinstance(parent, QGraphicsWidget): palette = parent.palette() else: palette = QPalette() self.__palette = palette if rect is None: rect = QRectF(0, 0, 20, 20) self.setRect(rect) if channel: self.setChannel(channel) self.__default_pen = QPen(palette.color(QPalette.Text), 1) self.__hover_pen = QPen(palette.color(QPalette.Text), 2) self.setPen(self.__default_pen) def setChannel(self, channel): """ Set the channel description. """ if channel != self.__channel: self.__channel = channel def channel(self): """ Return the channel description. """ return self.__channel def setEnabled(self, enabled): super().setEnabled(enabled) self.update() def setToolTip(self, toolTip: str) -> None: super().setToolTip(toolTip) if self.overlay is not None: self.overlay.setToolTip(toolTip) def setPalette(self, palette: QPalette) -> None: self.__palette = palette self.__default_pen.setColor(palette.color(QPalette.Text)) self.__hover_pen.setColor(palette.color(QPalette.Text)) pen = self.__hover_pen if self.isUnderMouse() else self.__default_pen self.setPen(pen) def palette(self) -> QPalette: return QPalette(self.__palette) def paint(self, painter, option, widget=None): rect = self.rect() palette = self.palette() pen = self.pen() if option.state & QStyle.State_Enabled: brush = palette.brush(QPalette.Base) else: brush = palette.brush(QPalette.Disabled, QPalette.Window) painter.setPen(pen) painter.setBrush(brush) painter.drawRect(rect) # if disabled, draw X over box if not option.state & QStyle.State_Enabled: painter.setClipRect(rect, Qt.ReplaceClip) painter.drawLine(rect.topLeft(), rect.bottomRight()) painter.drawLine(rect.topRight(), rect.bottomLeft()) def hoverEnterEvent(self, event): self.setPen(self.__hover_pen) super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.setPen(self.__default_pen) super().hoverLeaveEvent(event) class GraphicsTextWidget(QGraphicsWidget): """ A QGraphicsWidget subclass that manages a `QGraphicsTextItem`. """ def __init__(self, parent=None, textItem=None): super().__init__(parent) if textItem is None: textItem = QGraphicsTextItem() self.__textItem = textItem self.__textItem.setParentItem(self) self.__textItem.setPos(0, 0) doc_layout = self.document().documentLayout() doc_layout.documentSizeChanged.connect(self._onDocumentSizeChanged) def sizeHint(self, which, constraint=QSizeF()): if which == Qt.PreferredSize: doc = self.document() textwidth = doc.textWidth() if textwidth != constraint.width(): cloned = doc.clone(self) cloned.setTextWidth(constraint.width()) sh = cloned.size() cloned.deleteLater() else: sh = doc.size() return sh else: return super().sizeHint(which, constraint) def setGeometry(self, rect): super().setGeometry(rect) self.__textItem.setTextWidth(rect.width()) def setPlainText(self, text): self.__textItem.setPlainText(text) self.updateGeometry() def setHtml(self, text): self.__textItem.setHtml(text) def adjustSize(self): self.__textItem.adjustSize() self.updateGeometry() def setDefaultTextColor(self, color): self.__textItem.setDefaultTextColor(color) def document(self): return self.__textItem.document() def setDocument(self, doc): doc_layout = self.document().documentLayout() doc_layout.documentSizeChanged.disconnect(self._onDocumentSizeChanged) self.__textItem.setDocument(doc) doc_layout = self.document().documentLayout() doc_layout.documentSizeChanged.connect(self._onDocumentSizeChanged) self.updateGeometry() def _onDocumentSizeChanged(self, size): """The doc size has changed""" self.updateGeometry() def changeEvent(self, event: QEvent) -> None: if event.type() == QEvent.PaletteChange: palette = self.palette() self.__textItem.setDefaultTextColor(palette.color(QPalette.Text)) super().changeEvent(event) class LinkLineItem(QGraphicsLineItem): """ A line connecting two Channel Anchors. """ def __init__(self, parent=None): super().__init__(parent) self.setAcceptHoverEvents(True) self.__shape = None if isinstance(parent, QGraphicsWidget): palette = parent.palette() else: palette = QPalette() self.__palette = palette self.__default_pen = QPen(palette.color(QPalette.Text), 4) self.__default_pen.setCapStyle(Qt.RoundCap) self.__hover_pen = QPen(palette.color(QPalette.Text), 4) self.__hover_pen.setCapStyle(Qt.RoundCap) self.setPen(self.__default_pen) self.__shadow = QGraphicsDropShadowEffect( blurRadius=10, color=palette.color(QPalette.Shadow), offset=QPointF(0, 0) ) self.setGraphicsEffect(self.__shadow) self.prepareGeometryChange() self.__shadow.setEnabled(False) def setPalette(self, palette: QPalette) -> None: self.__palette = palette self.__default_pen.setColor(palette.color(QPalette.Text)) self.__hover_pen.setColor(palette.color(QPalette.Text)) self.setPen( self.__hover_pen if self.isUnderMouse() else self.__default_pen ) def palette(self) -> QPalette: return QPalette(self.__palette) def setLine(self, *args, **kwargs): super().setLine(*args, **kwargs) # extends mouse hit area stroke_path = QPainterPathStroker() stroke_path.setCapStyle(Qt.RoundCap) stroke_path.setWidth(10) self.__shape = stroke_path.createStroke(super().shape()) def shape(self): if self.__shape is None: return super().shape() return self.__shape def boundingRect(self) -> QRectF: rect = super().boundingRect() return rect.adjusted(5, -5, 5, 5) def hoverEnterEvent(self, event): self.prepareGeometryChange() self.__shadow.setEnabled(True) self.setPen(self.__hover_pen) self.setZValue(1.0) super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.prepareGeometryChange() self.__shadow.setEnabled(False) self.setPen(self.__default_pen) self.setZValue(0.0) super().hoverLeaveEvent(event) def paint(self, painter, option, widget=None): super().paint(painter, option, widget) if option.state & QStyle.State_MouseOver: line = self.line() center = line.center() painter.translate(center) painter.rotate(-line.angle()) pen = painter.pen() pen.setWidthF(3) painter.setPen(pen) painter.drawLine(-5, -5, 5, 5) painter.drawLine(-5, 5, 5, -5) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/interactions.py0000644000175100002000000023103614730024325024272 0ustar00runnerdocker""" ========================= User Interaction Handlers ========================= User interaction handlers for a :class:`~.SchemeEditWidget`. User interactions encapsulate the logic of user interactions with the scheme document. All interactions are subclasses of :class:`UserInteraction`. """ import typing from typing import Optional, Any, Tuple, List, Set, Iterable, Sequence, Dict import abc import logging from functools import reduce from AnyQt.QtWidgets import ( QApplication, QGraphicsRectItem, QGraphicsSceneMouseEvent, QGraphicsSceneContextMenuEvent, QWidget, QGraphicsItem, QGraphicsSceneDragDropEvent, QMenu, QAction, QWidgetAction, QLabel ) from AnyQt.QtGui import QPen, QBrush, QColor, QFontMetrics, QKeyEvent, QFont from AnyQt.QtCore import ( Qt, QObject, QCoreApplication, QSizeF, QPointF, QRect, QRectF, QLineF, QPoint, QMimeData, ) from AnyQt.QtCore import pyqtSignal as Signal from orangecanvas.document.commands import UndoCommand from .usagestatistics import UsageStatistics from ..registry.description import WidgetDescription, OutputSignal, InputSignal from ..registry.qt import QtWidgetRegistry, tooltip_helper, whats_this_helper from .. import scheme from ..scheme import ( SchemeNode as Node, SchemeLink as Link, Scheme, WorkflowEvent, compatible_channels ) from ..canvas import items from ..canvas.items import controlpoints from ..gui.quickhelp import QuickHelpTipEvent from . import commands from .editlinksdialog import EditLinksDialog from ..utils import unique from ..utils.pkgmeta import EntryPoint, entry_points if typing.TYPE_CHECKING: from .schemeedit import SchemeEditWidget A = typing.TypeVar("A") #: Output/Input pair of a link OIPair = Tuple[OutputSignal, InputSignal] log = logging.getLogger(__name__) def assert_not_none(optional): # type: (Optional[A]) -> A assert optional is not None return optional class UserInteraction(QObject): """ Base class for user interaction handlers. Parameters ---------- document : :class:`~.SchemeEditWidget` An scheme editor instance with which the user is interacting. parent : :class:`QObject`, optional A parent QObject deleteOnEnd : bool, optional Should the UserInteraction be deleted when it finishes (``True`` by default). """ # Cancel reason flags #: No specified reason NoReason = 0 #: User canceled the operation (e.g. pressing ESC) UserCancelReason = 1 #: Another interaction was set InteractionOverrideReason = 3 #: An internal error occurred ErrorReason = 4 #: Other (unspecified) reason OtherReason = 5 #: Emitted when the interaction is set on the scene. started = Signal() #: Emitted when the interaction finishes successfully. finished = Signal() #: Emitted when the interaction ends (canceled or finished) ended = Signal() #: Emitted when the interaction is canceled. canceled = Signal([], [int]) def __init__(self, document, parent=None, deleteOnEnd=True): # type: ('SchemeEditWidget', Optional[QObject], bool) -> None super().__init__(parent) self.document = document self.scene = document.scene() scheme_ = document.scheme() assert scheme_ is not None self.scheme = scheme_ # type: scheme.Scheme self.suggestions = document.suggestions() self.deleteOnEnd = deleteOnEnd self.cancelOnEsc = False self.__finished = False self.__canceled = False self.__cancelReason = self.NoReason def start(self): # type: () -> None """ Start the interaction. This is called by the :class:`CanvasScene` when the interaction is installed. .. note:: Must be called from subclass implementations. """ self.started.emit() def end(self): # type: () -> None """ Finish the interaction. Restore any leftover state in this method. .. note:: This gets called from the default :func:`cancel` implementation. """ self.__finished = True if self.scene.user_interaction_handler is self: self.scene.set_user_interaction_handler(None) if self.__canceled: self.canceled.emit() self.canceled[int].emit(self.__cancelReason) else: self.finished.emit() self.ended.emit() if self.deleteOnEnd: self.deleteLater() def cancel(self, reason=OtherReason): # type: (int) -> None """ Cancel the interaction with `reason`. """ self.__canceled = True self.__cancelReason = reason self.end() def isFinished(self): # type: () -> bool """ Is the interaction finished. """ return self.__finished def isCanceled(self): # type: () -> bool """ Was the interaction canceled. """ return self.__canceled def cancelReason(self): # type: () -> int """ Return the reason the interaction was canceled. """ return self.__cancelReason def postQuickTip(self, contents: str) -> None: """ Post a QuickHelpTipEvent with rich text `contents` to the document editor. """ hevent = QuickHelpTipEvent("", contents) QApplication.postEvent(self.document, hevent) def clearQuickTip(self): """Clear the quick tip help event.""" self.postQuickTip("") def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool """ Handle a `QGraphicsScene.mousePressEvent`. """ return False def mouseMoveEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool """ Handle a `GraphicsScene.mouseMoveEvent`. """ return False def mouseReleaseEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool """ Handle a `QGraphicsScene.mouseReleaseEvent`. """ return False def mouseDoubleClickEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool """ Handle a `QGraphicsScene.mouseDoubleClickEvent`. """ return False def keyPressEvent(self, event): # type: (QKeyEvent) -> bool """ Handle a `QGraphicsScene.keyPressEvent` """ if self.cancelOnEsc and event.key() == Qt.Key_Escape: self.cancel(self.UserCancelReason) return False def keyReleaseEvent(self, event): # type: (QKeyEvent) -> bool """ Handle a `QGraphicsScene.keyPressEvent` """ return False def contextMenuEvent(self, event): # type: (QGraphicsSceneContextMenuEvent) -> bool """ Handle a `QGraphicsScene.contextMenuEvent` """ return False def dragEnterEvent(self, event): # type: (QGraphicsSceneDragDropEvent) -> bool """ Handle a `QGraphicsScene.dragEnterEvent` .. versionadded:: 0.1.20 """ return False def dragMoveEvent(self, event): # type: (QGraphicsSceneDragDropEvent) -> bool """ Handle a `QGraphicsScene.dragMoveEvent` .. versionadded:: 0.1.20 """ return False def dragLeaveEvent(self, event): # type: (QGraphicsSceneDragDropEvent) -> bool """ Handle a `QGraphicsScene.dragLeaveEvent` .. versionadded:: 0.1.20 """ return False def dropEvent(self, event): # type: (QGraphicsSceneDragDropEvent) -> bool """ Handle a `QGraphicsScene.dropEvent` .. versionadded:: 0.1.20 """ return False class NoPossibleLinksError(ValueError): pass class UserCanceledError(ValueError): pass def reversed_arguments(func): """ Return a function with reversed argument order. """ def wrapped(*args): return func(*reversed(args)) return wrapped class NewLinkAction(UserInteraction): """ User drags a new link from an existing `NodeAnchorItem` to create a connection between two existing nodes or to a new node if the release is over an empty area, in which case a quick menu for new node selection is presented to the user. """ # direction of the drag FROM_SOURCE = 1 FROM_SINK = 2 def __init__(self, document, *args, **kwargs): super().__init__(document, *args, **kwargs) self.from_item = None # type: Optional[items.NodeItem] self.from_signal = None # type: Optional[Union[InputSignal, OutputSignal]] self.direction = 0 # type: int self.showing_incompatible_widget = False # type: bool # An `NodeItem` currently under the mouse as a possible # link drop target. self.current_target_item = None # type: Optional[items.NodeItem] # A temporary `LinkItem` used while dragging. self.tmp_link_item = None # type: Optional[items.LinkItem] # An temporary `AnchorPoint` inserted into `current_target_item` self.tmp_anchor_point = None # type: Optional[items.AnchorPoint] # An `AnchorPoint` following the mouse cursor self.cursor_anchor_point = None # type: Optional[items.AnchorPoint] # An UndoCommand self.macro = None # type: Optional[UndoCommand] # Cache viable signals of currently hovered node self.__target_compatible_signals: Sequence[Tuple[OutputSignal, InputSignal]] = [] self.cancelOnEsc = True def remove_tmp_anchor(self): # type: () -> None """ Remove a temporary anchor point from the current target item. """ assert self.current_target_item is not None assert self.tmp_anchor_point is not None if self.direction == self.FROM_SOURCE: self.current_target_item.removeInputAnchor(self.tmp_anchor_point) else: self.current_target_item.removeOutputAnchor(self.tmp_anchor_point) self.tmp_anchor_point = None def update_tmp_anchor(self, item, scenePos): # type: (items.NodeItem, QPointF) -> None """ If hovering over a new compatible channel, move it. """ assert self.tmp_anchor_point is not None if self.direction == self.FROM_SOURCE: signal = item.inputAnchorItem.signalAtPos(scenePos, self.__target_compatible_signals) else: signal = item.outputAnchorItem.signalAtPos(scenePos, self.__target_compatible_signals) self.tmp_anchor_point.setSignal(signal) def create_tmp_anchor(self, item, scenePos): # type: (items.NodeItem, QPointF) -> None """ Create a new tmp anchor at the `item` (:class:`NodeItem`). """ assert self.tmp_anchor_point is None if self.direction == self.FROM_SOURCE: anchor = item.inputAnchorItem signal = anchor.signalAtPos(scenePos, self.__target_compatible_signals) self.tmp_anchor_point = item.newInputAnchor(signal) else: anchor = item.outputAnchorItem signal = anchor.signalAtPos(scenePos, self.__target_compatible_signals) self.tmp_anchor_point = item.newOutputAnchor(signal) def __possible_connection_signal_pairs( self, target_item: items.NodeItem ) -> Sequence[Tuple[OutputSignal, InputSignal]]: """ Return possible connection signal pairs between current `self.from_item` and `target_item`. """ if self.from_item is None: return [] node1 = self.scene.node_for_item(self.from_item) node2 = self.scene.node_for_item(target_item) if self.direction == self.FROM_SOURCE: links = self.scheme.propose_links(node1, node2, source_signal=self.from_signal) else: links = self.scheme.propose_links(node2, node1, sink_signal=self.from_signal) return [(s1, s2) for s1, s2, _ in links] def can_connect(self, target_item): # type: (items.NodeItem) -> bool """ Is the connection between `self.from_item` (item where the drag started) and `target_item` possible. """ return bool(self.__possible_connection_signal_pairs(target_item)) def set_link_target_anchor(self, anchor): # type: (items.AnchorPoint) -> None """ Set the temp line target anchor. """ assert self.tmp_link_item is not None if self.direction == self.FROM_SOURCE: self.tmp_link_item.setSinkItem(None, anchor=anchor) else: self.tmp_link_item.setSourceItem(None, anchor=anchor) def target_node_item_at(self, pos): # type: (QPointF) -> Optional[items.NodeItem] """ Return a suitable :class:`NodeItem` at position `pos` on which a link can be dropped. """ # Test for a suitable `NodeAnchorItem` or `NodeItem` at pos. if self.direction == self.FROM_SOURCE: anchor_type = items.SinkAnchorItem else: anchor_type = items.SourceAnchorItem item = self.scene.item_at(pos, (anchor_type, items.NodeItem)) if isinstance(item, anchor_type): return item.parentNodeItem() elif isinstance(item, items.NodeItem): return item else: return None def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool anchor_item = self.scene.item_at( event.scenePos(), items.NodeAnchorItem ) if anchor_item is not None and event.button() == Qt.LeftButton: # Start a new link starting at item self.from_item = anchor_item.parentNodeItem() if isinstance(anchor_item, items.SourceAnchorItem): self.direction = NewLinkAction.FROM_SOURCE else: self.direction = NewLinkAction.FROM_SINK event.accept() self.postQuickTip( self.tr('

              Create new link

              ' '

              Drag a link to an existing node or release on ' 'an empty spot to create a new node.

              ' '

              Hold Shift when releasing the mouse button to ' 'edit connections.

              ' # '' # 'More ...' ) ) return True else: # Whoever put us in charge did not know what he was doing. self.cancel(self.ErrorReason) return False def mouseMoveEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if self.tmp_link_item is None: # On first mouse move event create the temp link item and # initialize it to follow the `cursor_anchor_point`. self.tmp_link_item = items.LinkItem() # An anchor under the cursor for the duration of this action. self.cursor_anchor_point = items.AnchorPoint() self.cursor_anchor_point.setPos(event.scenePos()) # Set the `fixed` end of the temp link (where the drag started). scenePos = event.scenePos() if self.direction == self.FROM_SOURCE: anchor = self.from_item.outputAnchorItem else: anchor = self.from_item.inputAnchorItem anchor.setHovered(False) anchor.setCompatibleSignals(None) if anchor.anchorOpen(): signal = anchor.signalAtPos(scenePos) anchor.setKeepAnchorOpen(signal) else: signal = None self.from_signal = signal if self.direction == self.FROM_SOURCE: self.tmp_link_item.setSourceItem(self.from_item, signal) else: self.tmp_link_item.setSinkItem(self.from_item, signal) self.set_link_target_anchor(self.cursor_anchor_point) self.scene.addItem(self.tmp_link_item) assert self.cursor_anchor_point is not None # `NodeItem` at the cursor position item = self.target_node_item_at(event.scenePos()) if self.current_target_item is not None and \ (item is None or item is not self.current_target_item): # `current_target_item` is no longer under the mouse cursor # (was replaced by another item or the the cursor was moved over # an empty scene spot. log.info("%r is no longer the target.", self.current_target_item) if self.direction == self.FROM_SOURCE: anchor = self.current_target_item.inputAnchorItem else: anchor = self.current_target_item.outputAnchorItem if self.showing_incompatible_widget: anchor.setIncompatible(False) self.showing_incompatible_widget = False else: self.remove_tmp_anchor() anchor.setHovered(False) anchor.setCompatibleSignals(None) self.current_target_item = None if item is not None and item is not self.from_item: # The mouse is over a node item (different from the starting node) if self.current_target_item is item: # Mouse is over the same item scenePos = event.scenePos() # Move to new potential anchor if not self.showing_incompatible_widget: self.update_tmp_anchor(item, scenePos) else: self.set_link_target_anchor(self.cursor_anchor_point) elif self.can_connect(item): # Mouse is over a new item links = self.__possible_connection_signal_pairs(item) log.info("%r is the new target.", item) if self.direction == self.FROM_SOURCE: self.__target_compatible_signals = [s2 for s1, s2 in links] item.inputAnchorItem.setCompatibleSignals( self.__target_compatible_signals) item.inputAnchorItem.setHovered(True) else: self.__target_compatible_signals = [s1 for s1, s2 in links] item.outputAnchorItem.setCompatibleSignals( self.__target_compatible_signals) item.outputAnchorItem.setHovered(True) scenePos = event.scenePos() self.create_tmp_anchor(item, scenePos) self.set_link_target_anchor( assert_not_none(self.tmp_anchor_point) ) self.current_target_item = item self.showing_incompatible_widget = False else: log.info("%r does not have compatible channels", item) self.__target_compatible_signals = [] if self.direction == self.FROM_SOURCE: anchor = item.inputAnchorItem else: anchor = item.outputAnchorItem anchor.setCompatibleSignals( self.__target_compatible_signals) anchor.setHovered(True) anchor.setIncompatible(True) self.showing_incompatible_widget = True self.set_link_target_anchor(self.cursor_anchor_point) self.current_target_item = item else: self.showing_incompatible_widget = item is not None self.__target_compatible_signals = [] self.set_link_target_anchor(self.cursor_anchor_point) self.cursor_anchor_point.setPos(event.scenePos()) return True def mouseReleaseEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if self.tmp_link_item is not None: item = self.target_node_item_at(event.scenePos()) node = None # type: Optional[Node] stack = self.document.undoStack() self.macro = UndoCommand(self.tr("Add link")) if item: # If the release was over a node item then connect them node = self.scene.node_for_item(item) else: # Release on an empty canvas part # Show a quick menu popup for a new widget creation. try: node = self.create_new(event) except Exception: log.error("Failed to create a new node, ending.", exc_info=True) node = None if node is not None: commands.AddNodeCommand(self.scheme, node, parent=self.macro) if node is not None and not self.showing_incompatible_widget: if self.direction == self.FROM_SOURCE: source_node = self.scene.node_for_item(self.from_item) source_signal = self.from_signal sink_node = node if item is not None and item.inputAnchorItem.anchorOpen(): sink_signal = item.inputAnchorItem.signalAtPos( event.scenePos(), self.__target_compatible_signals ) else: sink_signal = None else: source_node = node if item is not None and item.outputAnchorItem.anchorOpen(): source_signal = item.outputAnchorItem.signalAtPos( event.scenePos(), self.__target_compatible_signals ) else: source_signal = None sink_node = self.scene.node_for_item(self.from_item) sink_signal = self.from_signal self.suggestions.set_direction(self.direction) self.connect_nodes(source_node, sink_node, source_signal, sink_signal) self.reset_open_anchor() if not self.isCanceled() or not self.isFinished() and \ self.macro is not None: # Push (commit) the add link/node action on the stack. stack.push(self.macro) self.end() return True else: self.end() return False def create_new(self, event): # type: (QGraphicsSceneMouseEvent) -> Optional[Node] """ Create and return a new node with a `QuickMenu`. """ pos = event.screenPos() menu = self.document.quickMenu() node = self.scene.node_for_item(self.from_item) from_signal = self.from_signal from_desc = node.description def is_compatible( source_signal: OutputSignal, source: WidgetDescription, sink: WidgetDescription, sink_signal: InputSignal ) -> bool: return any(scheme.compatible_channels(output, input) for output in ([source_signal] if source_signal else source.outputs) for input in ([sink_signal] if sink_signal else sink.inputs)) from_sink = self.direction == self.FROM_SINK if from_sink: # Reverse the argument order. is_compatible = reversed_arguments(is_compatible) suggestion_sort = self.suggestions.get_source_suggestions(from_desc.name) else: suggestion_sort = self.suggestions.get_sink_suggestions(from_desc.name) def sort(left, right): # list stores frequencies, so sign is flipped return suggestion_sort[left] > suggestion_sort[right] menu.setSortingFunc(sort) def filter(index): desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE) if isinstance(desc, WidgetDescription): return is_compatible(from_signal, from_desc, desc, None) else: return False menu.setFilterFunc(filter) menu.triggerSearch() try: action = menu.exec(pos) finally: menu.setFilterFunc(None) if action: item = action.property("item") desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE) pos = event.scenePos() # a new widget should be placed so that the connection # stays as it was offset = 31 * (-1 if self.direction == self.FROM_SINK else 1 if self.direction == self.FROM_SOURCE else 0) statistics = self.document.usageStatistics() statistics.begin_extend_action(from_sink, node) node = self.document.newNodeHelper(desc, position=(pos.x() + offset, pos.y())) return node else: return None def connect_nodes( self, source_node: Node, sink_node: Node, source_signal: Optional[OutputSignal] = None, sink_signal: Optional[InputSignal] = None ) -> None: """ Connect `source_node` to `sink_node`. If there are more then one equally weighted and non conflicting links possible present a detailed dialog for link editing. """ UsageStatistics.set_sink_anchor_open(sink_signal is not None) UsageStatistics.set_source_anchor_open(source_signal is not None) try: possible = self.scheme.propose_links(source_node, sink_node, source_signal, sink_signal) log.debug("proposed (weighted) links: %r", [(s1.name, s2.name, w) for s1, s2, w in possible]) if not possible: raise NoPossibleLinksError source, sink, w = possible[0] # just a list of signal tuples for now, will be converted # to SchemeLinks later links_to_add = [] # type: List[Link] links_to_remove = [] # type: List[Link] show_link_dialog = False # Ambiguous new link request. if len(possible) >= 2: # Check for possible ties in the proposed link weights _, _, w2 = possible[1] if w == w2: show_link_dialog = True # Check for destructive action (i.e. would the new link # replace a previous link) except for explicit only link # candidates if sink.single and w2 > 0 and \ self.scheme.find_links(sink_node=sink_node, sink_channel=sink): show_link_dialog = True if show_link_dialog: existing = self.scheme.find_links(source_node=source_node, sink_node=sink_node) if existing: # edit_links will populate the view with existing links initial_links = None else: initial_links = [(source, sink)] try: rstatus, links_to_add, links_to_remove = self.edit_links( source_node, sink_node, initial_links ) except Exception: log.error("Failed to edit the links", exc_info=True) raise if rstatus == EditLinksDialog.Rejected: raise UserCanceledError else: # links_to_add now needs to be a list of actual SchemeLinks links_to_add = [ scheme.SchemeLink(source_node, source, sink_node, sink) ] links_to_add, links_to_remove = \ add_links_plan(self.scheme, links_to_add) # Remove temp items before creating any new links self.cleanup() for link in links_to_remove: commands.RemoveLinkCommand(self.scheme, link, parent=self.macro) for link in links_to_add: # Check if the new requested link is a duplicate of an # existing link duplicate = self.scheme.find_links( link.source_node, link.source_channel, link.sink_node, link.sink_channel ) if not duplicate: commands.AddLinkCommand(self.scheme, link, parent=self.macro) except scheme.IncompatibleChannelTypeError: log.info("Cannot connect: invalid channel types.") self.cancel() except scheme.SchemeTopologyError: log.info("Cannot connect: connection creates a cycle.") self.cancel() except NoPossibleLinksError: log.info("Cannot connect: no possible links.") self.cancel() except UserCanceledError: log.info("User canceled a new link action.") self.cancel(UserInteraction.UserCancelReason) except Exception: log.error("An error occurred during the creation of a new link.", exc_info=True) self.cancel() def edit_links( self, source_node: Node, sink_node: Node, initial_links: 'Optional[List[OIPair]]' = None ) -> 'Tuple[int, List[Link], List[Link]]': """ Show and execute the `EditLinksDialog`. Optional `initial_links` list can provide a list of initial `(source, sink)` channel tuples to show in the view, otherwise the dialog is populated with existing links in the scheme (passing an empty list will disable all initial links). """ status, links_to_add_spec, links_to_remove_spec = \ edit_links( self.scheme, source_node, sink_node, initial_links, parent=self.document ) if status == EditLinksDialog.Accepted: links_to_add = [ scheme.SchemeLink( source_node, source_channel, sink_node, sink_channel ) for source_channel, sink_channel in links_to_add_spec ] links_to_remove = list(reduce( list.__iadd__, ( self.scheme.find_links( source_node, source_channel, sink_node, sink_channel ) for source_channel, sink_channel in links_to_remove_spec ), [] )) # type: List[Link] conflicting = [conflicting_single_link(self.scheme, link) for link in links_to_add] conflicting = [link for link in conflicting if link is not None] for link in conflicting: if link not in links_to_remove: links_to_remove.append(link) return status, links_to_add, links_to_remove else: return status, [], [] def end(self): # type: () -> None self.cleanup() self.reset_open_anchor() # Remove the help tip set in mousePressEvent self.macro = None self.clearQuickTip() super().end() def cancel(self, reason=UserInteraction.OtherReason): # type: (int) -> None self.cleanup() self.reset_open_anchor() super().cancel(reason) def cleanup(self): # type: () -> None """ Cleanup all temporary items in the scene that are left. """ if self.tmp_link_item: self.tmp_link_item.setSinkItem(None) self.tmp_link_item.setSourceItem(None) if self.tmp_link_item.scene(): self.scene.removeItem(self.tmp_link_item) self.tmp_link_item = None if self.current_target_item: if not self.showing_incompatible_widget: self.remove_tmp_anchor() else: if self.direction == self.FROM_SOURCE: anchor = self.current_target_item.inputAnchorItem else: anchor = self.current_target_item.outputAnchorItem anchor.setIncompatible(False) self.current_target_item = None if self.cursor_anchor_point and self.cursor_anchor_point.scene(): self.scene.removeItem(self.cursor_anchor_point) self.cursor_anchor_point = None def reset_open_anchor(self): """ This isn't part of cleanup, because it should retain its value until the link is created. """ if self.direction == self.FROM_SOURCE: anchor = self.from_item.outputAnchorItem else: anchor = self.from_item.inputAnchorItem anchor.setKeepAnchorOpen(None) def edit_links( scheme: Scheme, source_node: Node, sink_node: Node, initial_links: 'Optional[List[OIPair]]' = None, parent: 'Optional[QWidget]' = None ) -> 'Tuple[int, List[OIPair], List[OIPair]]': """ Show and execute the `EditLinksDialog`. Optional `initial_links` list can provide a list of initial `(source, sink)` channel tuples to show in the view, otherwise the dialog is populated with existing links in the scheme (passing an empty list will disable all initial links). """ log.info("Constructing a Link Editor dialog.") dlg = EditLinksDialog(parent, windowTitle="Edit Links") # all SchemeLinks between the two nodes. links = scheme.find_links(source_node=source_node, sink_node=sink_node) existing_links = [(link.source_channel, link.sink_channel) for link in links] if initial_links is None: initial_links = list(existing_links) dlg.setNodes(source_node, sink_node) dlg.setLinks(initial_links) log.info("Executing a Link Editor Dialog.") rval = dlg.exec() if rval == EditLinksDialog.Accepted: edited_links = dlg.links() # Differences links_to_add = set(edited_links) - set(existing_links) links_to_remove = set(existing_links) - set(edited_links) return rval, list(links_to_add), list(links_to_remove) else: return rval, [], [] def add_links_plan(scheme, links, force_replace=False): # type: (Scheme, Iterable[Link], bool) -> Tuple[List[Link], List[Link]] """ Return a plan for adding a list of links to the scheme. """ links_to_add = list(links) links_to_remove = [conflicting_single_link(scheme, link) for link in links] links_to_remove = [link for link in links_to_remove if link is not None] if not force_replace: links_to_add, links_to_remove = remove_duplicates(links_to_add, links_to_remove) return links_to_add, links_to_remove def conflicting_single_link(scheme, link): # type: (Scheme, Link) -> Optional[Link] """ Find and return an existing link in `scheme` connected to the same input channel as `link` if the channel has the 'single' flag. If no such channel exists (or sink channel is not 'single') return `None`. """ if link.sink_channel.single: existing = scheme.find_links( sink_node=link.sink_node, sink_channel=link.sink_channel ) if existing: assert len(existing) == 1 return existing[0] return None def remove_duplicates(links_to_add, links_to_remove): # type: (List[Link], List[Link]) -> Tuple[List[Link], List[Link]] def link_key(link): # type: (Link) -> Tuple[Node, OutputSignal, Node, InputSignal] return (link.source_node, link.source_channel, link.sink_node, link.sink_channel) add_keys = list(map(link_key, links_to_add)) remove_keys = list(map(link_key, links_to_remove)) duplicate_keys = set(add_keys).intersection(remove_keys) def not_duplicate(link): # type: (Link) -> bool return link_key(link) not in duplicate_keys links_to_add = list(filter(not_duplicate, links_to_add)) links_to_remove = list(filter(not_duplicate, links_to_remove)) return links_to_add, links_to_remove class NewNodeAction(UserInteraction): """ Present the user with a quick menu for node selection and create the selected node. """ def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if event.button() == Qt.RightButton: self.create_new(event.screenPos()) self.end() return True def create_new(self, pos, search_text=""): # type: (QPoint, str) -> Optional[Node] """ Create and add new node to the workflow using `QuickMenu` popup at `pos` (in screen coordinates). """ menu = self.document.quickMenu() menu.setFilterFunc(None) # compares probability of the user needing the widget as a source def defaultSort(left, right): default_suggestions = self.suggestions.get_default_suggestions() left_frequency = sum(default_suggestions[left].values()) right_frequency = sum(default_suggestions[right].values()) return left_frequency > right_frequency menu.setSortingFunc(defaultSort) action = menu.exec(pos, search_text) if action: item = action.property("item") desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE) # Get the scene position view = self.document.view() pos = view.mapToScene(view.mapFromGlobal(pos)) statistics = self.document.usageStatistics() statistics.begin_action(UsageStatistics.QuickMenu) node = self.document.newNodeHelper(desc, position=(pos.x(), pos.y())) self.document.addNode(node) return node else: return None class RectangleSelectionAction(UserInteraction): """ Select items in the scene using a Rectangle selection """ def __init__(self, document, *args, **kwargs): # type: (SchemeEditWidget, Any, Any) -> None super().__init__(document, *args, **kwargs) # The initial selection at drag start self.initial_selection = None # type: Optional[Set[QGraphicsItem]] # Selection when last updated in a mouseMoveEvent self.last_selection = None # type: Optional[Set[QGraphicsItem]] # A selection rect (`QRectF`) self.selection_rect = None # type: Optional[QRectF] # Keyboard modifiers self.modifiers = Qt.NoModifier self.rect_item = None # type: Optional[QGraphicsRectItem] def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool pos = event.scenePos() any_item = self.scene.item_at(pos) if not any_item and event.button() & Qt.LeftButton: self.modifiers = event.modifiers() self.selection_rect = QRectF(pos, QSizeF(0, 0)) self.rect_item = QGraphicsRectItem( self.selection_rect.normalized() ) self.rect_item.setPen( QPen(QBrush(QColor(51, 153, 255, 192)), 0.4, Qt.SolidLine, Qt.RoundCap) ) self.rect_item.setBrush( QBrush(QColor(168, 202, 236, 192)) ) self.rect_item.setZValue(-100) # Clear the focus if necessary. if not self.scene.stickyFocus(): self.scene.clearFocus() if not self.modifiers & Qt.ControlModifier: self.scene.clearSelection() event.accept() return True else: self.cancel(self.ErrorReason) return False def mouseMoveEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if self.rect_item is not None and not self.rect_item.scene(): # Add the rect item to the scene when the mouse moves. self.scene.addItem(self.rect_item) self.update_selection(event) return True def mouseReleaseEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if event.button() == Qt.LeftButton: if self.initial_selection is None: # A single click. self.scene.clearSelection() else: self.update_selection(event) self.end() return True def update_selection(self, event): # type: (QGraphicsSceneMouseEvent) -> None """ Update the selection rectangle from a QGraphicsSceneMouseEvent `event` instance. """ if self.initial_selection is None: self.initial_selection = set(self.scene.selectedItems()) self.last_selection = self.initial_selection assert self.selection_rect is not None assert self.rect_item is not None assert self.initial_selection is not None assert self.last_selection is not None pos = event.scenePos() self.selection_rect = QRectF(self.selection_rect.topLeft(), pos) # Make sure the rect_item does not cause the scene rect to grow. rect = self._bound_selection_rect(self.selection_rect.normalized()) # Need that 0.5 constant otherwise the sceneRect will still # grow (anti-aliasing correction by QGraphicsScene?) pw = self.rect_item.pen().width() + 0.5 self.rect_item.setRect(rect.adjusted(pw, pw, -pw, -pw)) selected = self.scene.items(self.selection_rect.normalized(), Qt.IntersectsItemShape, Qt.AscendingOrder) selected = set([item for item in selected if item.flags() & QGraphicsItem.ItemIsSelectable]) if self.modifiers & Qt.ControlModifier: for item in selected | self.last_selection | \ self.initial_selection: item.setSelected( (item in selected) ^ (item in self.initial_selection) ) else: for item in selected.union(self.last_selection): item.setSelected(item in selected) self.last_selection = set(self.scene.selectedItems()) def end(self): # type: () -> None self.initial_selection = None self.last_selection = None self.modifiers = Qt.NoModifier if self.rect_item is not None: self.rect_item.hide() if self.rect_item.scene() is not None: self.scene.removeItem(self.rect_item) super().end() def viewport_rect(self): # type: () -> QRectF """ Return the bounding rect of the document's viewport on the scene. """ view = self.document.view() vsize = view.viewport().size() viewportrect = QRect(0, 0, vsize.width(), vsize.height()) return view.mapToScene(viewportrect).boundingRect() def _bound_selection_rect(self, rect): # type: (QRectF) -> QRectF """ Bound the selection `rect` to a sensible size. """ srect = self.scene.sceneRect() vrect = self.viewport_rect() maxrect = srect.united(vrect) return rect.intersected(maxrect) class EditNodeLinksAction(UserInteraction): """ Edit multiple links between two :class:`SchemeNode` instances using a :class:`EditLinksDialog` Parameters ---------- document : :class:`SchemeEditWidget` The editor widget. source_node : :class:`SchemeNode` The source (link start) node for the link editor. sink_node : :class:`SchemeNode` The sink (link end) node for the link editor. """ def __init__(self, document, source_node, sink_node, *args, **kwargs): # type: (SchemeEditWidget, Node, Node, Any, Any) -> None super().__init__(document, *args, **kwargs) self.source_node = source_node self.sink_node = sink_node def edit_links(self, initial_links=None): # type: (Optional[List[OIPair]]) -> None """ Show and execute the `EditLinksDialog`. Optional `initial_links` list can provide a list of initial `(source, sink)` channel tuples to show in the view, otherwise the dialog is populated with existing links in the scheme (passing an empty list will disable all initial links). """ log.info("Constructing a Link Editor dialog.") dlg = EditLinksDialog(self.document, windowTitle="Edit Links") links = self.scheme.find_links(source_node=self.source_node, sink_node=self.sink_node) existing_links = [(link.source_channel, link.sink_channel) for link in links] if initial_links is None: initial_links = list(existing_links) dlg.setNodes(self.source_node, self.sink_node) dlg.setLinks(initial_links) log.info("Executing a Link Editor Dialog.") rval = dlg.exec() if rval == EditLinksDialog.Accepted: links_spec = dlg.links() links_to_add = set(links_spec) - set(existing_links) links_to_remove = set(existing_links) - set(links_spec) stack = self.document.undoStack() stack.beginMacro("Edit Links") # First remove links into a 'Single' sink channel, # but only the ones that do not have self.source_node as # a source (they will be removed later from links_to_remove) for _, sink_channel in links_to_add: if sink_channel.single: existing = self.scheme.find_links( sink_node=self.sink_node, sink_channel=sink_channel ) existing = [link for link in existing if link.source_node is not self.source_node] if existing: assert len(existing) == 1 self.document.removeLink(existing[0]) for source_channel, sink_channel in links_to_remove: links = self.scheme.find_links(source_node=self.source_node, source_channel=source_channel, sink_node=self.sink_node, sink_channel=sink_channel) assert len(links) == 1 self.document.removeLink(links[0]) for source_channel, sink_channel in links_to_add: link = scheme.SchemeLink(self.source_node, source_channel, self.sink_node, sink_channel) self.document.addLink(link) stack.endMacro() def point_to_tuple(point): # type: (QPointF) -> Tuple[float, float] """ Convert a QPointF into a (x, y) tuple. """ return (point.x(), point.y()) class NewArrowAnnotation(UserInteraction): """ Create a new arrow annotation handler. """ def __init__(self, document, *args, **kwargs): # type: (SchemeEditWidget, Any, Any) -> None super().__init__(document, *args, **kwargs) self.down_pos = None # type: Optional[QPointF] self.arrow_item = None # type: Optional[items.ArrowAnnotation] self.annotation = None # type: Optional[scheme.SchemeArrowAnnotation] self.color = "red" self.cancelOnEsc = True def start(self): # type: () -> None self.document.view().setCursor(Qt.CrossCursor) self.postQuickTip( self.tr('

              New arrow annotation

              ' '

              Click and drag to create a new arrow annotation

              ' # ' bool if event.button() == Qt.LeftButton: self.down_pos = event.scenePos() event.accept() return True else: return super().mousePressEvent(event) def mouseMoveEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if event.buttons() & Qt.LeftButton: assert self.down_pos is not None if self.arrow_item is None and \ (self.down_pos - event.scenePos()).manhattanLength() > \ QApplication.instance().startDragDistance(): annot = scheme.SchemeArrowAnnotation( point_to_tuple(self.down_pos), point_to_tuple(event.scenePos()) ) annot.set_color(self.color) item = self.scene.add_annotation(annot) self.arrow_item = item self.annotation = annot if self.arrow_item is not None: p1, p2 = map(self.arrow_item.mapFromScene, (self.down_pos, event.scenePos())) self.arrow_item.setLine(QLineF(p1, p2)) event.accept() return True else: return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if event.button() == Qt.LeftButton: if self.arrow_item is not None: assert self.down_pos is not None and self.annotation is not None p1, p2 = self.down_pos, event.scenePos() # Commit the annotation to the scheme self.annotation.set_line(point_to_tuple(p1), point_to_tuple(p2)) self.document.addAnnotation(self.annotation) p1, p2 = map(self.arrow_item.mapFromScene, (p1, p2)) self.arrow_item.setLine(QLineF(p1, p2)) self.end() return True else: return super().mouseReleaseEvent(event) def cancel(self, reason=UserInteraction.OtherReason): # type: (int) -> None if self.arrow_item is not None: self.scene.removeItem(self.arrow_item) self.arrow_item = None super().cancel(reason) def end(self): # type: () -> None self.down_pos = None self.arrow_item = None self.annotation = None self.document.view().setCursor(Qt.ArrowCursor) self.clearQuickTip() super().end() def rect_to_tuple(rect): # type: (QRectF) -> Tuple[float, float, float, float] """ Convert a QRectF into a (x, y, width, height) tuple. """ return rect.x(), rect.y(), rect.width(), rect.height() class NewTextAnnotation(UserInteraction): """ A New Text Annotation interaction handler """ def __init__(self, document, *args, **kwargs): # type: (SchemeEditWidget, Any, Any) -> None super().__init__(document, *args, **kwargs) self.down_pos = None # type: Optional[QPointF] self.annotation_item = None # type: Optional[items.TextAnnotation] self.annotation = None # type: Optional[scheme.SchemeTextAnnotation] self.control = None # type: Optional[controlpoints.ControlPointRect] self.font = document.font() # type: QFont self.cancelOnEsc = True def setFont(self, font): # type: (QFont) -> None self.font = QFont(font) def start(self): # type: () -> None self.document.view().setCursor(Qt.CrossCursor) self.postQuickTip( self.tr('

              New text annotation

              ' '

              Click (and drag to resize) on the canvas to create ' 'a new text annotation item.

              ' '

              Right click on the annotation to change how it is ' 'rendered (Markdown, HTML, ...).

              ' # '
              ' # 'More ...' ) ) super().start() def createNewAnnotation(self, rect): # type: (QRectF) -> None """ Create a new TextAnnotation at with `rect` as the geometry. """ annot = scheme.SchemeTextAnnotation(rect_to_tuple(rect)) font = {"family": self.font.family(), "size": self.font.pixelSize()} annot.set_font(font) item = self.scene.add_annotation(annot) item.setTextInteractionFlags(Qt.TextEditorInteraction) item.setFramePen(QPen(Qt.DashLine)) self.annotation_item = item self.annotation = annot self.control = controlpoints.ControlPointRect() self.control.rectChanged.connect(item.setGeometry) self.scene.addItem(self.control) def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if event.button() == Qt.LeftButton: self.down_pos = event.scenePos() return True return super().mousePressEvent(event) def mouseMoveEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if event.buttons() & Qt.LeftButton: assert self.down_pos is not None if self.annotation_item is None and \ (self.down_pos - event.scenePos()).manhattanLength() > \ QApplication.instance().startDragDistance(): rect = QRectF(self.down_pos, event.scenePos()).normalized() self.createNewAnnotation(rect) if self.annotation_item is not None: assert self.control is not None rect = QRectF(self.down_pos, event.scenePos()).normalized() self.control.setRect(rect) return True return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool if event.button() == Qt.LeftButton: if self.annotation_item is None: self.createNewAnnotation(QRectF(event.scenePos(), event.scenePos())) rect = self.defaultTextGeometry(event.scenePos()) else: assert self.down_pos is not None rect = QRectF(self.down_pos, event.scenePos()).normalized() assert self.annotation_item is not None assert self.control is not None assert self.annotation is not None # Commit the annotation to the scheme. self.annotation.rect = rect_to_tuple(rect) self.document.addAnnotation(self.annotation) self.annotation_item.setGeometry(rect) self.control.rectChanged.disconnect( self.annotation_item.setGeometry ) self.control.hide() # Move the focus to the editor. self.annotation_item.setFramePen(QPen(Qt.NoPen)) self.annotation_item.setFocus(Qt.OtherFocusReason) self.annotation_item.startEdit() self.end() return True return super().mouseMoveEvent(event) def defaultTextGeometry(self, point): # type: (QPointF) -> QRectF """ Return the default text geometry. Used in case the user single clicked in the scene. """ assert self.annotation_item is not None font = self.annotation_item.font() metrics = QFontMetrics(font) spacing = metrics.lineSpacing() margin = self.annotation_item.document().documentMargin() rect = QRectF(QPointF(point.x(), point.y() - spacing - margin), QSizeF(150, spacing + 2 * margin)) return rect def cancel(self, reason=UserInteraction.OtherReason): # type: (int) -> None if self.annotation_item is not None: self.annotation_item.clearFocus() self.scene.removeItem(self.annotation_item) self.annotation_item = None super().cancel(reason) def end(self): # type: () -> None if self.control is not None: self.scene.removeItem(self.control) self.control = None self.down_pos = None self.annotation_item = None self.annotation = None self.document.view().setCursor(Qt.ArrowCursor) self.clearQuickTip() super().end() class ResizeTextAnnotation(UserInteraction): """ Resize a Text Annotation interaction handler. """ def __init__(self, document, *args, **kwargs): # type: (SchemeEditWidget, Any, Any) -> None super().__init__(document, *args, **kwargs) self.item = None # type: Optional[items.TextAnnotation] self.annotation = None # type: Optional[scheme.SchemeTextAnnotation] self.control = None # type: Optional[controlpoints.ControlPointRect] self.savedFramePen = None # type: Optional[QPen] self.savedRect = None # type: Optional[QRectF] def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool pos = event.scenePos() if event.button() & Qt.LeftButton and self.item is None: item = self.scene.item_at(pos, items.TextAnnotation) if item is not None and not item.hasFocus(): self.editItem(item) return False return super().mousePressEvent(event) def editItem(self, item): # type: (items.TextAnnotation) -> None annotation = self.scene.annotation_for_item(item) rect = item.geometry() # TODO: map to scene if item has a parent. control = controlpoints.ControlPointRect(rect=rect) self.scene.addItem(control) self.savedFramePen = item.framePen() self.savedRect = rect control.rectEdited.connect(item.setGeometry) control.setFocusProxy(item) item.setFramePen(QPen(Qt.DashDotLine)) item.geometryChanged.connect(self.__on_textGeometryChanged) self.item = item self.annotation = annotation self.control = control def commit(self): # type: () -> None """ Commit the current item geometry state to the document. """ if self.item is None: return rect = self.item.geometry() if self.savedRect != rect: command = commands.SetAttrCommand( self.annotation, "rect", (rect.x(), rect.y(), rect.width(), rect.height()), name="Edit text geometry" ) self.document.undoStack().push(command) self.savedRect = rect def __on_editingFinished(self): # type: () -> None self.commit() self.end() def __on_rectEdited(self, rect): # type: (QRectF) -> None assert self.item is not None self.item.setGeometry(rect) def __on_textGeometryChanged(self): # type: () -> None assert self.control is not None and self.item is not None if not self.control.isControlActive(): rect = self.item.geometry() self.control.setRect(rect) def start(self) -> None: super().start() self.postQuickTip( self.tr('

              Edit Text Annotation

              ' '

              Drag control points to resize the annotation.

              ' '

              Right click on the annotation to change how it is ' 'rendered (Markdown, HTML, ...).

              ') ) def cancel(self, reason=UserInteraction.OtherReason): # type: (int) -> None log.debug("ResizeTextAnnotation.cancel(%s)", reason) if self.item is not None and self.savedRect is not None: self.item.setGeometry(self.savedRect) super().cancel(reason) def end(self): # type: () -> None if self.control is not None: self.scene.removeItem(self.control) if self.item is not None and self.savedFramePen is not None: self.item.setFramePen(self.savedFramePen) self.item = None self.annotation = None self.control = None self.clearQuickTip() super().end() class ResizeArrowAnnotation(UserInteraction): """ Resize an Arrow Annotation interaction handler. """ def __init__(self, document, *args, **kwargs): # type: (SchemeEditWidget, Any, Any) -> None super().__init__(document, *args, **kwargs) self.item = None # type: Optional[items.ArrowAnnotation] self.annotation = None # type: Optional[scheme.SchemeArrowAnnotation] self.control = None # type: Optional[controlpoints.ControlPointLine] self.savedLine = None # type: Optional[QLineF] def mousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool pos = event.scenePos() if self.item is None: item = self.scene.item_at(pos, items.ArrowAnnotation) if item is not None and not item.hasFocus(): self.editItem(item) return False return super().mousePressEvent(event) def editItem(self, item): # type: (items.ArrowAnnotation) -> None annotation = self.scene.annotation_for_item(item) control = controlpoints.ControlPointLine() self.scene.addItem(control) line = item.line() self.savedLine = line p1, p2 = map(item.mapToScene, (line.p1(), line.p2())) control.setLine(QLineF(p1, p2)) control.setFocusProxy(item) control.lineEdited.connect(self.__on_lineEdited) item.geometryChanged.connect(self.__on_lineGeometryChanged) self.item = item self.annotation = annotation self.control = control def commit(self): # type: () -> None """Commit the current geometry of the item to the document. Does nothing if the actual geometry has not changed. """ if self.control is None or self.item is None: return line = self.control.line() p1, p2 = line.p1(), line.p2() if self.item.line() != self.savedLine: command = commands.SetAttrCommand( self.annotation, "geometry", ((p1.x(), p1.y()), (p2.x(), p2.y())), name="Edit arrow geometry", ) self.document.undoStack().push(command) self.savedLine = self.item.line() def __on_editingFinished(self): # type: () -> None self.commit() self.end() def __on_lineEdited(self, line): # type: (QLineF) -> None if self.item is not None: p1, p2 = map(self.item.mapFromScene, (line.p1(), line.p2())) self.item.setLine(QLineF(p1, p2)) def __on_lineGeometryChanged(self): # type: () -> None # Possible geometry change from out of our control, for instance # item move as a part of a selection group. assert self.control is not None and self.item is not None if not self.control.isControlActive(): assert self.item is not None line = self.item.line() p1, p2 = map(self.item.mapToScene, (line.p1(), line.p2())) self.control.setLine(QLineF(p1, p2)) def cancel(self, reason=UserInteraction.OtherReason): # type: (int) -> None log.debug("ResizeArrowAnnotation.cancel(%s)", reason) if self.item is not None and self.savedLine is not None: self.item.setLine(self.savedLine) super().cancel(reason) def end(self): # type: () -> None if self.control is not None: self.scene.removeItem(self.control) if self.item is not None: self.item.geometryChanged.disconnect(self.__on_lineGeometryChanged) self.control = None self.item = None self.annotation = None super().end() class DropHandler(abc.ABC): """ An abstract drop handler. .. versionadded:: 0.1.20 """ @abc.abstractmethod def accepts(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: """ Returns True if a `document` can accept a drop of the data from `event`. """ return False @abc.abstractmethod def doDrop(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: """ Complete the drop of data from `event` onto the `document`. """ return False class DropHandlerAction(abc.ABC): @abc.abstractmethod def actionFromDropEvent( self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent' ) -> QAction: """ Create and return an QAction representing a drop action. This action is used to disambiguate between possible drop actions. The action can have sub menus, however all actions in submenus **must** have the `DropHandler` instance set as their `QAction.data()`. The actions **must not** execute the actual drop from their triggered slot connections. The drop will be dispatched to the `action.data()` handler's `doDrop()` after that action is triggered and the menu is closed. """ raise NotImplementedError class NodeFromMimeDataDropHandler(DropHandler, DropHandlerAction): """ Create a new node from dropped mime data. Subclasses must override `canDropMimeData`, `parametersFromMimeData`, and `qualifiedName`. .. versionadded:: 0.1.20 """ @abc.abstractmethod def qualifiedName(self) -> str: """ The qualified name for the node created by this handler. The handler will not be invoked if this name does not appear in the registry associated with the workflow. """ raise NotImplementedError @abc.abstractmethod def canDropMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> bool: """ Can the handler create a node from the drop mime data. Reimplement this in a subclass to check if the `data` has appropriate format. """ raise NotImplementedError @abc.abstractmethod def parametersFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Dict[str, Any]': """ Return the node parameters based from the drop mime data. """ raise NotImplementedError def accepts(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: """Reimplemented.""" reg = document.registry() if not reg.has_widget(self.qualifiedName()): return False return self.canDropMimeData(document, event.mimeData()) def nodeFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Node': reg = document.registry() wd = reg.widget(self.qualifiedName()) node = document.newNodeHelper(wd) parameters = self.parametersFromMimeData(document, data) node.properties = parameters return node def doDrop(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: """Reimplemented.""" reg = document.registry() if not reg.has_widget(self.qualifiedName()): return False node = self.nodeFromMimeData(document, event.mimeData()) node.position = (event.scenePos().x(), event.scenePos().y()) activate = self.shouldActivateNode() wd = document.widgetManager() if activate and wd is not None: def activate(node_, widget): if node_ is node: try: self.activateNode(document, node, widget) finally: # self-disconnect the slot wd.widget_for_node_added.disconnect(activate) wd.widget_for_node_added.connect(activate) document.addNode(node) if activate: QApplication.postEvent(node, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) return True def shouldActivateNode(self) -> bool: """ Should the new dropped node activate (open GUI controller) immediately. If this method returns `True` then the `activateNode` method will be called after the node has been added and the GUI controller created. The default implementation returns False. """ return False def activateNode(self, document: 'SchemeEditWidget', node: 'Node', widget: 'QWidget') -> None: """ Activate (open) the `node`'s GUI controller `widget` after a completed drop. Reimplement this if the node requires further configuration via the GUI. The default implementation delegates to the :class:`WidgetManager` associated with the document. """ wd = document.widgetManager() if wd is not None: wd.activate_widget_for_node(node, widget) else: widget.show() def actionFromDropEvent( self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent' ) -> QAction: """Reimplemented.""" reg = document.registry() ac = QAction(None) ac.setData(self) if reg is not None: desc = reg.widget(self.qualifiedName()) ac.setText(desc.name) ac.setToolTip(tooltip_helper(desc)) ac.setWhatsThis(whats_this_helper(desc)) else: ac.setText(f"{self.qualifiedName()}") ac.setEnabled(False) ac.setVisible(False) return ac def load_entry_point( ep: EntryPoint, log: logging.Logger = None, ) -> Tuple['EntryPoint', Any]: if log is None: log = logging.getLogger(__name__) try: value = ep.load() except (ImportError, AttributeError): log.exception("Could not load %s", ep) except Exception: # noqa log.exception("Unexpected Error; %s will be skipped", ep) else: return ep, value def iter_load_entry_points( iter: Iterable[EntryPoint], log: logging.Logger = None, ): if log is None: log = logging.getLogger(__name__) for ep in iter: try: ep, value = load_entry_point(ep, log) except Exception: pass else: yield ep, value class PluginDropHandler(DropHandler): """ Delegate drop event processing to plugin drop handlers. .. versionadded:: 0.1.20 """ #: The default entry point group ENTRY_POINT = "orangecanvas.document.interactions.DropHandler" def __init__(self, group=ENTRY_POINT, **kwargs): super().__init__(**kwargs) self.__group = group def iterEntryPoints(self) -> Iterable['EntryPoint']: """ Return an iterator over all entry points. """ eps = entry_points(group=self.__group) # Can have duplicated entries here if a distribution is *found* via # different `sys.meta_path` handlers and/or on different `sys.path` # entries. return unique(eps, key=lambda ep: ep.value) __entryPoints = None def entryPoints(self) -> Iterable[Tuple['EntryPoint', 'DropHandler']]: """ Return an iterator over entry points and instantiated drop handlers. """ eps = [] if self.__entryPoints: ep_iter = self.__entryPoints store_eps = lambda ep, value: None else: ep_iter = self.iterEntryPoints() ep_iter = iter_load_entry_points(ep_iter, log) store_eps = lambda ep, value: eps.append((ep, value)) for ep, value in ep_iter: if not issubclass(value, DropHandler): log.error( f"{ep} yielded {type(value)}, expected a " f"{DropHandler} subtype" ) continue try: handler = value() except Exception: # noqa log.exception("Error in default constructor of %s", value) else: yield ep, handler store_eps(ep, value) self.__entryPoints = tuple(eps) __accepts: Sequence[Tuple[EntryPoint, DropHandler]] = () def accepts(self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent') -> bool: """ Reimplemented. Accept the event if any plugin handlers accept the event. """ accepts = [] self.__accepts = () for ep, handler in self.entryPoints(): if handler.accepts(document, event): accepts.append((ep, handler)) self.__accepts = tuple(accepts) return bool(accepts) def doDrop( self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent' ) -> bool: """ Reimplemented. Dispatch the drop to the plugin handler that accepted the event. In case there are multiple handlers that accepted the event, a menu is used to select the handler. """ handler: Optional[DropHandler] = None if len(self.__accepts) == 1: ep, handler = self.__accepts[0] elif len(self.__accepts) > 1: class Title(QWidgetAction): def createWidget(self, parent): return QLabel("Select a widget", parent, margin=2) menu = QMenu(event.widget()) menu.addAction(Title(menu)) separator = QAction() separator.setSeparator(True) menu.addAction(separator) for ep_, handler_ in self.__accepts: ac = action_for_handler(handler_, document, event) if ac is None: ac = menu.addAction(ep_.name, ) else: menu.addAction(ac) ac.setParent(menu) if not ac.toolTip(): ac.setToolTip(f"{ep_.name} ({ep_.module_name})") ac.setData(handler_) action = menu.exec(event.screenPos()) if action is not None: handler = action.data() if handler is None: return False return handler.doDrop(document, event) def action_for_handler(handler: DropHandler, document, event) -> Optional[QAction]: if isinstance(handler, DropHandlerAction): return handler.actionFromDropEvent(document, event) else: return None class DropAction(UserInteraction): """ A drop action on the workflow. """ def __init__( self, document, *args, dropHandlers: Sequence[DropHandler] = (), **kwargs ) -> None: super().__init__(document, *args, **kwargs) self.__designatedAction: Optional[DropHandler] = None self.__dropHandlers = dropHandlers def dropHandlers(self) -> Iterable[DropHandler]: """Return an iterable over drop handlers.""" return iter(self.__dropHandlers) def canHandleDrop(self, event: 'QGraphicsSceneDragDropEvent') -> bool: """ Can this interactions handle the drop `event`. The default implementation checks each `dropHandler` if it :func:`~DropHandler.accepts` the event. The first such handler that accepts is selected to be the designated handler and will receive the drop (:func:`~DropHandler.doDrop`). """ for ep in self.dropHandlers(): if ep.accepts(self.document, event): self.__designatedAction = ep return True else: return False def dragEnterEvent(self, event): if self.canHandleDrop(event): event.acceptProposedAction() return True else: return False def dragMoveEvent(self, event): if self.canHandleDrop(event): event.acceptProposedAction() return True else: return False def dragLeaveEvent(self, even): self.__designatedAction = None self.end() return False def dropEvent(self, event): if self.__designatedAction is not None: try: res = self.__designatedAction.doDrop(self.document, event) except Exception: # noqa log.exception("") res = False if res: event.acceptProposedAction() self.end() return True else: self.end() return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/quickmenu.py0000644000175100002000000017123614730024325023576 0ustar00runnerdocker""" ========== Quick Menu ========== A :class:`QuickMenu` widget provides lists of actions organized in tabs with a quick search functionality. """ import typing import statistics import sys import logging import warnings from collections import namedtuple from typing import Optional, Any, List, Callable from AnyQt.QtWidgets import ( QWidget, QFrame, QToolButton, QAbstractButton, QAction, QTreeView, QButtonGroup, QStackedWidget, QHBoxLayout, QVBoxLayout, QSizePolicy, QStyleOptionToolButton, QStylePainter, QStyle, QApplication, QStyleOptionViewItem, QSizeGrip, QAbstractItemView, QStyledItemDelegate ) from AnyQt.QtGui import ( QIcon, QStandardItemModel, QPolygon, QRegion, QBrush, QPalette, QPaintEvent, QColor, QPainter, QMouseEvent ) from AnyQt.QtCore import ( Qt, QObject, QPoint, QPointF, QSize, QRect, QRectF, QEventLoop, QEvent, QModelIndex, QTimer, QRegularExpression, QSortFilterProxyModel, QItemSelectionModel, QAbstractItemModel, QSettings ) from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from .usagestatistics import UsageStatistics from .. import styles from ..gui.framelesswindow import FramelessWindow from ..gui.lineedit import LineEdit from ..gui.iconengine import StyledIconEngine from ..gui.tooltree import ToolTree, FlattenedTreeItemModel from ..gui.utils import StyledWidget_paintEvent, innerGlowBackgroundPixmap, innerShadowPixmap from ..registry.qt import QtWidgetRegistry from ..registry.utils import search_filter_query_helper from ..resources import icon_loader, load_styled_svg_icon log = logging.getLogger(__name__) class _MenuItemDelegate(QStyledItemDelegate): def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): widget = option.widget if widget is not None: style = widget.style() else: style = QApplication.style() opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) rect = option.rect tl = rect.topLeft() br = rect.bottomRight() """ Draw icon background """ # get category color brush = as_qbrush(index.data(QtWidgetRegistry.BACKGROUND_ROLE)) if brush is not None: color = brush.color() else: color = QColor("FFA840") # orange! # (get) cache(d) pixmap bg = innerGlowBackgroundPixmap(color, QSize(rect.height(), rect.height())) # draw background bgRect = QRect(tl.x(), tl.y(), rect.height(), rect.height()) painter.drawPixmap(bgRect, bg, bg.rect()) """ Draw icon """ # get item decoration (icon) dec = opt.icon decSize = option.decorationSize # use as approximate/minimum size x = rect.left() + rect.height() / 2 - decSize.width() / 2 y = rect.top() + rect.height() / 2 - decSize.height() / 2 # decoration rect, where the icon is drawn decTl = QPointF(x, y) decBr = QPointF(x + decSize.width(), y + decSize.height()) decRect = QRectF(decTl, decBr) # draw icon pixmap with StyledIconEngine.setOverridePalette(styles.breeze_light()): dec.paint(painter, decRect.toAlignedRect()) # draw display rect = QRect(opt.rect) rect.setLeft(bgRect.left() + bgRect.width()) # move to icon area end opt.rect = rect # no focus display (selected state is the sole indicator) opt.state &= ~ QStyle.State_KeyboardFocusChange opt.state &= ~ QStyle.State_HasFocus # no icon opt.decorationSize = QSize() opt.icon = QIcon() opt.features &= ~QStyleOptionViewItem.HasDecoration if not opt.state & QStyle.State_Selected: style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget) return # draw as 2 side by side items, first with the actual text, # the second with 'enter key' shortcut indicator optleft = QStyleOptionViewItem(opt) optright = QStyleOptionViewItem(opt) optright.decorationSize = QSize() optright.icon = QIcon() optright.features &= ~QStyleOptionViewItem.HasDecoration optright.viewItemPosition = QStyleOptionViewItem.End optright.textElideMode = Qt.ElideNone optright.text = "\u21B5" sh = style.sizeFromContents( QStyle.CT_ItemViewItem, optright, QSize(), widget) rectright = QRect(opt.rect) rectright.setLeft(rectright.left() + rectright.width() - sh.width()) optright.rect = rectright rectleft = QRect(opt.rect) rectleft.setRight(rectright.left()) optleft.rect = rectleft optleft.viewItemPosition = QStyleOptionViewItem.Beginning optleft.textElideMode = Qt.ElideRight style.drawControl(QStyle.CE_ItemViewItem, optright, painter, widget) style.drawControl(QStyle.CE_ItemViewItem, optleft, painter, widget) def sizeHint(self, option, index): # type: (QStyleOptionViewItem, QModelIndex) -> QSize if option.widget is not None: style = option.widget.style() else: style = QApplication.style() opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) # content size without the icon optnoicon = QStyleOptionViewItem(opt) optnoicon.decorationSize = QSize() optnoicon.icon = QIcon() optnoicon.features &= ~QStyleOptionViewItem.HasDecoration sh = style.sizeFromContents( QStyle.CT_ItemViewItem, optnoicon, QSize(), option.widget ) # size with the icon shicon = style.sizeFromContents( QStyle.CT_ItemViewItem, opt, QSize(), option.widget ) sh.setHeight(max(sh.height(), shicon.height(), 25)) # add the custom drawn icon area rect to sh (height x height) sh.setWidth(sh.width() + sh.height()) return sh class MenuPage(ToolTree): """ A menu page in a :class:`QuickMenu` widget, showing a list of actions. Shown actions can be disabled by setting a filtering function using the :func:`setFilterFunc`. """ def __init__(self, parent=None, title="", icon=QIcon(), **kwargs): # type: (Optional[QWidget], str, QIcon, Any) -> None super().__init__(parent, **kwargs) self.__title = title self.__icon = QIcon(icon) self.__sizeHint = None # type: Optional[QSize] self.view().setItemDelegate(_MenuItemDelegate(self.view())) self.view().viewport().setMouseTracking(True) self.view().viewport().installEventFilter(self) # Make sure the initial model is wrapped in a ItemDisableFilter. self.setModel(self.model()) def setTitle(self, title): # type: (str) -> None """ Set the title of the page. """ if self.__title != title: self.__title = title self.update() def title(self): # type: () -> str """ Return the title of this page. """ return self.__title title_ = Property(str, fget=title, fset=setTitle, doc="Title of the page.") def setIcon(self, icon): # type: (QIcon) -> None """ Set icon for this menu page. """ if self.__icon != icon: self.__icon = icon self.update() def icon(self): # type: () -> QIcon """ Return the icon of this menu page. """ return QIcon(self.__icon) icon_ = Property(QIcon, fget=icon, fset=setIcon, doc="Page icon") def setFilterFunc(self, func): # type: (Optional[Callable[[QModelIndex], bool]]) -> None """ Set the filtering function. `func` should a function taking a single :class:`QModelIndex` argument and returning True if the item at index should be disabled and False otherwise. To disable filtering `func` can be set to ``None``. """ proxyModel = self.view().model() proxyModel.setFilterFunc(func) def setModel(self, model): # type: (QAbstractItemModel) -> None """ Reimplemented from :func:`ToolTree.setModel`. """ proxyModel = ItemDisableFilter(self) proxyModel.setSourceModel(model) super().setModel(proxyModel) self.__invalidateSizeHint() def setRootIndex(self, index): # type: (QModelIndex) -> None """ Reimplemented from :func:`ToolTree.setRootIndex` """ proxyModel = self.view().model() mappedIndex = proxyModel.mapFromSource(index) super().setRootIndex(mappedIndex) self.__invalidateSizeHint() def rootIndex(self): # type: () -> QModelIndex """ Reimplemented from :func:`ToolTree.rootIndex` """ proxyModel = self.view().model() return proxyModel.mapToSource(super().rootIndex()) def sizeHint(self): # type: () -> QSize """ Reimplemented from :func:`QWidget.sizeHint`. """ if self.__sizeHint is None: view = self.view() model = view.model() # This will not work for nested items (tree). count = model.rowCount(view.rootIndex()) # 'sizeHintForColumn' is the reason for size hint caching # since it must traverse all items in the column. width = view.sizeHintForColumn(0) if count: height = view.sizeHintForRow(0) height = height * count else: height = 0 # add scrollbar width scroll = self.view().verticalScrollBar() isTransient = scroll.style().styleHint(QStyle.SH_ScrollBar_Transient, widget=scroll) if not isTransient: width += scroll.style().pixelMetric(QStyle.PM_ScrollBarExtent, widget=scroll) self.__sizeHint = QSize(width, height) return self.__sizeHint def __invalidateSizeHint(self): # type: () -> None self.__sizeHint = None self.updateGeometry() def eventFilter(self, recv: QObject, event: QEvent) -> bool: if event.type() == QEvent.MouseMove and recv is self.view().viewport(): mouseevent = typing.cast(QMouseEvent, event) view = self.view() index = view.indexAt(mouseevent.pos()) if index.isValid() and index.flags() & Qt.ItemIsEnabled: view.setCurrentIndex(index) return super().eventFilter(recv, event) if typing.TYPE_CHECKING: FilterFunc = Callable[[QModelIndex], bool] class ItemDisableFilter(QSortFilterProxyModel): """ An filter proxy model used to disable selected items based on a filtering function. """ def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.__filterFunc = None # type: Optional[FilterFunc] def setFilterFunc(self, func): # type: (Optional[FilterFunc]) -> None """ Set the filtering function. """ if not (callable(func) or func is None): raise TypeError("A callable object or None expected.") if self.__filterFunc != func: self.__filterFunc = func # Mark the whole model as changed. self.dataChanged.emit(self.index(0, 0), self.index(self.rowCount(), 0)) def flags(self, index): # type: (QModelIndex) -> Qt.ItemFlags """ Reimplemented from :class:`QSortFilterProxyModel.flags` """ source = self.mapToSource(index) flags = source.flags() if self.__filterFunc is not None: enabled = flags & Qt.ItemIsEnabled if enabled and not self.__filterFunc(source): flags = Qt.ItemFlags(flags ^ Qt.ItemIsEnabled) return flags class SuggestMenuPage(MenuPage): """ A MenuMage for the QuickMenu widget supporting item filtering (searching). """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def setSearchQuery(self, text): """ Called upon text edited in search query text box. """ proxy = self.__proxy() proxy.setSearchQuery(text) # re-sorts to make sure items that match by title are on top proxy.invalidate() proxy.sort(0) self.ensureCurrent() def setModel(self, model): # type: (QAbstractItemModel) -> None """ Reimplemented from :ref:`MenuPage.setModel`. """ flat = FlattenedTreeItemModel(self) flat.setSourceModel(model) flat.setFlatteningMode(flat.InternalNodesDisabled) flat.setFlatteningMode(flat.LeavesOnly) proxy = SortFilterProxyModel(self) proxy.setFilterCaseSensitivity(Qt.CaseSensitive) proxy.setSourceModel(flat) # bypass MenuPage.setModel and its own proxy # TODO: store my self.__proxy ToolTree.setModel(self, proxy) self.ensureCurrent() def __proxy(self): # type: () -> SortFilterProxyModel model = self.view().model() assert isinstance(model, SortFilterProxyModel) assert model.parent() is self return model def setFilterFixedString(self, pattern): # type: (str) -> None """ Set the fixed string filtering pattern. Only items which contain the `pattern` string will be shown. """ proxy = self.__proxy() proxy.setFilterFixedString(pattern) self.ensureCurrent() def setFilterRegularExpression(self, pattern): # type: (QRegularExpression) -> None """ Set the regular expression filtering pattern. Only items matching the `pattern` expression will be shown. """ filter_proxy = self.__proxy() filter_proxy.setFilterRegularExpression(pattern) # re-sorts to make sure items that match by title are on top filter_proxy.invalidate() filter_proxy.sort(0) self.ensureCurrent() def setFilterFunc(self, func): # type: (Optional[FilterFunc]) -> None """ Set a filtering function. """ filter_proxy = self.__proxy() filter_proxy.setFilterFunc(func) def setSortingFunc(self, func): # type: (Callable[[Any, Any], bool]) -> None """ Set a sorting function. """ filter_proxy = self.__proxy() filter_proxy.setSortingFunc(func) class SortFilterProxyModel(QSortFilterProxyModel): """ An filter proxy model used to sort and filter items based on a sort and filtering function. """ def __init__(self, parent=None): # type: (Optional[QObject]) -> None super().__init__(parent) self.__filterFunc = None # type: Optional[FilterFunc] self.__sortingFunc = None self.__query = '' def setSearchQuery(self, text): """ Set the search query, used for filtering and sorting widgets alongside the filter and sort functions. :type text: str """ self.__query = text.lstrip().lower() def setFilterFunc(self, func): """ Set the filtering function, used for filtering out widgets without compatible signals. :type func: Optional[FilterFunc] """ if not (func is None or callable(func)): raise ValueError("A callable object or None expected.") if self.__filterFunc is not func: self.__filterFunc = func self.invalidateFilter() def filterFunc(self): # type: () -> Optional[FilterFunc] return self.__filterFunc def filterAcceptsRow(self, row, parent=QModelIndex()): # type: (int, QModelIndex) -> bool flat_model = self.sourceModel() index = flat_model.index(row, self.filterKeyColumn(), parent) description = flat_model.data(index, role=QtWidgetRegistry.WIDGET_DESC_ROLE) if description is None: return False accepted = search_filter_query_helper(description, self.__query) # if matches query, apply filter function (compatibility with paired widget) if accepted and self.__filterFunc is not None: model = self.sourceModel() index = model.index(row, self.filterKeyColumn(), parent) return self.__filterFunc(index) else: return accepted def setSortingFunc(self, func): """ Set the sorting function, used for sorting according to statistics. :type func: Callable[[Any, Any], bool] """ self.__sortingFunc = func self.invalidate() self.sort(0) def sortingFunc(self): return self.__sortingFunc def lessThan(self, left, right): # type: (QModelIndex, QModelIndex) -> bool if self.__sortingFunc is None: return super().lessThan(left, right) model = self.sourceModel() left_data = model.data(left) right_data = model.data(right) flat_model = self.sourceModel() left_description = flat_model.data(left, role=QtWidgetRegistry.WIDGET_DESC_ROLE) right_description = flat_model.data(right, role=QtWidgetRegistry.WIDGET_DESC_ROLE) def eval_lessthan(predicate, left, right): left_match = predicate(left) right_match = predicate(right) # if one matches, we know the answer if left_match != right_match: return left_match # if both match, fallback to sorting func elif left_match and right_match: return self.__sortingFunc(left_data, right_data) # else, move on return None query = self.__query left_title = left_description.name.lower() right_title = right_description.name.lower() sorting_predicates = [ lambda t: query == t, # full title match lambda t: query == t.replace(' ', ''), # full title match no spaces lambda t: t.startswith(query), # startswith title match lambda t: t.replace(' ', '').startswith(query), # startswith title match no spaces ] for p in sorting_predicates: match = eval_lessthan(p, left_title, right_title) if match is not None: return match return self.__sortingFunc(left_data, right_data) class SearchWidget(LineEdit): def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.setAttribute(Qt.WA_MacShowFocusRect, False) self.__setupUi() def __setupUi(self): icon = QIcon(load_styled_svg_icon("Search.svg")) action = QAction(icon, self.tr("Search"), self) self.setAction(action, LineEdit.LeftPosition) button = self.button(SearchWidget.LeftPosition) button.setCheckable(True) def setChecked(self, checked): button = self.button(SearchWidget.LeftPosition) if button.isChecked() != checked: button.setChecked(checked) button.update() button.style().polish(button) # QTBUG-2982 class MenuStackWidget(QStackedWidget): """ Stack widget for the menu pages. """ def sizeHint(self): # type: () -> QSize """ Size hint is the maximum width and median height of the widgets contained in the stack. """ default_size = QSize(200, 400) widget_hints = [default_size] for i in range(self.count()): hint = self.widget(i).sizeHint() widget_hints.append(hint) width = max([s.width() for s in widget_hints]) if widget_hints: # Take the median for the height height = statistics.median([s.height() for s in widget_hints]) else: height = default_size.height() return QSize(width, int(height)) def __sizeHintForTreeView(self, view): # type: (QTreeView) -> QSize hint = view.sizeHint() model = view.model() count = model.rowCount() width = view.sizeHintForColumn(0) if count: height = view.sizeHintForRow(0) height = height * count else: height = hint.height() return QSize(max(width, hint.width()), max(height, hint.height())) class TabButton(QToolButton): def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.setToolButtonStyle(Qt.ToolButtonIconOnly) self.setCheckable(True) self.__flat = True self.__showMenuIndicator = False self.__shadowLength = 5 self.__shadowColor = QColor("#000000") self.shadowPosition = 0 def setShadowLength(self, shadowSize): if self.__shadowLength != shadowSize: self.__shadowLength = shadowSize self.update() def shadowLength(self): return self.__shadowLength shadowLength_ = Property(int, fget=shadowLength, fset=setShadowLength, designable=True) def setShadowColor(self, shadowColor): if self.__shadowColor != shadowColor: self.__shadowColor = shadowColor self.update() def shadowColor(self): return self.__shadowColor shadowColor_ = Property(QColor, fget=shadowColor, fset=setShadowColor, designable=True) def setFlat(self, flat): # type: (bool) -> None if self.__flat != flat: self.__flat = flat self.update() def flat(self): # type: () -> bool return self.__flat flat_ = Property(bool, fget=flat, fset=setFlat, designable=True) def setShownMenuIndicator(self, show): # type: (bool) -> None if self.__showMenuIndicator != show: self.__showMenuIndicator = show self.update() def showMenuIndicator(self): # type: () -> bool return self.__showMenuIndicator showMenuIndicator_ = Property(bool, fget=showMenuIndicator, fset=setShownMenuIndicator, designable=True) def paintEvent(self, event): # type: (QPaintEvent) -> None opt = QStyleOptionToolButton() self.initStyleOption(opt) if self.__showMenuIndicator and self.isChecked(): opt.features |= QStyleOptionToolButton.HasMenu with StyledIconEngine.setOverridePalette(styles.breeze_light()): if self.__flat: # Use default widget background/border styling. StyledWidget_paintEvent(self, event) p = QStylePainter(self) p.drawControl(QStyle.CE_ToolButtonLabel, opt) else: p = QStylePainter(self) p.drawComplexControl(QStyle.CC_ToolButton, opt) # if checked, no shadow if self.isChecked(): return targetShadowRect = QRect(self.rect().x(), self.rect().y(), self.width(), self.height()) shadow = innerShadowPixmap(self.__shadowColor, targetShadowRect.size(), self.shadowPosition, self.__shadowLength) p.drawPixmap(targetShadowRect, shadow, shadow.rect()) def sizeHint(self): # type: () -> QSize opt = QStyleOptionToolButton() self.initStyleOption(opt) if self.__showMenuIndicator and self.isChecked(): opt.features |= QStyleOptionToolButton.HasMenu style = self.style() hint = style.sizeFromContents(QStyle.CT_ToolButton, opt, opt.iconSize, self) # should there be no margin around the icon, add extra margin; # in the absence of a better alternative use the text <-> border margin of a push button margin = style.pixelMetric(QStyle.PM_ButtonMargin, None, self) width = max(hint.width(), opt.iconSize.width() + margin) height = max(hint.height(), opt.iconSize.height() + margin) hint.setWidth(width) hint.setHeight(height) return hint _Tab = namedtuple( "_Tab", ["text", "icon", "toolTip", "button", "data", "palette"] ) class TabBarWidget(QWidget): """ A vertical tab bar widget using tool buttons as for tabs. """ currentChanged = Signal(int) def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.__tabs = [] # type: List[_Tab] self.__currentIndex = -1 self.__changeOnHover = False self.__iconSize = QSize(26, 26) self.__group = QButtonGroup(self, exclusive=True) self.__group.buttonPressed[QAbstractButton].connect( self.__onButtonPressed ) self.setMouseTracking(True) self.__sloppyButton = None # type: Optional[QAbstractButton] self.__sloppyRegion = QRegion() self.__sloppyTimer = QTimer(self, singleShot=True) self.__sloppyTimer.timeout.connect(self.__onSloppyTimeout) self.currentChanged.connect(self.__updateShadows) def setChangeOnHover(self, changeOnHover): # type: (bool) -> None """ If set to ``True`` the tab widget will change the current index when the mouse hovers over a tab button. """ if self.__changeOnHover != changeOnHover: self.__changeOnHover = changeOnHover def changeOnHover(self): # type: () -> bool """ Does the current tab index follow the mouse cursor. """ return self.__changeOnHover def count(self): # type: () -> int """ Return the number of tabs in the widget. """ return len(self.__tabs) def addTab(self, text, icon=QIcon(), toolTip=""): # type: (str, QIcon, str) -> int """ Add a new tab and return it's index. """ return self.insertTab(self.count(), text, icon, toolTip) def insertTab(self, index, text, icon=QIcon(), toolTip=""): # type: (int, str, QIcon, str) -> int """ Insert a tab at `index` """ button = TabButton(self, objectName="tab-button") button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) button.setIconSize(self.__iconSize) button.setMouseTracking(True) self.__group.addButton(button) button.installEventFilter(self) tab = _Tab(text, icon, toolTip, button, None, None) self.layout().insertWidget(index, button) if self.count() > 0: self.button(-1).setProperty('lastCategoryButton', False) self.__tabs.insert(index, tab) button.setProperty('lastCategoryButton', True) self.__updateTab(index) if self.currentIndex() == -1: self.setCurrentIndex(0) self.__updateShadows() return index def removeTab(self, index): # type: (int) -> None """ Remove a tab at `index`. """ if 0 <= index < self.count(): tab = self.__tabs.pop(index) layout_index = self.layout().indexOf(tab.button) if layout_index != -1: self.layout().takeAt(layout_index) self.__group.removeButton(tab.button) tab.button.removeEventFilter(self) if tab.button is self.__sloppyButton: self.__sloppyButton = None self.__sloppyRegion = QRegion() tab.button.deleteLater() tab.button.setParent(None) if self.currentIndex() == index: if self.count(): self.setCurrentIndex(max(index - 1, 0)) else: self.setCurrentIndex(-1) self.__updateShadows() def setTabIcon(self, index, icon): # type: (int, QIcon) -> None """ Set the `icon` for tab at `index`. """ self.__tabs[index] = self.__tabs[index]._replace(icon=QIcon(icon)) self.__updateTab(index) def setTabToolTip(self, index, toolTip): # type: (int, str) -> None """ Set `toolTip` for tab at `index`. """ self.__tabs[index] = self.__tabs[index]._replace(toolTip=toolTip) self.__updateTab(index) def setTabText(self, index, text): # type: (int, str) -> None """ Set tab `text` for tab at `index` """ self.__tabs[index] = self.__tabs[index]._replace(text=text) self.__updateTab(index) def setTabPalette(self, index, palette): # type: (int, QPalette) -> None """ Set the tab button palette. """ self.__tabs[index] = self.__tabs[index]._replace(palette=QPalette(palette)) self.__updateTab(index) def setCurrentIndex(self, index): # type: (int) -> None """ Set the current tab index. """ if self.__currentIndex != index: self.__currentIndex = index self.__sloppyRegion = QRegion() self.__sloppyButton = None if index != -1: self.__tabs[index].button.setChecked(True) self.currentChanged.emit(index) def currentIndex(self): # type: () -> int """ Return the current index. """ return self.__currentIndex def button(self, index): # type: (int) -> QAbstractButton """ Return the `TabButton` instance for index. """ return self.__tabs[index].button def setIconSize(self, size): # type: (QSize) -> None if self.__iconSize != size: self.__iconSize = QSize(size) for tab in self.__tabs: tab.button.setIconSize(self.__iconSize) def __updateTab(self, index): # type: (int) -> None """ Update the tab button. """ tab = self.__tabs[index] b = tab.button if tab.text: b.setText(tab.text) if tab.icon is not None and not tab.icon.isNull(): b.setIcon(tab.icon) if tab.palette: b.setPalette(tab.palette) def __updateShadows(self): currentIndex = self.currentIndex() buttons = [tab.button for tab in self.__tabs if tab.button.isVisibleTo(self.parent())] if not buttons: return # set right shadow buttonShadows = [2] * len(buttons) # if button not visible if self.__tabs[currentIndex].button not in buttons: belowChosen = aboveChosen = None else: i = currentIndex + 1 belowChosen = self.__tabs[i].button if i < len(self.__tabs) else None i = currentIndex - 1 aboveChosen = self.__tabs[i].button if i >= 0 else None for i in range(len(buttons)): button = buttons[i] if button is belowChosen: buttonShadows[i] |= 1 if button is aboveChosen: buttonShadows[i] |= 4 if buttonShadows[i] != button.shadowPosition: button.shadowPosition = buttonShadows[i] button.update() def __onButtonPressed(self, button): # type: (QAbstractButton) -> None for i, tab in enumerate(self.__tabs): if tab.button is button: self.setCurrentIndex(i) break def __calcSloppyRegion(self, current): # type: (QPoint) -> QRegion """ Given a current mouse cursor position return a region of the widget where hover/move events should change the current tab only on a timeout. """ p1 = current + QPoint(0, 2) p2 = current + QPoint(0, -2) p3 = self.pos() + QPoint(self.width()+10, 0) p4 = self.pos() + QPoint(self.width()+10, self.height()) return QRegion(QPolygon([p1, p2, p3, p4])) def __setSloppyButton(self, button): # type: (QAbstractButton) -> None """ Set the current sloppy button (a tab button inside sloppy region) and reset the sloppy timeout. """ if not button.isChecked(): self.__sloppyButton = button delay = self.style().styleHint(QStyle.SH_Menu_SubMenuPopupDelay, None) # The delay timeout is the same as used by Qt in the QMenu. self.__sloppyTimer.start(delay) else: self.__sloppyTimer.stop() def __onSloppyTimeout(self): # type: () -> None if self.__sloppyButton is not None: button = self.__sloppyButton self.__sloppyButton = None if not button.isChecked(): index = [tab.button for tab in self.__tabs].index(button) self.setCurrentIndex(index) def eventFilter(self, receiver, event): if event.type() == QEvent.MouseMove and \ isinstance(receiver, TabButton) and \ self.__changeOnHover: pos = receiver.mapTo(self, event.pos()) if self.__sloppyRegion.contains(pos): self.__setSloppyButton(receiver) else: if not receiver.isChecked(): index = [tab.button for tab in self.__tabs].index(receiver) self.setCurrentIndex(index) #also update sloppy region if mouse is moved on the same icon self.__sloppyRegion = self.__calcSloppyRegion(pos) return super().eventFilter(receiver, event) def leaveEvent(self, event): self.__sloppyButton = None self.__sloppyRegion = QRegion() return super().leaveEvent(event) class PagedMenu(QWidget): """ Tabbed container for :class:`MenuPage` instances. """ triggered = Signal(QAction) hovered = Signal(QAction) currentChanged = Signal(int) def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.__currentIndex = -1 layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.__tab = TabBarWidget(self) self.__tab.currentChanged.connect(self.setCurrentIndex) self.__tab.setChangeOnHover(True) self.__stack = MenuStackWidget(self) self.navigator = ItemViewKeyNavigator(self) layout.addWidget(self.__tab, alignment=Qt.AlignTop) layout.addWidget(self.__stack) self.setLayout(layout) self.update_from_settings() def addPage(self, page, title, icon=QIcon(), toolTip=""): # type: (QWidget, str, QIcon, str) -> int """ Add a `page` to the menu and return its index. """ return self.insertPage(self.count(), page, title, icon, toolTip) def insertPage(self, index, page, title, icon=QIcon(), toolTip=""): # type: (int, QWidget, str, QIcon, str) -> int """ Insert `page` at `index`. """ page.triggered.connect(self.triggered) page.hovered.connect(self.hovered) self.__stack.insertWidget(index, page) self.__tab.insertTab(index, title, icon, toolTip) return index def page(self, index): # type: (int) -> QWidget """ Return the page at index. """ return self.__stack.widget(index) def removePage(self, index): # type: (int) -> None """ Remove the page at `index`. """ page = self.__stack.widget(index) page.triggered.disconnect(self.triggered) page.hovered.disconnect(self.hovered) self.__stack.removeWidget(page) self.__tab.removeTab(index) def count(self): # type: () -> int """ Return the number of pages. """ return self.__stack.count() def setCurrentIndex(self, index): # type: (int) -> None """ Set the current page index. """ if self.__currentIndex != index: self.__currentIndex = index self.__tab.setCurrentIndex(index) self.__stack.setCurrentIndex(index) view = self.currentPage().view() self.navigator.setView(view) self.navigator.ensureCurrent() view.setFocus() self.currentChanged.emit(index) def currentIndex(self): # type: () -> int """ Return the index of the current page. """ return self.__currentIndex def nextPage(self): """ Set current index to next index, if one exists. """ index = self.currentIndex() + 1 if index < self.__stack.count(): self.setCurrentIndex(index) def previousPage(self): """ Set current index to previous index, if one exists. """ index = self.currentIndex() - 1 if index >= 0: self.setCurrentIndex(index) def setCurrentPage(self, page): # type: (QWidget) -> None """ Set `page` to be the current shown page. """ index = self.__stack.indexOf(page) self.setCurrentIndex(index) def currentPage(self): # type: () -> QWidget """ Return the current page. """ return self.__stack.currentWidget() def setChangeOnHover(self, enabled): self.__tab.setChangeOnHover(enabled) def indexOf(self, page): # type: (QWidget) -> int """ Return the index of `page`. """ return self.__stack.indexOf(page) def tabButton(self, index): # type: (int) -> QAbstractButton """ Return the tab button instance for index. """ return self.__tab.button(index) def update_from_settings(self): settings = QSettings() showCategories = settings.value("quickmenu/show-categories", False, bool) if self.count() != 0 and not showCategories: self.setCurrentIndex(0) self.__tab.setVisible(showCategories) if showCategories: self.__tab._TabBarWidget__updateShadows() # why must this be called manually? self.navigator.setCategoriesEnabled(showCategories) def as_qbrush(value): # type: (Any) -> Optional[QBrush] if isinstance(value, QBrush): return value else: return None # format with: # {0} - inactive background # {1} - active/checked/hover background # {2} - shadow color TAB_BUTTON_STYLE_TEMPLATE = """\ TabButton {{ qproperty-flat_: false; qproperty-shadowColor_: {2}; background: {0}; border: none; border-right: 3px solid {0}; border-bottom: 1px solid #9CACB4; border-top: 1px solid {0} }} TabButton:checked {{ background: {1}; border: none; }} TabButton[lastCategoryButton='true']:checked {{ border-bottom: 1px solid #9CACB4; }} """ # TODO: Cleanup the QuickMenu interface. It should not have a 'dual' public # interface (i.e. as an item model view (`setModel` method) and `addPage`, # ...) class QuickMenu(FramelessWindow): """ A quick menu popup for the widgets. The widgets are set using :func:`QuickMenu.setModel` which must be a model as returned by :func:`QtWidgetRegistry.model` """ #: An action has been triggered in the menu. triggered = Signal(QAction) #: An action has been hovered in the menu hovered = Signal(QAction) def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.setWindowFlags(self.windowFlags() | Qt.Popup) self.__filterFunc = None # type: Optional[FilterFunc] self.__sortingFunc = None # type: Optional[Callable[[Any, Any], bool]] self.setLayout(QVBoxLayout(self)) self.layout().setContentsMargins(6, 6, 6, 6) self.layout().setSpacing(self.radius()) self.__search = SearchWidget(self, objectName="search-line") self.__search.setPlaceholderText( self.tr("Search for a widget...") ) self.__search.setChecked(True) self.layout().addWidget(self.__search) self.__frame = QFrame(self, objectName="menu-frame") layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) self.__frame.setLayout(layout) self.layout().addWidget(self.__frame) self.__pages = PagedMenu(self, objectName="paged-menu") self.__pages.currentChanged.connect(self.setCurrentIndex) self.__pages.triggered.connect(self.triggered) self.__pages.hovered.connect(self.hovered) self.__frame.layout().addWidget(self.__pages) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) self.__suggestPage = SuggestMenuPage(self, objectName="suggest-page") self.__suggestPage.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) self.__suggestPage.setIcon(icon_loader().get("icons/Search.svg")) self.__search.installEventFilter(self.__pages.navigator) self.__pages.navigator.setView(self.__suggestPage.view()) if sys.platform == "darwin": view = self.__suggestPage.view() view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True) # Don't show the focus frame because it expands into the tab bar. view.setAttribute(Qt.WA_MacShowFocusRect, False) i = self.addPage(self.tr("Quick Search"), self.__suggestPage) button = self.__pages.tabButton(i) button.setVisible(False) searchAction = self.__search.actionAt(SearchWidget.LeftPosition) searchAction.hovered.connect(self.triggerSearch) self.__pages.currentChanged.connect(lambda index: self.__search.setChecked(i == index)) self.__search.textEdited.connect(self.__on_textEdited) self.__grip = WindowSizeGrip(self) # type: Optional[WindowSizeGrip] self.__grip.raise_() self.__loop = None # type: Optional[QEventLoop] self.__model = None # type: Optional[QAbstractItemModel] self.setModel(QStandardItemModel()) self.__triggeredAction = None # type: Optional[QAction] def setSizeGripEnabled(self, enabled): # type: (bool) -> None """ Enable the resizing of the menu with a size grip in a bottom right corner (enabled by default). """ if bool(enabled) != bool(self.__grip): if self.__grip: self.__grip.deleteLater() self.__grip = None else: self.__grip = WindowSizeGrip(self) self.__grip.raise_() def sizeGripEnabled(self): # type: () -> bool """ Is the size grip enabled. """ return bool(self.__grip) def addPage(self, name, page): # type: (str, MenuPage) -> int """ Add the `page` (:class:`MenuPage`) with `name` and return it's index. The `page.icon()` will be used as the icon in the tab bar. """ return self.insertPage(self.__pages.count(), name, page) def insertPage(self, index, name, page): # type: (int, str, MenuPage) -> int icon = page.icon() tip = name if page.toolTip(): tip = page.toolTip() index = self.__pages.insertPage(index, page, name, icon, tip) # Route the page's signals page.triggered.connect(self.__onTriggered) page.hovered.connect(self.hovered) # All page views focus on the search LineEdit page.view().setFocusProxy(self.__search) return index def createPage(self, index): # type: (QModelIndex) -> MenuPage """ Create a new page based on the contents of an index (:class:`QModeIndex`) item. """ page = MenuPage(self) page.setModel(index.model()) page.setRootIndex(index) view = page.view() if sys.platform == "darwin": view.verticalScrollBar().setAttribute(Qt.WA_MacMiniSize, True) # Don't show the focus frame because it expands into the tab # bar at the top. view.setAttribute(Qt.WA_MacShowFocusRect, False) name = str(index.data(Qt.DisplayRole)) page.setTitle(name) icon = index.data(Qt.DecorationRole) if isinstance(icon, QIcon): page.setIcon(icon) page.setToolTip(index.data(Qt.ToolTipRole)) return page def __clear(self): # type: () -> None for i in range(self.__pages.count() - 1, 0, -1): self.__pages.removePage(i) def setModel(self, model): # type: (QAbstractItemModel) -> None """ Set the model containing the actions. """ if self.__model is not None: self.__model.dataChanged.disconnect(self.__on_dataChanged) self.__model.rowsInserted.disconnect(self.__on_rowsInserted) self.__model.rowsRemoved.disconnect(self.__on_rowsRemoved) self.__clear() for i in range(model.rowCount()): index = model.index(i, 0) self.__insertPage(i + 1, index) self.__model = model self.__suggestPage.setModel(model) if model is not None: model.dataChanged.connect(self.__on_dataChanged) model.rowsInserted.connect(self.__on_rowsInserted) model.rowsRemoved.connect(self.__on_rowsRemoved) def __on_dataChanged(self, topLeft, bottomRight): # type: (QModelIndex, QModelIndex) -> None parent = topLeft.parent() # Only handle top level item (categories). if not parent.isValid(): for row in range(topLeft.row(), bottomRight.row() + 1): index = topLeft.sibling(row, 0) # Note: the tab buttons are offest by 1 (to accommodate # the Suggest Page). button = self.__pages.tabButton(row + 1) brush = as_qbrush(index.data(QtWidgetRegistry.BACKGROUND_ROLE)) if brush is not None: base_color = brush.color() shadow_color = base_color.fromHsv(base_color.hsvHue(), base_color.hsvSaturation(), 100) button.setStyleSheet( TAB_BUTTON_STYLE_TEMPLATE.format (base_color.darker(110).name(), base_color.name(), shadow_color.name()) ) def __on_rowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None # Only handle top level item (categories). assert self.__model is not None if not parent.isValid(): for row in range(start, end + 1): index = self.__model.index(row, 0) self.__insertPage(row + 1, index) def __on_rowsRemoved(self, parent, start, end): # type: (QModelIndex, int, int) -> None # Only handle top level item (categories). if not parent.isValid(): for row in range(end, start - 1, -1): self.__removePage(row + 1) def __insertPage(self, row, index): # type: (int, QModelIndex) -> None page = self.createPage(index) page.setActionRole(QtWidgetRegistry.WIDGET_ACTION_ROLE) i = self.insertPage(row, page.title(), page) brush = as_qbrush(index.data(QtWidgetRegistry.BACKGROUND_ROLE)) if brush is not None: base_color = brush.color() shadow_color = base_color.fromHsv(base_color.hsvHue(), base_color.hsvSaturation(), 100) button = self.__pages.tabButton(i) button.setStyleSheet( TAB_BUTTON_STYLE_TEMPLATE.format (base_color.darker(110).name(), base_color.name(), shadow_color.name()) ) def __removePage(self, row): # type: (int) -> None page = self.__pages.page(row) page.triggered.disconnect(self.__onTriggered) page.hovered.disconnect(self.hovered) page.view().removeEventFilter(self) self.__pages.removePage(row) def setSortingFunc(self, func): # type: (Optional[Callable[[Any, Any], bool]]) -> None """ Set a sorting function in the suggest (search) menu. """ if self.__sortingFunc != func: self.__sortingFunc = func for i in range(0, self.__pages.count()): page = self.__pages.page(i) if isinstance(page, SuggestMenuPage): page.setSortingFunc(func) def setFilterFunc(self, func): # type: (Optional[FilterFunc]) -> None """ Set a filter function. """ if func != self.__filterFunc: self.__filterFunc = func for i in range(0, self.__pages.count()): self.__pages.page(i).setFilterFunc(func) def popup(self, pos=None, searchText=""): # type: (Optional[QPoint], str) -> None """ Popup the menu at `pos` (in screen coordinates). 'Search' text field is initialized with `searchText` if provided. """ if pos is None: pos = QPoint() screen = QApplication.screenAt(pos) else: pos = QPoint(pos) screen = QApplication.screenAt(pos) # to avoid accidental category hovers, offset the quickmenu # these were calculated by hand, the actual values can't be grabbed # before showing the menu for the first time (and they're defined in qss) x_offset = 33 if self.__pages.navigator.categoriesEnabled(): x_offset += 33 pos.setX(pos.x() - x_offset) self.__clearCurrentItems() self.__search.setText(searchText) self.__suggestPage.setSearchQuery(searchText) self.__pages.setChangeOnHover(not bool(searchText.strip())) UsageStatistics.set_last_search_query(searchText) self.ensurePolished() if self.testAttribute(Qt.WA_Resized) and self.sizeGripEnabled(): size = self.size() else: size = self.sizeHint() settings = QSettings() ssize = settings.value('quickmenu/size', defaultValue=QSize(), type=QSize) if ssize.isValid(): size.setHeight(ssize.height()) size = size.expandedTo(self.minimumSizeHint()) if screen is None: screen = QApplication.primaryScreen() screen_geom = screen.availableGeometry() # Adjust the size to fit inside the screen. if size.height() > screen_geom.height(): size.setHeight(screen_geom.height()) if size.width() > screen_geom.width(): size.setWidth(screen_geom.width()) geom = QRect(pos, size) if geom.top() < screen_geom.top(): geom.setTop(screen_geom.top()) if geom.left() < screen_geom.left(): geom.setLeft(screen_geom.left()) bottom_margin = screen_geom.bottom() - geom.bottom() right_margin = screen_geom.right() - geom.right() if bottom_margin < 0: # Falls over the bottom of the screen, move it up. geom.translate(0, bottom_margin) # TODO: right to left locale if right_margin < 0: # Falls over the right screen edge, move it left. geom.translate(right_margin, 0) self.setGeometry(geom) self.show() self.setFocusProxy(self.__search) def exec(self, pos=None, searchText=""): # type: (Optional[QPoint], str) -> Optional[QAction] """ Execute the menu at position `pos` (in global screen coordinates). Return the triggered :class:`QAction` or `None` if no action was triggered. 'Search' text field is initialized with `searchText` if provided. """ self.popup(pos, searchText) self.setFocus(Qt.PopupFocusReason) self.__triggeredAction = None self.__loop = QEventLoop() self.__loop.exec() self.__loop.deleteLater() self.__loop = None action = self.__triggeredAction self.__triggeredAction = None return action def exec_(self, *args, **kwargs): warnings.warn( "exec_ is deprecated, use exec", DeprecationWarning, stacklevel=2 ) return self.exec(*args, **kwargs) def hideEvent(self, event): """ Reimplemented from :class:`QWidget` """ settings = QSettings() settings.setValue('quickmenu/size', self.size()) super().hideEvent(event) if self.__loop: self.__loop.exit() def setCurrentPage(self, page): # type: (MenuPage) -> None """ Set the current shown page to `page`. """ self.__pages.setCurrentPage(page) def setCurrentIndex(self, index): # type: (int) -> None """ Set the current page index. """ self.__pages.setCurrentIndex(index) def __clearCurrentItems(self): # type: () -> None """ Clear any selected (or current) items in all the menus. """ for i in range(self.__pages.count()): self.__pages.page(i).view().selectionModel().clear() def __onTriggered(self, action): # type: (QAction) -> None """ Re-emit the action from the page. """ self.__triggeredAction = action # Hide and exit the event loop if necessary. self.hide() self.triggered.emit(action) def __on_textEdited(self, text): # type: (str) -> None self.__suggestPage.setSearchQuery(text) self.__pages.setCurrentPage(self.__suggestPage) self.__pages.setChangeOnHover(not bool(text.strip())) self.__selectFirstIndex() UsageStatistics.set_last_search_query(text) def __selectFirstIndex(self): # type: () -> None view = self.__pages.currentPage().view() model = view.model() index = model.index(0, 0) view.setCurrentIndex(index) def triggerSearch(self): # type: () -> None """ Trigger action search. This changes to current page to the 'Suggest' page and sets the keyboard focus to the search line edit. """ self.__pages.setCurrentPage(self.__suggestPage) self.__search.setFocus(Qt.ShortcutFocusReason) # Make sure that the first enabled item is set current. self.__suggestPage.ensureCurrent() def update_from_settings(self): self.__pages.update_from_settings() class ItemViewKeyNavigator(QObject): """ A event filter class listening to key press events and responding by moving 'currentItem` on a :class:`QListView`. """ def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.__view = None # type: Optional[QAbstractItemView] self.__categoriesEnabled = False def setCategoriesEnabled(self, enabled): self.__categoriesEnabled = enabled def categoriesEnabled(self): return self.__categoriesEnabled def setView(self, view): # type: (Optional[QAbstractItemView]) -> None """ Set the QListView. """ if self.__view != view: self.__view = view def view(self): # type: () -> Optional[QAbstractItemView] """ Return the view """ return self.__view def eventFilter(self, obj, event): etype = event.type() if etype == QEvent.KeyPress: key = event.key() # down if key == Qt.Key_Down: self.moveCurrent(1, 0) return True # up elif key == Qt.Key_Up: self.moveCurrent(-1, 0) return True # enter / return elif key == Qt.Key_Enter or key == Qt.Key_Return: self.activateCurrent() return True # shift + tab elif key == Qt.Key_Backtab: if self.__categoriesEnabled: self.parent().previousPage() return True # tab elif key == Qt.Key_Tab: if self.__categoriesEnabled: self.parent().nextPage() return True return super().eventFilter(obj, event) def moveCurrent(self, rows, columns=0): # type: (int, int) -> None """ Move the current index by rows, columns. """ if self.__view is not None: view = self.__view model = view.model() root = view.rootIndex() curr = view.currentIndex() curr_row, curr_col = curr.row(), curr.column() sign = 1 if rows >= 0 else -1 row = curr_row row_count = model.rowCount(root) for _ in range(row_count): row = (row + sign) % row_count index = model.index(row, 0, root) if index.flags() & Qt.ItemIsEnabled: view.selectionModel().setCurrentIndex( index, QItemSelectionModel.ClearAndSelect ) break # TODO: move by columns def activateCurrent(self): # type: () -> None """ Activate the current index. """ if self.__view is not None: curr = self.__view.currentIndex() if curr.isValid(): self.__view.activated.emit(curr) def ensureCurrent(self): # type: () -> None """ Ensure the view has a current item if one is available. """ if self.__view is not None: model = self.__view.model() curr = self.__view.currentIndex() if not curr.isValid(): root = self.__view.rootIndex() for i in range(model.rowCount(root)): index = model.index(i, 0, root) if index.flags() & Qt.ItemIsEnabled: self.__view.setCurrentIndex(index) break class WindowSizeGrip(QSizeGrip): """ Automatically positioning :class:`QSizeGrip`. The widget automatically maintains its position in the window corner during resize events. """ def __init__(self, parent): super().__init__(parent) self.__corner = Qt.BottomRightCorner self.resize(self.sizeHint()) self.__updatePos() def setCorner(self, corner): """ Set the corner (:class:`Qt.Corner`) where the size grip should position itself. """ if corner not in [Qt.TopLeftCorner, Qt.TopRightCorner, Qt.BottomLeftCorner, Qt.BottomRightCorner]: raise ValueError("Qt.Corner flag expected") if self.__corner != corner: self.__corner = corner self.__updatePos() def corner(self): """ Return the corner where the size grip is positioned. """ return self.__corner def eventFilter(self, obj, event): if obj is self.window(): if event.type() == QEvent.Resize: self.__updatePos() return super().eventFilter(obj, event) def sizeHint(self): self.ensurePolished() sh = super().sizeHint() # Qt5 on macOS forces size grip to be zero size. if sh.width() == 0 and \ QApplication.style().metaObject().className() == "QMacStyle": sh.setWidth(sh.height()) return sh def changeEvent(self, event): # type: (QEvent) -> None super().changeEvent(event) if event.type() in (QEvent.StyleChange, QEvent.MacSizeChange): self.resize(self.sizeHint()) self.__updatePos() super().changeEvent(event) def __updatePos(self): window = self.window() if window is not self.parent(): return corner = self.__corner size = self.size() window_geom = window.geometry() window_size = window_geom.size() if corner in [Qt.TopLeftCorner, Qt.BottomLeftCorner]: x = 0 else: x = window_geom.width() - size.width() if corner in [Qt.TopLeftCorner, Qt.TopRightCorner]: y = 0 else: y = window_size.height() - size.height() self.move(x, y) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/schemeedit.py0000644000175100002000000026732014730024325023707 0ustar00runnerdocker""" ==================== Scheme Editor Widget ==================== """ import enum import io import logging import itertools import re import sys import unicodedata import copy import dictdiffer from operator import attrgetter from urllib.parse import urlencode from contextlib import ExitStack, contextmanager from typing import ( List, Tuple, Optional, Container, Dict, Any, Iterable, Generator, Sequence ) from AnyQt.QtWidgets import ( QWidget, QVBoxLayout, QMenu, QAction, QActionGroup, QUndoStack, QGraphicsItem, QGraphicsTextItem, QFormLayout, QComboBox, QDialog, QDialogButtonBox, QMessageBox, QCheckBox, QGraphicsSceneDragDropEvent, QGraphicsSceneMouseEvent, QGraphicsSceneContextMenuEvent, QGraphicsView, QGraphicsScene, QApplication ) from AnyQt.QtGui import ( QKeySequence, QCursor, QFont, QPainter, QPixmap, QColor, QIcon, QWhatsThisClickedEvent, QKeyEvent, QPalette ) from AnyQt.QtCore import ( Qt, QObject, QEvent, QSignalMapper, QCoreApplication, QPointF, QMimeData, Slot) from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal from orangecanvas.document.commands import UndoCommand from .interactions import DropHandler from ..registry import WidgetDescription, WidgetRegistry from .suggestions import Suggestions from .usagestatistics import UsageStatistics from ..registry.qt import whats_this_helper, QtWidgetRegistry from ..gui.quickhelp import QuickHelpTipEvent from ..gui.utils import ( message_information, disabled, clipboard_has_format, clipboard_data ) from ..scheme import ( scheme, signalmanager, Scheme, SchemeNode, SchemeLink, BaseSchemeAnnotation, SchemeTextAnnotation, WorkflowEvent ) from ..scheme.widgetmanager import WidgetManager from ..canvas.scene import CanvasScene from ..canvas.view import CanvasView from ..canvas import items from ..canvas.items.annotationitem import Annotation as AnnotationItem from . import interactions from . import commands from . import quickmenu from ..utils import findf, UNUSED from ..utils.qinvoke import connect_with_context Pos = Tuple[float, float] RuntimeState = signalmanager.SignalManager.State # Private MIME type for clipboard contents MimeTypeWorkflowFragment = "application/vnd.{}-ows-fragment+xml".format(__name__) log = logging.getLogger(__name__) DuplicateOffset = QPointF(0, 120) class NoWorkflowError(RuntimeError): def __init__(self, message: str = "No workflow model is set", **kwargs): super().__init__(message, *kwargs) class UndoStack(QUndoStack): indexIncremented = Signal() def __init__(self, parent, statistics: UsageStatistics): QUndoStack.__init__(self, parent) self.__statistics = statistics self.__previousIndex = self.index() self.__currentIndex = self.index() self.indexChanged.connect(self.__refreshIndex) @Slot(int) def __refreshIndex(self, newIndex): self.__previousIndex = self.__currentIndex self.__currentIndex = newIndex if self.__previousIndex < newIndex: self.indexIncremented.emit() @Slot() def undo(self): self.__statistics.begin_action(UsageStatistics.Undo) super().undo() self.__statistics.end_action() @Slot() def redo(self): self.__statistics.begin_action(UsageStatistics.Redo) super().redo() self.__statistics.end_action() def push(self, macro): super().push(macro) self.__statistics.end_action() class SchemeEditWidget(QWidget): """ A widget for editing a :class:`~.scheme.Scheme` instance. """ #: Undo command has become available/unavailable. undoAvailable = Signal(bool) #: Redo command has become available/unavailable. redoAvailable = Signal(bool) #: Document modified state has changed. modificationChanged = Signal(bool) #: Undo command was added to the undo stack. undoCommandAdded = Signal() #: Item selection has changed. selectionChanged = Signal() #: Document title has changed. titleChanged = Signal(str) #: Document path has changed. pathChanged = Signal(str) # Quick Menu triggers (NoTriggers, RightClicked, DoubleClicked, SpaceKey, AnyKey) = [0, 1, 2, 4, 8] class OpenAnchors(enum.Enum): """Interactions with individual anchors""" #: Channel anchors never separate Never = "Never" #: Channel anchors separate on hover Always = "Always" #: Channel anchors separate on hover on Shift key OnShift = "OnShift" def __init__(self, parent=None, ): super().__init__(parent) self.__modified = False self.__registry = None # type: Optional[WidgetRegistry] self.__scheme = None # type: Optional[Scheme] self.__widgetManager = None # type: Optional[WidgetManager] self.__path = "" self.__quickMenuTriggers = SchemeEditWidget.SpaceKey | \ SchemeEditWidget.DoubleClicked self.__openAnchorsMode = SchemeEditWidget.OpenAnchors.OnShift self.__emptyClickButtons = Qt.NoButton self.__channelNamesVisible = True self.__nodeAnimationEnabled = True self.__possibleSelectionHandler = None self.__possibleMouseItemsMove = False self.__itemsMoving = {} self.__contextMenuTarget = None # type: Optional[SchemeLink] self.__dropTarget = None # type: Optional[items.LinkItem] self.__quickMenu = None # type: Optional[quickmenu.QuickMenu] self.__quickTip = "" self.__statistics = UsageStatistics(self) self.__undoStack = UndoStack(self, self.__statistics) self.__undoStack.cleanChanged[bool].connect(self.__onCleanChanged) self.__undoStack.indexIncremented.connect(self.undoCommandAdded) # Preferred position for paste command. Updated on every mouse button # press and copy operation. self.__pasteOrigin = QPointF(20, 20) # scheme node properties when set to a clean state self.__cleanProperties = {} # list of links when set to a clean state self.__cleanLinks = [] # list of annotations when set to a clean state self.__cleanAnnotations = [] self.__dropHandlers = () # type: Sequence[DropHandler] self.__editFinishedMapper = QSignalMapper(self) self.__editFinishedMapper.mappedObject.connect( self.__onEditingFinished ) self.__annotationGeomChanged = QSignalMapper(self) self.__setupActions() self.__setupUi() # Edit menu for a main window menu bar. self.__editMenu = QMenu(self.tr("&Edit"), self) self.__editMenu.addAction(self.__undoAction) self.__editMenu.addAction(self.__redoAction) self.__editMenu.addSeparator() self.__editMenu.addAction(self.__removeSelectedAction) self.__editMenu.addAction(self.__duplicateSelectedAction) self.__editMenu.addAction(self.__copySelectedAction) self.__editMenu.addAction(self.__pasteAction) self.__editMenu.addAction(self.__selectAllAction) # Widget context menu self.__widgetMenu = QMenu(self.tr("Widget"), self) self.__widgetMenu.addAction(self.__openSelectedAction) self.__widgetMenu.addSeparator() self.__widgetMenu.addAction(self.__renameAction) self.__widgetMenu.addAction(self.__removeSelectedAction) self.__widgetMenu.addAction(self.__duplicateSelectedAction) self.__widgetMenu.addAction(self.__copySelectedAction) self.__widgetMenu.addSeparator() self.__widgetMenu.addAction(self.__helpAction) # Widget menu for a main window menu bar. self.__menuBarWidgetMenu = QMenu(self.tr("&Widget"), self) self.__menuBarWidgetMenu.addAction(self.__openSelectedAction) self.__menuBarWidgetMenu.addSeparator() self.__menuBarWidgetMenu.addAction(self.__renameAction) self.__menuBarWidgetMenu.addAction(self.__removeSelectedAction) self.__menuBarWidgetMenu.addSeparator() self.__menuBarWidgetMenu.addAction(self.__helpAction) self.__linkMenu = QMenu(self.tr("Link"), self) self.__linkMenu.addAction(self.__linkEnableAction) self.__linkMenu.addSeparator() self.__linkMenu.addAction(self.__nodeInsertAction) self.__linkMenu.addSeparator() self.__linkMenu.addAction(self.__linkRemoveAction) self.__linkMenu.addAction(self.__linkResetAction) self.__suggestions = Suggestions() def __setupActions(self): self.__cleanUpAction = QAction( self.tr("Clean Up"), self, objectName="cleanup-action", shortcut=QKeySequence("Shift+A"), toolTip=self.tr("Align widgets to a grid (Shift+A)"), triggered=self.alignToGrid, ) self.__newTextAnnotationAction = QAction( self.tr("Text"), self, objectName="new-text-action", toolTip=self.tr("Add a text annotation to the workflow."), checkable=True, toggled=self.__toggleNewTextAnnotation, ) # Create a font size menu for the new annotation action. self.__fontMenu = QMenu("Font Size", self) self.__fontActionGroup = group = QActionGroup( self, triggered=self.__onFontSizeTriggered ) def font(size): f = QFont(self.font()) f.setPixelSize(size) return f for size in [12, 14, 16, 18, 20, 22, 24]: action = QAction( "%ipx" % size, group, checkable=True, font=font(size) ) self.__fontMenu.addAction(action) group.actions()[2].setChecked(True) self.__newTextAnnotationAction.setMenu(self.__fontMenu) self.__newArrowAnnotationAction = QAction( self.tr("Arrow"), self, objectName="new-arrow-action", toolTip=self.tr("Add a arrow annotation to the workflow."), checkable=True, toggled=self.__toggleNewArrowAnnotation, ) # Create a color menu for the arrow annotation action self.__arrowColorMenu = QMenu("Arrow Color",) self.__arrowColorActionGroup = group = QActionGroup( self, triggered=self.__onArrowColorTriggered ) def color_icon(color): icon = QIcon() for size in [16, 24, 32]: pixmap = QPixmap(size, size) pixmap.fill(QColor(0, 0, 0, 0)) p = QPainter(pixmap) p.setRenderHint(QPainter.Antialiasing) p.setBrush(color) p.setPen(Qt.NoPen) p.drawEllipse(1, 1, size - 2, size - 2) p.end() icon.addPixmap(pixmap) return icon for color in ["#000", "#C1272D", "#662D91", "#1F9CDF", "#39B54A"]: icon = color_icon(QColor(color)) action = QAction(group, icon=icon, checkable=True, iconVisibleInMenu=True) action.setData(color) self.__arrowColorMenu.addAction(action) group.actions()[1].setChecked(True) self.__newArrowAnnotationAction.setMenu(self.__arrowColorMenu) self.__undoAction = self.__undoStack.createUndoAction(self) self.__undoAction.setShortcut(QKeySequence.Undo) self.__undoAction.setObjectName("undo-action") self.__redoAction = self.__undoStack.createRedoAction(self) self.__redoAction.setShortcut(QKeySequence.Redo) self.__redoAction.setObjectName("redo-action") self.__selectAllAction = QAction( self.tr("Select all"), self, objectName="select-all-action", toolTip=self.tr("Select all items."), triggered=self.selectAll, shortcut=QKeySequence.SelectAll ) self.__openSelectedAction = QAction( self.tr("Open"), self, objectName="open-action", toolTip=self.tr("Open selected widget"), triggered=self.openSelected, enabled=False ) self.__removeSelectedAction = QAction( self.tr("Remove"), self, objectName="remove-selected", toolTip=self.tr("Remove selected items"), triggered=self.removeSelected, enabled=False ) shortcuts = [QKeySequence(Qt.Key_Backspace), QKeySequence(Qt.Key_Delete), QKeySequence("Ctrl+Backspace")] self.__removeSelectedAction.setShortcuts(shortcuts) self.__renameAction = QAction( self.tr("Rename"), self, objectName="rename-action", toolTip=self.tr("Rename selected widget"), triggered=self.__onRenameAction, shortcut=QKeySequence(Qt.Key_F2), enabled=False ) if sys.platform == "darwin": self.__renameAction.setShortcuts([ QKeySequence(Qt.Key_F2), QKeySequence(Qt.Key_Enter), QKeySequence(Qt.Key_Return) ]) self.__helpAction = QAction( self.tr("Help"), self, objectName="help-action", toolTip=self.tr("Show widget help"), triggered=self.__onHelpAction, shortcut=QKeySequence("F1"), enabled=False, ) self.__linkEnableAction = QAction( self.tr("Enabled"), self, objectName="link-enable-action", triggered=self.__toggleLinkEnabled, checkable=True, ) self.__linkRemoveAction = QAction( self.tr("Remove"), self, objectName="link-remove-action", triggered=self.__linkRemove, toolTip=self.tr("Remove link."), ) self.__nodeInsertAction = QAction( self.tr("Insert Widget"), self, objectName="node-insert-action", triggered=self.__nodeInsert, toolTip=self.tr("Insert widget."), ) self.__linkResetAction = QAction( self.tr("Reset Signals"), self, objectName="link-reset-action", triggered=self.__linkReset, ) self.__duplicateSelectedAction = QAction( self.tr("Duplicate"), self, objectName="duplicate-action", enabled=False, shortcut=QKeySequence("Ctrl+D"), triggered=self.__duplicateSelected, ) self.__copySelectedAction = QAction( self.tr("Copy"), self, objectName="copy-action", enabled=False, shortcut=QKeySequence("Ctrl+C"), triggered=self.__copyToClipboard, ) self.__pasteAction = QAction( self.tr("Paste"), self, objectName="paste-action", enabled=clipboard_has_format(MimeTypeWorkflowFragment), shortcut=QKeySequence("Ctrl+V"), triggered=self.__pasteFromClipboard, ) QApplication.clipboard().dataChanged.connect( self.__updatePasteActionState ) self.addActions([ self.__newTextAnnotationAction, self.__newArrowAnnotationAction, self.__linkEnableAction, self.__linkRemoveAction, self.__nodeInsertAction, self.__linkResetAction, self.__duplicateSelectedAction, self.__copySelectedAction, self.__pasteAction ]) # Actions which should be disabled while a multistep # interaction is in progress. self.__disruptiveActions = [ self.__undoAction, self.__redoAction, self.__removeSelectedAction, self.__selectAllAction, self.__duplicateSelectedAction, self.__copySelectedAction, self.__pasteAction ] #: Top 'Window Groups' action self.__windowGroupsAction = QAction( self.tr("Window Groups"), self, objectName="window-groups-action", toolTip="Manage preset widget groups" ) #: Action group containing action for every window group self.__windowGroupsActionGroup = QActionGroup( self.__windowGroupsAction, objectName="window-groups-action-group", ) self.__windowGroupsActionGroup.triggered.connect( self.__activateWindowGroup ) self.__saveWindowGroupAction = QAction( self.tr("Save Window Group..."), self, objectName="window-groups-save-action", toolTip="Create and save a new window group." ) self.__saveWindowGroupAction.triggered.connect(self.__saveWindowGroup) self.__clearWindowGroupsAction = QAction( self.tr("Delete All Groups"), self, objectName="window-groups-clear-action", toolTip="Delete all saved widget presets" ) self.__clearWindowGroupsAction.triggered.connect( self.__clearWindowGroups ) groups_menu = QMenu(self) sep = groups_menu.addSeparator() sep.setObjectName("groups-separator") groups_menu.addAction(self.__saveWindowGroupAction) groups_menu.addSeparator() groups_menu.addAction(self.__clearWindowGroupsAction) self.__windowGroupsAction.setMenu(groups_menu) # the counterpart to Control + Key_Up to raise the containing workflow # view (maybe move that shortcut here) self.__raiseWidgetsAction = QAction( self.tr("Bring Widgets to Front"), self, objectName="bring-widgets-to-front-action", shortcut=QKeySequence("Ctrl+Down"), shortcutContext=Qt.WindowShortcut, ) self.__raiseWidgetsAction.triggered.connect(self.__raiseToFont) self.addAction(self.__raiseWidgetsAction) def __setupUi(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) scene = CanvasScene(self) scene.setItemIndexMethod(CanvasScene.NoIndex) self.__setupScene(scene) view = CanvasView(scene) view.setFrameStyle(CanvasView.NoFrame) view.setRenderHint(QPainter.Antialiasing) self.__view = view self.__scene = scene layout.addWidget(view) self.setLayout(layout) def __setupScene(self, scene): # type: (CanvasScene) -> None """ Set up a :class:`CanvasScene` instance for use by the editor. .. note:: If an existing scene is in use it must be teared down using __teardownScene """ scene.set_channel_names_visible(self.__channelNamesVisible) scene.set_node_animation_enabled( self.__nodeAnimationEnabled ) if self.__openAnchorsMode == SchemeEditWidget.OpenAnchors.Always: scene.set_widget_anchors_open(True) scene.setFont(self.font()) scene.setPalette(self.palette()) scene.installEventFilter(self) if self.__registry is not None: scene.set_registry(self.__registry) scene.focusItemChanged.connect(self.__onFocusItemChanged) scene.selectionChanged.connect(self.__onSelectionChanged) scene.link_item_activated.connect(self.__onLinkActivate) scene.link_item_added.connect(self.__onLinkAdded) scene.node_item_activated.connect(self.__onNodeActivate) scene.annotation_added.connect(self.__onAnnotationAdded) scene.annotation_removed.connect(self.__onAnnotationRemoved) self.__annotationGeomChanged = QSignalMapper(self) def __teardownScene(self, scene): # type: (CanvasScene) -> None """ Tear down an instance of :class:`CanvasScene` that was used by the editor. """ # Clear the current item selection in the scene so edit action # states are updated accordingly. scene.clearSelection() # Clear focus from any item. scene.setFocusItem(None) # Clear the annotation mapper self.__annotationGeomChanged.deleteLater() self.__annotationGeomChanged = None scene.focusItemChanged.disconnect(self.__onFocusItemChanged) scene.selectionChanged.disconnect(self.__onSelectionChanged) scene.removeEventFilter(self) # Clear all items from the scene scene.blockSignals(True) scene.clear_scene() def toolbarActions(self): # type: () -> List[QAction] """ Return a list of actions that can be inserted into a toolbar. At the moment these are: - 'Zoom in' action - 'Zoom out' action - 'Zoom Reset' action - 'Clean up' action (align to grid) - 'New text annotation' action (with a size menu) - 'New arrow annotation' action (with a color menu) """ view = self.__view zoomin = view.findChild(QAction, "action-zoom-in") zoomout = view.findChild(QAction, "action-zoom-out") zoomreset = view.findChild(QAction, "action-zoom-reset") assert zoomin and zoomout and zoomreset return [zoomin, zoomout, zoomreset, self.__cleanUpAction, self.__newTextAnnotationAction, self.__newArrowAnnotationAction] def menuBarActions(self): # type: () -> List[QAction] """ Return a list of actions that can be inserted into a `QMenuBar`. """ return [self.__editMenu.menuAction(), self.__menuBarWidgetMenu.menuAction()] def isModified(self): # type: () -> bool """ Is the document is a modified state. """ return self.__modified or not self.__undoStack.isClean() def setModified(self, modified): # type: (bool) -> None """ Set the document modified state. """ if self.__modified != modified: self.__modified = modified if not modified: if self.__scheme: self.__cleanProperties = node_properties(self.__scheme) self.__cleanLinks = self.__scheme.links self.__cleanAnnotations = self.__scheme.annotations else: self.__cleanProperties = {} self.__cleanLinks = [] self.__cleanAnnotations = [] self.__undoStack.setClean() else: self.__cleanProperties = {} self.__cleanLinks = [] self.__cleanAnnotations = [] modified = Property(bool, fget=isModified, fset=setModified) def isModifiedStrict(self): """ Is the document modified. Run a strict check against all node properties as they were at the time when the last call to `setModified(True)` was made. """ propertiesChanged = self.__cleanProperties != \ node_properties(self.__scheme) log.debug("Modified strict check (modified flag: %s, " "undo stack clean: %s, properties: %s)", self.__modified, self.__undoStack.isClean(), propertiesChanged) return self.isModified() or propertiesChanged def uncleanProperties(self): """ Returns node properties differences since last clean state, excluding unclean nodes. """ currentProperties = node_properties(self.__scheme) # ignore diff for newly created nodes cleanNodes = self.cleanNodes() currentCleanNodeProperties = {k: v for k, v in currentProperties.items() if k in cleanNodes} cleanProperties = self.__cleanProperties # ignore diff for deleted nodes currentNodes = self.__scheme.nodes cleanCurrentNodeProperties = {k: v for k, v in cleanProperties.items() if k in currentNodes} # ignore contexts ignore = set((node, "context_settings") for node in currentCleanNodeProperties.keys()) return list(dictdiffer.diff( cleanCurrentNodeProperties, currentCleanNodeProperties, ignore=ignore )) def restoreProperties(self, dict_diff): ref_properties = { node: node.properties for node in self.__scheme.nodes } dictdiffer.patch(dict_diff, ref_properties, in_place=True) def cleanNodes(self): return list(self.__cleanProperties.keys()) def cleanLinks(self): return self.__cleanLinks def cleanAnnotations(self): return self.__cleanAnnotations def setQuickMenuTriggers(self, triggers): # type: (int) -> None """ Set quick menu trigger flags. Flags can be a bitwise `or` of: - `SchemeEditWidget.NoTrigeres` - `SchemeEditWidget.RightClicked` - `SchemeEditWidget.DoubleClicked` - `SchemeEditWidget.SpaceKey` - `SchemeEditWidget.AnyKey` """ if self.__quickMenuTriggers != triggers: self.__quickMenuTriggers = triggers def quickMenuTriggers(self): # type: () -> int """ Return quick menu trigger flags. """ return self.__quickMenuTriggers def setChannelNamesVisible(self, visible): # type: (bool) -> None """ Set channel names visibility state. When enabled the links in the view will have a source/sink channel names displayed over them. """ if self.__channelNamesVisible != visible: self.__channelNamesVisible = visible self.__scene.set_channel_names_visible(visible) def channelNamesVisible(self): # type: () -> bool """ Return the channel name visibility state. """ return self.__channelNamesVisible def setNodeAnimationEnabled(self, enabled): # type: (bool) -> None """ Set the node item animation enabled state. """ if self.__nodeAnimationEnabled != enabled: self.__nodeAnimationEnabled = enabled self.__scene.set_node_animation_enabled(enabled) def nodeAnimationEnabled(self): # type () -> bool """ Return the node item animation enabled state. """ return self.__nodeAnimationEnabled def setOpenAnchorsMode(self, state: OpenAnchors): self.__openAnchorsMode = state self.__scene.set_widget_anchors_open( state == SchemeEditWidget.OpenAnchors.Always ) def openAnchorsMode(self) -> OpenAnchors: return self.__openAnchorsMode def undoStack(self): # type: () -> QUndoStack """ Return the undo stack. """ return self.__undoStack def setPath(self, path): # type: (str) -> None """ Set the path associated with the current scheme. .. note:: Calling `setScheme` will invalidate the path (i.e. set it to an empty string) """ if self.__path != path: self.__path = path self.pathChanged.emit(self.__path) def path(self): # type: () -> str """ Return the path associated with the scheme """ return self.__path def setScheme(self, scheme): # type: (Scheme) -> None """ Set the :class:`~.scheme.Scheme` instance to display/edit. """ if self.__scheme is not scheme: if self.__scheme: self.__scheme.title_changed.disconnect(self.titleChanged) self.__scheme.window_group_presets_changed.disconnect( self.__reset_window_group_menu ) self.__scheme.removeEventFilter(self) sm = self.__scheme.findChild(signalmanager.SignalManager) if sm: sm.stateChanged.disconnect( self.__signalManagerStateChanged) self.__widgetManager = None self.__scheme.node_added.disconnect(self.__statistics.log_node_add) self.__scheme.node_removed.disconnect(self.__statistics.log_node_remove) self.__scheme.link_added.disconnect(self.__statistics.log_link_add) self.__scheme.link_removed.disconnect(self.__statistics.log_link_remove) self.__statistics.write_statistics() self.__scheme = scheme self.__suggestions.set_scheme(self) self.setPath("") if self.__scheme: self.__scheme.title_changed.connect(self.titleChanged) self.titleChanged.emit(scheme.title) self.__scheme.window_group_presets_changed.connect( self.__reset_window_group_menu ) self.__cleanProperties = node_properties(scheme) self.__cleanLinks = scheme.links self.__cleanAnnotations = scheme.annotations sm = scheme.findChild(signalmanager.SignalManager) if sm: sm.stateChanged.connect(self.__signalManagerStateChanged) self.__widgetManager = getattr(scheme, "widget_manager", None) self.__scheme.node_added.connect(self.__statistics.log_node_add) self.__scheme.node_removed.connect(self.__statistics.log_node_remove) self.__scheme.link_added.connect(self.__statistics.log_link_add) self.__scheme.link_removed.connect(self.__statistics.log_link_remove) self.__statistics.log_scheme(self.__scheme) else: self.__cleanProperties = {} self.__cleanLinks = [] self.__cleanAnnotations = [] self.__teardownScene(self.__scene) self.__scene.deleteLater() self.__undoStack.clear() self.__scene = CanvasScene(self) self.__scene.setItemIndexMethod(CanvasScene.NoIndex) self.__setupScene(self.__scene) self.__scene.set_scheme(scheme) self.__view.setScene(self.__scene) if self.__scheme: self.__scheme.installEventFilter(self) nodes = self.__scheme.nodes if nodes: self.ensureVisible(nodes[0]) self.__reset_window_group_menu() def ensureVisible(self, node): # type: (SchemeNode) -> None """ Scroll the contents of the viewport so that `node` is visible. Parameters ---------- node: SchemeNode """ if self.__scheme is None: return item = self.__scene.item_for_node(node) self.__view.ensureVisible(item) def scheme(self): # type: () -> Optional[Scheme] """ Return the :class:`~.scheme.Scheme` edited by the widget. """ return self.__scheme def scene(self): # type: () -> QGraphicsScene """ Return the :class:`QGraphicsScene` instance used to display the current scheme. """ return self.__scene def view(self): # type: () -> QGraphicsView """ Return the :class:`QGraphicsView` instance used to display the current scene. """ return self.__view def suggestions(self): """ Return the widget suggestion prediction class. """ return self.__suggestions def usageStatistics(self): """ Return the usage statistics logging class. """ return self.__statistics def setRegistry(self, registry): # Is this method necessary? # It should be removed when the scene (items) is fixed # so all information regarding the visual appearance is # included in the node/widget description. self.__registry = registry if self.__scene: self.__scene.set_registry(registry) self.__quickMenu = None def registry(self): return self.__registry def quickMenu(self): # type: () -> quickmenu.QuickMenu """ Return a :class:`~.quickmenu.QuickMenu` popup menu instance for new node creation. """ if self.__quickMenu is None: menu = quickmenu.QuickMenu(self) if self.__registry is not None: menu.setModel(self.__registry.model()) self.__quickMenu = menu return self.__quickMenu def setTitle(self, title): # type: (str) -> None """ Set the scheme title. """ self.__undoStack.push( commands.SetAttrCommand(self.__scheme, "title", title) ) def setDescription(self, description): # type: (str) -> None """ Set the scheme description string. """ self.__undoStack.push( commands.SetAttrCommand(self.__scheme, "description", description) ) def addNode(self, node): # type: (SchemeNode) -> None """ Add a new node (:class:`.SchemeNode`) to the document. """ if self.__scheme is None: raise NoWorkflowError() command = commands.AddNodeCommand(self.__scheme, node) self.__undoStack.push(command) def createNewNode(self, description, title=None, position=None): # type: (WidgetDescription, Optional[str], Optional[Pos]) -> SchemeNode """ Create a new :class:`.SchemeNode` and add it to the document. The new node is constructed using :func:`~SchemeEdit.newNodeHelper` method """ node = self.newNodeHelper(description, title, position) self.addNode(node) return node def newNodeHelper(self, description, title=None, position=None): # type: (WidgetDescription, Optional[str], Optional[Pos]) -> SchemeNode """ Return a new initialized :class:`.SchemeNode`. If `title` and `position` are not supplied they are initialized to sensible defaults. """ if title is None: title = self.enumerateTitle(description.name) if position is None: position = self.nextPosition() return SchemeNode(description, title=title, position=position) def enumerateTitle(self, title): # type: (str) -> str """ Enumerate a `title` string (i.e. add a number in parentheses) so it is not equal to any node title in the current scheme. """ if self.__scheme is None: return title curr_titles = set([node.title for node in self.__scheme.nodes]) template = title + " ({0})" enumerated = (template.format(i) for i in itertools.count(1)) candidates = itertools.chain([title], enumerated) seq = itertools.dropwhile(curr_titles.__contains__, candidates) return next(seq) def nextPosition(self): # type: () -> Tuple[float, float] """ Return the next default node position as a (x, y) tuple. This is a position left of the last added node. """ if self.__scheme is not None: nodes = self.__scheme.nodes else: nodes = [] if nodes: x, y = nodes[-1].position position = (x + 150, y) else: position = (150, 150) return position def removeNode(self, node): # type: (SchemeNode) -> None """ Remove a `node` (:class:`.SchemeNode`) from the scheme """ if self.__scheme is None: raise NoWorkflowError() command = commands.RemoveNodeCommand(self.__scheme, node) self.__undoStack.push(command) def renameNode(self, node, title): # type: (SchemeNode, str) -> None """ Rename a `node` (:class:`.SchemeNode`) to `title`. """ if self.__scheme is None: raise NoWorkflowError() self.__undoStack.push( commands.RenameNodeCommand(self.__scheme, node, node.title, title) ) def addLink(self, link): # type: (SchemeLink) -> None """ Add a `link` (:class:`.SchemeLink`) to the scheme. """ if self.__scheme is None: raise NoWorkflowError() command = commands.AddLinkCommand(self.__scheme, link) self.__undoStack.push(command) def removeLink(self, link): # type: (SchemeLink) -> None """ Remove a link (:class:`.SchemeLink`) from the scheme. """ if self.__scheme is None: raise NoWorkflowError() command = commands.RemoveLinkCommand(self.__scheme, link) self.__undoStack.push(command) def insertNode(self, new_node, old_link): # type: (SchemeNode, SchemeLink) -> None """ Insert a node in-between two linked nodes. """ if self.__scheme is None: raise NoWorkflowError() source_node = old_link.source_node sink_node = old_link.sink_node source_channel = old_link.source_channel sink_channel = old_link.sink_channel proposed_links = (self.__scheme.propose_links(source_node, new_node), self.__scheme.propose_links(new_node, sink_node)) # Preserve existing {source,sink}_channel if possible; use first # proposed if not. first = findf(proposed_links[0], lambda t: t[0] == source_channel, default=proposed_links[0][0]) second = findf(proposed_links[1], lambda t: t[1] == sink_channel, default=proposed_links[1][0]) new_links = ( SchemeLink(source_node, first[0], new_node, first[1]), SchemeLink(new_node, second[0], sink_node, second[1]) ) command = commands.InsertNodeCommand(self.__scheme, new_node, old_link, new_links) self.__undoStack.push(command) def onNewLink(self, func): """ Runs function when new link is added to current scheme. """ self.__scheme.link_added.connect(func) def addAnnotation(self, annotation): # type: (BaseSchemeAnnotation) -> None """ Add `annotation` (:class:`.BaseSchemeAnnotation`) to the scheme """ if self.__scheme is None: raise NoWorkflowError() command = commands.AddAnnotationCommand(self.__scheme, annotation) self.__undoStack.push(command) def removeAnnotation(self, annotation): # type: (BaseSchemeAnnotation) -> None """ Remove `annotation` (:class:`.BaseSchemeAnnotation`) from the scheme. """ if self.__scheme is None: raise NoWorkflowError() command = commands.RemoveAnnotationCommand(self.__scheme, annotation) self.__undoStack.push(command) def removeSelected(self): # type: () -> None """ Remove all selected items in the scheme. """ selected = self.scene().selectedItems() if not selected: return scene = self.scene() self.__undoStack.beginMacro(self.tr("Remove")) # order LinkItem removes before NodeItems; Removing NodeItems also # removes links so some links in selected could already be removed by # a preceding NodeItem remove selected = sorted( selected, key=lambda item: not isinstance(item, items.LinkItem)) for item in selected: assert self.__scheme is not None if isinstance(item, items.NodeItem): node = scene.node_for_item(item) self.__undoStack.push( commands.RemoveNodeCommand(self.__scheme, node) ) elif isinstance(item, items.annotationitem.Annotation): if item.hasFocus() or item.isAncestorOf(scene.focusItem()): # Clear input focus from the item to be removed. scene.focusItem().clearFocus() annot = scene.annotation_for_item(item) self.__undoStack.push( commands.RemoveAnnotationCommand(self.__scheme, annot) ) elif isinstance(item, items.LinkItem): link = scene.link_for_item(item) self.__undoStack.push( commands.RemoveLinkCommand(self.__scheme, link) ) self.__undoStack.endMacro() def selectAll(self): # type: () -> None """ Select all selectable items in the scheme. """ for item in self.__scene.items(): if item.flags() & QGraphicsItem.ItemIsSelectable: item.setSelected(True) def alignToGrid(self): # type: () -> None """ Align nodes to a grid. """ # TODO: The the current layout implementation is BAD (fix is urgent). if self.__scheme is None: return tile_size = 150 tiles = {} # type: Dict[Tuple[int, int], SchemeNode] nodes = sorted(self.__scheme.nodes, key=attrgetter("position")) if nodes: self.__undoStack.beginMacro(self.tr("Align To Grid")) for node in nodes: x, y = node.position x = int(round(float(x) / tile_size) * tile_size) y = int(round(float(y) / tile_size) * tile_size) while (x, y) in tiles: x += tile_size self.__undoStack.push( commands.MoveNodeCommand(self.__scheme, node, node.position, (x, y)) ) tiles[x, y] = node self.__scene.item_for_node(node).setPos(x, y) self.__undoStack.endMacro() def focusNode(self): # type: () -> Optional[SchemeNode] """ Return the current focused :class:`.SchemeNode` or ``None`` if no node has focus. """ focus = self.__scene.focusItem() node = None if isinstance(focus, items.NodeItem): try: node = self.__scene.node_for_item(focus) except KeyError: # in case the node has been removed but the scene was not # yet fully updated. node = None return node def selectedNodes(self): # type: () -> List[SchemeNode] """ Return all selected :class:`.SchemeNode` items. """ return list(map(self.scene().node_for_item, self.scene().selected_node_items())) def selectedLinks(self): # type: () -> List[SchemeLink] return list(map(self.scene().link_for_item, self.scene().selected_link_items())) def selectedAnnotations(self): # type: () -> List[BaseSchemeAnnotation] """ Return all selected :class:`.BaseSchemeAnnotation` items. """ return list(map(self.scene().annotation_for_item, self.scene().selected_annotation_items())) def openSelected(self): # type: () -> None """ Open (show and raise) all widgets for the current selected nodes. """ selected = self.selectedNodes() for node in selected: QCoreApplication.sendEvent( node, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) def editNodeTitle(self, node): # type: (SchemeNode) -> None """ Edit (rename) the `node`'s title. """ self.__view.setFocus(Qt.OtherFocusReason) scene = self.__scene item = scene.item_for_node(node) item.editTitle() def commit(): name = item.title() if name == node.title: return # pragma: no cover self.__undoStack.push( commands.RenameNodeCommand(self.__scheme, node, node.title, name) ) connect_with_context( item.titleEditingFinished, self, commit ) def __onCleanChanged(self, clean): # type: (bool) -> None if self.isWindowModified() != (not clean): self.setWindowModified(not clean) self.modificationChanged.emit(not clean) def setDropHandlers(self, dropHandlers: Sequence[DropHandler]) -> None: """ Set handlers for drop events onto the workflow view. """ self.__dropHandlers = tuple(dropHandlers) def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.FontChange: self.__updateFont() elif event.type() == QEvent.PaletteChange: if self.__scene is not None: self.__scene.setPalette(self.palette()) super().changeEvent(event) def __lookup_registry(self, qname: str) -> Optional[WidgetDescription]: if self.__registry is not None: try: return self.__registry.widget(qname) except KeyError: pass return None def __desc_from_mime_data(self, data: QMimeData) -> Optional[WidgetDescription]: MIME_TYPES = [ "application/vnd.orange-canvas.registry.qualified-name", # A back compatible misspelling "application/vnv.orange-canvas.registry.qualified-name", ] for typ in MIME_TYPES: if data.hasFormat(typ): qname_bytes = bytes(data.data(typ).data()) try: qname = qname_bytes.decode("utf-8") except UnicodeDecodeError: return None return self.__lookup_registry(qname) return None def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool # Filter the scene's drag/drop events. if obj is self.scene(): etype = event.type() if etype == QEvent.GraphicsSceneDragEnter or \ etype == QEvent.GraphicsSceneDragMove: assert isinstance(event, QGraphicsSceneDragDropEvent) drop_target = None desc = self.__desc_from_mime_data(event.mimeData()) if desc is not None: item = self.__scene.item_at(event.scenePos(), items.LinkItem) link = self.scene().link_for_item(item) if item else None if link is not None and can_insert_node(desc, link): drop_target = item drop_target.setHoverState(True) event.acceptProposedAction() if self.__dropTarget is not None and \ self.__dropTarget is not drop_target: self.__dropTarget.setHoverState(False) self.__dropTarget = drop_target if desc is not None: return True elif etype == QEvent.GraphicsSceneDragLeave: if self.__dropTarget is not None: self.__dropTarget.setHoverState(False) self.__dropTarget = None elif etype == QEvent.GraphicsSceneDrop: assert isinstance(event, QGraphicsSceneDragDropEvent) desc = self.__desc_from_mime_data(event.mimeData()) if desc is not None: statistics = self.usageStatistics() pos = event.scenePos() item = self.__scene.item_at(event.scenePos(), items.LinkItem) link = self.scene().link_for_item(item) if item else None if link and can_insert_node(desc, link): statistics.begin_insert_action(True, link) node = self.newNodeHelper(desc, position=(pos.x(), pos.y())) self.insertNode(node, link) else: statistics.begin_action(UsageStatistics.ToolboxDrag) self.createNewNode(desc, position=(pos.x(), pos.y())) self.view().setFocus(Qt.OtherFocusReason) return True if etype == QEvent.GraphicsSceneDragEnter: return self.sceneDragEnterEvent(event) elif etype == QEvent.GraphicsSceneDragMove: return self.sceneDragMoveEvent(event) elif etype == QEvent.GraphicsSceneDragLeave: return self.sceneDragLeaveEvent(event) elif etype == QEvent.GraphicsSceneDrop: return self.sceneDropEvent(event) elif etype == QEvent.GraphicsSceneMousePress: self.__pasteOrigin = event.scenePos() return self.sceneMousePressEvent(event) elif etype == QEvent.GraphicsSceneMouseMove: return self.sceneMouseMoveEvent(event) elif etype == QEvent.GraphicsSceneMouseRelease: return self.sceneMouseReleaseEvent(event) elif etype == QEvent.GraphicsSceneMouseDoubleClick: return self.sceneMouseDoubleClickEvent(event) elif etype == QEvent.KeyPress: return self.sceneKeyPressEvent(event) elif etype == QEvent.KeyRelease: return self.sceneKeyReleaseEvent(event) elif etype == QEvent.GraphicsSceneContextMenu: return self.sceneContextMenuEvent(event) elif obj is self.__scheme: if event.type() == QEvent.WhatsThisClicked: # Re post the event self.__showHelpFor(event.href()) elif event.type() == WorkflowEvent.ActivateParentRequest: self.window().activateWindow() self.window().raise_() return super().eventFilter(obj, event) def sceneMousePressEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool scene = self.__scene if scene.user_interaction_handler: return False pos = event.scenePos() anchor_item = scene.item_at( pos, items.NodeAnchorItem, buttons=Qt.LeftButton) if anchor_item and event.button() == Qt.LeftButton: # Start a new link starting at item scene.clearSelection() handler = interactions.NewLinkAction(self) self._setUserInteractionHandler(handler) return handler.mousePressEvent(event) link_item = scene.item_at(pos, items.LinkItem) if link_item and event.button() == Qt.MiddleButton: link = self.scene().link_for_item(link_item) self.removeLink(link) event.accept() return True any_item = scene.item_at(pos) # start node name edit on selected clicked if sys.platform == "darwin" \ and event.button() == Qt.LeftButton \ and isinstance(any_item, items.nodeitem.GraphicsTextEdit) \ and isinstance(any_item.parentItem(), items.NodeItem): node = scene.node_for_item(any_item.parentItem()) selected = self.selectedNodes() if node in selected: # deselect all other elements except the node item # and start the edit for selected_node in selected: selected_node_item = scene.item_for_node(selected_node) selected_node_item.setSelected(selected_node is node) self.editNodeTitle(node) return True if not any_item: self.__emptyClickButtons |= event.button() if not any_item and event.button() == Qt.LeftButton: # Create a RectangleSelectionAction but do not set in on the scene # just yet (instead wait for the mouse move event). handler = interactions.RectangleSelectionAction(self) rval = handler.mousePressEvent(event) if rval is True: self.__possibleSelectionHandler = handler return rval if any_item and event.button() == Qt.LeftButton: self.__possibleMouseItemsMove = True self.__itemsMoving.clear() self.__scene.node_item_position_changed.connect( self.__onNodePositionChanged ) self.__annotationGeomChanged.mappedObject.connect( self.__onAnnotationGeometryChanged ) set_enabled_all(self.__disruptiveActions, False) return False def sceneMouseMoveEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool scene = self.__scene if scene.user_interaction_handler: return False if self.__emptyClickButtons & Qt.LeftButton and \ event.buttons() & Qt.LeftButton and \ self.__possibleSelectionHandler: # Set the RectangleSelection (initialized in mousePressEvent) # on the scene handler = self.__possibleSelectionHandler self._setUserInteractionHandler(handler) self.__possibleSelectionHandler = None return handler.mouseMoveEvent(event) return False def sceneMouseReleaseEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool scene = self.__scene if scene.user_interaction_handler: return False if event.button() == Qt.LeftButton and self.__possibleMouseItemsMove: self.__possibleMouseItemsMove = False self.__scene.node_item_position_changed.disconnect( self.__onNodePositionChanged ) self.__annotationGeomChanged.mappedObject.disconnect( self.__onAnnotationGeometryChanged ) set_enabled_all(self.__disruptiveActions, True) if self.__itemsMoving: self.__scene.mouseReleaseEvent(event) scheme = self.__scheme assert scheme is not None stack = self.undoStack() stack.beginMacro(self.tr("Move")) for scheme_item, (old, new) in self.__itemsMoving.items(): if isinstance(scheme_item, SchemeNode): command = commands.MoveNodeCommand( scheme, scheme_item, old, new ) elif isinstance(scheme_item, BaseSchemeAnnotation): command = commands.AnnotationGeometryChange( scheme, scheme_item, old, new ) else: continue stack.push(command) stack.endMacro() self.__itemsMoving.clear() return True elif event.button() == Qt.LeftButton: self.__possibleSelectionHandler = None return False def sceneMouseDoubleClickEvent(self, event): # type: (QGraphicsSceneMouseEvent) -> bool scene = self.__scene if scene.user_interaction_handler: return False item = scene.item_at(event.scenePos()) if not item and self.__quickMenuTriggers & \ SchemeEditWidget.DoubleClicked: # Double click on an empty spot # Create a new node using QuickMenu action = interactions.NewNodeAction(self) with disable_undo_stack_actions( self.__undoAction, self.__redoAction, self.__undoStack): action.create_new(event.screenPos()) event.accept() return True return False def sceneKeyPressEvent(self, event): # type: (QKeyEvent) -> bool self.__updateOpenWidgetAnchors(event) scene = self.__scene if scene.user_interaction_handler: return False # If a QGraphicsItem is in text editing mode, don't interrupt it focusItem = scene.focusItem() if focusItem and isinstance(focusItem, QGraphicsTextItem) and \ focusItem.textInteractionFlags() & Qt.TextEditable: return False # If the mouse is not over out view if not self.view().underMouse(): return False handler = None searchText = "" if (event.key() == Qt.Key_Space and \ self.__quickMenuTriggers & SchemeEditWidget.SpaceKey): handler = interactions.NewNodeAction(self) elif len(event.text()) and \ self.__quickMenuTriggers & SchemeEditWidget.AnyKey and \ is_printable(event.text()[0]): handler = interactions.NewNodeAction(self) searchText = event.text() if handler is not None: # Control + Backspace (remove widget action on Mac OSX) conflicts # with the 'Clear text' action in the search widget (there might # be selected items in the canvas), so we disable the # remove widget action so the text editing follows standard # 'look and feel' with ExitStack() as stack: stack.enter_context(disabled(self.__removeSelectedAction)) stack.enter_context( disable_undo_stack_actions( self.__undoAction, self.__redoAction, self.__undoStack) ) handler.create_new(QCursor.pos(), searchText) event.accept() return True return False def sceneKeyReleaseEvent(self, event): # type: (QKeyEvent) -> bool self.__updateOpenWidgetAnchors(event) return False def __updateOpenWidgetAnchors(self, event=None): if self.__openAnchorsMode == SchemeEditWidget.OpenAnchors.Never: return scene = self.__scene mode = self.__openAnchorsMode # Open widget anchors on shift. New link action should work during this if event: shift_down = event.modifiers() == Qt.ShiftModifier else: shift_down = QApplication.keyboardModifiers() == Qt.ShiftModifier if mode == SchemeEditWidget.OpenAnchors.Never: scene.set_widget_anchors_open(False) elif mode == SchemeEditWidget.OpenAnchors.OnShift: scene.set_widget_anchors_open(shift_down) else: scene.set_widget_anchors_open(True) def sceneContextMenuEvent(self, event): # type: (QGraphicsSceneContextMenuEvent) -> bool scenePos = event.scenePos() globalPos = event.screenPos() item = self.scene().item_at(scenePos, items.NodeItem) if item is not None: node = self.scene().node_for_item(item) # type: SchemeNode actions = [] # type: List[QAction] manager = self.widgetManager() if manager is not None: actions = manager.actions_for_context_menu(node) # TODO: Inspect actions for all selected nodes and merge 'same' # actions (by name) if actions and len(self.selectedNodes()) == 1: # The node has extra actions for the context menu. # Copy the default context menu and append the extra actions. menu = QMenu(self) for a in self.__widgetMenu.actions(): menu.addAction(a) menu.addSeparator() for a in actions: menu.addAction(a) menu.setAttribute(Qt.WA_DeleteOnClose) else: menu = self.__widgetMenu menu.popup(globalPos) return True item = self.scene().item_at(scenePos, items.LinkItem) if item is not None: link = self.scene().link_for_item(item) self.__linkEnableAction.setChecked(link.enabled) self.__contextMenuTarget = link self.__linkMenu.popup(globalPos) return True item = self.scene().item_at(scenePos) if not item and \ self.__quickMenuTriggers & SchemeEditWidget.RightClicked: action = interactions.NewNodeAction(self) with disable_undo_stack_actions( self.__undoAction, self.__redoAction, self.__undoStack): action.create_new(globalPos) return True return False def sceneDragEnterEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: UNUSED(event) delegate = self._userInteractionHandler() if delegate is not None: return False handler = interactions.DropAction(self, dropHandlers=self.__dropHandlers) self._setUserInteractionHandler(handler) return False def sceneDragMoveEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: UNUSED(event) return False def sceneDragLeaveEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: UNUSED(event) return False def sceneDropEvent(self, event: QGraphicsSceneDragDropEvent) -> bool: UNUSED(event) return False def _userInteractionHandler(self): return self.__scene.user_interaction_handler def _setUserInteractionHandler(self, handler): # type: (Optional[interactions.UserInteraction]) -> None """ Helper method for setting the user interaction handlers. """ if self.__scene.user_interaction_handler: if isinstance(self.__scene.user_interaction_handler, (interactions.ResizeArrowAnnotation, interactions.ResizeTextAnnotation)): self.__scene.user_interaction_handler.commit() self.__scene.user_interaction_handler.ended.disconnect( self.__onInteractionEnded ) if handler: handler.ended.connect(self.__onInteractionEnded) # Disable actions which could change the model set_enabled_all(self.__disruptiveActions, False) self.__scene.set_user_interaction_handler(handler) def __onInteractionEnded(self): # type: () -> None self.sender().ended.disconnect(self.__onInteractionEnded) set_enabled_all(self.__disruptiveActions, True) self.__updateOpenWidgetAnchors() def __onSelectionChanged(self): # type: () -> None nodes = self.selectedNodes() annotations = self.selectedAnnotations() links = self.selectedLinks() self.__renameAction.setEnabled(len(nodes) == 1) self.__openSelectedAction.setEnabled(bool(nodes)) self.__removeSelectedAction.setEnabled( bool(nodes or annotations or links) ) self.__helpAction.setEnabled(len(nodes) == 1) self.__renameAction.setEnabled(len(nodes) == 1) self.__duplicateSelectedAction.setEnabled(bool(nodes)) self.__copySelectedAction.setEnabled(bool(nodes)) if len(nodes) > 1: self.__openSelectedAction.setText(self.tr("Open All")) else: self.__openSelectedAction.setText(self.tr("Open")) if len(nodes) + len(annotations) + len(links) > 1: self.__removeSelectedAction.setText(self.tr("Remove All")) else: self.__removeSelectedAction.setText(self.tr("Remove")) focus = self.focusNode() if focus is not None: desc = focus.description tip = whats_this_helper(desc, include_more_link=True) else: tip = "" if tip != self.__quickTip: self.__quickTip = tip ev = QuickHelpTipEvent("", self.__quickTip, priority=QuickHelpTipEvent.Permanent) QCoreApplication.sendEvent(self, ev) def __onLinkActivate(self, item): link = self.scene().link_for_item(item) action = interactions.EditNodeLinksAction(self, link.source_node, link.sink_node) action.edit_links() def __onLinkAdded(self, item: items.LinkItem) -> None: item.setFlag(QGraphicsItem.ItemIsSelectable) def __onNodeActivate(self, item): # type: (items.NodeItem) -> None node = self.__scene.node_for_item(item) QCoreApplication.sendEvent( node, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) def __onNodePositionChanged(self, item, pos): # type: (items.NodeItem, QPointF) -> None node = self.__scene.node_for_item(item) new = (pos.x(), pos.y()) if node not in self.__itemsMoving: self.__itemsMoving[node] = (node.position, new) else: old, _ = self.__itemsMoving[node] self.__itemsMoving[node] = (old, new) def __onAnnotationGeometryChanged(self, item): # type: (AnnotationItem) -> None annot = self.scene().annotation_for_item(item) if annot not in self.__itemsMoving: self.__itemsMoving[annot] = (annot.geometry, geometry_from_annotation_item(item)) else: old, _ = self.__itemsMoving[annot] self.__itemsMoving[annot] = (old, geometry_from_annotation_item(item)) def __onAnnotationAdded(self, item): # type: (AnnotationItem) -> None log.debug("Annotation added (%r)", item) item.setFlag(QGraphicsItem.ItemIsSelectable) item.setFlag(QGraphicsItem.ItemIsMovable) item.setFlag(QGraphicsItem.ItemIsFocusable) if isinstance(item, items.ArrowAnnotation): pass elif isinstance(item, items.TextAnnotation): # Make the annotation editable. item.setTextInteractionFlags(Qt.TextEditorInteraction) self.__editFinishedMapper.setMapping(item, item) item.editingFinished.connect( self.__editFinishedMapper.map ) self.__annotationGeomChanged.setMapping(item, item) item.geometryChanged.connect( self.__annotationGeomChanged.map ) def __onAnnotationRemoved(self, item): # type: (AnnotationItem) -> None log.debug("Annotation removed (%r)", item) if isinstance(item, items.ArrowAnnotation): pass elif isinstance(item, items.TextAnnotation): item.editingFinished.disconnect( self.__editFinishedMapper.map ) self.__annotationGeomChanged.removeMappings(item) item.geometryChanged.disconnect( self.__annotationGeomChanged.map ) def __onFocusItemChanged(self, newFocusItem, oldFocusItem): # type: (Optional[QGraphicsItem], Optional[QGraphicsItem]) -> None if isinstance(oldFocusItem, items.annotationitem.Annotation): self.__endControlPointEdit() if isinstance(newFocusItem, items.annotationitem.Annotation): if not self.__scene.user_interaction_handler: self.__startControlPointEdit(newFocusItem) def __onEditingFinished(self, item): # type: (items.TextAnnotation) -> None """ Text annotation editing has finished. """ annot = self.__scene.annotation_for_item(item) assert isinstance(annot, SchemeTextAnnotation) content_type = item.contentType() content = item.content() if annot.text != content or annot.content_type != content_type: assert self.__scheme is not None self.__undoStack.push( commands.TextChangeCommand( self.__scheme, annot, annot.text, annot.content_type, content, content_type ) ) def __toggleNewArrowAnnotation(self, checked): # type: (bool) -> None if self.__newTextAnnotationAction.isChecked(): # Uncheck the text annotation action if needed. self.__newTextAnnotationAction.setChecked(not checked) action = self.__newArrowAnnotationAction if not checked: # The action was unchecked (canceled by the user) handler = self.__scene.user_interaction_handler if isinstance(handler, interactions.NewArrowAnnotation): # Cancel the interaction and restore the state handler.ended.disconnect(action.toggle) handler.cancel(interactions.UserInteraction.UserCancelReason) log.info("Canceled new arrow annotation") else: handler = interactions.NewArrowAnnotation(self) checked_action = self.__arrowColorActionGroup.checkedAction() handler.setColor(checked_action.data()) handler.ended.connect(action.toggle) self._setUserInteractionHandler(handler) def __onFontSizeTriggered(self, action): # type: (QAction) -> None if not self.__newTextAnnotationAction.isChecked(): # When selecting from the (font size) menu the 'Text' # action does not get triggered automatically. self.__newTextAnnotationAction.trigger() else: # Update the preferred font on the interaction handler. handler = self.__scene.user_interaction_handler if isinstance(handler, interactions.NewTextAnnotation): handler.setFont(action.font()) def __toggleNewTextAnnotation(self, checked): # type: (bool) -> None if self.__newArrowAnnotationAction.isChecked(): # Uncheck the arrow annotation if needed. self.__newArrowAnnotationAction.setChecked(not checked) action = self.__newTextAnnotationAction if not checked: # The action was unchecked (canceled by the user) handler = self.__scene.user_interaction_handler if isinstance(handler, interactions.NewTextAnnotation): # cancel the interaction and restore the state handler.ended.disconnect(action.toggle) handler.cancel(interactions.UserInteraction.UserCancelReason) log.info("Canceled new text annotation") else: handler = interactions.NewTextAnnotation(self) checked_action = self.__fontActionGroup.checkedAction() handler.setFont(checked_action.font()) handler.ended.connect(action.toggle) self._setUserInteractionHandler(handler) def __onArrowColorTriggered(self, action): # type: (QAction) -> None if not self.__newArrowAnnotationAction.isChecked(): # When selecting from the (color) menu the 'Arrow' # action does not get triggered automatically. self.__newArrowAnnotationAction.trigger() else: # Update the preferred color on the interaction handler handler = self.__scene.user_interaction_handler if isinstance(handler, interactions.NewArrowAnnotation): handler.setColor(action.data()) def __onRenameAction(self): # type: () -> None """ Rename was requested for the selected widget. """ selected = self.selectedNodes() if len(selected) == 1: self.editNodeTitle(selected[0]) def __onHelpAction(self): # type: () -> None """ Help was requested for the selected widget. """ nodes = self.selectedNodes() help_url = None if len(nodes) == 1: node = nodes[0] desc = node.description help_url = "help://search?" + urlencode({"id": desc.qualified_name}) self.__showHelpFor(help_url) def __showHelpFor(self, help_url): # type: (str) -> None """ Show help for an "help" url. """ # Notify the parent chain and let them respond ev = QWhatsThisClickedEvent(help_url) handled = QCoreApplication.sendEvent(self, ev) if not handled: message_information( self.tr("Sorry there is no documentation available for " "this widget."), parent=self) def __toggleLinkEnabled(self, enabled): # type: (bool) -> None """ Link 'enabled' state was toggled in the context menu. """ if self.__contextMenuTarget: link = self.__contextMenuTarget command = commands.SetAttrCommand( link, "enabled", enabled, name=self.tr("Set enabled"), ) self.__undoStack.push(command) def __linkRemove(self): # type: () -> None """ Remove link was requested from the context menu. """ if self.__contextMenuTarget: self.removeLink(self.__contextMenuTarget) def __linkReset(self): # type: () -> None """ Link reset from the context menu was requested. """ if self.__contextMenuTarget: link = self.__contextMenuTarget action = interactions.EditNodeLinksAction( self, link.source_node, link.sink_node ) action.edit_links() def __nodeInsert(self): # type: () -> None """ Node insert was requested from the context menu. """ if not self.__contextMenuTarget: return original_link = self.__contextMenuTarget source_node = original_link.source_node sink_node = original_link.sink_node def filterFunc(index): desc = index.data(QtWidgetRegistry.WIDGET_DESC_ROLE) if isinstance(desc, WidgetDescription): return can_insert_node(desc, original_link) else: return False x = (source_node.position[0] + sink_node.position[0]) / 2 y = (source_node.position[1] + sink_node.position[1]) / 2 menu = self.quickMenu() menu.setFilterFunc(filterFunc) menu.setSortingFunc(None) view = self.view() try: action = menu.exec(view.mapToGlobal(view.mapFromScene(QPointF(x, y)))) finally: menu.setFilterFunc(None) if action: item = action.property("item") desc = item.data(QtWidgetRegistry.WIDGET_DESC_ROLE) else: return if can_insert_node(desc, original_link): statistics = self.usageStatistics() statistics.begin_insert_action(False, original_link) new_node = self.newNodeHelper(desc, position=(x, y)) self.insertNode(new_node, original_link) else: log.info("Cannot insert node: links not possible.") def __duplicateSelected(self): # type: () -> None """ Duplicate currently selected nodes. """ nodedups, linkdups = self.__copySelected() if not nodedups: return pos = nodes_top_left(nodedups) self.__paste(nodedups, linkdups, pos + DuplicateOffset, commandname=self.tr("Duplicate")) def __copyToClipboard(self): """ Copy currently selected nodes to system clipboard. """ cb = QApplication.clipboard() selected = self.__copySelected() nodes, links = selected if not nodes: return s = Scheme() for n in nodes: s.add_node(n) for e in links: s.add_link(e) buff = io.BytesIO() try: s.save_to(buff, pickle_fallback=True) except Exception: log.error("copyToClipboard:", exc_info=True) QApplication.beep() return mime = QMimeData() mime.setData(MimeTypeWorkflowFragment, buff.getvalue()) cb.setMimeData(mime) self.__pasteOrigin = nodes_top_left(nodes) + DuplicateOffset def __updatePasteActionState(self): self.__pasteAction.setEnabled( clipboard_has_format(MimeTypeWorkflowFragment) ) def __copySelected(self): """ Return a deep copy of currently selected nodes and links between them. """ scheme = self.scheme() if scheme is None: return [], [] # ensure up to date node properties (settings) scheme.sync_node_properties() # original nodes and links nodes = self.selectedNodes() links = [link for link in scheme.links if link.source_node in nodes and link.sink_node in nodes] # deepcopied nodes and links nodedups = [copy_node(node) for node in nodes] node_to_dup = dict(zip(nodes, nodedups)) linkdups = [copy_link(link, source=node_to_dup[link.source_node], sink=node_to_dup[link.sink_node]) for link in links] return nodedups, linkdups def __pasteFromClipboard(self): """Paste a workflow part from system clipboard.""" buff = clipboard_data(MimeTypeWorkflowFragment) if buff is None: return sch = Scheme() try: sch.load_from(io.BytesIO(buff), registry=self.__registry, ) except Exception: log.error("pasteFromClipboard:", exc_info=True) QApplication.beep() return self.__paste(sch.nodes, sch.links, self.__pasteOrigin) self.__pasteOrigin = self.__pasteOrigin + DuplicateOffset def __paste(self, nodedups, linkdups, pos: Optional[QPointF] = None, commandname=None): """ Paste nodes and links to canvas. Arguments are expected to be duplicated nodes/links. """ scheme = self.scheme() if scheme is None: return # find unique names for new nodes allnames = {node.title for node in scheme.nodes} for nodedup in nodedups: nodedup.title = uniquify( remove_copy_number(nodedup.title), allnames, pattern="{item} ({_})", start=1 ) allnames.add(nodedup.title) if pos is not None: # top left of nodedups brect origin = nodes_top_left(nodedups) delta = pos - origin # move nodedups to be relative to pos for nodedup in nodedups: nodedup.position = ( nodedup.position[0] + delta.x(), nodedup.position[1] + delta.y(), ) if commandname is None: commandname = self.tr("Paste") # create nodes, links command = UndoCommand(commandname) macrocommands = [] for nodedup in nodedups: macrocommands.append( commands.AddNodeCommand(scheme, nodedup, parent=command)) for linkdup in linkdups: macrocommands.append( commands.AddLinkCommand(scheme, linkdup, parent=command)) statistics = self.usageStatistics() statistics.begin_action(UsageStatistics.Duplicate) self.__undoStack.push(command) scene = self.__scene # deselect selected selected = self.scene().selectedItems() for item in selected: item.setSelected(False) # select pasted for node in nodedups: item = scene.item_for_node(node) item.setSelected(True) def __startControlPointEdit(self, item): # type: (items.annotationitem.Annotation) -> None """ Start a control point edit interaction for `item`. """ if isinstance(item, items.ArrowAnnotation): handler = interactions.ResizeArrowAnnotation(self) elif isinstance(item, items.TextAnnotation): handler = interactions.ResizeTextAnnotation(self) else: log.warning("Unknown annotation item type %r" % item) return handler.editItem(item) self._setUserInteractionHandler(handler) log.info("Control point editing started (%r)." % item) def __endControlPointEdit(self): # type: () -> None """ End the current control point edit interaction. """ handler = self.__scene.user_interaction_handler if isinstance(handler, (interactions.ResizeArrowAnnotation, interactions.ResizeTextAnnotation)) and \ not handler.isFinished() and not handler.isCanceled(): handler.commit() handler.end() log.info("Control point editing finished.") def __updateFont(self): # type: () -> None """ Update the font for the "Text size' menu and the default font used in the `CanvasScene`. """ actions = self.__fontActionGroup.actions() font = self.font() for action in actions: size = action.font().pixelSize() action_font = QFont(font) action_font.setPixelSize(size) action.setFont(action_font) if self.__scene: self.__scene.setFont(font) def __signalManagerStateChanged(self, state): # type: (RuntimeState) -> None if state == RuntimeState.Running: role = QPalette.Base else: role = QPalette.Window self.__view.viewport().setBackgroundRole(role) def __reset_window_group_menu(self): group = self.__windowGroupsActionGroup menu = self.__windowGroupsAction.menu() # remove old actions actions = group.actions() for a in actions: group.removeAction(a) menu.removeAction(a) a.deleteLater() sep = menu.findChild(QAction, "groups-separator") workflow = self.__scheme if workflow is None: return presets = workflow.window_group_presets() for g in presets: a = QAction(g.name, menu) a.setShortcut( QKeySequence("Meta+P, Ctrl+{}" .format(len(group.actions()) + 1)) ) a.setData(g) group.addAction(a) menu.insertAction(sep, a) def __saveWindowGroup(self): # type: () -> None """Run a 'Save Window Group' dialog""" workflow = self.__scheme manager = self.__widgetManager if manager is None or workflow is None: return state = manager.save_window_state() presets = workflow.window_group_presets() items = [g.name for g in presets] default = [i for i, g in enumerate(presets) if g.default] dlg = SaveWindowGroup( self, windowTitle="Save Group as...") dlg.setWindowModality(Qt.ApplicationModal) dlg.setItems(items) if default: dlg.setDefaultIndex(default[0]) def store_group(): text = dlg.selectedText() default = dlg.isDefaultChecked() try: idx = items.index(text) except ValueError: idx = -1 newpresets = [copy.copy(g) for g in presets] # shallow copy newpreset = Scheme.WindowGroup(text, default, state) if idx == -1: # new group slot newpresets.append(newpreset) else: newpresets[idx] = newpreset if newpreset.default: idx_ = idx if idx >= 0 else len(newpresets) - 1 for g in newpresets[:idx_] + newpresets[idx_ + 1:]: g.default = False if idx == -1: text = "Store Window Group" else: text = "Update Window Group" self.__undoStack.push( commands.SetWindowGroupPresets(workflow, newpresets, text=text) ) dlg.accepted.connect(store_group) dlg.show() dlg.raise_() def __activateWindowGroup(self, action): # type: (QAction) -> None data = action.data() # type: Scheme.WindowGroup wm = self.__widgetManager if wm is not None: wm.activate_window_group(data) def __clearWindowGroups(self): # type: () -> None workflow = self.__scheme if workflow is None: return self.__undoStack.push( commands.SetWindowGroupPresets( workflow, [], text="Delete All Window Groups") ) def __raiseToFont(self): # Raise current visible widgets to front wm = self.__widgetManager if wm is not None: wm.raise_widgets_to_front() def activateDefaultWindowGroup(self): # type: () -> bool """ Activate the default window group if one exists. Return `True` if a default group exists and was activated; `False` if not. """ for action in self.__windowGroupsActionGroup.actions(): g = action.data() if g.default: action.trigger() return True return False def widgetManager(self): # type: () -> Optional[WidgetManager] """ Return the widget manager. """ return self.__widgetManager class SaveWindowGroup(QDialog): """ A dialog for saving window groups. The user can select an existing group to overwrite or enter a new group name. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) layout = QVBoxLayout() form = QFormLayout( fieldGrowthPolicy=QFormLayout.AllNonFixedFieldsGrow) layout.addLayout(form) self._combobox = cb = QComboBox( editable=True, minimumContentsLength=16, sizeAdjustPolicy=QComboBox.AdjustToMinimumContentsLengthWithIcon, insertPolicy=QComboBox.NoInsert, ) cb.currentIndexChanged.connect(self.__currentIndexChanged) # default text if no items are present cb.setEditText(self.tr("Window Group 1")) cb.lineEdit().selectAll() form.addRow(self.tr("Save As:"), cb) self._checkbox = check = QCheckBox( self.tr("Use as default"), toolTip="Automatically use this preset when opening the workflow." ) form.setWidget(1, QFormLayout.FieldRole, check) bb = QDialogButtonBox( standardButtons=QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(self.__accept_check) bb.rejected.connect(self.reject) layout.addWidget(bb) layout.setSizeConstraint(QVBoxLayout.SetFixedSize) self.setLayout(layout) self.setWhatsThis( "Save the current open widgets' window arrangement to the " "workflow view presets." ) cb.setFocus(Qt.NoFocusReason) def __currentIndexChanged(self, idx): # type: (int) -> None state = self._combobox.itemData(idx, Qt.UserRole + 1) if not isinstance(state, bool): state = False self._checkbox.setChecked(state) def __accept_check(self): # type: () -> None cb = self._combobox text = cb.currentText() if cb.findText(text) == -1: self.accept() return # Ask for overwrite confirmation mb = QMessageBox( self, windowTitle=self.tr("Confirm Overwrite"), icon=QMessageBox.Question, standardButtons=QMessageBox.Yes | QMessageBox.Cancel, text=self.tr("The window group '{}' already exists. Do you want " + "to replace it?").format(text), ) mb.setDefaultButton(QMessageBox.Yes) mb.setEscapeButton(QMessageBox.Cancel) mb.setWindowModality(Qt.WindowModal) button = mb.button(QMessageBox.Yes) button.setText(self.tr("Replace")) def on_finished(status): # type: (int) -> None if status == QMessageBox.Yes: self.accept() mb.finished.connect(on_finished) mb.show() def setItems(self, items): # type: (List[str]) -> None """Set a list of existing items/names to present to the user""" self._combobox.clear() self._combobox.addItems(items) if items: self._combobox.setCurrentIndex(len(items) - 1) def setDefaultIndex(self, idx): # type: (int) -> None self._combobox.setItemData(idx, True, Qt.UserRole + 1) self._checkbox.setChecked(self._combobox.currentIndex() == idx) def selectedText(self): # type: () -> str """Return the current entered text.""" return self._combobox.currentText() def isDefaultChecked(self): # type: () -> bool """Return the state of the 'Use as default' check box.""" return self._checkbox.isChecked() def geometry_from_annotation_item(item): if isinstance(item, items.ArrowAnnotation): line = item.line() p1 = item.mapToScene(line.p1()) p2 = item.mapToScene(line.p2()) return ((p1.x(), p1.y()), (p2.x(), p2.y())) elif isinstance(item, items.TextAnnotation): geom = item.geometry() return (geom.x(), geom.y(), geom.width(), geom.height()) def mouse_drag_distance(event, button=Qt.LeftButton): # type: (QGraphicsSceneMouseEvent, Qt.MouseButton) -> float """ Return the (manhattan) distance between the mouse position when the `button` was pressed and the current mouse position. """ diff = (event.buttonDownScreenPos(button) - event.screenPos()) return diff.manhattanLength() def set_enabled_all(objects, enable): # type: (Iterable[Any], bool) -> None """ Set `enabled` properties on all objects (objects with `setEnabled` method). """ for obj in objects: obj.setEnabled(enable) # All control character categories. _control = set(["Cc", "Cf", "Cs", "Co", "Cn"]) def is_printable(unichar): # type: (str) -> bool """ Return True if the unicode character `unichar` is a printable character. """ return unicodedata.category(unichar) not in _control def node_properties(scheme): # type: (Scheme) -> Dict[str, Dict[str, Any]] scheme.sync_node_properties() return { node: dict(node.properties) for node in scheme.nodes } def can_insert_node(new_node_desc, original_link): # type: (WidgetDescription, SchemeLink) -> bool return any(any(scheme.compatible_channels(output, input) for input in new_node_desc.inputs) for output in original_link.source_node.output_channels()) and \ any(any(scheme.compatible_channels(output, input) for output in new_node_desc.outputs) for input in original_link.sink_node.input_channels()) def remove_copy_number(name): """ >>> remove_copy_number("foo (1)") foo """ match = re.search(r"\s+\(\d+\)\s*$", name) if match: return name[:match.start()] return name def uniquify(item, names, pattern="{item}-{_}", start=0): # type: (str, Container[str], str, int) -> str candidates = (pattern.format(item=item, _=i) for i in itertools.count(start)) candidates = itertools.dropwhile( lambda item: item in names, itertools.chain((item,), candidates) ) return next(candidates) def copy_node(node): # type: (SchemeNode) -> SchemeNode return SchemeNode( node.description, node.title, position=node.position, properties=copy.deepcopy(node.properties) ) def copy_link(link, source=None, sink=None): # type: (SchemeLink, Optional[SchemeNode], Optional[SchemeNode]) -> SchemeLink source = link.source_node if source is None else source sink = link.sink_node if sink is None else sink return SchemeLink( source, link.source_channel, sink, link.sink_channel, enabled=link.enabled, properties=copy.deepcopy(link.properties)) def nodes_top_left(nodes): # type: (List[SchemeNode]) -> QPointF """Return the top left point of bbox containing all the node positions.""" return QPointF( min((n.position[0] for n in nodes), default=0), min((n.position[1] for n in nodes), default=0) ) @contextmanager def disable_undo_stack_actions( undo: QAction, redo: QAction, stack: QUndoStack ) -> Generator[None, None, None]: """ Disable the undo/redo actions of an undo stack. On exit restore the enabled state to match the `stack.canUndo()` and `stack.canRedo()`. Parameters ---------- undo: QAction redo: QAction stack: QUndoStack Returns ------- context: ContextManager """ undo.setEnabled(False) redo.setEnabled(False) try: yield finally: undo.setEnabled(stack.canUndo()) redo.setEnabled(stack.canRedo()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/suggestions.py0000644000175100002000000001014314730024325024134 0ustar00runnerdockerimport os import pickle from collections import defaultdict import logging from .. import config from .interactions import NewLinkAction log = logging.getLogger(__name__) class Suggestions: """ Handles sorting of quick menu items when dragging a link from a widget onto empty canvas. """ class __Suggestions: def __init__(self): self.__frequencies_path = os.path.join(config.data_dir(), "widget-use-frequency.pickle") self.__import_factor = 0.8 # upon starting Orange, imported frequencies are reduced self.__scheme = None self.__direction = None self.link_frequencies = defaultdict(int) self.source_probability = defaultdict(lambda: defaultdict(float)) self.sink_probability = defaultdict(lambda: defaultdict(float)) if not self.load_link_frequency(): self.default_link_frequency() def load_link_frequency(self): if not os.path.isfile(self.__frequencies_path): return False try: with open(self.__frequencies_path, "rb") as f: imported_freq = pickle.load(f) except Exception: # pylint: disable=broad-except log.warning("Failed to open widget link frequencies.") return False for k, v in imported_freq.items(): imported_freq[k] = self.__import_factor * v self.link_frequencies = imported_freq self.overwrite_probabilities_with_frequencies() return True def default_link_frequency(self): self.link_frequencies[("File", "Data Table", NewLinkAction.FROM_SOURCE)] = 3 self.overwrite_probabilities_with_frequencies() def overwrite_probabilities_with_frequencies(self): for link, count in self.link_frequencies.items(): self.increment_probability(link[0], link[1], link[2], count) def new_link(self, link): # direction is none when a widget was not added+linked via quick menu if self.__direction is None: return source_id = link.source_node.description.name sink_id = link.sink_node.description.name link_key = (source_id, sink_id, self.__direction) self.link_frequencies[link_key] += 1 self.increment_probability(source_id, sink_id, self.__direction, 1) self.write_link_frequency() self.__direction = None def increment_probability(self, source_id, sink_id, direction, factor): if direction == NewLinkAction.FROM_SOURCE: self.source_probability[source_id][sink_id] += factor self.sink_probability[sink_id][source_id] += factor * 0.5 else: # FROM_SINK self.source_probability[source_id][sink_id] += factor * 0.5 self.sink_probability[sink_id][source_id] += factor def write_link_frequency(self): try: with open(self.__frequencies_path, "wb") as f: pickle.dump(self.link_frequencies, f) except OSError: log.warning("Failed to write widget link frequencies.") return def set_direction(self, direction): """ When opening quick menu, before the widget is created, set the direction of creation (FROM_SINK, FROM_SOURCE). """ self.__direction = direction def set_scheme(self, scheme): self.__scheme = scheme scheme.onNewLink(self.new_link) def get_sink_suggestions(self, source_id): return self.source_probability[source_id] def get_source_suggestions(self, sink_id): return self.sink_probability[sink_id] def get_default_suggestions(self): return self.source_probability instance = None def __init__(self): if not Suggestions.instance: Suggestions.instance = Suggestions.__Suggestions() def __getattr__(self, name): return getattr(self.instance, name) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.786081 orange_canvas_core-0.2.5/orangecanvas/document/tests/0000755000175100002000000000000014730024333022352 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/tests/__init__.py0000644000175100002000000000000014730024325024452 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/tests/test_editlinksdialog.py0000644000175100002000000000743414730024325027142 0ustar00runnerdockerfrom AnyQt.QtGui import QPalette from AnyQt.QtWidgets import QGraphicsScene, QGraphicsView from AnyQt.QtCore import Qt, QPoint from ...utils import findf from ...registry.tests import small_testing_registry from ...gui import test from ..editlinksdialog import EditLinksDialog, EditLinksNode, \ GraphicsTextWidget, LinksEditWidget, LinkLineItem, ChannelAnchor from ...scheme import SchemeNode class TestLinksEditDialog(test.QAppTestCase): def test_links_edit(self): dlg = EditLinksDialog() reg = small_testing_registry() one_desc = reg.widget("one") negate_desc = reg.widget("negate") source_node = SchemeNode(one_desc, title="This is 1") sink_node = SchemeNode(negate_desc) source_channel = source_node.output_channel("value") sink_channel = sink_node.input_channel("value") links = [(source_channel, sink_channel)] dlg.setNodes(source_node, sink_node) dlg.show() dlg.setLinks(links) self.assertSequenceEqual(dlg.links(), links) self.singleShot(50, dlg.close) status = dlg.exec() self.assertTrue(dlg.links() == [] or dlg.links() == links) def test_editlinksnode(self): reg = small_testing_registry() one_desc = reg.widget("one") negate_desc = reg.widget("negate") source_node = SchemeNode(one_desc, title="This is 1") sink_node = SchemeNode(negate_desc) scene = QGraphicsScene() view = QGraphicsView(scene) node = EditLinksNode(node=source_node) scene.addItem(node) node = EditLinksNode(direction=Qt.RightToLeft) node.setSchemeNode(sink_node) node.setPos(300, 0) scene.addItem(node) view.show() view.resize(800, 300) self.qWait() def test_links_edit_widget(self): reg = small_testing_registry() one_desc = reg.widget("one") negate_desc = reg.widget("negate") source_node = SchemeNode(one_desc, title="This is 1") sink_node = SchemeNode(negate_desc) source_channel = source_node.output_channel("value") sink_channel = sink_node.input_channel("value") scene = QGraphicsScene() view = QGraphicsView(scene) view.resize(800, 600) widget = LinksEditWidget() scene.addItem(widget) widget.setNodes(source_node, sink_node) widget.addLink(source_channel, sink_channel) view.grab() linkitems = widget.childItems() link = findf(linkitems, lambda item: isinstance(item, LinkLineItem)) center = link.line().center() pos = view.mapFromScene(link.mapToScene(center)) test.mouseMove(view.viewport(), Qt.NoButton, pos=pos) # hover over line view.grab() # paint in hovered state test.mouseMove(view.viewport(), Qt.NoButton, pos=QPoint(0, 0)) # hover leave palette = QPalette() palette.setColor(QPalette.Text, Qt.red) widget.setPalette(palette) view.grab() anchor = findf(widget.sourceNodeWidget.childItems(), lambda item: isinstance(item, ChannelAnchor)) pos = view.mapFromScene(anchor.mapToScene(anchor.rect().center())) test.mouseMove(view.viewport(), Qt.NoButton, pos=pos) # hover over anchor view.grab() # paint in hovered state test.mouseMove(view.viewport(), Qt.NoButton, pos=QPoint(0, 0)) # hover leave anchor.setEnabled(False) view.grab() # paint in disabled state class TestGraphicsTextWidget(test.QAppTestCase): def test_graphicstextwidget(self): scene = QGraphicsScene() view = QGraphicsView(scene) view.resize(400, 300) text = GraphicsTextWidget() text.setHtml("
              a text

              paragraph

              ") scene.addItem(text) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/tests/test_quickmenu.py0000644000175100002000000000542714730024325025775 0ustar00runnerdockerfrom AnyQt.QtWidgets import QAction from AnyQt.QtCore import QPoint, QStringListModel from ..quickmenu import QuickMenu, SuggestMenuPage, FlattenedTreeItemModel, \ MenuPage from ...gui.test import QAppTestCase from ...registry.qt import QtWidgetRegistry from ...registry.tests import small_testing_registry class TestMenu(QAppTestCase): def test_menu(self): menu = QuickMenu() def triggered(action): print("Triggered", action.text()) def hovered(action): print("Hover", action.text()) menu.triggered.connect(triggered) menu.hovered.connect(hovered) items_page = MenuPage() model = QStringListModel(["one", "two", "file not found"]) items_page.setModel(model) menu.addPage("w", items_page) page_c = MenuPage() menu.addPage("c", page_c) menu.popup(QPoint(200, 200)) menu.activateWindow() self.qWait() def test_menu_with_registry(self): registry = QtWidgetRegistry(small_testing_registry()) menu = QuickMenu() menu.setModel(registry.model()) triggered_action = [] def triggered(action): print("Triggered", action.text()) self.assertIsInstance(action, QAction) triggered_action.append(action) def hovered(action): self.assertIsInstance(action, QAction) print("Hover", action.text()) menu.triggered.connect(triggered) menu.hovered.connect(hovered) self.app.setActiveWindow(menu) self.singleShot(100, menu.close) rval = menu.exec(QPoint(200, 200)) if triggered_action: self.assertIs(triggered_action[0], rval) def test_search(self): registry = QtWidgetRegistry(small_testing_registry()) menu = SuggestMenuPage() menu.setModel(registry.model()) menu.grab() menu.setFilterFixedString("o") menu.setFilterFixedString("z") menu.setFilterFixedString("m") menu.grab() def test_flattened_model(self): model = QStringListModel(["0", "1", "2", "3"]) flat = FlattenedTreeItemModel() flat.setSourceModel(model) def get(row): return flat.index(row, 0).data() self.assertEqual(get(0), "0") self.assertEqual(get(1), "1") self.assertEqual(get(3), "3") self.assertEqual(flat.rowCount(), model.rowCount()) self.assertEqual(flat.columnCount(), 1) def test_popup_position(self): menu = QuickMenu() screen = menu.screen() screen_geom = screen.availableGeometry() menu.popup(QPoint(screen_geom.topLeft() - QPoint(20, 20))) geom = menu.geometry() self.assertEqual(screen_geom.intersected(geom), geom) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/tests/test_schemeedit.py0000644000175100002000000006030014730024325026075 0ustar00runnerdocker""" Tests for scheme document. """ import re import sys import unittest from unittest import mock from typing import Iterable from AnyQt.QtCore import Qt, QPoint, QMimeData from AnyQt.QtGui import QPainterPath from AnyQt.QtWidgets import ( QGraphicsWidget, QAction, QApplication, QMenu, QWidget ) from AnyQt.QtTest import QSignalSpy, QTest from .. import commands from ..schemeedit import SchemeEditWidget, SaveWindowGroup from ..interactions import ( DropHandler, PluginDropHandler, NodeFromMimeDataDropHandler, EntryPoint ) from ...canvas import items from ...scheme import Scheme, SchemeNode, SchemeLink, SchemeTextAnnotation, \ SchemeArrowAnnotation from ...registry.tests import small_testing_registry from ...gui.test import QAppTestCase, mouseMove, dragDrop, dragEnterLeave, \ contextMenu from ...utils import findf from ...scheme.tests.test_widgetmanager import TestingWidgetManager def action_by_name(actions, name): # type: (Iterable[QAction], str) -> QAction for a in actions: if a.objectName() == name: return a raise LookupError(name) class TestSchemeEdit(QAppTestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.reg = small_testing_registry() @classmethod def tearDownClass(cls): super().tearDownClass() del cls.reg def setUp(self): super().setUp() self.w = SchemeEditWidget() self.w.setScheme(Scheme()) self.w.setRegistry(self.reg) self.w.resize(300, 300) def tearDown(self): del self.w super().tearDown() def test_schemeedit(self): reg = self.reg w = self.w scheme = Scheme() w.setScheme(scheme) self.assertIs(w.scheme(), scheme) self.assertFalse(w.isModified()) scheme = Scheme() w.setScheme(scheme) self.assertIs(w.scheme(), scheme) self.assertFalse(w.isModified()) w.show() one_desc = reg.widget("one") negate_desc = reg.widget("negate") node_list = [] link_list = [] annot_list = [] scheme.node_added.connect(node_list.append) scheme.node_removed.connect(node_list.remove) scheme.link_added.connect(link_list.append) scheme.link_removed.connect(link_list.remove) scheme.annotation_added.connect(annot_list.append) scheme.annotation_removed.connect(annot_list.remove) node = SchemeNode(one_desc, title="title1", position=(100, 100)) w.addNode(node) self.assertSequenceEqual(node_list, [node]) self.assertSequenceEqual(scheme.nodes, node_list) self.assertTrue(w.isModified()) stack = w.undoStack() stack.undo() self.assertSequenceEqual(node_list, []) self.assertSequenceEqual(scheme.nodes, node_list) self.assertTrue(not w.isModified()) stack.redo() node1 = SchemeNode(negate_desc, title="title2", position=(300, 100)) w.addNode(node1) self.assertSequenceEqual(node_list, [node, node1]) self.assertSequenceEqual(scheme.nodes, node_list) self.assertTrue(w.isModified()) link = SchemeLink(node, "value", node1, "value") w.addLink(link) self.assertSequenceEqual(link_list, [link]) stack.undo() stack.undo() stack.redo() stack.redo() w.removeNode(node1) self.assertSequenceEqual(link_list, []) self.assertSequenceEqual(node_list, [node]) stack.undo() self.assertSequenceEqual(link_list, [link]) self.assertSequenceEqual(node_list, [node, node1]) spy = QSignalSpy(node.title_changed) w.renameNode(node, "foo bar") self.assertSequenceEqual(list(spy), [["foo bar"]]) self.assertTrue(w.isModified()) stack.undo() self.assertSequenceEqual(list(spy), [["foo bar"], ["title1"]]) w.removeLink(link) self.assertSequenceEqual(link_list, []) stack.undo() self.assertSequenceEqual(link_list, [link]) annotation = SchemeTextAnnotation((200, 300, 50, 20), "text") w.addAnnotation(annotation) self.assertSequenceEqual(annot_list, [annotation]) stack.undo() self.assertSequenceEqual(annot_list, []) stack.redo() self.assertSequenceEqual(annot_list, [annotation]) w.removeAnnotation(annotation) self.assertSequenceEqual(annot_list, []) stack.undo() self.assertSequenceEqual(annot_list, [annotation]) self.assertTrue(w.isModified()) self.assertFalse(stack.isClean()) w.setModified(False) self.assertFalse(w.isModified()) self.assertTrue(stack.isClean()) w.setModified(True) self.assertTrue(w.isModified()) def test_modified(self): node = SchemeNode( self.reg.widget("one"), title="title1", position=(100, 100)) self.w.addNode(node) self.assertTrue(self.w.isModified()) self.w.setModified(False) self.assertFalse(self.w.isModified()) self.w.setTitle("Title") self.assertTrue(self.w.isModified()) self.w.setDescription("AAA") self.assertTrue(self.w.isModified()) undo = self.w.undoStack() undo.undo() undo.undo() self.assertFalse(self.w.isModified()) def test_teardown(self): w = self.w w.undoStack().isClean() new = Scheme() w.setScheme(new) def test_actions(self): w = self.w actions = w.toolbarActions() action_by_name(actions, "action-zoom-in").trigger() action_by_name(actions, "action-zoom-out").trigger() action_by_name(actions, "action-zoom-reset").trigger() def test_node_rename(self): w = self.w view = w.view() node = SchemeNode(self.reg.widget("one"), title="A") w.addNode(node) w.editNodeTitle(node) # simulate editing QTest.keyClicks(view.viewport(), "BB") QTest.keyClick(view.viewport(), Qt.Key_Enter) self.assertEqual(node.title, "BB") # last undo command must be rename command undo = w.undoStack() command = undo.command(undo.count() - 1) self.assertIsInstance(command, commands.RenameNodeCommand) @unittest.skipUnless(sys.platform == "darwin", "macos only") def test_node_rename_click_selected(self): w = self.w scene = w.scene() view = w.view() w.show() w.raise_() w.activateWindow() node = SchemeNode(self.reg.widget("one"), title="A") w.addNode(node) w.selectAll() item = scene.item_for_node(node) assert isinstance(item, items.NodeItem) point = item.captionTextItem.boundingRect().center() point = item.captionTextItem.mapToScene(point) point = view.mapFromScene(point) QTest.mouseClick(view.viewport(), Qt.LeftButton, Qt.NoModifier, point) self.assertTrue(item.captionTextItem.isEditing()) contextMenu(view.viewport(), point) def test_arrow_annotation_action(self): w = self.w workflow = w.scheme() workflow.clear() view = w.view() actions = w.toolbarActions() action_by_name(actions, "new-arrow-action").trigger() QTest.mousePress(view.viewport(), Qt.LeftButton, pos=QPoint(50, 50)) mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100)) QTest.mouseRelease(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100)) self.assertEqual(len(workflow.annotations), 1) self.assertIsInstance(workflow.annotations[0], SchemeArrowAnnotation) def test_arrow_annotation_action_cancel(self): w = self.w workflow = w.scheme() view = w.view() actions = w.toolbarActions() action = action_by_name(actions, "new-arrow-action") action.trigger() self.assertTrue(action.isChecked()) # cancel immediately after activating QTest.keyClick(view.viewport(), Qt.Key_Escape) self.assertFalse(action.isChecked()) action.trigger() # cancel after mouse press and drag QTest.mousePress(view.viewport(), Qt.LeftButton, pos=QPoint(50, 50)) mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100)) QTest.keyClick(view.viewport(), Qt.Key_Escape) self.assertFalse(action.isChecked()) self.assertEqual(workflow.annotations, []) def test_text_annotation_action(self): w = self.w workflow = w.scheme() workflow.clear() view = w.view() actions = w.toolbarActions() action_by_name(actions, "new-text-action").trigger() QTest.mousePress(view.viewport(), Qt.LeftButton, pos=QPoint(50, 50)) mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100)) QTest.mouseRelease(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100)) # need to steal focus from the item for it to be commited. w.scene().setFocusItem(None) self.assertEqual(len(workflow.annotations), 1) self.assertIsInstance(workflow.annotations[0], SchemeTextAnnotation) def test_text_annotation_action_cancel(self): w = self.w workflow = w.scheme() view = w.view() actions = w.toolbarActions() action = action_by_name(actions, "new-text-action") action.trigger() self.assertTrue(action.isChecked()) # cancel immediately after activating QTest.keyClick(view.viewport(), Qt.Key_Escape) self.assertFalse(action.isChecked()) action.trigger() # cancel after mouse press and drag QTest.mousePress(view.viewport(), Qt.LeftButton, pos=QPoint(50, 50)) mouseMove(view.viewport(), Qt.LeftButton, pos=QPoint(100, 100)) QTest.keyClick(view.viewport(), Qt.Key_Escape) self.assertFalse(action.isChecked()) w.scene().setFocusItem(None) self.assertEqual(workflow.annotations, []) def test_path(self): w = self.w spy = QSignalSpy(w.pathChanged) self.w.setPath("/dev/null") self.assertSequenceEqual(list(spy), [["/dev/null"]]) def test_ensure_visible(self): w = self.w node = SchemeNode( self.reg.widget("one"), title="title1", position=(10000, 100)) self.w.addNode(node) w.setFixedSize(300, 300) w.show() assert QTest.qWaitForWindowExposed(w, 500) w.ensureVisible(node) view = w.view() viewrect = view.mapToScene(view.viewport().geometry()).boundingRect() self.assertTrue(viewrect.contains(10000., 100.)) def test_select(self): w = self.w self.setup_test_workflow(w.scheme()) w.selectAll() self.assertSequenceEqual( w.selectedNodes(), w.scheme().nodes) self.assertSequenceEqual( w.selectedAnnotations(), w.scheme().annotations) self.assertSequenceEqual( w.selectedLinks(), w.scheme().links) w.removeSelected() self.assertEqual(w.scheme().nodes, []) self.assertEqual(w.scheme().annotations, []) self.assertEqual(w.scheme().links, []) def test_select_remove_link(self): def link_curve(link: SchemeLink) -> QPainterPath: item = scene.item_for_link(link) # type: items.LinkItem path = item.curveItem.curvePath() return item.mapToScene(path) w = self.w workflow = self.setup_test_workflow(w.scheme()) w.alignToGrid() scene, view = w.scene(), w.view() link = workflow.links[0] path = link_curve(link) p = path.pointAtPercent(0.5) QTest.mouseClick(view.viewport(), Qt.LeftButton, pos=view.mapFromScene(p)) self.assertSequenceEqual(w.selectedLinks(), [link]) w.removeSelected() self.assertSequenceEqual(w.selectedLinks(), []) self.assertTrue(link not in workflow.links) def test_open_selected(self): w = self.w w.setScheme(self.setup_test_workflow()) w.selectAll() w.openSelected() def test_insert_node_on_link(self): w = self.w workflow = self.setup_test_workflow(w.scheme()) neg = SchemeNode(self.reg.widget("negate")) target = workflow.links[0] spyrem = QSignalSpy(workflow.link_removed) spyadd = QSignalSpy(workflow.link_added) w.insertNode(neg, target) self.assertEqual(workflow.nodes[-1], neg) self.assertSequenceEqual(list(spyrem), [[target]]) self.assertEqual(len(spyadd), 2) w.undoStack().undo() def test_align_to_grid(self): w = self.w self.setup_test_workflow(w.scheme()) w.alignToGrid() def test_activate_node(self): w = self.w workflow = self.setup_test_workflow() w.setScheme(workflow) view, scene = w.view(), w.scene() item = scene.item_for_node(workflow.nodes[0]) # type: QGraphicsWidget item.setSelected(True) item.setFocus(Qt.OtherFocusReason) self.assertIs(w.focusNode(), workflow.nodes[0]) item.activated.emit() def test_duplicate(self): w = self.w workflow = self.setup_test_workflow() w.setScheme(workflow) w.selectAll() nnodes, nlinks = len(workflow.nodes), len(workflow.links) a = action_by_name(w.actions(), "duplicate-action") a.trigger() self.assertEqual(len(workflow.nodes), 2 * nnodes) self.assertEqual(len(workflow.links), 2 * nlinks) w.selectAll() a.trigger() self.assertEqual(len(workflow.nodes), 4 * nnodes) self.assertEqual(len(workflow.links), 4 * nlinks) self.assertEqual(len(workflow.nodes), len(set(n.title for n in workflow.nodes))) match = re.compile(r"\(\d+\)\s*\(\d+\)") self.assertFalse(any(match.search(n.title) for n in workflow.nodes), "Duplicated renumbering ('foo (2) (1)')") def test_copy_paste(self): w = self.w workflow = self.setup_test_workflow() w.setRegistry(self.reg) w.setScheme(workflow) w.selectAll() nnodes, nlinks = len(workflow.nodes), len(workflow.links) ca = action_by_name(w.actions(), "copy-action") cp = action_by_name(w.actions(), "paste-action") cb = QApplication.clipboard() spy = QSignalSpy(cb.dataChanged) ca.trigger() if not len(spy): self.assertTrue(spy.wait()) self.assertEqual(len(spy), 1) cp.trigger() self.assertEqual(len(workflow.nodes), 2 * nnodes) self.assertEqual(len(workflow.links), 2 * nlinks) w1 = SchemeEditWidget() w1.setRegistry(self.reg) w1.setScheme((Scheme())) cp = action_by_name(w1.actions(), "paste-action") self.assertTrue(cp.isEnabled()) cp.trigger() wf1 = w1.scheme() self.assertEqual(len(wf1.nodes), nnodes) self.assertEqual(len(wf1.links), nlinks) def test_redo_remove_preserves_order(self): w = self.w workflow = self.setup_test_workflow() w.setRegistry(self.reg) w.setScheme(workflow) undo = w.undoStack() links = workflow.links nodes = workflow.nodes annotations = workflow.annotations assert len(links) > 2 w.removeLink(links[1]) self.assertSequenceEqual(links[:1] + links[2:], workflow.links) undo.undo() self.assertSequenceEqual(links, workflow.links) # find add node that has multiple in/out links node = findf(workflow.nodes, lambda n: n.title == "add") w.removeNode(node) undo.undo() self.assertSequenceEqual(links, workflow.links) self.assertSequenceEqual(nodes, workflow.nodes) w.removeAnnotation(annotations[0]) self.assertSequenceEqual(annotations[1:], workflow.annotations) undo.undo() self.assertSequenceEqual(annotations, workflow.annotations) def test_window_groups(self): w = self.w workflow = self.setup_test_workflow() workflow.set_window_group_presets([ Scheme.WindowGroup("G1", False, [(workflow.nodes[0], b'\xff\x00')]), Scheme.WindowGroup("G2", True, [(workflow.nodes[0], b'\xff\x00')]), ]) manager = TestingWidgetManager() workflow.widget_manager = manager with mock.patch.object(manager, "activate_window_group") as m: w.setScheme(workflow) w.activateDefaultWindowGroup() m.assert_called_once_with(workflow.window_group_presets()[1]) a = w.findChild(QAction, "window-groups-save-action") with mock.patch.object( workflow, "set_window_group_presets", wraps=workflow.set_window_group_presets ) as m: a.trigger() dlg = w.findChild(SaveWindowGroup) dlg.accept() m.assert_called_once() with mock.patch.object( workflow, "set_window_group_presets", wraps=workflow.set_window_group_presets ) as m: w.undoStack().undo() m.assert_called_once() with mock.patch.object( workflow, "set_window_group_presets", wraps=workflow.set_window_group_presets ) as m: a = w.findChild(QAction, "window-groups-clear-action") a.trigger() m.assert_called_once_with([]) workflow.clear() def test_drop_event(self): w = self.w w.setRegistry(self.reg) workflow = w.scheme() desc = self.reg.widget("one") viewport = w.view().viewport() mime = QMimeData() mime.setData( "application/vnd.orange-canvas.registry.qualified-name", desc.qualified_name.encode("utf-8") ) self.assertTrue(dragDrop(viewport, mime, QPoint(10, 10))) self.assertEqual(len(workflow.nodes), 1) self.assertEqual(workflow.nodes[0].description, desc) dragEnterLeave(viewport, mime) self.assertEqual(len(workflow.nodes), 1) def test_drag_drop(self): w = self.w w.setRegistry(self.reg) handler = TestDropHandler() w.setDropHandlers([handler]) viewport = w.view().viewport() mime = QMimeData() mime.setData(handler.format_, b'abc') dragDrop(viewport, mime, QPoint(10, 10)) self.assertEqual(handler.doDrop_calls, 1) self.assertGreaterEqual(handler.accepts_calls, 1) self.assertIsNone(w._userInteractionHandler()) handler.accepts_calls = 0 handler.doDrop_calls = 0 mime = QMimeData() mime.setData("application/prs.do-not-accept-this", b'abc') dragDrop(viewport, mime, QPoint(10, 10)) self.assertGreaterEqual(handler.accepts_calls, 1) self.assertEqual(handler.doDrop_calls, 0) self.assertIsNone(w._userInteractionHandler()) dragEnterLeave(viewport, mime, QPoint(10, 10)) self.assertIsNone(w._userInteractionHandler()) @mock.patch.object( PluginDropHandler, "iterEntryPoints", lambda _: [ EntryPoint( "AA", f"{__name__}:TestDropHandler", "aa" ), EntryPoint( "BB", f"{__name__}:TestNodeFromMimeData", "aa" ) ] ) def test_plugin_drag_drop(self): handler = PluginDropHandler() w = self.w w.setRegistry(self.reg) w.setDropHandlers([handler]) workflow = w.scheme() viewport = w.view().viewport() # Test empty handler mime = QMimeData() mime.setData(TestDropHandler.format_, b'abc') dragDrop(viewport, mime, QPoint(10, 10)) self.assertIsNone(w._userInteractionHandler()) # test create node handler mime = QMimeData() mime.setData(TestNodeFromMimeData.format_, b'abc') dragDrop(viewport, mime, QPoint(10, 10)) self.assertIsNone(w._userInteractionHandler()) self.assertEqual(len(workflow.nodes), 1) self.assertEqual(workflow.nodes[0].description.name, "one") self.assertEqual(workflow.nodes[0].properties, {"a": "from drop"}) workflow.clear() # Test both simultaneously (menu for selection) mime = QMimeData() mime.setData(TestDropHandler.format_, b'abc') mime.setData(TestNodeFromMimeData.format_, b'abc') def exec(self, *args): return action_by_name(self.actions(), "-pick-me") # intercept QMenu.exec, force select the TestNodeFromMimeData handler with mock.patch.object(QMenu, "exec", exec): dragDrop(viewport, mime, QPoint(10, 10)) self.assertEqual(len(workflow.nodes), 1) self.assertEqual(workflow.nodes[0].description.name, "one") self.assertEqual(workflow.nodes[0].properties, {"a": "from drop"}) def test_activate_drop_node(self): class NodeFromMimeData(TestNodeFromMimeData): def shouldActivateNode(self) -> bool: self.shouldActivateNode_called += 1 return True shouldActivateNode_called = 0 def activateNode(self, document: 'SchemeEditWidget', node: 'Node', widget: 'QWidget') -> None: self.activateNode_called += 1 super().activateNode(document, node, widget) widget.didActivate = True activateNode_called = 0 w = self.w viewport = w.view().viewport() workflow = Scheme() wm = workflow.widget_manager = TestingWidgetManager() wm.set_creation_policy(TestingWidgetManager.Immediate) wm.set_workflow(workflow) w.setScheme(workflow) handler = NodeFromMimeData() w.setDropHandlers([handler]) mime = QMimeData() mime.setData(TestNodeFromMimeData.format_, b'abc') record = [] wm.widget_for_node_added.connect( lambda obj, widget: record.append((obj, widget)) ) dragDrop(viewport, mime, QPoint(10, 10)) self.assertEqual(len(record), 1) self.assertGreaterEqual(handler.shouldActivateNode_called, 1) self.assertGreaterEqual(handler.activateNode_called, 1) _, widget = record[0] self.assertTrue(widget.didActivate) workflow.clear() @classmethod def setup_test_workflow(cls, scheme=None): # type: (Scheme) -> Scheme if scheme is None: scheme = Scheme() reg = cls.reg zero_desc = reg.widget("zero") one_desc = reg.widget("one") add_desc = reg.widget("add") negate = reg.widget("negate") zero_node = SchemeNode(zero_desc) one_node = SchemeNode(one_desc) add_node = SchemeNode(add_desc) negate_node = SchemeNode(negate) scheme.add_node(zero_node) scheme.add_node(one_node) scheme.add_node(add_node) scheme.add_node(negate_node) scheme.add_link(SchemeLink(zero_node, "value", add_node, "left")) scheme.add_link(SchemeLink(one_node, "value", add_node, "right")) scheme.add_link(SchemeLink(add_node, "result", negate_node, "value")) scheme.add_annotation(SchemeArrowAnnotation((0, 0), (10, 10))) scheme.add_annotation(SchemeTextAnnotation((0, 100, 200, 200), "$$")) return scheme class TestDropHandler(DropHandler): format_ = "application/prs.test" accepts_calls = 0 doDrop_calls = 0 def accepts(self, document, event) -> bool: self.accepts_calls += 1 return event.mimeData().hasFormat(self.format_) def doDrop(self, document, event) -> bool: self.doDrop_calls += 1 return event.mimeData().hasFormat(self.format_) class TestNodeFromMimeData(NodeFromMimeDataDropHandler): format_ = "application/prs.one" def qualifiedName(self) -> str: return "one" def canDropMimeData(self, document, data: 'QMimeData') -> bool: return data.hasFormat(self.format_) def parametersFromMimeData(self, document, data: 'QMimeData') -> 'Dict[str, Any]': return {"a": "from drop"} def actionFromDropEvent( self, document: 'SchemeEditWidget', event: 'QGraphicsSceneDragDropEvent' ) -> QAction: a = super().actionFromDropEvent(document, event) a.setObjectName("-pick-me") return a ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/tests/test_usagestatistics.py0000644000175100002000000000330114730024325027200 0ustar00runnerdockerfrom AnyQt.QtWidgets import QToolButton from orangecanvas.application.tests.test_mainwindow import TestMainWindowBase from orangecanvas.application.widgettoolbox import WidgetToolBox from orangecanvas.document.usagestatistics import UsageStatistics, EventType class TestUsageStatistics(TestMainWindowBase): def setUp(self): super().setUp() self.stats = self.w.current_document().usageStatistics() self.stats.set_enabled(True) reg = self.w.scheme_widget._SchemeEditWidget__registry first_cat = reg.categories()[0] data_descriptions = reg.widgets(first_cat) self.descs = [reg.action_for_widget(desc).data() for desc in data_descriptions] toolbox = self.w.findChild(WidgetToolBox) widget = toolbox.widget(0) self.buttons = widget.findChildren(QToolButton) def tearDown(self): super().tearDown() self.stats._clear_action() self.stats._actions = [] self.stats.set_enabled(False) def test_node_add_toolbox_click(self): self.assertEqual(len(self.stats._actions), 0) w_desc = self.descs[0] button = self.buttons[0] # ToolboxClick button.click() self.assertEqual(len(self.stats._actions), 1) log = self.stats._actions[0] expected = {'Type': UsageStatistics.ToolboxClick, 'Events': [ { 'Type': EventType.NodeAdd, 'Widget Name': w_desc.name, 'Widget': 0 } ] } self.assertEqual(expected, log) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/document/usagestatistics.py0000644000175100002000000003246714730024325025016 0ustar00runnerdockerimport enum import itertools from datetime import datetime import platform import json import logging import os from typing import List from AnyQt.QtCore import QCoreApplication, QSettings from orangecanvas import config from orangecanvas.scheme import SchemeNode, SchemeLink, Scheme log = logging.getLogger(__name__) class EventType(enum.IntEnum): NodeAdd = 0 NodeRemove = 1 LinkAdd = 2 LinkRemove = 3 class ActionType(enum.IntEnum): Unclassified = 0 ToolboxClick = 1 ToolboxDrag = 2 QuickMenu = 3 ExtendFromSource = 4 ExtendFromSink = 5 InsertDrag = 6 InsertMenu = 7 Undo = 8 Redo = 9 Duplicate = 10 Load = 11 class UsageStatistics: """ Tracks usage statistics if enabled (is disabled by default). Data is tracked and stored in application data directory in 'usage-statistics.json' file. It is the application's responsibility to ask for permission and appropriately handle the collected statistics. Data tracked per canvas session: date, application version, operating system, anaconda boolean, UUID (in Orange3), a sequence of actions of type ActionType An action consists of one or more events of type EventType. Events refer to nodes according to a unique integer ID. Each node is also associated with a widget name, assigned in a NodeAdd event. Link events also reference corresponding source/sink channel names. Some actions carry metadata (e.g. search query for QuickMenu, Extend). Parameters ---------- parent: SchemeEditWidget """ _is_enabled = False statistics_sessions = [] last_search_query = None source_open = False sink_open = False Unclassified, ToolboxClick, ToolboxDrag, QuickMenu, ExtendFromSink, ExtendFromSource, \ InsertDrag, InsertMenu, Undo, Redo, Duplicate, Load \ = list(ActionType) def __init__(self, parent): self.parent = parent self._actions = [] self._events = [] self._widget_ids = {} self._id_iter = itertools.count() self._action_type = ActionType.Unclassified self._metadata = None UsageStatistics.statistics_sessions.append(self) @classmethod def is_enabled(cls) -> bool: """ Returns ------- enabled : bool Is usage collection enabled. """ return cls._is_enabled @classmethod def set_enabled(cls, state: bool) -> None: """ Enable/disable usage collection. Parameters ---------- state : bool """ if cls._is_enabled == state: return cls._is_enabled = state log.info("{} usage statistics tracking".format( "Enabling" if state else "Disabling" )) for session in UsageStatistics.statistics_sessions: if state: # log current scheme state after enabling of statistics scheme = session.parent.scheme() session.log_scheme(scheme) else: session.drop_statistics() def begin_action(self, action_type): """ Sets the type of action that will be logged upon next call to a log method. Each call to begin_action() should be matched with a call to end_action(). Parameters ---------- action_type : ActionType """ if not self.is_enabled(): return if self._action_type != self.Unclassified: raise ValueError("Tried to set " + str(action_type) + \ " but " + str(self._action_type) + " was already set.") self._prepare_action(action_type) def begin_extend_action(self, from_sink, extended_widget): """ Sets the type of action to widget extension in the specified direction, noting the extended widget and query. Each call to begin_extend_action() should be matched with a call to end_action(). Parameters ---------- from_sink : bool extended_widget : SchemeNode """ if not self.is_enabled(): return if self._events: log.error("Tried to start extend action while current action already has events") return # set action type if from_sink: action_type = ActionType.ExtendFromSink else: action_type = ActionType.ExtendFromSource # set metadata if extended_widget not in self._widget_ids: log.error("Attempted to extend widget before it was logged. No action type was set.") return extended_id = self._widget_ids[extended_widget] metadata = {"Extended Widget": extended_id} self._prepare_action(action_type, metadata) def begin_insert_action(self, via_drag, original_link): """ Sets the type of action to widget insertion via the specified way, noting the old link's source and sink widgets. Each call to begin_insert_action() should be matched with a call to end_action(). Parameters ---------- via_drag : bool original_link : SchemeLink """ if not self.is_enabled(): return if self._events: log.error("Tried to start insert action while current action already has events") return source_widget = original_link.source_node sink_widget = original_link.sink_node # set action type if via_drag: action_type = ActionType.InsertDrag else: action_type = ActionType.InsertMenu # set metadata if source_widget not in self._widget_ids or sink_widget not in self._widget_ids: log.error("Attempted to log insert action between unknown widgets. " "No action was logged.") self._clear_action() return src_id, sink_id = self._widget_ids[source_widget], self._widget_ids[sink_widget] metadata = {"Source Widget": src_id, "Sink Widget": sink_id} self._prepare_action(action_type, metadata) def _prepare_action(self, action_type, metadata=None): """ Sets the type of action and metadata that will be logged upon next call to a log method. Parameters ---------- action_type : ActionType metadata : Dict[str, Any] """ self._action_type = action_type self._metadata = metadata def end_action(self): """ Ends the started action, concatenating the relevant events and adding it to the list of actions. """ if not self.is_enabled(): return if not self._events: log.info("End action called but no events were logged.") self._clear_action() return action = { "Type": self._action_type, "Events": self._events } # add metadata if self._metadata: action.update(self._metadata) # add search query if relevant if self._action_type in {ActionType.ExtendFromSource, ActionType.ExtendFromSink, ActionType.QuickMenu}: action["Query"] = self.last_search_query self._actions.append(action) self._clear_action() def _clear_action(self): """ Clear the current action. """ self._events = [] self._action_type = ActionType.Unclassified self._metadata = None self.last_search_query = "" def log_node_add(self, widget): """ Logs an node addition action, based on the currently set action type. Parameters ---------- widget : SchemeNode """ if not self.is_enabled(): return # get or generate id for widget if widget in self._widget_ids: widget_id = self._widget_ids[widget] else: widget_id = next(self._id_iter) self._widget_ids[widget] = widget_id event = { "Type": EventType.NodeAdd, "Widget Name": widget.description.id, "Widget": widget_id } self._events.append(event) def log_node_remove(self, widget): """ Logs an node removal action. Parameters ---------- widget : SchemeNode """ if not self.is_enabled(): return # get id for widget if widget not in self._widget_ids: log.error("Attempted to log node removal before its addition. No action was logged.") self._clear_action() return widget_id = self._widget_ids[widget] event = { "Type": EventType.NodeRemove, "Widget": widget_id } self._events.append(event) def log_link_add(self, link): """ Logs a link addition action. Parameters ---------- link : SchemeLink """ if not self.is_enabled(): return self._log_link(EventType.LinkAdd, link) def log_link_remove(self, link): """ Logs a link removal action. Parameters ---------- link : SchemeLink """ if not self.is_enabled(): return self._log_link(EventType.LinkRemove, link) def _log_link(self, action_type, link): source_widget = link.source_node sink_widget = link.sink_node # get id for widgets if source_widget not in self._widget_ids or sink_widget not in self._widget_ids: log.error("Attempted to log link action between unknown widgets. No action was logged.") self._clear_action() return src_id, sink_id = self._widget_ids[source_widget], self._widget_ids[sink_widget] event = { "Type": action_type, "Source Widget": src_id, "Sink Widget": sink_id, "Source Channel": link.source_channel.name, "Sink Channel": link.sink_channel.name, "Source Open": UsageStatistics.source_open, "Sink Open:": UsageStatistics.sink_open, } self._events.append(event) def log_scheme(self, scheme): """ Log all nodes and links in a scheme. Parameters ---------- scheme : Scheme """ if not self.is_enabled(): return if not scheme or not scheme.nodes: return self.begin_action(ActionType.Load) # first log nodes for node in scheme.nodes: self.log_node_add(node) # then log links for link in scheme.links: self.log_link_add(link) self.end_action() def drop_statistics(self): """ Clear all data in the statistics session. """ self._actions = [] self._widget_ids = {} self._id_iter = itertools.count() def write_statistics(self): """ Write the statistics session to file, and clear it. """ if not self.is_enabled(): return statistics = { "Date": str(datetime.now().date()), "Application Version": QCoreApplication.applicationVersion(), "Operating System": platform.system() + " " + platform.release(), "Launch Count": QSettings().value('startup/launch-count', 0, type=int), "Session": self._actions } data = self.load() data.append(statistics) self.store(data) self.drop_statistics() def close(self): """ Close statistics session, effectively not updating it upon toggling statistics tracking. """ UsageStatistics.statistics_sessions.remove(self) @staticmethod def set_last_search_query(query): if not UsageStatistics.is_enabled(): return UsageStatistics.last_search_query = query @staticmethod def set_source_anchor_open(is_open): if not UsageStatistics.is_enabled(): return UsageStatistics.source_open = is_open @staticmethod def set_sink_anchor_open(is_open): if not UsageStatistics.is_enabled(): return UsageStatistics.sink_open = is_open @staticmethod def filename() -> str: """ Return the filename path where the statistics are saved """ return os.path.join(config.data_dir(), "usage-statistics.json") @staticmethod def load() -> 'List[dict]': """ Load and return the usage statistics data. """ if not UsageStatistics.is_enabled(): return [] try: with open(UsageStatistics.filename(), "r", encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, PermissionError, IsADirectoryError, UnicodeDecodeError, json.JSONDecodeError): return [] @staticmethod def store(data: List[dict]) -> None: """ Store the usage statistics data. """ if not UsageStatistics.is_enabled(): return try: with open(UsageStatistics.filename(), "w", encoding="utf-8") as f: json.dump(data, f) except (OSError, UnicodeEncodeError): return ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.790081 orange_canvas_core-0.2.5/orangecanvas/gui/0000755000175100002000000000000014730024333020156 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/__init__.py0000644000175100002000000000024314730024325022267 0ustar00runnerdocker""" =========== GUI toolkit =========== A GUI toolkit with widgets used by other parts of Orange Canvas. Extends basic Qt classes with extra functionality. """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/dock.py0000644000175100002000000002164414730024325021460 0ustar00runnerdocker""" ======================= Collapsible Dock Widget ======================= A dock widget that can be a collapsed/expanded. """ from typing import Optional, Any from AnyQt.QtWidgets import ( QDockWidget, QAbstractButton, QSizePolicy, QStyle, QWidget, QWIDGETSIZE_MAX ) from AnyQt.QtGui import QIcon, QTransform from AnyQt.QtCore import Qt, QEvent, QObject from AnyQt.QtCore import pyqtProperty as Property, pyqtSignal as Signal from .stackedwidget import AnimatedStackedWidget class CollapsibleDockWidget(QDockWidget): """ This :class:`QDockWidget` subclass overrides the `close` header button to instead collapse to a smaller size. The contents to show when in each state can be set using the :func:`setExpandedWidget` and :func:`setCollapsedWidget`. Note ---- Do not use the base class :func:`QDockWidget.setWidget` method to set the dock contents. Use :func:`setExpandedWidget` and :func:`setCollapsedWidget` instead. """ #: Emitted when the dock widget's expanded state changes. expandedChanged = Signal(bool) def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) self.__expandedWidget = None # type: Optional[QWidget] self.__collapsedWidget = None # type: Optional[QWidget] self.__expanded = True self.__trueMinimumWidth = -1 self.setFeatures(QDockWidget.DockWidgetClosable | QDockWidget.DockWidgetMovable) self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) self.dockLocationChanged.connect(self.__onDockLocationChanged) # Use the toolbar horizontal extension button icon as the default # for the expand/collapse button icon = self.style().standardIcon( QStyle.SP_ToolBarHorizontalExtensionButton) # Mirror the icon transform = QTransform() transform = transform.scale(-1.0, 1.0) icon_rev = QIcon() for s in (8, 12, 14, 16, 18, 24, 32, 48, 64): pm = icon.pixmap(s, s) icon_rev.addPixmap(pm.transformed(transform)) self.__iconRight = QIcon(icon) self.__iconLeft = QIcon(icon_rev) # Find the close button an install an event filter or close event close = self.findChild(QAbstractButton, name="qt_dockwidget_closebutton") assert close is not None close.installEventFilter(self) self.__closeButton = close self.__stack = AnimatedStackedWidget() self.__stack.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) super().setWidget(self.__stack) self.__closeButton.setIcon(self.__iconLeft) def setExpanded(self, state): # type: (bool) -> None """ Set the widgets `expanded` state. """ if self.__expanded != state: self.__expanded = state if state and self.__expandedWidget is not None: self.__stack.setCurrentWidget(self.__expandedWidget) elif not state and self.__collapsedWidget is not None: self.__stack.setCurrentWidget(self.__collapsedWidget) self.__fixIcon() self.expandedChanged.emit(state) def expanded(self): # type: () -> bool """ Is the dock widget in expanded state. If `True` the ``expandedWidget`` will be shown, and ``collapsedWidget`` otherwise. """ return self.__expanded expanded_ = Property(bool, fset=setExpanded, fget=expanded) def setWidget(self, w): raise NotImplementedError( "Please use the 'setExpandedWidget'/'setCollapsedWidget' " "methods to set the contents of the dock widget." ) def setExpandedWidget(self, widget): # type: (QWidget) -> None """ Set the widget with contents to show while expanded. """ if widget is self.__expandedWidget: return if self.__expandedWidget is not None: self.__stack.removeWidget(self.__expandedWidget) self.__stack.insertWidget(0, widget) self.__expandedWidget = widget if self.__expanded: self.__stack.setCurrentWidget(widget) self.updateGeometry() def expandedWidget(self): # type: () -> Optional[QWidget] """ Return the widget previously set with ``setExpandedWidget``, or ``None`` if no widget has been set. """ return self.__expandedWidget def setCollapsedWidget(self, widget): # type: (QWidget) -> None """ Set the widget with contents to show while collapsed. """ if widget is self.__collapsedWidget: return if self.__collapsedWidget is not None: self.__stack.removeWidget(self.__collapsedWidget) self.__stack.insertWidget(1, widget) self.__collapsedWidget = widget if not self.__expanded: self.__stack.setCurrentWidget(widget) self.updateGeometry() def collapsedWidget(self): # type: () -> Optional[QWidget] """ Return the widget previously set with ``setCollapsedWidget``, or ``None`` if no widget has been set. """ return self.__collapsedWidget def setAnimationEnabled(self, animationEnabled): self.__stack.setAnimationEnabled(animationEnabled) def animationEnabled(self): return self.__stack.animationEnabled() def currentWidget(self): # type: () -> Optional[QWidget] """ Return the current shown widget depending on the `expanded` state """ if self.__expanded: return self.__expandedWidget else: return self.__collapsedWidget def expand(self): # type: () -> None """ Expand the dock (same as ``setExpanded(True)``) """ self.setExpanded(True) def collapse(self): # type: () -> None """ Collapse the dock (same as ``setExpanded(False)``) """ self.setExpanded(False) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool """Reimplemented.""" if obj is self.__closeButton: etype = event.type() if etype == QEvent.MouseButtonPress: self.setExpanded(not self.__expanded) return True elif etype == QEvent.MouseButtonDblClick or \ etype == QEvent.MouseButtonRelease: return True # TODO: which other events can trigger the button (is the button # focusable). return super().eventFilter(obj, event) def event(self, event): # type: (QEvent) -> bool """Reimplemented.""" if event.type() == QEvent.LayoutRequest: self.__fixMinimumWidth() return super().event(event) def __onDockLocationChanged(self, area): # type: (Qt.DockWidgetArea) -> None if area == Qt.LeftDockWidgetArea: self.setLayoutDirection(Qt.LeftToRight) else: self.setLayoutDirection(Qt.RightToLeft) self.__stack.setLayoutDirection(self.parentWidget().layoutDirection()) self.__fixIcon() def __fixMinimumWidth(self): # type: () -> None # A workaround for forcing the QDockWidget layout to disregard the # default minimumSize which can be to wide for us (overriding the # minimumSizeHint or setting the minimum size directly does not # seem to have an effect (Qt 4.8.3). size = self.__stack.sizeHint() if size.isValid() and not size.isEmpty(): margins = self.contentsMargins() width = size.width() + margins.left() + margins.right() if width < self.minimumSizeHint().width(): if not self.__hasFixedWidth(): self.__trueMinimumWidth = self.minimumSizeHint().width() self.setFixedWidth(width) else: if self.__hasFixedWidth(): if width >= self.__trueMinimumWidth: self.__trueMinimumWidth = -1 self.setFixedWidth(QWIDGETSIZE_MAX) self.updateGeometry() else: self.setFixedWidth(width) def __hasFixedWidth(self): # type: () -> bool return self.__trueMinimumWidth >= 0 def __fixIcon(self): # type: () -> None """Fix the dock close icon. """ direction = self.layoutDirection() if direction == Qt.LeftToRight: if self.__expanded: icon = self.__iconLeft else: icon = self.__iconRight else: if self.__expanded: icon = self.__iconRight else: icon = self.__iconLeft self.__closeButton.setIcon(icon) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/dropshadow.py0000644000175100002000000002560214730024325022710 0ustar00runnerdocker""" ================= Drop Shadow Frame ================= A widget providing a drop shadow (gaussian blur effect) around another widget. """ from typing import Optional, Any, Union, List from AnyQt.QtWidgets import ( QWidget, QGraphicsScene, QGraphicsRectItem, QGraphicsDropShadowEffect, QStyleOption, QAbstractScrollArea, QToolBar ) from AnyQt.QtGui import ( QPainter, QPixmap, QColor, QPen, QPalette, QRegion, QPaintEvent ) from AnyQt.QtCore import ( Qt, QPoint, QPointF, QRect, QRectF, QSize, QSizeF, QEvent, QObject ) from AnyQt.QtCore import pyqtProperty as Property def render_drop_shadow_frame(pixmap, shadow_rect, shadow_color, offset, radius, rect_fill_color): # type: (QPixmap, QRectF, QColor, QPointF, float, QColor) -> QPixmap pixmap.fill(Qt.transparent) scene = QGraphicsScene() rect = QGraphicsRectItem(shadow_rect) rect.setBrush(QColor(rect_fill_color)) rect.setPen(QPen(Qt.NoPen)) scene.addItem(rect) effect = QGraphicsDropShadowEffect(color=shadow_color, blurRadius=radius, offset=offset) rect.setGraphicsEffect(effect) scene.setSceneRect(QRectF(QPointF(0, 0), QSizeF(pixmap.size()))) painter = QPainter(pixmap) scene.render(painter) painter.end() scene.clear() scene.deleteLater() return pixmap class DropShadowFrame(QWidget): """ A widget drawing a drop shadow effect around the geometry of another widget (works similar to :class:`QFocusFrame`). Parameters ---------- parent : :class:`QObject` Parent object. color : :class:`QColor` The color of the drop shadow. radius : float Shadow radius. """ def __init__(self, parent=None, color=QColor(), radius=5, **kwargs): # type: (Optional[QWidget], QColor, int, Any) -> None super().__init__(parent, **kwargs) self.setAttribute(Qt.WA_TransparentForMouseEvents, True) self.setAttribute(Qt.WA_NoChildEventsForParent, True) self.setFocusPolicy(Qt.NoFocus) self.__color = QColor(color) self.__radius = radius self.__offset = QPoint(0, 0) self.__widget = None # type: Optional[QWidget] self.__widgetParent = None # type: Optional[QWidget] self.__cachedShadowPixmap = None # type: Optional[QPixmap] def setColor(self, color): # type: (Union[QColor, Qt.GlobalColor]) -> None """ Set the color of the shadow. """ if not isinstance(color, QColor): color = QColor(color) if self.__color != color: self.__color = QColor(color) self.__updatePixmap() def color(self): # type: () -> QColor """ Return the color of the drop shadow. By default this is a color from the `palette` (for `self.foregroundRole()`) """ if self.__color.isValid(): return QColor(self.__color) else: return self.palette().color(self.foregroundRole()) color_ = Property(QColor, fget=color, fset=setColor, designable=True, doc="Drop shadow color") def setRadius(self, radius): # type: (int) -> None """ Set the drop shadow's blur radius. """ if self.__radius != radius: self.__radius = radius self.__updateGeometry() self.__updatePixmap() def radius(self): # type: () -> int """ Return the shadow blur radius. """ return self.__radius radius_ = Property(int, fget=radius, fset=setRadius, designable=True, doc="Drop shadow blur radius.") def setOffset(self, offset): # type: (QPoint) -> None if self.__offset != QPoint(offset): self.__offset = QPoint(offset) self.__updateGeometry() self.__updatePixmap() def offset(self): # type: () -> QPoint return QPoint(self.__offset) offset_ = Property(QPoint, fget=offset, fset=setOffset, designable=True, doc="Drop shadow offset.") def setWidget(self, widget): # type: (Optional[QWidget]) -> None """ Set the widget around which to show the shadow. """ if self.__widget: self.__widget.removeEventFilter(self) self.__widget = widget if widget is not None: widget.installEventFilter(self) # Find the parent for the frame # This is the top level window a toolbar or a viewport # of a scroll area parent = widget.parentWidget() while not (isinstance(parent, (QAbstractScrollArea, QToolBar)) or \ parent.isWindow()): parent = parent.parentWidget() if isinstance(parent, QAbstractScrollArea): parent = parent.viewport() self.__widgetParent = parent self.setParent(parent) self.stackUnder(widget) self.__updateGeometry() self.setVisible(widget.isVisible()) def widget(self): # type: () -> Optional[QWidget] """ Return the widget that was set by `setWidget`. """ return self.__widget def paintEvent(self, event): # type: (QPaintEvent) -> None # TODO: Use QPainter.drawPixmapFragments on Qt 4.7 if self.__widget is None: return opt = QStyleOption() opt.initFrom(self) radius = self.__radius offset = self.__offset pixmap = self.__shadowPixmap() pixr = pixmap.devicePixelRatio() assert pixr == self.devicePixelRatioF() shadow_rect = QRectF(opt.rect) widget_rect = QRectF(self.__widget.geometry()) widget_rect.moveTo(radius - offset.x(), radius - offset.y()) left = top = right = bottom = radius * pixr pixmap_rect = QRectF(QPointF(0, 0), QSizeF(pixmap.size())) # Shadow casting rectangle in the source pixmap. pixmap_shadow_rect = pixmap_rect.adjusted(left, top, -right, -bottom) pixmap_shadow_rect.translate(-offset.x() * pixr, -offset.y() * pixr) source_rects = self.__shadowPixmapFragments(pixmap_rect, pixmap_shadow_rect) target_rects = self.__shadowPixmapFragments(shadow_rect, widget_rect) painter = QPainter(self) for source, target in zip(source_rects, target_rects): painter.drawPixmap(target, pixmap, source) painter.end() def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool etype = event.type() if etype == QEvent.Move or etype == QEvent.Resize: self.__updateGeometry() elif etype == QEvent.Show: self.__updateGeometry() self.show() elif etype == QEvent.Hide: self.hide() return super().eventFilter(obj, event) def __updateGeometry(self): # type: () -> None """ Update the shadow geometry to fit the widget's changed geometry. """ assert self.__widget is not None widget = self.__widget parent = self.__widgetParent radius = self.radius_ offset = self.__offset pos = widget.pos() if parent is not None and parent != widget.parentWidget(): pos = widget.parentWidget().mapTo(parent, pos) geom = QRect(pos, widget.size()) geom = geom.adjusted(-radius, -radius, radius, radius) geom = geom.translated(offset) if geom != self.geometry(): self.setGeometry(geom) # Set the widget mask (punch a hole through to the `widget` instance. rect = self.rect() mask = QRegion(rect) rect = rect.adjusted(radius, radius, -radius, -radius) rect = rect.translated(-offset) transparent = QRegion(rect) mask = mask.subtracted(transparent) self.setMask(mask) def __updatePixmap(self): # type: () -> None """Invalidate the cached shadow pixmap.""" self.__cachedShadowPixmap = None def __shadowPixmapForDpr(self, dpr=1.0): # type: (float) -> QPixmap """ Return a shadow pixmap rendered in `dpr` device pixel ratio. """ offset = self.offset() radius = self.radius() color = self.color() fill_color = self.palette().color(QPalette.Window) rect_size = QSize(int(50 * dpr), int(50 * dpr)) left = top = right = bottom = int(radius * dpr) # Size of the pixmap. pixmap_size = QSize(rect_size.width() + left + right, rect_size.height() + top + bottom) shadow_rect = QRect(QPoint(left, top) - offset * dpr, rect_size) pixmap = QPixmap(pixmap_size) pixmap.fill(Qt.transparent) pixmap = render_drop_shadow_frame( pixmap, QRectF(shadow_rect), shadow_color=color, offset=QPointF(offset * dpr), radius=radius * dpr, rect_fill_color=fill_color ) pixmap.setDevicePixelRatio(dpr) return pixmap def __shadowPixmap(self): # type: () -> QPixmap if self.__cachedShadowPixmap is None \ or self.__cachedShadowPixmap.devicePixelRatioF() \ != self.devicePixelRatioF(): self.__cachedShadowPixmap = self.__shadowPixmapForDpr( self.devicePixelRatioF()) return QPixmap(self.__cachedShadowPixmap) def __shadowPixmapFragments(self, pixmap_rect, shadow_rect): # type: (QRect, QRect) -> List[QRectF] """ Return a list of 8 QRectF fragments for drawing a shadow. """ s_left, s_top, s_right, s_bottom = \ shadow_rect.left(), shadow_rect.top(), \ shadow_rect.right(), shadow_rect.bottom() s_width, s_height = shadow_rect.width(), shadow_rect.height() p_width, p_height = pixmap_rect.width(), pixmap_rect.height() top_left = QRectF(0.0, 0.0, s_left, s_top) top = QRectF(s_left, 0.0, s_width, s_top) top_right = QRectF(s_right, 0.0, p_width - s_width, s_top) right = QRectF(s_right, s_top, p_width - s_right, s_height) right_bottom = QRectF(shadow_rect.bottomRight(), pixmap_rect.bottomRight()) bottom = QRectF(shadow_rect.bottomLeft(), pixmap_rect.bottomRight() - \ QPointF(p_width - s_right, 0.0)) bottom_left = QRectF(shadow_rect.bottomLeft() - QPointF(s_left, 0.0), pixmap_rect.bottomLeft() + QPointF(s_left, 0.0)) left = QRectF(pixmap_rect.topLeft() + QPointF(0.0, s_top), shadow_rect.bottomLeft()) return [top_left, top, top_right, right, right_bottom, bottom, bottom_left, left] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/framelesswindow.py0000644000175100002000000000507114730024325023745 0ustar00runnerdocker""" A frameless window widget """ from typing import Optional, Any from AnyQt.QtWidgets import QWidget, QStyleOption from AnyQt.QtGui import QPalette, QPainter, QBitmap, QPaintEvent from AnyQt.QtCore import Qt, pyqtProperty as Property from .utils import is_transparency_supported, StyledWidget_paintEvent class FramelessWindow(QWidget): """ A basic frameless window widget with rounded corners (if supported by the windowing system). """ def __init__(self, parent=None, radius=6, **kwargs): # type: (Optional[QWidget], int, Any) -> None super().__init__(parent, **kwargs) self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint) self.__radius = radius self.__isTransparencySupported = is_transparency_supported() self.setAttribute(Qt.WA_TranslucentBackground, self.__isTransparencySupported) def setRadius(self, radius): # type: (int) -> None """ Set the window rounded border radius. """ if self.__radius != radius: self.__radius = radius if not self.__isTransparencySupported: self.__updateMask() self.update() def radius(self): # type: () -> int """ Return the border radius. """ return self.__radius radius_ = Property(int, fget=radius, fset=setRadius, designable=True, doc="Window border radius") def resizeEvent(self, event): super().resizeEvent(event) if not self.__isTransparencySupported: self.__updateMask() def __updateMask(self): # type: () -> None opt = QStyleOption() opt.initFrom(self) rect = opt.rect size = rect.size() mask = QBitmap(size) p = QPainter(mask) p.setRenderHint(QPainter.Antialiasing) p.setBrush(Qt.black) p.setPen(Qt.NoPen) p.drawRoundedRect(rect, self.__radius, self.__radius) p.end() self.setMask(mask) def paintEvent(self, event): # type: (QPaintEvent) -> None if self.__isTransparencySupported: opt = QStyleOption() opt.initFrom(self) rect = opt.rect p = QPainter(self) p.setRenderHint(QPainter.Antialiasing, True) p.setBrush(opt.palette.brush(QPalette.Window)) p.setPen(Qt.NoPen) p.drawRoundedRect(rect, self.__radius, self.__radius) p.end() else: StyledWidget_paintEvent(self, event) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/iconengine.py0000644000175100002000000001356214730024325022656 0ustar00runnerdockerfrom itertools import count from contextlib import contextmanager from typing import Optional from AnyQt.QtCore import Qt, QObject, QSize, QRect, QT_VERSION_INFO from AnyQt.QtGui import ( QIconEngine, QPalette, QIcon, QPixmap, QPixmapCache, QImage, QPainter ) from AnyQt.QtWidgets import QApplication, QStyleOption from orangecanvas.gui.utils import luminance from orangecanvas.utils.image import grayscale_invert __all__ = [ "StyledIconEngine", "SymbolIconEngine", ] _cache_id_gen = count() class StyledIconEngine(QIconEngine): """ An abstract base class for icon engines that adapt to effective palette. """ __slots__ = ("__palette", "__styleObject") def __init__(self, *args, palette: Optional[QPalette] = None, styleObject: Optional[QObject] = None, **kwargs): self.__palette = QPalette(palette) if palette is not None else None self.__styleObject = styleObject super().__init__(*args, **kwargs) @staticmethod def paletteFromStyleObject(obj: QObject) -> Optional[QPalette]: palette = obj.property("palette") if isinstance(palette, QPalette): return palette else: return None __paletteOverride = None @staticmethod @contextmanager def setOverridePalette(palette: QPalette): """ Temporarily override used QApplication.palette() with this class. This can be used when the icon is drawn on a non default background and as such might not contrast with it when using the default palette, and neither paint device nor styleObject can be used for this. """ old = StyledIconEngine.__paletteOverride try: StyledIconEngine.__paletteOverride = palette yield finally: StyledIconEngine.__paletteOverride = old @staticmethod def paletteOverride() -> Optional[QPalette]: return StyledIconEngine.__paletteOverride def effectivePalette(self) -> QPalette: if StyledIconEngine.__paletteOverride is not None: return StyledIconEngine.__paletteOverride if self.__palette is not None: return self.__palette elif self.__styleObject is not None: palette = self.paletteFromStyleObject(self.__styleObject) if palette is not None: return palette return QApplication.palette() # shorthands for eliminating runtime attr load in hot path _QIcon_Active_Modes = (QIcon.Active, QIcon.Selected) _QIcon_Disabled = QIcon.Disabled _QPalette_Active = QPalette.Active _QPalette_WindowText = QPalette.WindowText _QPalette_Disabled = QPalette.Disabled _QPalette_HighlightedText = QPalette.HighlightedText class SymbolIconEngine(StyledIconEngine): """ A *Symbolic* icon engine adapter for turning simple grayscale base icon to current effective appearance. Arguments --------- base: QIcon The base icon. """ def __init__(self, base: QIcon): super().__init__() self.__base = QIcon(base) self.__cache_key = next(_cache_id_gen) def paint( self, painter: QPainter, rect: QRect, mode: QIcon.Mode, state: QIcon.State ) -> None: if not self.__base.isNull(): palette = self.effectivePalette() size = rect.size() dpr = painter.device().devicePixelRatioF() size = size * dpr pm = self.__renderStyledPixmap(size, mode, state, palette) painter.drawPixmap(rect, pm) def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap: return self.__renderStyledPixmap(size, mode, state, self.effectivePalette()) def __renderStyledPixmap( self, size: QSize, mode: QIcon.Mode, state: QIcon.State, palette: QPalette ) -> QPixmap: active = mode in _QIcon_Active_Modes disabled = mode == _QIcon_Disabled cg = _QPalette_Disabled if disabled else _QPalette_Active role = _QPalette_WindowText if active else _QPalette_HighlightedText namespace = f"{__name__}:SymbolIconEngine/{self.__cache_key}" cachekey = f"{size.width()}x{size.height()}" style_key = f"{hex(palette.cacheKey())}-{cg}-{role}" pmcachekey = f"{namespace}/{cachekey}/{style_key}" pm = QPixmapCache.find(pmcachekey) if pm is None or pm.isNull(): color = palette.color(QPalette.Text) src = qicon_pixmap(self.__base, size, 1.0, mode, state) src = src.toImage().convertToFormat(QImage.Format_ARGB32_Premultiplied) if luminance(color) > 0.5: dest = grayscale_invert( src, palette.color(QPalette.Text), palette.color(QPalette.Base), ) else: dest = src pm = QPixmap.fromImage(dest) QPixmapCache.insert(pmcachekey, pm) self.__style = style = QApplication.style() if style is not None: opt = QStyleOption() opt.palette = palette pm = style.generatedIconPixmap(mode, pm, opt) return pm def clone(self) -> 'QIconEngine': return SymbolIconEngine(self.__base) def qicon_pixmap( base: QIcon, size: QSize, scale: float, mode: QIcon.Mode, state: QIcon.State ) -> QPixmap: """ Like QIcon.pixmap(size: QSize, scale: float, ...) overload in Qt6. On Qt 6 this directly calls the corresponding overload. On Qt 5 this is emulated by painting on a suitable constructed pixmap. """ size = base.actualSize(size * scale, mode, state) pm = QPixmap(size) pm.setDevicePixelRatio(scale) pm.fill(Qt.transparent) p = QPainter(pm) base.paint(p, 0, 0, size.width(), size.height(), Qt.AlignCenter, mode, state) p.end() return pm if QT_VERSION_INFO >= (6, 0): qicon_pixmap = QIcon.pixmap ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/iconview.py0000644000175100002000000000654214730024325022363 0ustar00runnerdockerfrom typing import Any, Optional, Iterable from AnyQt.QtWidgets import ( QListView, QSizePolicy, QStyle, QStyleOptionViewItem, QWidget ) from AnyQt.QtCore import Qt, QSize, QModelIndex class LinearIconView(QListView): """ An list view (in QListView.IconMode) with no item wrapping. Suitable for displaying large(ish) icons with text in a single row/column. """ def __init__(self, parent=None, iconSize=QSize(120, 80), **kwargs): # type: (Optional[QWidget], QSize, Any)-> None super().__init__(parent, **kwargs) self.setViewMode(QListView.IconMode) self.setWrapping(False) self.setWordWrap(True) self.setSelectionMode(QListView.SingleSelection) self.setEditTriggers(QListView.NoEditTriggers) self.setMovement(QListView.Static) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.setIconSize(iconSize) def sizeHint(self): # type: () -> QSize """ Reimplemented. Provide sensible size hint based on the view's contents. """ flow = self.flow() if self.model() is None or not self.model().rowCount(): style = self.style() opt = self.viewOptions() opt.features = QStyleOptionViewItem.ViewItemFeature( opt.features | QStyleOptionViewItem.HasDecoration | QStyleOptionViewItem.HasDisplay | QStyleOptionViewItem.WrapText ) opt.text = "X" * 12 + "\nX" sh = style.sizeFromContents( QStyle.CT_ItemViewItem, opt, QSize(), self) else: # Sample the first 20 items for a size hint. The objective is to # get a representative height due to the word wrapping model = self.model() samplesize = min(20, model.rowCount()) shs = [self.sizeHintForIndex(model.index(i, 0)) for i in range(samplesize)] if flow == QListView.TopToBottom: sh = QSize(max(s.width() for s in shs), 200) else: sh = QSize(200, max(s.height() for s in shs)) margins = self.contentsMargins() if flow == QListView.TopToBottom: sh = sh + QSize(margins.left() + margins.right(), 0) else: sh = sh + QSize(0, margins.top() + margins.bottom()) if flow == QListView.TopToBottom and \ self.verticalScrollBarPolicy() != Qt.ScrollBarAlwaysOff: ssh = self.verticalScrollBar().sizeHint() return QSize(sh.width() + ssh.width(), sh.height()) elif self.flow() == QListView.LeftToRight and \ self.horizontalScrollBarPolicy() != Qt.ScrollBarAlwaysOff: ssh = self.horizontalScrollBar().sizeHint() return QSize(sh.width(), sh.height() + ssh.height()) else: return sh def updateGeometries(self): # type: () -> None """Reimplemented""" super().updateGeometries() self.updateGeometry() def dataChanged(self, topLeft, bottomRight, roles=()): # type: (QModelIndex, QModelIndex, Iterable[int]) -> None """Reimplemented""" super().dataChanged(topLeft, bottomRight, roles) self.updateGeometry() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/itemmodels.py0000644000175100002000000000304614730024325022676 0ustar00runnerdockerfrom typing import Callable, Any, Sequence, NamedTuple, Optional, List from AnyQt.QtCore import Qt, QSortFilterProxyModel, QModelIndex, QObject class FilterProxyModel(QSortFilterProxyModel): """ A simple filter proxy model with settable filter predicates. Example ------- >>> proxy = FilterProxyModel() >>> proxy.setFilters([ ... FilterProxyModel.Filter(0, Qt.DisplayRole, lambda value: value < 1) ... ]) """ Filter = NamedTuple("Filter", [ ("column", int), ("role", Qt.ItemDataRole), ("predicate", Callable[[Any], bool]) ]) def __init__(self, parent=None, **kwargs): # type: (Optional[QObject], Any) -> None super().__init__(parent, **kwargs) self.__filters = [] # type: List[FilterProxyModel.Filter] def setFilters(self, filters): # type: (Sequence[FilterProxyModel.Filter]) -> None filters = [FilterProxyModel.Filter(f.column, f.role, f.predicate) for f in filters] self.__filters = filters self.invalidateFilter() def filterAcceptsRow(self, row, parent): # type: (int, QModelIndex) -> bool source = self.sourceModel() assert source is not None def apply(f: FilterProxyModel.Filter): index = source.index(row, f.column, parent) data = source.data(index, f.role) try: return f.predicate(data) except (TypeError, ValueError): return False return all(apply(f) for f in self.__filters) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/lineedit.py0000644000175100002000000001660514730024325022336 0ustar00runnerdocker""" A LineEdit class with a button on left/right side. """ from collections import namedtuple from typing import Any, Optional, List, NamedTuple from AnyQt.QtWidgets import ( QLineEdit, QToolButton, QStyleOptionToolButton, QStylePainter, QStyle, QAction, QWidget, ) from AnyQt.QtGui import QPaintEvent, QPainter, QColor from AnyQt.QtCore import Qt, QSize, QRect from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from orangecanvas.gui.utils import innerShadowPixmap _ActionSlot = NamedTuple( "_ActionSlot", [ ("position", 'int'), # Left/Right position ("action", 'QAction'), # QAction ("button", 'LineEditButton'), # LineEditButton instance ("autoHide", 'Any'), # Auto hide when line edit is empty (unused??) ] ) class LineEditButton(QToolButton): """ A button in the :class:`LineEdit`. """ def __init__(self, parent=None, flat=True, **kwargs): # type: (Optional[QWidget], bool, Any) -> None super().__init__(parent, **kwargs) self.__flat = flat self.__shadowLength = 5 self.__shadowPosition = 0 self.__shadowColor = QColor("#000000") def setFlat(self, flat): # type: (bool) -> None if self.__flat != flat: self.__flat = flat self.update() def flat(self): # type: () -> bool return self.__flat flat_ = Property(bool, fget=flat, fset=setFlat, designable=True) def setShadowLength(self, shadowSize): if self.__shadowLength != shadowSize: self.__shadowLength = shadowSize self.update() def shadowLength(self): return self.__shadowLength shadowLength_ = Property(int, fget=shadowLength, fset=setShadowLength, designable=True) def setShadowPosition(self, shadowPosition): if self.__shadowPosition != shadowPosition: self.__shadowPosition = shadowPosition self.update() def shadowPosition(self): return self.__shadowPosition shadowPosition_ = Property(int, fget=shadowPosition, fset=setShadowPosition, designable=True) def setShadowColor(self, shadowColor): if self.__shadowColor != shadowColor: self.__shadowColor = shadowColor self.update() def shadowColor(self): return self.__shadowColor shadowColor_ = Property(QColor, fget=shadowColor, fset=setShadowColor, designable=True) def paintEvent(self, event): # type: (QPaintEvent) -> None if self.__flat: opt = QStyleOptionToolButton() self.initStyleOption(opt) p = QStylePainter(self) p.drawControl(QStyle.CE_ToolButtonLabel, opt) p.end() else: super().paintEvent(event) # paint shadow shadow = innerShadowPixmap(self.__shadowColor, self.size(), self.__shadowPosition, length=self.__shadowLength) p = QPainter(self) rect = self.rect() targetRect = QRect(rect.left() + 1, rect.top() + 1, rect.width() - 2, rect.height() - 2) p.drawPixmap(targetRect, shadow, shadow.rect()) p.end() class LineEdit(QLineEdit): """ A line edit widget with support for adding actions (buttons) to the left/right of the edited text """ #: Position flags LeftPosition, RightPosition = 1, 2 #: Emitted when the action is triggered. triggered = Signal(QAction) #: The left action was triggered. leftTriggered = Signal() #: The right action was triggered. rightTriggered = Signal() def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) self.__actions = [None, None] # type: List[Optional[_ActionSlot]] def setAction(self, action, position=LeftPosition): # type: (QAction, int) -> None """ Set `action` to be displayed at `position`. Existing action (if present) will be removed. Parameters ---------- action : :class:`QAction` position : int Position where to set the action (default: ``LeftPosition``). """ curr = self.actionAt(position) if curr is not None: self.removeActionAt(position) # Add the action using QWidget.addAction (for shortcuts) self.addAction(action) button = LineEditButton(self) button.setToolButtonStyle(Qt.ToolButtonIconOnly) button.setDefaultAction(action) button.setVisible(self.isVisible()) button.show() button.setCursor(Qt.ArrowCursor) button.triggered.connect(self.triggered) button.triggered.connect(self.__onTriggered) slot = _ActionSlot(position, action, button, False) self.__actions[position - 1] = slot if not self.testAttribute(Qt.WA_Resized): # Need some sensible height to do the layout. self.adjustSize() self.__layoutActions() def actionAt(self, position): # type: (int) -> Optional[QAction] """ Return :class:`QAction` at `position`. """ self._checkPosition(position) slot = self.__actions[position - 1] if slot: return slot.action else: return None def removeActionAt(self, position): # type: (int) -> None """ Remove the action at position. """ self._checkPosition(position) slot = self.__actions[position - 1] self.__actions[position - 1] = None if slot is not None: slot.button.hide() slot.button.deleteLater() self.removeAction(slot.action) self.__layoutActions() def button(self, position): # type: (int) -> Optional[LineEditButton] """ Return the button (:class:`LineEditButton`) for the action at `position`. """ self._checkPosition(position) slot = self.__actions[position - 1] if slot is not None: return slot.button else: return None def _checkPosition(self, position): # type: (int) -> None if position not in [self.LeftPosition, self.RightPosition]: raise ValueError("Invalid position") def resizeEvent(self, event): super().resizeEvent(event) self.__layoutActions() def __layoutActions(self): # type: () -> None left, right = self.__actions contents = self.contentsRect() buttonSize = QSize(contents.height(), contents.height()) margins = self.textMargins() if left: geom = QRect(contents.topLeft(), buttonSize) left.button.setGeometry(geom) margins.setLeft(buttonSize.width()) if right: geom = QRect(contents.topRight(), buttonSize) right.button.setGeometry(geom.translated(-buttonSize.width(), 0)) margins.setLeft(buttonSize.width()) self.setTextMargins(margins) def __onTriggered(self, action): # type: (QAction) -> None left, right = self.__actions if left and action == left.action: self.leftTriggered.emit() elif right and action == right.action: self.rightTriggered.emit() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/quickhelp.py0000644000175100002000000001111314730024325022513 0ustar00runnerdockerfrom typing import Any from AnyQt.QtWidgets import QTextBrowser from AnyQt.QtGui import QStatusTipEvent, QWhatsThisClickedEvent from AnyQt.QtCore import QObject, QCoreApplication, QEvent, QTimer, QUrl from AnyQt.QtCore import pyqtSignal as Signal class QuickHelp(QTextBrowser): #: Emitted when the shown text changes. textChanged = Signal() def __init__(self, *args, **kwargs): # type: (Any, Any) -> None super().__init__(*args, **kwargs) self.setOpenExternalLinks(False) self.setOpenLinks(False) self.__text = "" self.__permanentText = "" self.__defaultText = "" self.__timer = QTimer(self, timeout=self.__on_timeout, singleShot=True) self.anchorClicked.connect(self.__on_anchorClicked) def showHelp(self, text, timeout=0): # type: (str, int) -> None """ Show help for `timeout` milliseconds. if timeout is 0 then show the text until it is cleared with clearHelp or showHelp is called with an empty string. """ if self.__text != text: self.__text = text self.__update() self.textChanged.emit() if timeout > 0: self.__timer.start(timeout) def clearHelp(self): # type: () -> None """ Clear help text previously set with `showHelp`. """ self.__timer.stop() self.showHelp("") def showPermanentHelp(self, text): # type: (str) -> None """ Set permanent help text. The text may be temporarily overridden by showHelp but will be shown again when that is cleared. """ if self.__permanentText != text: self.__permanentText = text self.__update() self.textChanged.emit() def setDefaultText(self, text): # type: (str) -> None """ Set default help text. The text is overriden by normal and permanent help messages, but is show again after such messages are cleared. """ if self.__defaultText != text: self.__defaultText = text self.__update() self.textChanged.emit() def currentText(self): # type: () -> str """ Return the current shown text. """ return self.__text or self.__permanentText def __update(self): # type: () -> None if self.__text: self.setHtml(self.__text) elif self.__permanentText: self.setHtml(self.__permanentText) else: self.setHtml(self.__defaultText) def __on_timeout(self): # type: () -> None if self.__text: self.__text = "" self.__update() self.textChanged.emit() def __on_anchorClicked(self, anchor): # type: (QUrl) -> None ev = QuickHelpDetailRequestEvent(anchor.toString(), anchor) QCoreApplication.postEvent(self, ev) class QuickHelpTipEvent(QStatusTipEvent): Temporary, Normal, Permanent = range(1, 4) def __init__(self, tip, html="", priority=Normal, timeout=0): # type: (str, str, int, int) -> None super().__init__(tip) self.__html = html or "" self.__priority = priority self.__timeout = timeout def html(self): # type: () -> str return self.__html def priority(self): # type: () -> int return self.__priority def timeout(self): # type: () -> int return self.__timeout class QuickHelpDetailRequestEvent(QWhatsThisClickedEvent): def __init__(self, href, url): # type: (str, QUrl) -> None super().__init__(href) self.__url = QUrl(url) def url(self): # type: () -> QUrl return QUrl(self.__url) class StatusTipPromoter(QObject): """ Promotes `QStatusTipEvent` to `QuickHelpTipEvent` using ``whatsThis`` property of the object. """ def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.StatusTip and \ not isinstance(event, QuickHelpTipEvent) and \ hasattr(obj, "whatsThis") and \ callable(obj.whatsThis): assert isinstance(event, QStatusTipEvent) tip = event.tip() try: text = obj.whatsThis() except Exception: text = None if text: ev = QuickHelpTipEvent(tip, text if tip else "") return QCoreApplication.sendEvent(obj, ev) return super().eventFilter(obj, event) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/splashscreen.py0000644000175100002000000001156314730024325023231 0ustar00runnerdocker""" A splash screen widget with support for positioning of the message text. """ from typing import Union from AnyQt.QtWidgets import QSplashScreen, QWidget from AnyQt.QtGui import ( QPixmap, QPainter, QTextDocument, QTextBlockFormat, QTextCursor, QColor ) from AnyQt.QtCore import Qt, QRect, QEvent from .utils import is_transparency_supported if hasattr(Qt, "mightBeRichText"): mightBeRichText = Qt.mightBeRichText else: def mightBeRichText(text): return False class SplashScreen(QSplashScreen): """ Splash screen widget. Parameters ---------- parent : :class:`QWidget` Parent widget pixmap : :class:`QPixmap` Splash window pixmap. textRect : :class:`QRect` Bounding rectangle of the shown message on the widget. textFormat : Qt.TextFormat How message text format should be interpreted. """ def __init__(self, parent=None, pixmap=None, textRect=None, textFormat=Qt.PlainText, **kwargs): super().__init__(parent, **kwargs) self.__textRect = textRect or QRect() self.__message = "" self.__color = Qt.black self.__alignment = Qt.AlignLeft self.__textFormat = textFormat self.__pixmap = QPixmap() if pixmap is None: pixmap = QPixmap() self.setPixmap(pixmap) self.setAutoFillBackground(False) # Also set FramelessWindowHint (if not already set) self.setWindowFlags(self.windowFlags() | Qt.FramelessWindowHint) def setTextRect(self, rect): # type: (QRect) -> None """ Set the rectangle (:class:`QRect`) in which to show the message text. """ if self.__textRect != rect: self.__textRect = QRect(rect) self.update() def textRect(self): # type: () -> QRect """ Return the text message rectangle. """ return QRect(self.__textRect) def textFormat(self): # type: () -> Qt.TextFormat return self.__textFormat def setTextFormat(self, format): # type: (Qt.TextFormat) -> None if format != self.__textFormat: self.__textFormat = format self.update() def showEvent(self, event): super().showEvent(event) # Raise to top on show. self.raise_() def drawContents(self, painter): # type: (QPainter) -> None """ Reimplementation of drawContents to limit the drawing inside `textRect`. """ painter.setPen(self.__color) painter.setFont(self.font()) if self.__textRect.isValid(): rect = self.__textRect else: rect = self.rect().adjusted(5, 5, -5, -5) tformat = self.__textFormat if tformat == Qt.AutoText: if mightBeRichText(self.__message): tformat = Qt.RichText else: tformat = Qt.PlainText if tformat == Qt.RichText: doc = QTextDocument() doc.setHtml(self.__message) doc.setTextWidth(rect.width()) cursor = QTextCursor(doc) cursor.select(QTextCursor.Document) fmt = QTextBlockFormat() fmt.setAlignment(self.__alignment) cursor.mergeBlockFormat(fmt) painter.save() painter.translate(rect.topLeft()) doc.drawContents(painter) painter.restore() else: painter.drawText(rect, self.__alignment, self.__message) def showMessage(self, message, alignment=Qt.AlignLeft, color=Qt.black): # type: (str, int, Union[QColor, Qt.GlobalColor]) -> None """ Show the `message` with `color` and `alignment`. """ # Need to store all this arguments for drawContents (no access # methods) self.__alignment = alignment self.__color = QColor(color) self.__message = message super().showMessage(message, alignment, color) # Reimplemented to allow graceful fall back if the windowing system # does not support transparency. def setPixmap(self, pixmap): # type: (QPixmap) -> None self.setAttribute(Qt.WA_TranslucentBackground, pixmap.hasAlpha() and is_transparency_supported()) self.__pixmap = QPixmap(pixmap) super().setPixmap(pixmap) if pixmap.hasAlpha() and not is_transparency_supported(): self.setMask(pixmap.createHeuristicMask()) def event(self, event): # type: (QEvent) -> bool if event.type() == QEvent.Paint: pixmap = self.__pixmap painter = QPainter(self) painter.setRenderHints(QPainter.SmoothPixmapTransform) if not pixmap.isNull(): painter.drawPixmap(0, 0, pixmap) self.drawContents(painter) return True return super().event(event) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/stackedwidget.py0000644000175100002000000002714614730024325023365 0ustar00runnerdocker""" ===================== AnimatedStackedWidget ===================== A widget similar to :class:`QStackedWidget` supporting animated transitions between widgets. """ from typing import Any, Union import logging from AnyQt.QtWidgets import ( QWidget, QFrame, QStackedLayout, QSizePolicy, QLayout ) from AnyQt.QtGui import QPixmap, QPainter from AnyQt.QtCore import Qt, QPoint, QRect, QSize, QPropertyAnimation from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from .utils import updates_disabled log = logging.getLogger(__name__) def clipMinMax(size, minSize, maxSize): # type: (QSize, QSize, QSize) -> QSize """ Clip the size so it is bigger then minSize but smaller than maxSize. """ return size.expandedTo(minSize).boundedTo(maxSize) def fixSizePolicy(size, hint, policy): # type: (QSize, QSize, QSizePolicy) -> QSize """ Fix size so it conforms to the size policy and the given size hint. """ width, height = hint.width(), hint.height() expanding = policy.expandingDirections() hpolicy, vpolicy = policy.horizontalPolicy(), policy.verticalPolicy() if expanding & Qt.Horizontal: width = max(width, size.width()) if hpolicy == QSizePolicy.Maximum: width = min(width, size.width()) if expanding & Qt.Vertical: height = max(height, size.height()) if vpolicy == QSizePolicy.Maximum: height = min(height, hint.height()) return QSize(width, height).boundedTo(size) class StackLayout(QStackedLayout): """ A stacked layout with ``sizeHint`` always the same as that of the `current` widget. """ def __init__(self, parent=None, **kwargs): # type: (Union[QWidget, QLayout, None], Any) -> None self.__rect = QRect() if parent is not None: super().__init__(parent, **kwargs) else: super().__init__(**kwargs) self.currentChanged.connect(self._onCurrentChanged) def sizeHint(self): # type: () -> QSize current = self.currentWidget() if current: hint = current.sizeHint() # Clip the hint with min/max sizes. hint = clipMinMax(hint, current.minimumSize(), current.maximumSize()) return hint else: return super().sizeHint() def minimumSize(self): # type: () -> QSize current = self.currentWidget() if current: return current.minimumSize() else: return super().minimumSize() def maximumSize(self): # type: () -> QSize current = self.currentWidget() if current: return current.maximumSize() else: return super().maximumSize() def hasHeightForWidth(self) -> bool: current = self.currentWidget() if current is not None: return current.hasHeightForWidth() else: return False def heightForWidth(self, width: int) -> int: current = self.currentWidget() if current is not None: return current.heightForWidth(width) else: return -1 def geometry(self): # type: () -> QRect # Reimplemented due to QTBUG-47107. return QRect(self.__rect) def setGeometry(self, rect): # type: (QRect) -> None if rect == self.__rect: return self.__rect = QRect(rect) super().setGeometry(rect) for i in range(self.count()): w = self.widget(i) hint = w.sizeHint() geom = QRect(rect) size = clipMinMax(rect.size(), w.minimumSize(), w.maximumSize()) size = fixSizePolicy(size, hint, w.sizePolicy()) geom.setSize(size) if geom != w.geometry(): w.setGeometry(geom) def addWidget(self, w): QStackedLayout.addWidget(self, w) rect = self.__rect hint = w.sizeHint() geom = QRect(rect) size = clipMinMax(rect.size(), w.minimumSize(), w.maximumSize()) size = fixSizePolicy(size, hint, w.sizePolicy()) geom.setSize(size) if geom != w.geometry(): w.setGeometry(geom) def _onCurrentChanged(self, index): """ Current widget changed, invalidate the layout. """ self.invalidate() class AnimatedStackedWidget(QFrame): # Current widget has changed currentChanged = Signal(int) # Transition animation has started transitionStarted = Signal() # Transition animation has finished transitionFinished = Signal() def __init__(self, parent=None, animationEnabled=True): super().__init__(parent) self.__animationEnabled = animationEnabled layout = StackLayout() self.__fadeWidget = CrossFadePixmapWidget(self) self.transitionAnimation = \ QPropertyAnimation(self.__fadeWidget, b"blendingFactor_", self) self.transitionAnimation.setStartValue(0.0) self.transitionAnimation.setEndValue(1.0) self.transitionAnimation.setDuration(100 if animationEnabled else 0) self.transitionAnimation.finished.connect( self.__onTransitionFinished ) layout.addWidget(self.__fadeWidget) layout.currentChanged.connect(self.__onLayoutCurrentChanged) self.setLayout(layout) self.__widgets = [] self.__currentIndex = -1 self.__nextCurrentIndex = -1 def setAnimationEnabled(self, animationEnabled): """ Enable/disable transition animations. """ if self.__animationEnabled != animationEnabled: self.__animationEnabled = animationEnabled self.transitionAnimation.setDuration( 100 if animationEnabled else 0 ) def animationEnabled(self): """ Is the transition animation enabled. """ return self.__animationEnabled def addWidget(self, widget): """ Append the widget to the stack and return its index. """ return self.insertWidget(self.layout().count(), widget) def insertWidget(self, index, widget): """ Insert `widget` into the stack at `index`. """ index = min(index, self.count()) self.__widgets.insert(index, widget) if index <= self.__currentIndex or self.__currentIndex == -1: self.__currentIndex += 1 return self.layout().insertWidget(index, widget) def removeWidget(self, widget): """ Remove `widget` from the stack. .. note:: The widget is hidden but is not deleted. """ index = self.__widgets.index(widget) self.layout().removeWidget(widget) self.__widgets.pop(index) def widget(self, index): """ Return the widget at `index` """ return self.__widgets[index] def indexOf(self, widget): """ Return the index of `widget` in the stack. """ return self.__widgets.index(widget) def count(self): """ Return the number of widgets in the stack. """ return max(self.layout().count() - 1, 0) def setCurrentWidget(self, widget): """ Set the current shown widget. """ index = self.__widgets.index(widget) self.setCurrentIndex(index) def setCurrentIndex(self, index): """ Set the current shown widget index. """ index = max(min(index, self.count() - 1), 0) if self.__currentIndex == -1: self.layout().setCurrentIndex(index) self.__currentIndex = index return # if not self.animationEnabled(): # self.layout().setCurrentIndex(index) # self.__currentIndex = index # return # else start the animation current = self.__widgets[self.__currentIndex] next_widget = self.__widgets[index] def has_pending_resize(widget): return widget.testAttribute(Qt.WA_PendingResizeEvent) or \ not widget.testAttribute(Qt.WA_WState_Created) current_pix = next_pix = None if not has_pending_resize(current): current_pix = current.grab() if not has_pending_resize(next_widget): next_pix = next_widget.grab() with updates_disabled(self): self.__fadeWidget.setPixmap(current_pix) self.__fadeWidget.setPixmap2(next_pix) self.__nextCurrentIndex = index self.__transitionStart() def currentIndex(self): """ Return the current shown widget index. """ return self.__currentIndex def sizeHint(self): hint = super().sizeHint() if hint.isEmpty(): hint = QSize(0, 0) return hint def __transitionStart(self): """ Start the transition. """ log.debug("Stack transition start (%s)", str(self.objectName())) # Set the fade widget as the current widget self.__fadeWidget.blendingFactor_ = 0.0 self.layout().setCurrentWidget(self.__fadeWidget) self.transitionAnimation.start() self.transitionStarted.emit() def __onTransitionFinished(self): """ Transition has finished. """ log.debug("Stack transition finished (%s)" % str(self.objectName())) self.__fadeWidget.blendingFactor_ = 1.0 self.__currentIndex = self.__nextCurrentIndex with updates_disabled(self): self.layout().setCurrentIndex(self.__currentIndex) self.transitionFinished.emit() def __onLayoutCurrentChanged(self, index): # Suppress transitional __fadeWidget current widget if index != self.count(): self.currentChanged.emit(index) class CrossFadePixmapWidget(QWidget): """ A widget for cross fading between two pixmaps. """ def __init__(self, parent=None, pixmap1=None, pixmap2=None): super().__init__(parent) self.setPixmap(pixmap1) self.setPixmap2(pixmap2) self.blendingFactor_ = 0.0 self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) def setPixmap(self, pixmap): """ Set pixmap 1 """ self.pixmap1 = pixmap self.updateGeometry() def setPixmap2(self, pixmap): """ Set pixmap 2 """ self.pixmap2 = pixmap self.updateGeometry() def setBlendingFactor(self, factor): """ Set the blending factor between the two pixmaps. """ self.__blendingFactor = factor self.updateGeometry() def blendingFactor(self): """ Pixmap blending factor between 0.0 and 1.0 """ return self.__blendingFactor blendingFactor_ = Property(float, fget=blendingFactor, fset=setBlendingFactor) def sizeHint(self): """ Return an interpolated size between pixmap1.size() and pixmap2.size() """ if self.pixmap1 and self.pixmap2: size1 = self.pixmap1.size() size2 = self.pixmap2.size() return size1 + self.blendingFactor_ * (size2 - size1) else: return super().sizeHint() def paintEvent(self, event): """ Paint the interpolated pixmap image. """ p = QPainter(self) p.setClipRect(event.rect()) factor = self.blendingFactor_ ** 2 if self.pixmap1 and 1. - factor: p.setOpacity(1. - factor) p.drawPixmap(QPoint(0, 0), self.pixmap1) if self.pixmap2 and factor: p.setOpacity(factor) p.drawPixmap(QPoint(0, 0), self.pixmap2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/svgiconengine.py0000644000175100002000000002543614730024325023401 0ustar00runnerdockerimport io from typing import IO, Optional from itertools import count from xml.sax import make_parser, handler, saxutils from AnyQt.QtCore import Qt, QSize, QRect, QRectF, QObject from AnyQt.QtGui import ( QIconEngine, QIcon, QPixmap, QPainter, QPixmapCache, QPalette, QColor, ) from AnyQt.QtSvg import QSvgRenderer from AnyQt.QtWidgets import QStyleOption, QApplication from .iconengine import StyledIconEngine from .utils import luminance, merged_color _cache_id_gen = count() class SvgIconEngine(QIconEngine): """ A svg icon engine reimplementation drawing from in-memory svg contents. Arguments --------- contents : bytes The svg icon contents """ __slots__ = ("__contents", "__generator", "__cache_id") def __init__(self, contents): # type: (bytes) -> None super().__init__() self.__contents = contents self.__renderer = QSvgRenderer(contents) self.__cache_id = next(_cache_id_gen) def paint(self, painter, rect, mode, state): # type: (QPainter, QRect, QIcon.Mode, QIcon.State) -> None if self.__renderer.isValid(): size = rect.size() dpr = 1.0 try: dpr = painter.device().devicePixelRatioF() except AttributeError: pass if dpr != 1.0: size = size * dpr painter.drawPixmap(rect, self.pixmap(size, mode, state)) def pixmap(self, size, mode, state): # type: (QSize, QIcon.Mode, QIcon.State) -> QPixmap if not self.__renderer.isValid(): return QPixmap() dsize = self.__renderer.defaultSize() # type: QSize if not dsize.isNull(): dsize.scale(size, Qt.KeepAspectRatio) size = dsize key = "{}.SVGIconEngine/{}/{}x{}".format( __name__, self.__cache_id, size.width(), size.height() ) pm = QPixmapCache.find(key) if pm is None or pm.isNull(): pm = QPixmap(size) pm.fill(Qt.transparent) painter = QPainter(pm) self.__renderer.render( painter, QRectF(0, 0, size.width(), size.height())) painter.end() QPixmapCache.insert(key, pm) style = QApplication.style() if style is not None: opt = QStyleOption() opt.palette = QApplication.palette() pm = style.generatedIconPixmap(mode, pm, opt) return pm def clone(self): # type: () -> QIconEngine return SvgIconEngine(self.__contents) _QPalette_Text = QPalette.Text _QPalette_HighlightedText = QPalette.HighlightedText _QPalette_WindowText = QPalette.WindowText _QPalette_Window = QPalette.Window _QPalette_Active = QPalette.Active _QPalette_Disabled = QPalette.Disabled _QIcon_Active = QIcon.Active _QIcon_Selected = QIcon.Selected _QIcon_Disabled = QIcon.Disabled _QIcon_Active_Modes = (QIcon.Active, QIcon.Selected) _Qt_KeepAspectRatio = Qt.KeepAspectRatio class StyledSvgIconEngine(StyledIconEngine): """ A basic styled icon engine based on a QPalette colors. This engine can draw css styled svg icons of specific format to conform to the current color scheme based on effective `QPalette`. (Loosely based on KDE's KIconLoader) Parameters ---------- contents: str The svg icon content. palette: Optional[QPalette] A fixed palette colors to use. styleObject: Optional[QObject] An optional QObject whose 'palette' property defines the effective palette. If neither `palette` nor `styleObject` are specified then the current `QApplication.palette` is used. """ __slots__ = ( "__contents", "__styled_contents_cache", "__renderer", "__cache_key", ) def __init__( self, contents: bytes, *, palette: Optional[QPalette] = None, styleObject: Optional[QObject] = None, ) -> None: super().__init__(palette=palette, styleObject=styleObject) self.__contents = contents self.__styled_contents_cache = {} if palette is not None and styleObject is not None: raise TypeError("only one of palette or styleObject can be defined") self.__palette = QPalette(palette) if palette is not None else None self.__renderer = QSvgRenderer(contents) self.__cache_key = next(_cache_id_gen) self.__style_object = styleObject def paint(self, painter, rect, mode, state): # type: (QPainter, QRect, QIcon.Mode, QIcon.State) -> None if self.__renderer.isValid(): palette = self.effectivePalette() size = rect.size() dpr = painter.device().devicePixelRatioF() size = size * dpr pm = self.__renderStyledPixmap(size, mode, state, palette) painter.drawPixmap(rect, pm) def pixmap(self, size, mode, state): # type: (QSize, QIcon.Mode, QIcon.State) -> QPixmap return self.__renderStyledPixmap(size, mode, state, self.effectivePalette()) def __renderStyledPixmap( self, size: QSize, mode: QIcon.Mode, state: QIcon.State, palette: QPalette ) -> QPixmap: active = mode in _QIcon_Active_Modes disabled = mode == _QIcon_Disabled cg = _QPalette_Disabled if disabled else _QPalette_Active role = _QPalette_HighlightedText if active else _QPalette_WindowText namespace = f"{__name__}:{__class__.__name__}/{self.__cache_key}" style_key = f"{hex(palette.cacheKey())}-{cg}-{role}" renderer = self.__styled_contents_cache.get(style_key) if renderer is None: css = render_svg_color_scheme_css(palette, state) contents_ = replace_css_style(io.BytesIO(self.__contents), css) renderer = QSvgRenderer(contents_) self.__styled_contents_cache[style_key] = renderer if not renderer.isValid(): return QPixmap() dsize = renderer.defaultSize() # type: QSize if not dsize.isNull(): dsize.scale(size, _Qt_KeepAspectRatio) size = dsize pmcachekey = f"{namespace}/{style_key}/{size.width()}x{size.height()}" pm = QPixmapCache.find(pmcachekey) if pm is None or pm.isNull(): pm = QPixmap(size) pm.fill(Qt.transparent) painter = QPainter(pm) renderer.render(painter, QRectF(0, 0, size.width(), size.height())) painter.end() QPixmapCache.insert(pmcachekey, pm) self.__style = style = QApplication.style() if style is not None: opt = QStyleOption() opt.palette = palette pm = style.generatedIconPixmap(mode, pm, opt) return pm def clone(self) -> 'QIconEngine': return StyledSvgIconEngine( self.__contents, palette=self.__palette, styleObject=self.__style_object ) #: Like KDE's KIconLoader TEMPLATE = """ * {{ color: {text}; }} .ColorScheme-Text {{ color: {text}; }} .ColorScheme-Background {{ color: {background}; }} .ColorScheme-Highlight {{ color: {highlight}; }} .ColorScheme-Disabled-Text {{ color: {disabled_text}; }} .ColorScheme-Contrast {{ color: {contrast}; }} .ColorScheme-Complement {{ color: {complement}; }} """ def _hexrgb_solid(color: QColor, contrast: QColor = None) -> str: """ Return a #RRGGBB color string from color. If color has alpha component multipy the color components with alpha to get a solid color. """ # On macOS the disabled text color is black/white with an alpha # component but QtSvg does not support alpha component declarations # (in hex or rgba syntax) so we pre-multiply with alpha to get solid # gray scale. if color.alpha() != 255: if contrast is None: contrast = QColor(Qt.black) if luminance(color) > 0.5 else QColor(Qt.white) color = merged_color(color, contrast, color.alphaF()) return color.name(QColor.HexRgb) def render_svg_color_scheme_css(palette: QPalette, state: QIcon.State) -> str: selected = state == QIcon.Selected text = QPalette.HighlightedText if selected else QPalette.WindowText background = QPalette.Highlight if selected else QPalette.Window hligh = QPalette.HighlightedText if selected else QPalette.Highlight lum = luminance(palette.color(background)) complement = QColor(Qt.white) if lum > 0.5 else QColor(Qt.black) contrast = QColor(Qt.black) if lum > 0.5 else QColor(Qt.white) return TEMPLATE.format( text=_hexrgb_solid(palette.color(text), palette.color(background)), background=_hexrgb_solid(palette.color(background)), highlight=_hexrgb_solid(palette.color(hligh)), disabled_text=_hexrgb_solid(palette.color(QPalette.Disabled, text), palette.color(QPalette.Disabled, background)), contrast=_hexrgb_solid(contrast), complement=_hexrgb_solid(complement), ) def replace_css_style( svgcontents: IO, stylesheet: str, id="current-color-scheme", ) -> bytes: """ Insert/replace an inline css style in the svgcontents with `stylesheet`. Parameters ---------- svgcontents: IO A file like stream object open for reading. stylesheet: str CSS contents to insert. id: str The if of the existing # with the supplied stylesheet. super().startElement("style", attrs) super().characters("\n" + stylesheet + "\n") super().endElement("style") self._in_style = True else: super().startElement(tag, attrs) def characters(self, content): # skip original css style contents if not self._in_style: super().characters(content) def endElement(self, name): if self._in_style and name == "style": self._in_style = False else: super().endElement(name) buffer = io.BytesIO() writer = saxutils.XMLGenerator(out=buffer, encoding="utf-8") # build the parser and disable external entity resolver (bpo-17239) # (this is the default in Python 3.8) parser = make_parser() parser.setFeature(handler.feature_external_ges, False) parser.setFeature(handler.feature_external_pes, False) filter = StyleReplaceFilter(parent=parser) filter.setContentHandler(writer) filter.parse(svgcontents) return buffer.getvalue() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/test.py0000644000175100002000000001262314730024325021514 0ustar00runnerdocker""" Basic Qt testing framework ========================== """ import unittest import gc from typing import Callable, Any from AnyQt.QtWidgets import QApplication, QWidget from AnyQt.QtCore import ( QCoreApplication, QTimer, QStandardPaths, QPoint, Qt, QMimeData, QPointF ) from AnyQt.QtGui import ( QMouseEvent, QDragEnterEvent, QDropEvent, QDragMoveEvent, QDragLeaveEvent, QContextMenuEvent ) from AnyQt.QtTest import QTest from AnyQt.QtCore import PYQT_VERSION DEFAULT_TIMEOUT = 50 class QCoreAppTestCase(unittest.TestCase): _AppClass = QCoreApplication app = None # type: QCoreApplication __appdomain = "" __appname = "" @classmethod def setUpClass(cls): super(QCoreAppTestCase, cls).setUpClass() QStandardPaths.setTestModeEnabled(True) app = cls._AppClass.instance() if app is None: app = cls._AppClass([]) cls.app = app cls.__appname = cls.app.applicationName() cls.__appdomain = cls.app.organizationDomain() cls.app.setApplicationName("orangecanvas.testing") cls.app.setOrganizationDomain("biolab.si") def setUp(self): super(QCoreAppTestCase, self).setUp() def tearDown(self): super(QCoreAppTestCase, self).tearDown() @classmethod def tearDownClass(cls): gc.collect() cls.app.setApplicationName(cls.__appname) cls.app.setOrganizationDomain(cls.__appdomain) cls.app.sendPostedEvents(None, 0) # Keep app instance alive between tests with PyQt5 5.14.0 and later if PYQT_VERSION <= 0x050e00: cls.app = None super(QCoreAppTestCase, cls).tearDownClass() QStandardPaths.setTestModeEnabled(False) @classmethod def qWait(cls, timeout=DEFAULT_TIMEOUT): QTest.qWait(timeout) @classmethod def singleShot(cls, timeout: int, slot: 'Callable[[], Any]'): QTimer.singleShot(timeout, slot) class QAppTestCase(QCoreAppTestCase): _AppClass = QApplication app = None # type: QApplication def mouseMove(widget, buttons, modifier=Qt.NoModifier, pos=QPoint(), delay=-1): # type: (QWidget, Qt.MouseButtons, Qt.KeyboardModifier, QPoint, int) -> None """ Like QTest.mouseMove, but with `buttons` and `modifier` parameters. Parameters ---------- widget : QWidget buttons: Qt.MouseButtons modifier : Qt.KeyboardModifiers pos : QPoint delay : int """ if pos.isNull(): pos = widget.rect().center() me = QMouseEvent( QMouseEvent.MouseMove, QPointF(pos), QPointF(widget.mapToGlobal(pos)), Qt.NoButton, buttons, modifier ) if delay > 0: QTest.qWait(delay) QCoreApplication.sendEvent(widget, me) def contextMenu(widget: QWidget, pos: QPoint, delay=-1) -> None: """ Simulates a contextMenuEvent on the widget. """ ev = QContextMenuEvent( QContextMenuEvent.Mouse, pos, widget.mapToGlobal(pos) ) if delay > 0: QTest.qWait(delay) QCoreApplication.sendEvent(widget, ev) def dragDrop( widget: QWidget, mime: QMimeData, pos: QPoint = QPoint(), action=Qt.CopyAction, buttons=Qt.LeftButton, modifiers=Qt.NoModifier ) -> bool: """ Simulate a drag/drop interaction on the `widget`. A `QDragEnterEvent`, `QDragMoveEvent` and `QDropEvent` are created and dispatched to the `widget`. However if any of the `QDragEnterEvent` or `QDragMoveEvent` are not accepted, a `QDragLeaveEvent` is dispatched to 'reset' the widget state before this function returns `False` Parameters ---------- widget: QWidget The target widget. mime: QMimeData The mime data associated with the drag/drop. pos: QPoint Position of the drop action: Qt.DropActions Type of acceptable drop actions buttons: Qt.MouseButtons: Pressed mouse buttons. modifiers: Qt.KeyboardModifiers Pressed keyboard modifiers. Returns ------- state: bool Were the events accepted. See Also -------- QDragEnterEvent, QDropEvent """ if pos.isNull(): pos = widget.rect().center() ev = QDragEnterEvent(pos, action, mime, buttons, modifiers) ev.setAccepted(False) QApplication.sendEvent(widget, ev) ev = QDragMoveEvent(pos, action, mime, buttons, modifiers) ev.setAccepted(False) QApplication.sendEvent(widget, ev) if not ev.isAccepted(): QApplication.sendEvent(widget, QDragLeaveEvent()) return False ev = QDropEvent(QPointF(pos), action, mime, buttons, modifiers) ev.setAccepted(False) QApplication.sendEvent(widget, ev) return ev.isAccepted() def dragEnterLeave( widget: QWidget, mime: QMimeData, pos=QPoint(), action=Qt.CopyAction, buttons=Qt.LeftButton, modifiers=Qt.NoModifier ) -> None: """ Simulate a drag/move/leave interaction on the `widget`. A QDragEnterEvent, QDragMoveEvent and a QDragLeaveEvent are created and dispatched to the widget. """ if pos.isNull(): pos = widget.rect().center() ev = QDragEnterEvent(pos, action, mime, buttons, modifiers) ev.setAccepted(False) QApplication.sendEvent(widget, ev) ev = QDragMoveEvent( pos, action, mime, buttons, modifiers, QDragMoveEvent.DragMove ) ev.setAccepted(False) QApplication.sendEvent(widget, ev) ev = QDragLeaveEvent() ev.setAccepted(False) QApplication.sendEvent(widget, ev) return ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.790081 orange_canvas_core-0.2.5/orangecanvas/gui/tests/0000755000175100002000000000000014730024333021320 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/__init__.py0000644000175100002000000000003714730024325023432 0ustar00runnerdocker""" Tests for gui toolkit """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_dock.py0000644000175100002000000000311314730024325023650 0ustar00runnerdocker""" Tests for the DockWidget. """ from AnyQt.QtWidgets import ( QWidget, QMainWindow, QListView, QTextEdit, QToolButton, QHBoxLayout, QLabel ) from AnyQt.QtCore import Qt, QTimer, QStringListModel from .. import test from ..dock import CollapsibleDockWidget class TestDock(test.QAppTestCase): def test_dock_standalone(self): widget = QWidget() layout = QHBoxLayout() widget.setLayout(layout) layout.addStretch(1) widget.show() dock = CollapsibleDockWidget() layout.addWidget(dock) list_view = QListView() list_view.setModel(QStringListModel(["a", "b"], list_view)) label = QLabel("A label. ") label.setWordWrap(True) dock.setExpandedWidget(label) dock.setCollapsedWidget(list_view) dock.setExpanded(True) dock.setExpanded(False) timer = QTimer(dock, interval=50) timer.timeout.connect(lambda: dock.setExpanded(not dock.expanded())) timer.start() self.qWait() timer.stop() def test_dock_mainwinow(self): mw = QMainWindow() dock = CollapsibleDockWidget() w1 = QTextEdit() w2 = QToolButton() w2.setFixedSize(38, 200) dock.setExpandedWidget(w1) dock.setCollapsedWidget(w2) mw.addDockWidget(Qt.LeftDockWidgetArea, dock) mw.setCentralWidget(QTextEdit()) mw.show() timer = QTimer(dock, interval=50) timer.timeout.connect(lambda: dock.setExpanded(not dock.expanded())) timer.start() self.qWait() timer.stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_dropshadow.py0000644000175100002000000000562614730024325025115 0ustar00runnerdocker""" Tests for DropShadowFrame wiget. """ import math from AnyQt.QtWidgets import ( QMainWindow, QWidget, QListView, QTextEdit, QHBoxLayout, QToolBar, QVBoxLayout ) from AnyQt.QtGui import QColor from AnyQt.QtCore import Qt, QPoint, QPropertyAnimation, QVariantAnimation from .. import dropshadow from .. import test class TestDropShadow(test.QAppTestCase): def test(self): lv = QListView() mw = QMainWindow() # Add two tool bars, the shadow should extend over them. mw.addToolBar(Qt.BottomToolBarArea, QToolBar()) mw.addToolBar(Qt.TopToolBarArea, QToolBar()) mw.setCentralWidget(lv) f = dropshadow.DropShadowFrame(color=Qt.blue, radius=20) f.setWidget(lv) self.assertIs(f.parentWidget(), mw) self.assertIs(f.widget(), lv) mw.show() canim = QPropertyAnimation( f, b"color_", f, startValue=QColor(Qt.red), endValue=QColor(Qt.blue), loopCount=-1, duration=2000 ) canim.start() ranim = QPropertyAnimation( f, b"radius_", f, startValue=30, endValue=40, loopCount=-1, duration=3000 ) ranim.start() self.qWait() def test1(self): class FT(QToolBar): def paintEvent(self, e): pass w = QMainWindow() ftt, ftb = FT(), FT() ftt.setFixedHeight(15) ftb.setFixedHeight(15) w.addToolBar(Qt.TopToolBarArea, ftt) w.addToolBar(Qt.BottomToolBarArea, ftb) f = dropshadow.DropShadowFrame() te = QTextEdit() c = QWidget() c.setLayout(QVBoxLayout()) c.layout().setContentsMargins(20, 0, 20, 0) c.layout().addWidget(te) w.setCentralWidget(c) f.setWidget(te) f.setRadius(15) f.setColor(Qt.blue) w.show() canim = QPropertyAnimation( f, b"color_", f, startValue=QColor(Qt.red), endValue=QColor(Qt.blue), loopCount=-1, duration=2000 ) canim.start() ranim = QPropertyAnimation( f, b"radius_", f, startValue=30, endValue=40, loopCount=-1, duration=3000 ) ranim.start() self.qWait() def test_offset(self): w = QWidget() w.setLayout(QHBoxLayout()) w.setContentsMargins(30, 30, 30, 30) ww = QTextEdit() w.layout().addWidget(ww) f = dropshadow.DropShadowFrame(radius=20) f.setWidget(ww) oanim = QVariantAnimation( f, startValue=0.0, endValue=2 * math.pi, loopCount=-1, duration=2000, ) @oanim.valueChanged.connect def _(value): f.setOffset(QPoint(int(15 * math.cos(value)), int(15 * math.sin(value)))) oanim.start() w.show() self.qWait() if __name__ == "__main__": test.unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_framelesswindow.py0000644000175100002000000000076414730024325026152 0ustar00runnerdockerfrom AnyQt.QtCore import QTimer from ..framelesswindow import FramelessWindow from ..test import QAppTestCase class TestFramelessWindow(QAppTestCase): def test_framelesswindow(self): window = FramelessWindow() window.show() window.setRadius(5) def cycle(): window.setRadius((window.radius() + 3) % 30) timer = QTimer(window, interval=50) timer.timeout.connect(cycle) timer.start() self.qWait() timer.stop() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_iconengine.py0000644000175100002000000000221214730024325025045 0ustar00runnerdockerfrom AnyQt.QtCore import QSize, Qt from AnyQt.QtGui import QIcon, QPixmap, QColor, QPalette from ..iconengine import SymbolIconEngine from ..test import QAppTestCase class TestSymbolIconEngine(QAppTestCase): def test(self): pm = QPixmap(10, 10) pm.fill(QColor(0, 0, 0)) base = QIcon() base.addPixmap(pm) engine = SymbolIconEngine(base) palette = QPalette() palette.setColor(QPalette.Text, QColor(200, 200, 200)) palette.setColor(QPalette.Base, QColor(Qt.black)) with SymbolIconEngine.setOverridePalette(palette): img = engine.pixmap(QSize(10, 10), QIcon.Active, QIcon.Off).toImage() pixel = QColor(img.pixel(5, 5)) self.assertEqual(QColor(pixel).name(), palette.text().color().name()) palette.setColor(QPalette.Text, QColor(Qt.black)) palette.setColor(QPalette.Base, QColor(Qt.white)) with SymbolIconEngine.setOverridePalette(palette): img = engine.pixmap(QSize(10, 10), QIcon.Active, QIcon.Off).toImage() pixel = QColor(img.pixel(5, 5)) self.assertEqual(QColor(pixel).name(), QColor(0, 0, 0).name()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_lineedit.py0000644000175100002000000000267614730024325024542 0ustar00runnerdocker""" Test for searchwidget """ from AnyQt.QtWidgets import QAction, QStyle, QMenu from AnyQt.QtGui import QIcon from ..lineedit import LineEdit from ..test import QAppTestCase class TestSearchWidget(QAppTestCase): def test_lineedit(self): """test LineEdit """ line = LineEdit() line.show() action1 = QAction(line.style().standardIcon(QStyle.SP_ArrowBack), "Search", line) menu = QMenu() menu.addAction("Regex") menu.addAction("Wildcard") action1.setMenu(menu) line.setAction(action1, LineEdit.LeftPosition) self.assertIs(line.actionAt(LineEdit.LeftPosition), action1) self.assertTrue(line.button(LineEdit.LeftPosition) is not None) self.assertTrue(line.button(LineEdit.RightPosition) is None) with self.assertRaises(ValueError): line.removeActionAt(100) line.removeActionAt(LineEdit.LeftPosition) self.assertIs(line.actionAt(LineEdit.LeftPosition), None) line.setAction(action1, LineEdit.LeftPosition) action2 = QAction(line.style().standardIcon(QStyle.SP_TitleBarCloseButton), "Delete", line) line.setAction(action2, LineEdit.RightPosition) line.setPlaceholderText("Search") self.assertEqual(line.placeholderText(), "Search") b = line.button(LineEdit.RightPosition) b.setFlat(False) self.qWait() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_splashscreen.py0000644000175100002000000000237414730024325025432 0ustar00runnerdocker""" Test for splashscreen """ import pkgutil from datetime import datetime from AnyQt.QtGui import QPixmap, QImage from AnyQt.QtCore import Qt, QRect, QTimer from ..splashscreen import SplashScreen from ..test import QAppTestCase from ... import config class TestSplashScreen(QAppTestCase): def test_splashscreen(self): contents = pkgutil.get_data( config.__package__, "icons/orange-canvas-core-splash.svg" ) img = QImage.fromData(contents) w = SplashScreen() w.setPixmap(QPixmap.fromImage(img)) w.setTextRect(QRect(100, 100, 400, 50)) w.show() def advance_time(): now = datetime.now() time = now.strftime("%c : %f") i = now.second % 3 if i == 2: w.setTextFormat(Qt.RichText) time = "" + time + "" else: w.setTextFormat(Qt.PlainText) w.showMessage(time, alignment=Qt.AlignCenter) rect = QRect(100, 100 + i * 20, 400, 50) w.setTextRect(rect) self.assertEqual(w.textRect(), rect) timer = QTimer(w, interval=1) timer.timeout.connect(advance_time) timer.start() self.qWait() timer.stop()././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_stackedwidget.py0000644000175100002000000000422514730024325025557 0ustar00runnerdocker""" Test for StackedWidget """ from AnyQt.QtWidgets import QWidget, QLabel, QGroupBox, QListView, QVBoxLayout from AnyQt.QtCore import QTimer from .. import test from .. import stackedwidget class TestStackedWidget(test.QAppTestCase): def test(self): window = QWidget() layout = QVBoxLayout() window.setLayout(layout) stack = stackedwidget.AnimatedStackedWidget(animationEnabled=False) layout.addStretch(2) layout.addWidget(stack) layout.addStretch(2) window.show() widget1 = QLabel("A label " * 10) widget1.setWordWrap(True) widget2 = QGroupBox("Group") widget3 = QListView() self.assertEqual(stack.count(), 0) self.assertEqual(stack.currentIndex(), -1) stack.addWidget(widget1) self.assertEqual(stack.count(), 1) self.assertEqual(stack.currentIndex(), 0) stack.addWidget(widget2) stack.addWidget(widget3) self.assertEqual(stack.count(), 3) self.assertEqual(stack.currentIndex(), 0) def widgets(): return [stack.widget(i) for i in range(stack.count())] self.assertSequenceEqual([widget1, widget2, widget3], widgets()) stack.show() stack.removeWidget(widget2) self.assertEqual(stack.count(), 2) self.assertEqual(stack.currentIndex(), 0) self.assertSequenceEqual([widget1, widget3], widgets()) stack.setCurrentIndex(1) self.qWait() self.assertEqual(stack.currentIndex(), 1) widget2 = QGroupBox("Group") stack.insertWidget(1, widget2) self.assertEqual(stack.count(), 3) self.assertEqual(stack.currentIndex(), 2) self.assertSequenceEqual([widget1, widget2, widget3], widgets()) def toogle(): idx = stack.currentIndex() stack.setCurrentIndex((idx + 1) % stack.count()) timer = QTimer(stack, interval=100) timer.timeout.connect(toogle) timer.start() self.qWait(200) timer.stop() window.deleteLater() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_toolbar.py0000644000175100002000000000224214730024325024374 0ustar00runnerdocker""" Test for DynamicResizeToolbar """ import logging from AnyQt.QtWidgets import QAction from AnyQt.QtCore import Qt from .. import test from .. import toolbar class ToolBoxTest(test.QAppTestCase): def test_dynamic_toolbar(self): logging.basicConfig(level=logging.DEBUG) w = toolbar.DynamicResizeToolBar(None) w.setStyleSheet("QToolButton { border: 1px solid red; }") w.addAction(QAction("1", w)) w.addAction(QAction("2", w)) w.addAction(QAction("A long name", w)) actions = list(w.actions()) self.assertSequenceEqual([str(action.text()) for action in actions], ["1", "2", "A long name"]) w.resize(100, 30) w.show() w.raise_() w.removeAction(actions[1]) w.insertAction(actions[2], actions[1]) self.assertSequenceEqual(actions, list(w.actions()), msg="insertAction does not preserve " "action order") self.singleShot(10, lambda: w.setOrientation(Qt.Vertical)) self.singleShot(50, lambda: w.removeAction(actions[1])) self.qWait() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_toolbox.py0000644000175100002000000000425414730024325024425 0ustar00runnerdocker""" Tests for ToolBox widget. """ from .. import test from .. import toolbox from AnyQt.QtWidgets import QLabel, QListView, QSpinBox, QAbstractButton from AnyQt.QtGui import QIcon class TestToolBox(test.QAppTestCase): def test_tool_box(self): w = toolbox.ToolBox() style = self.app.style() icon = QIcon(style.standardIcon(style.SP_FileIcon)) p1 = QLabel("A Label") p2 = QListView() p3 = QLabel("Another\nlabel") p4 = QSpinBox() i1 = w.addItem(p1, "T1", icon) i2 = w.addItem(p2, "Tab " * 10, icon, "a tab") i3 = w.addItem(p3, "t3") i4 = w.addItem(p4, "t4") self.assertSequenceEqual([i1, i2, i3, i4], range(4)) self.assertEqual(w.count(), 4) for i, item in enumerate([p1, p2, p3, p4]): self.assertIs(item, w.widget(i)) b = w.tabButton(i) a = w.tabAction(i) self.assertIsInstance(b, QAbstractButton) self.assertIs(b.defaultAction(), a) w.show() w.removeItem(2) self.assertEqual(w.count(), 3) self.assertIs(w.widget(2), p4) p3 = QLabel("Once More Unto the Breach") w.insertItem(2, p3, "Dear friend") self.assertEqual(w.count(), 4) self.assertIs(w.widget(1), p2) self.assertIs(w.widget(2), p3) self.assertIs(w.widget(3), p4) self.qWait() def test_tool_box_exclusive(self): w = toolbox.ToolBox() w.setExclusive(True) w.addItem(QLabel(), "A") w.addItem(QLabel(), "B") w.addItem(QLabel(), "C") a0, a1 = w.tabAction(0), w.tabAction(1) self.assertTrue(a0.isChecked()) a1.toggle() self.assertFalse(a0.isChecked()) self.assertFalse(w.widget(0).isVisibleTo(w)) self.assertTrue(w.widget(1).isVisibleTo(w)) w.setExclusive(False) a0.toggle() self.assertTrue(a0.isChecked() and a1.isChecked()) self.assertTrue(w.widget(0).isVisibleTo(w)) self.assertTrue(w.widget(1).isVisibleTo(w)) w.setExclusive(True) self.assertEqual( sum([w.widget(i).isVisibleTo(w) for i in range(w.count())]), 1 ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_toolgrid.py0000644000175100002000000000556514730024325024570 0ustar00runnerdockerfrom AnyQt.QtWidgets import QAction, QToolButton from .. import test from ..toolgrid import ToolGrid class TestToolGrid(test.QAppTestCase): def test_tool_grid(self): w = ToolGrid() w.show() self.app.processEvents() def buttonsOrderedVisual(): # Process layout events so the buttons have right positions self.app.processEvents() buttons = w.findChildren(QToolButton) return list(sorted(buttons, key=lambda b: (b.y(), b.x()))) def buttonsOrderedLogical(): return list(map(w.buttonForAction, w.actions())) def assertOrdered(): self.assertSequenceEqual(buttonsOrderedLogical(), buttonsOrderedVisual()) action_a = QAction("A", w) action_b = QAction("B", w) action_c = QAction("C", w) action_d = QAction("D", w) w.addAction(action_b) w.insertAction(0, action_a) self.assertSequenceEqual(w.actions(), [action_a, action_b]) assertOrdered() w.addAction(action_d) w.insertAction(action_d, action_c) self.assertSequenceEqual(w.actions(), [action_a, action_b, action_c, action_d]) assertOrdered() w.removeAction(action_c) self.assertSequenceEqual(w.actions(), [action_a, action_b, action_d]) assertOrdered() w.removeAction(action_a) self.assertSequenceEqual(w.actions(), [action_b, action_d]) assertOrdered() w.insertAction(0, action_a) self.assertSequenceEqual(w.actions(), [action_a, action_b, action_d]) assertOrdered() w.setColumnCount(2) self.assertSequenceEqual(w.actions(), [action_a, action_b, action_d]) assertOrdered() w.insertAction(2, action_c) self.assertSequenceEqual(w.actions(), [action_a, action_b, action_c, action_d]) assertOrdered() w.clear() # test no 'before' action edge case w.insertAction(0, action_a) self.assertIs(action_a, w.actions()[0]) w.insertAction(1, action_b) self.assertSequenceEqual(w.actions(), [action_a, action_b]) w.clear() w.setActions([action_a, action_b, action_c, action_d]) self.assertSequenceEqual(w.actions(), [action_a, action_b, action_c, action_d]) assertOrdered() triggered_actions = [] def p(action): print(action.text()) w.actionTriggered.connect(p) w.actionTriggered.connect(triggered_actions.append) action_a.trigger() w.show() self.qWait() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tests/test_tooltree.py0000644000175100002000000000505014730024325024567 0ustar00runnerdocker""" Test for tooltree """ from AnyQt.QtWidgets import QAction from AnyQt.QtGui import QStandardItemModel, QStandardItem from AnyQt.QtCore import Qt from ..tooltree import ToolTree, FlattenedTreeItemModel from ...registry.qt import QtWidgetRegistry from ...registry.tests import small_testing_registry from ..test import QAppTestCase class TestToolTree(QAppTestCase): def test_tooltree(self): tree = ToolTree() role = tree.actionRole() model = QStandardItemModel() tree.setModel(model) item = QStandardItem("One") item.setData(QAction("One", tree), role) model.appendRow([item]) cat = QStandardItem("A Category") item = QStandardItem("Two") item.setData(QAction("Two", tree), role) cat.appendRow([item]) item = QStandardItem("Three") item.setData(QAction("Three", tree), role) cat.appendRow([item]) model.appendRow([cat]) def p(action): print("triggered", action.text()) tree.triggered.connect(p) tree.show() self.qWait() def test_tooltree_registry(self): reg = QtWidgetRegistry(small_testing_registry()) tree = ToolTree() tree.setModel(reg.model()) tree.setActionRole(reg.WIDGET_ACTION_ROLE) tree.show() def p(action): print("triggered", action.text()) tree.triggered.connect(p) self.qWait() def test_flattened(self): reg = QtWidgetRegistry(small_testing_registry()) source = reg.model() model = FlattenedTreeItemModel() model.setSourceModel(source) tree = ToolTree() tree.setActionRole(reg.WIDGET_ACTION_ROLE) tree.setModel(model) tree.show() changed = [] model.dataChanged.connect( lambda start, end: changed.append((start, end)) ) item = source.item(0).child(0) item.setText("New text") self.assertTrue(len(changed) == 1) self.assertEqual(changed[-1][0].data(Qt.DisplayRole), "New text") self.assertEqual(model.data(model.index(1)), "New text") model.setFlatteningMode(FlattenedTreeItemModel.InternalNodesDisabled) self.assertFalse(model.index(0, 0).flags() & Qt.ItemIsEnabled) model.setFlatteningMode(FlattenedTreeItemModel.LeavesOnly) self.assertEqual(model.rowCount(), len(reg.widgets())) def p(action): print("triggered", action.text()) tree.triggered.connect(p) self.qWait() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/textlabel.py0000644000175100002000000000563414730024325022525 0ustar00runnerdockerfrom AnyQt.QtCore import Qt, QSize, QEvent from AnyQt.QtGui import QPaintEvent from AnyQt.QtWidgets import QWidget, QSizePolicy, QStyleOption, QStylePainter class TextLabel(QWidget): """A plain text label widget with support for elided text. """ def __init__(self, *args, text="", alignment=Qt.AlignLeft | Qt.AlignVCenter, textElideMode=Qt.ElideMiddle, **kwargs): super().__init__(*args, **kwargs) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) self.setAttribute(Qt.WA_WState_OwnSizePolicy, True) self.__text = text self.__textElideMode = textElideMode self.__alignment = alignment self.__sizeHint = None def setText(self, text): # type: (str) -> None """Set the `text` string to display.""" if self.__text != text: self.__text = text self.__update() def text(self): # type: () -> str """Return the text.""" return self.__text def setTextElideMode(self, mode): # type: (Qt.TextElideMode ) -> None """Set text elide mode (`Qt.TextElideMode`)""" if self.__textElideMode != mode: self.__textElideMode = mode self.__update() def elideMode(self): # type: () -> Qt.TextElideMode """Return the text elide mode.""" return self.__elideMode def setAlignment(self, align): # type: (Qt.AlignmentFlag) -> None """Set text alignment (`Qt.Alignment`).""" if self.__alignment != align: self.__alignment = align self.__update() def alignment(self): # type: () -> Qt.AlignmentFlag """Return text alignment.""" return Qt.AlignmentFlag(self.__alignment) def sizeHint(self): # type: () -> QSize """Reimplemented.""" if self.__sizeHint is None: option = QStyleOption() option.initFrom(self) metrics = option.fontMetrics self.__sizeHint = QSize(200, metrics.height()) return self.__sizeHint def paintEvent(self, event): # type: (QPaintEvent) -> None """Reimplemented.""" painter = QStylePainter(self) option = QStyleOption() option.initFrom(self) rect = option.rect metrics = option.fontMetrics text = metrics.elidedText(self.__text, self.__textElideMode, rect.width()) painter.drawItemText(rect, self.__alignment, option.palette, self.isEnabled(), text, self.foregroundRole()) painter.end() def changeEvent(self, event): # type: (QEvent) -> None """Reimplemented.""" if event.type() == QEvent.FontChange: self.__update() super().changeEvent(event) def __update(self) -> None: self.__sizeHint = None self.updateGeometry() self.update() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/toolbar.py0000644000175100002000000000667614730024325022212 0ustar00runnerdocker""" A custom toolbar with linear uniform size layout. """ from typing import List from AnyQt.QtCore import Qt, QSize, QEvent, QRect from AnyQt.QtGui import QResizeEvent, QActionEvent from AnyQt.QtWidgets import QToolBar, QWidget class DynamicResizeToolBar(QToolBar): """ A :class:`QToolBar` subclass that dynamically resizes its tool buttons to fit available space (this is done by setting fixed size on the button instances). .. note:: the class does not support `QWidgetAction`, separators, etc. """ def resizeEvent(self, event): # type: (QResizeEvent) -> None super().resizeEvent(event) size = event.size() self.__layout(size) def actionEvent(self, event): # type: (QActionEvent) -> None super().actionEvent(event) if event.type() == QEvent.ActionAdded or \ event.type() == QEvent.ActionRemoved: self.__layout(self.size()) def sizeHint(self): # type: () -> QSize hint = super().sizeHint() width, height = hint.width(), hint.height() m1 = self.contentsMargins() m2 = self.layout().contentsMargins() dx1, dy1, dw1, dh1 = m1.left(), m1.top(), m1.right(), m1.bottom() dx2, dy2, dw2, dh2 = m2.left(), m2.top(), m2.right(), m2.bottom() dx, dy = dx1 + dx2, dy1 + dy2 dw, dh = dw1 + dw2, dh1 + dh2 count = len(self.actions()) spacing = self.layout().spacing() space_spacing = max(count - 1, 0) * spacing if self.orientation() == Qt.Horizontal: width = int(height * 1.618) * count + space_spacing + dw + dx else: height = int(width * 1.618) * count + space_spacing + dh + dy return QSize(width, height) def __layout(self, size): # type: (QSize) -> None """Layout the buttons to fit inside size. """ mygeom = self.geometry() mygeom.setSize(size) # Adjust for margins (both the widgets and the layouts). mygeom = mygeom.marginsRemoved(self.contentsMargins()) mygeom = mygeom.marginsRemoved(self.layout().contentsMargins()) actions = self.actions() widgets_it = map(self.widgetForAction, actions) orientation = self.orientation() if orientation == Qt.Horizontal: widgets = sorted(widgets_it, key=lambda w: w.pos().x()) else: widgets = sorted(widgets_it, key=lambda w: w.pos().y()) spacing = self.layout().spacing() uniform_layout_helper(widgets, mygeom, orientation, spacing=spacing) def uniform_layout_helper(items, contents_rect, expanding, spacing): # type: (List[QWidget], QRect, Qt.Orientation, int) -> None """Set fixed sizes on 'items' so they can be lay out in contents rect anf fil the whole space. """ if len(items) == 0: return spacing_space = (len(items) - 1) * spacing if expanding == Qt.Horizontal: def setter(w, s): # type: (QWidget, int) -> None w.setFixedWidth(max(s, 0)) space = contents_rect.width() - spacing_space else: def setter(w, s): # type: (QWidget, int) -> None w.setFixedHeight(max(s, 0)) space = contents_rect.height() - spacing_space base_size = space // len(items) remainder = space % len(items) for i, item in enumerate(items): item_size = base_size + (1 if i < remainder else 0) setter(item, item_size) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/toolbox.py0000644000175100002000000005277314730024325022235 0ustar00runnerdocker""" =============== Tool Box Widget =============== A reimplementation of the :class:`QToolBox` widget that keeps all the tabs in a single :class:`QScrollArea` instance and can keep multiple open tabs. """ import enum from operator import eq, attrgetter import typing from typing import NamedTuple, List, Iterable, Optional, Any, Callable from AnyQt.QtWidgets import ( QWidget, QFrame, QSizePolicy, QStyle, QStyleOptionToolButton, QScrollArea, QVBoxLayout, QToolButton, QAction, QActionGroup, QApplication, QAbstractButton, QWIDGETSIZE_MAX, ) from AnyQt.QtGui import ( QIcon, QFontMetrics, QPainter, QPalette, QBrush, QPen, QColor, QFont ) from AnyQt.QtCore import ( Qt, QObject, QSize, QRect, QPoint, QSignalMapper ) from AnyQt.QtCore import Signal, Property from .iconengine import StyledIconEngine from .. import styles from ..utils import set_flag from .utils import brush_darker, ScrollBar __all__ = [ "ToolBox" ] _ToolBoxPage = NamedTuple( "_ToolBoxPage", [ ("index", int), ("widget", QWidget), ("action", QAction), ("button", QAbstractButton), ] ) class ToolBoxTabButton(QToolButton): """ A tab button for an item in a :class:`ToolBox`. """ class TabPosition(enum.IntFlag): Beginning = 0 Middle = 1 End = 2 OnlyOneTab = 3 Beginning = TabPosition.Beginning Middle = TabPosition.Middle End = TabPosition.End OnlyOneTab = TabPosition.OnlyOneTab class SelectedPosition(enum.IntFlag): NotAdjacent = 0 NextIsSelected = 1 PreviousIsSelected = 2 NotAdjacent = SelectedPosition.NotAdjacent NextIsSelected = SelectedPosition.NextIsSelected PreviousIsSelected = SelectedPosition.PreviousIsSelected def setNativeStyling(self, state): # type: (bool) -> None """ Render tab buttons as native (or css styled) :class:`QToolButtons`. If set to `False` (default) the button is pained using a custom paint routine. """ self.__nativeStyling = state self.update() def nativeStyling(self): # type: () -> bool """ Use :class:`QStyle`'s to paint the class:`QToolButton` look. """ return self.__nativeStyling nativeStyling_ = Property(bool, fget=nativeStyling, fset=setNativeStyling, designable=True) def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None self.__nativeStyling = False self.position = ToolBoxTabButton.OnlyOneTab self.selected = ToolBoxTabButton.NotAdjacent font = kwargs.pop("font", None) # type: Optional[QFont] super().__init__(parent, **kwargs) if font is None: self.setFont(QApplication.font("QAbstractButton")) self.setAttribute(Qt.WA_SetFont, False) else: self.setFont(font) def enterEvent(self, event): super().enterEvent(event) self.update() def leaveEvent(self, event): super().leaveEvent(event) self.update() def paintEvent(self, event): if self.__nativeStyling: super().paintEvent(event) else: self.__paintEventNoStyle() def __paintEventNoStyle(self): p = QPainter(self) opt = QStyleOptionToolButton() self.initStyleOption(opt) fm = QFontMetrics(opt.font) palette = opt.palette # highlight brush is used as the background for the icon and background # when the tab is expanded and as mouse hover color (lighter). brush_highlight = palette.highlight() foregroundrole = QPalette.ButtonText if opt.state & QStyle.State_Sunken: # State 'down' pressed during a mouse press (slightly darker). background_brush = brush_darker(brush_highlight, 110) foregroundrole = QPalette.HighlightedText elif opt.state & QStyle.State_MouseOver: background_brush = brush_darker(brush_highlight, 95) foregroundrole = QPalette.HighlightedText elif opt.state & QStyle.State_On: background_brush = brush_highlight foregroundrole = QPalette.HighlightedText else: # The default button brush. background_brush = palette.button() rect = opt.rect icon_area_rect = QRect(rect) icon_area_rect.setWidth(int(icon_area_rect.height() * 1.26)) text_rect = QRect(rect) text_rect.setLeft(icon_area_rect.x() + icon_area_rect.width() + 10) # Background # TODO: Should the tab button have native toolbutton shape, drawn # using PE_PanelButtonTool or even QToolBox tab shape # Default outline pen pen = QPen(palette.color(QPalette.Mid)) p.save() p.setPen(Qt.NoPen) p.setBrush(QBrush(background_brush)) p.drawRect(rect) # Draw the background behind the icon if the background_brush # is different. if not opt.state & QStyle.State_On: p.setBrush(brush_highlight) p.drawRect(icon_area_rect) # Line between the icon and text p.setPen(pen) p.drawLine( icon_area_rect.x() + icon_area_rect.width(), icon_area_rect.y(), icon_area_rect.x() + icon_area_rect.width(), icon_area_rect.y() + icon_area_rect.height()) if opt.state & QStyle.State_HasFocus: # Set the focus frame pen and draw the border pen = QPen(QColor(brush_highlight)) p.setPen(pen) p.setBrush(Qt.NoBrush) # Adjust for pen rect = rect.adjusted(0, 0, -1, -1) p.drawRect(rect) else: p.setPen(pen) # Draw the top/bottom border if self.position == ToolBoxTabButton.OnlyOneTab or \ self.position == ToolBoxTabButton.Beginning or \ self.selected & ToolBoxTabButton.PreviousIsSelected: p.drawLine(rect.x(), rect.y(), rect.x() + rect.width(), rect.y()) p.drawLine(rect.x(), rect.y() + rect.height(), rect.x() + rect.width(), rect.y() + rect.height()) p.restore() p.save() text = fm.elidedText(opt.text, Qt.ElideRight, text_rect.width()) p.setPen(QPen(palette.color(foregroundrole))) p.setFont(opt.font) p.drawText(text_rect, int(Qt.AlignVCenter | Qt.AlignLeft) | int(Qt.TextSingleLine), text) if not opt.icon.isNull(): if opt.state & QStyle.State_Enabled: mode = QIcon.Normal else: mode = QIcon.Disabled if opt.state & QStyle.State_On: state = QIcon.On else: state = QIcon.Off icon_area_rect = icon_area_rect icon_rect = QRect(QPoint(0, 0), opt.iconSize) icon_rect.moveCenter(icon_area_rect.center()) with StyledIconEngine.setOverridePalette(styles.breeze_light()): opt.icon.paint(p, icon_rect, Qt.AlignCenter, mode, state) p.restore() class _ToolBoxLayout(QVBoxLayout): def __init__(self, *args, **kwargs): # type: (Any, Any) -> None self.__minimumSize = None # type: Optional[QSize] self.__maximumSize = None # type: Optional[QSize] super().__init__(*args, **kwargs) def minimumSize(self): # type: () -> QSize """Reimplemented from `QBoxLayout.minimumSize`.""" if self.__minimumSize is None: msize = super().minimumSize() # Extend the minimum size by including the minimum width of # hidden widgets (which QBoxLayout ignores), so the minimum # width does not depend on the tab open/close state. for i in range(self.count()): item = self.itemAt(i) if item.isEmpty() and item.widget() is not None: msize.setWidth(max(item.widget().minimumWidth(), msize.width())) self.__minimumSize = msize return self.__minimumSize def maximumSize(self): # type: () -> QSize """Reimplemented from `QBoxLayout.maximumSize`.""" msize = super().maximumSize() # Allow the contents to grow horizontally (expand within the # containing scroll area - joining the tab buttons to the # right edge), but have a suitable maximum height (displaying an # empty area on the bottom if the contents are smaller then the # viewport). msize.setWidth(QWIDGETSIZE_MAX) return msize def invalidate(self): # type: () -> None """Reimplemented from `QVBoxLayout.invalidate`.""" self.__minimumSize = None self.__maximumSize = None super().invalidate() class ToolBox(QFrame): """ A tool box widget. """ # Signal emitted when a tab is toggled. tabToggled = Signal(int, bool) __exclusive = False # type: bool def setExclusive(self, exclusive): # type: (bool) -> None """ Set exclusive tabs (only one tab can be open at a time). """ if self.__exclusive != exclusive: self.__exclusive = exclusive self.__tabActionGroup.setExclusive(exclusive) checked = self.__tabActionGroup.checkedAction() if checked is None: # The action group can be out of sync with the actions state # when switching between exclusive states. actions_checked = [page.action for page in self.__pages if page.action.isChecked()] if actions_checked: checked = actions_checked[0] # Trigger/toggle remaining open pages if exclusive and checked is not None: for page in self.__pages: if checked != page.action and page.action.isChecked(): page.action.trigger() def exclusive(self): # type: () -> bool """ Are the tabs in the toolbox exclusive. """ return self.__exclusive exclusive_ = Property(bool, fget=exclusive, fset=setExclusive, designable=True, doc="Exclusive tabs") def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any)-> None super().__init__(parent, **kwargs) self.__pages = [] # type: List[_ToolBoxPage] self.__tabButtonHeight = -1 self.__tabIconSize = QSize() self.__exclusive = False layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) # Scroll area for the contents. self.__scrollArea = QScrollArea( self, objectName="toolbox-scroll-area", sizePolicy=QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding), horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, widgetResizable=True, ) sb = ScrollBar() sb.styleChange.connect(self.updateGeometry) self.__scrollArea.setVerticalScrollBar(sb) self.__scrollArea.setFrameStyle(QScrollArea.NoFrame) # A widget with all of the contents. # The tabs/contents are placed in the layout inside this widget self.__contents = QWidget(self.__scrollArea, objectName="toolbox-contents") self.__contentsLayout = _ToolBoxLayout( sizeConstraint=_ToolBoxLayout.SetMinAndMaxSize, spacing=0 ) self.__contentsLayout.setContentsMargins(0, 0, 0, 0) self.__contents.setLayout(self.__contentsLayout) self.__scrollArea.setWidget(self.__contents) layout.addWidget(self.__scrollArea) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.__tabActionGroup = QActionGroup( self, objectName="toolbox-tab-action-group", ) self.__tabActionGroup.setExclusive(self.__exclusive) self.__actionMapper = QSignalMapper(self) self.__actionMapper.mappedObject.connect(self.__onTabActionToggled) def setTabButtonHeight(self, height): # type: (int) -> None """ Set the tab button height. """ if self.__tabButtonHeight != height: self.__tabButtonHeight = height for page in self.__pages: page.button.setFixedHeight(height) def tabButtonHeight(self): # type: () -> int """ Return the tab button height. """ return self.__tabButtonHeight def setTabIconSize(self, size): # type: (QSize) -> None """ Set the tab button icon size. """ if self.__tabIconSize != size: self.__tabIconSize = QSize(size) for page in self.__pages: page.button.setIconSize(size) def tabIconSize(self): # type: () -> QSize """ Return the tab icon size. """ return QSize(self.__tabIconSize) def tabButton(self, index): # type: (int) -> QAbstractButton """ Return the tab button at `index` """ return self.__pages[index].button def tabAction(self, index): # type: (int) -> QAction """ Return open/close action for the tab at `index`. """ return self.__pages[index].action def addItem(self, widget, text, icon=QIcon(), toolTip=""): # type: (QWidget, str, QIcon, str) -> int """ Append the `widget` in a new tab and return its index. Parameters ---------- widget : QWidget A widget to be inserted. The toolbox takes ownership of the widget. text : str Name/title of the new tab. icon : QIcon An icon for the tab button. toolTip : str Tool tip for the tab button. Returns ------- index : int Index of the inserted tab """ return self.insertItem(self.count(), widget, text, icon, toolTip) def insertItem(self, index, widget, text, icon=QIcon(), toolTip=""): # type: (int, QWidget, str, QIcon, str) -> int """ Insert the `widget` in a new tab at position `index`. See also -------- ToolBox.addItem """ button = self.createTabButton(widget, text, icon, toolTip) self.__contentsLayout.insertWidget(index * 2, button) self.__contentsLayout.insertWidget(index * 2 + 1, widget) widget.hide() page = _ToolBoxPage(index, widget, button.defaultAction(), button) self.__pages.insert(index, page) # update the indices __pages list for i in range(index + 1, self.count()): self.__pages[i] = self.__pages[i]._replace(index=i) self.__updatePositions() # Show (open) the first tab. if self.count() == 1 and index == 0: page.action.trigger() self.__updateSelected() self.updateGeometry() return index def removeItem(self, index): # type: (int) -> None """ Remove the widget at `index`. Note ---- The widget is hidden but is is not deleted. It is up to the caller to delete it. """ self.__contentsLayout.takeAt(2 * index + 1) self.__contentsLayout.takeAt(2 * index) page = self.__pages.pop(index) # Update the page indexes for i in range(index, self.count()): self.__pages[i] = self.__pages[i]._replace(index=i) page.button.deleteLater() # Hide the widget and reparent to self # This follows QToolBox.removeItem page.widget.hide() page.widget.setParent(self) self.__updatePositions() self.__updateSelected() self.updateGeometry() def count(self): # type: () -> int """ Return the number of widgets inserted in the toolbox. """ return len(self.__pages) def widget(self, index): # type: (int) -> QWidget """ Return the widget at `index`. """ return self.__pages[index].widget def createTabButton(self, widget, text, icon=QIcon(), toolTip=""): # type: (QWidget, str, QIcon, str) -> QAbstractButton """ Create the tab button for `widget`. """ action = QAction(text, self) action.setCheckable(True) if icon: action.setIcon(icon) if toolTip: action.setToolTip(toolTip) self.__tabActionGroup.addAction(action) self.__actionMapper.setMapping(action, action) action.toggled.connect(self.__actionMapper.map) button = ToolBoxTabButton(self, objectName="toolbox-tab-button") button.setDefaultAction(action) button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) if self.__tabIconSize.isValid(): button.setIconSize(self.__tabIconSize) if self.__tabButtonHeight > 0: button.setFixedHeight(self.__tabButtonHeight) return button def ensureWidgetVisible(self, child, xmargin=50, ymargin=50): # type: (QWidget, int, int) -> None """ Scroll the contents so child widget instance is visible inside the viewport. """ self.__scrollArea.ensureWidgetVisible(child, xmargin, ymargin) def sizeHint(self): # type: () -> QSize """ Reimplemented. """ hint = self.__contentsLayout.sizeHint() if self.count(): # Compute max width of hidden widgets also. scroll = self.__scrollArea # check if scrollbar is transient scrollBar = self.__scrollArea.verticalScrollBar() transient = scrollBar.style().styleHint(QStyle.SH_ScrollBar_Transient, widget=scrollBar) scroll_w = scroll.verticalScrollBar().sizeHint().width() if not transient else 0 frame_w = self.frameWidth() * 2 + scroll.frameWidth() * 2 max_w = max([p.widget.sizeHint().width() for p in self.__pages]) hint = QSize(max(max_w, hint.width()) + scroll_w + frame_w, hint.height()) return QSize(200, 200).expandedTo(hint) def __onTabActionToggled(self, action): # type: (QAction) -> None page = find(self.__pages, action, key=attrgetter("action")) on = action.isChecked() page.widget.setVisible(on) index = page.index if index > 0: # Update the `previous` tab buttons style hints previous = self.__pages[index - 1].button previous.selected = set_flag( previous.selected, ToolBoxTabButton.NextIsSelected, on ) previous.update() if index < self.count() - 1: next = self.__pages[index + 1].button next.selected = set_flag( next.selected, ToolBoxTabButton.PreviousIsSelected, on ) next.update() self.tabToggled.emit(index, on) self.__contentsLayout.invalidate() def __updateSelected(self): # type: () -> None """Update the tab buttons selected style flags. """ if self.count() == 0: return def update(button, next_sel, prev_sel): # type: (ToolBoxTabButton, bool, bool) -> None button.selected = set_flag( button.selected, ToolBoxTabButton.NextIsSelected, next_sel ) button.selected = set_flag( button.selected, ToolBoxTabButton.PreviousIsSelected, prev_sel ) button.update() if self.count() == 1: update(self.__pages[0].button, False, False) elif self.count() >= 2: pages = self.__pages for i in range(1, self.count() - 1): update(pages[i].button, pages[i + 1].action.isChecked(), pages[i - 1].action.isChecked()) def __updatePositions(self): # type: () -> None """Update the tab buttons position style flags. """ if self.count() == 0: return elif self.count() == 1: self.__pages[0].button.position = ToolBoxTabButton.OnlyOneTab else: self.__pages[0].button.position = ToolBoxTabButton.Beginning self.__pages[-1].button.position = ToolBoxTabButton.End for p in self.__pages[1:-1]: p.button.position = ToolBoxTabButton.Middle for p in self.__pages: p.button.update() if typing.TYPE_CHECKING: A = typing.TypeVar("A") B = typing.TypeVar("B") C = typing.TypeVar("C") def identity(arg): return arg def find(iterable, what, key=identity, predicate=eq): # type: (Iterable[A], B, Callable[[A], C], Callable[[C, B], bool]) -> A """ find(iterable, what, [key=None, [predicate=operator.eq]]) """ for item in iterable: item_key = key(item) if predicate(item_key, what): return item else: raise ValueError(what) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/toolgrid.py0000644000175100002000000004212514730024325022360 0ustar00runnerdocker""" A widget containing a grid of clickable actions/buttons. """ import sys from collections import deque from typing import NamedTuple, List, Iterable, Optional, Any, Union, cast from AnyQt.QtWidgets import ( QFrame, QAction, QToolButton, QGridLayout, QSizePolicy, QStyleOptionToolButton, QStylePainter, QStyle, QApplication, QWidget ) from AnyQt.QtGui import ( QFont, QFontMetrics, QActionEvent, QPaintEvent, QResizeEvent, ) from AnyQt.QtCore import Qt, QObject, QSize, QEvent, QSignalMapper from AnyQt.QtCore import Signal, Slot from orangecanvas.registry import WidgetDescription __all__ = [ "ToolGrid" ] _ToolGridSlot = NamedTuple( "_ToolGridSlot", ( ("button", QToolButton), ("action", QAction), ("row", int), ("column", int), ) ) def qfont_scaled(font, factor): # type: (QFont, float) -> QFont scaled = QFont(font) if font.pointSizeF() != -1: scaled.setPointSizeF(font.pointSizeF() * factor) elif font.pixelSize() != -1: scaled.setPixelSize(int(font.pixelSize() * factor)) return scaled class ToolGridButton(QToolButton): def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.__text = "" self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) if sys.platform != "darwin": font = QApplication.font("QWidget") self.setFont(qfont_scaled(font, 0.85)) self.setAttribute(Qt.WA_SetFont, False) def actionEvent(self, event): # type: (QActionEvent) -> None super().actionEvent(event) if event.type() == QEvent.ActionChanged or \ event.type() == QEvent.ActionAdded: self.__textLayout() def resizeEvent(self, event): # type: (QResizeEvent) -> None super().resizeEvent(event) self.__textLayout() def __textLayout(self): # type: () -> None fm = self.fontMetrics() desc = self.defaultAction().data() if isinstance(desc, WidgetDescription) and desc.short_name: self.__text = desc.short_name return text = self.defaultAction().text() words = text.split() option = QStyleOptionToolButton() option.initFrom(self) margin = self.style().pixelMetric(QStyle.PM_ButtonMargin, option, self) min_width = self.width() - 2 * margin lines = [] if fm.boundingRect(" ".join(words)).width() <= min_width or len(words) <= 1: lines = [" ".join(words)] else: best_w, best_l = sys.maxsize, ['', ''] for i in range(1, len(words)): l1 = " ".join(words[:i]) l2 = " ".join(words[i:]) width = max( fm.boundingRect(l1).width(), fm.boundingRect(l2).width() ) if width < best_w: best_w = width best_l = [l1, l2] lines = best_l # elide the end of each line if too long lines = [ fm.elidedText(l, Qt.ElideRight, self.width() - margin) for l in lines ] text = "\n".join(lines) text = text.replace('&', '&&') # Need escaped ampersand to show self.__text = text def paintEvent(self, event): # type: (QPaintEvent) -> None p = QStylePainter(self) opt = QStyleOptionToolButton() self.initStyleOption(opt) p.drawComplexControl(QStyle.CC_ToolButton, opt) p.end() def initStyleOption(self, option): # type: (QStyleOptionToolButton) -> None super().initStyleOption(option) if self.__text: option.text = self.__text def sizeHint(self): # type: () -> QSize opt = QStyleOptionToolButton() self.initStyleOption(opt) style = self.style() csize = opt.iconSize # type: QSize fm = opt.fontMetrics # type: QFontMetrics margin = style.pixelMetric(QStyle.PM_ButtonMargin) # content size is: # * vertical: icon + margin + 2 * font ascent # * horizontal: icon * 3 / 2 csize.setHeight(csize.height() + margin + 2 * fm.lineSpacing()) csize.setWidth(csize.width() * 3 // 2) size = style.sizeFromContents( QStyle.CT_ToolButton, opt, csize, self) return size class ToolGrid(QFrame): """ A widget containing a grid of actions/buttons. Actions can be added using standard :func:`QWidget.addAction(QAction)` and :func:`QWidget.insertAction(int, QAction)` methods. Parameters ---------- parent : :class:`QWidget` Parent widget. columns : int Number of columns in the grid layout. buttonSize : QSize Size of tool buttons in the grid. iconSize : QSize Size of icons in the buttons. toolButtonStyle : :class:`Qt.ToolButtonStyle` Tool button style. """ #: Signal emitted when an action is triggered actionTriggered = Signal(QAction) #: Signal emitted when an action is hovered actionHovered = Signal(QAction) def __init__(self, parent=None, columns=4, buttonSize=QSize(), iconSize=QSize(), toolButtonStyle=Qt.ToolButtonTextUnderIcon, **kwargs): # type: (Optional[QWidget], int, QSize, QSize, Qt.ToolButtonStyle, Any) -> None sizePolicy = kwargs.pop("sizePolicy", None) # type: Optional[QSizePolicy] super().__init__(parent, **kwargs) if buttonSize is None: buttonSize = QSize() if iconSize is None: iconSize = QSize() self.__columns = columns self.__buttonSize = QSize(buttonSize) self.__iconSize = QSize(iconSize) self.__toolButtonStyle = toolButtonStyle self.__gridSlots = [] # type: List[_ToolGridSlot] self.__mapper = QSignalMapper() self.__mapper.mappedObject.connect(self.__onClicked) layout = QGridLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.setColumnStretch(columns - 1, 1000) self.setLayout(layout) if sizePolicy is None: self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding) self.setAttribute(Qt.WA_WState_OwnSizePolicy, True) else: self.setSizePolicy(sizePolicy) def setButtonSize(self, size): # type: (QSize) -> None """ Set the button size. """ if self.__buttonSize != size: self.__buttonSize = QSize(size) for slot in self.__gridSlots: slot.button.setFixedSize(size) def buttonSize(self): # type: () -> QSize """ Return the button size. """ return QSize(self.__buttonSize) def setIconSize(self, size): # type: (QSize) -> None """ Set the button icon size. The default icon size is style defined. """ if self.__iconSize != size: self.__iconSize = QSize(size) size = self.__effectiveIconSize() for slot in self.__gridSlots: slot.button.setIconSize(size) def iconSize(self): # type: () -> QSize """ Return the icon size. If no size is set a default style defined size is returned. """ return self.__effectiveIconSize() def __effectiveIconSize(self): # type: () -> QSize if not self.__iconSize.isValid(): opt = QStyleOptionToolButton() opt.initFrom(self) s = self.style().pixelMetric(QStyle.PM_LargeIconSize, opt, None) return QSize(s, s) else: return QSize(self.__iconSize) def changeEvent(self, event): # type: (QEvent) -> None if event.type() == QEvent.StyleChange: size = self.__effectiveIconSize() for item in self.__gridSlots: item.button.setIconSize(size) super().changeEvent(event) def setToolButtonStyle(self, style): # type: (Qt.ToolButtonStyle) -> None """ Set the tool button style. """ if self.__toolButtonStyle != style: self.__toolButtonStyle = style for slot in self.__gridSlots: slot.button.setToolButtonStyle(style) def toolButtonStyle(self): # type: () -> Qt.ToolButtonStyle """ Return the tool button style. """ return self.__toolButtonStyle def setColumnCount(self, columns): # type: (int) -> None """ Set the number of button/action columns. """ if self.__columns != columns: layout = cast(QGridLayout, self.layout()) layout.setColumnStretch(self.__columns - 1, 0) layout.setColumnStretch(columns - 1, 1000) self.__columns = columns self.__relayout() def columns(self): # type: () -> int """ Return the number of columns in the grid. """ return self.__columns def clear(self): # type: () -> None """ Clear all actions/buttons. """ for slot in reversed(list(self.__gridSlots)): self.removeAction(slot.action) self.__gridSlots = [] def insertAction(self, before, action): # type: (Union[QAction, int], QAction) -> None """ Insert a new action at the position currently occupied by `before` (can also be an index). Parameters ---------- before : :class:`QAction` or int Position where the `action` should be inserted. action : :class:`QAction` Action to insert """ if isinstance(before, int): actions = list(self.actions()) if len(actions) == 0 or before >= len(actions): # Insert as the first action or the last action. return self.addAction(action) before = actions[before] return super().insertAction(before, action) def setActions(self, actions): # type: (Iterable[QAction]) -> None """ Clear the grid and add `actions`. """ self.clear() for action in actions: self.addAction(action) def buttonForAction(self, action): # type: (QAction) -> QToolButton """ Return the :class:`QToolButton` instance button for `action`. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) return self.__gridSlots[index].button def createButtonForAction(self, action): # type: (QAction) -> QToolButton """ Create and return a :class:`QToolButton` for action. """ button = ToolGridButton(self) button.setDefaultAction(action) if self.__buttonSize.isValid(): button.setFixedSize(self.__buttonSize) button.setIconSize(self.__effectiveIconSize()) button.setToolButtonStyle(self.__toolButtonStyle) button.setProperty("tool-grid-button", True) return button def count(self): # type: () -> int """ Return the number of buttons/actions in the grid. """ return len(self.__gridSlots) def actionEvent(self, event): # type: (QActionEvent) -> None super().actionEvent(event) if event.type() == QEvent.ActionAdded: # Note: the action is already in the self.actions() list. actions = list(self.actions()) index = actions.index(event.action()) self.__insertActionButton(index, event.action()) elif event.type() == QEvent.ActionRemoved: self.__removeActionButton(event.action()) def __insertActionButton(self, index, action): # type: (int, QAction) -> None """Create a button for the action and add it to the layout at index. """ self.__shiftGrid(index, 1) button = self.createButtonForAction(action) row = index // self.__columns column = index % self.__columns layout = cast(QGridLayout, self.layout()) layout.addWidget(button, row, column, alignment=Qt.AlignTop | Qt.AlignLeft) self.__gridSlots.insert( index, _ToolGridSlot(button, action, row, column) ) self.__mapper.setMapping(button, action) button.clicked.connect(self.__mapper.map) button.installEventFilter(self) def __removeActionButton(self, action): # type: (QAction) -> None """Remove the button for the action from the layout and delete it. """ actions = [slot.action for slot in self.__gridSlots] index = actions.index(action) slot = self.__gridSlots.pop(index) slot.button.removeEventFilter(self) self.__mapper.removeMappings(slot.button) self.layout().removeWidget(slot.button) self.__shiftGrid(index + 1, -1) slot.button.deleteLater() def __shiftGrid(self, start, count=1): # type: (int, int) -> None """Shift all buttons starting at index `start` by `count` cells. """ layout = cast(QGridLayout, self.layout()) cell_count = layout.rowCount() * layout.columnCount() columns = self.__columns direction = 1 if count >= 0 else -1 if direction == 1: start, end = cell_count - 1, start - 1 else: start, end = start, cell_count for index in range(start, end, -direction): item = layout.itemAtPosition( index // columns, index % columns ) if item: button = item.widget() new_index = index + count layout.addWidget( button, new_index // columns, new_index % columns, Qt.AlignLeft | Qt.AlignTop ) def __relayout(self): # type: () -> None """Relayout the buttons. """ layout = cast(QGridLayout, self.layout()) for i in reversed(range(layout.count())): layout.takeAt(i) self.__gridSlots = [ _ToolGridSlot(slot.button, slot.action, i // self.__columns, i % self.__columns) for i, slot in enumerate(self.__gridSlots) ] for slot in self.__gridSlots: layout.addWidget(slot.button, slot.row, slot.column) def __indexOf(self, button): # type: (QWidget) -> int """Return the index of button widget. """ buttons = [slot.button for slot in self.__gridSlots] return buttons.index(button) def __onButtonEnter(self, button): # type: (QToolButton) -> None action = button.defaultAction() self.actionHovered.emit(action) @Slot(QObject) def __onClicked(self, action): # type: (QAction) -> None assert isinstance(action, QAction) self.actionTriggered.emit(action) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool etype = event.type() if etype == QEvent.KeyPress and obj.hasFocus(): key = event.key() if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: if self.__focusMove(obj, key): event.accept() return True elif etype == QEvent.HoverEnter and obj.parent() is self: self.__onButtonEnter(obj) return super().eventFilter(obj, event) def focusNextPrevChild(self, next: bool) -> bool: return self.__focusMove( self.focusWidget(), Qt.Key_Right if next else Qt.Key_Left ) def __focusMove(self, focus, key): # type: (QWidget, Qt.Key) -> bool assert focus is self.focusWidget() try: index = self.__indexOf(focus) except IndexError: return False if key == Qt.Key_Down: index += self.__columns elif key == Qt.Key_Up: index -= self.__columns elif key == Qt.Key_Left: index -= 1 elif key == Qt.Key_Right: index += 1 if 0 <= index < self.count(): button = self.__gridSlots[index].button button.setFocus(Qt.TabFocusReason) return True else: return False def sizeHint(self) -> QSize: sh = super().sizeHint() if self.__buttonSize.isValid(): width = self.__buttonSize.width() else: option = QStyleOptionToolButton() option.initFrom(self) option.iconSize = self.iconSize() option.toolButtonStyle = self.toolButtonStyle() csize = QSize(option.iconSize) csize.setWidth(csize.width() * 3 // 2) # see ToolGridButton size = self.style().sizeFromContents(QStyle.CT_ToolButton, option, csize, None) width = size.width() layout = self.layout() spacing = layout.horizontalSpacing() columns = self.__columns width = width * columns + (max(columns - 1, 0) * spacing) sh.setWidth(max(sh.width(), width)) return sh ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/tooltree.py0000644000175100002000000003177614730024325022404 0ustar00runnerdocker""" ========= Tool Tree ========= A ToolTree widget presenting the user with a set of actions organized in a tree structure. """ import enum from typing import Any, Dict, Tuple, List, Optional from AnyQt.QtWidgets import ( QTreeView, QWidget, QVBoxLayout, QSizePolicy, QStyle, QAction, ) from AnyQt.QtGui import QStandardItemModel from AnyQt.QtCore import ( Qt, QEvent, QModelIndex, QAbstractItemModel, QAbstractProxyModel, QObject ) from AnyQt.QtCore import pyqtSignal as Signal __all__ = [ "ToolTree", "FlattenedTreeItemModel" ] class ToolTree(QWidget): """ A ListView like presentation of a list of actions. """ triggered = Signal(QAction) hovered = Signal(QAction) def __init__(self, parent=None, **kwargs): # type: (Optional[QWidget], Any) -> None super().__init__(parent, **kwargs) self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding) self.__model = QStandardItemModel() # type: QAbstractItemModel self.__flattened = False self.__actionRole = Qt.UserRole layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) view = QTreeView(objectName="tool-tree-view") view.setUniformRowHeights(True) view.setFrameStyle(QTreeView.NoFrame) view.setModel(self.__model) view.setRootIsDecorated(False) view.setHeaderHidden(True) view.setItemsExpandable(True) view.setEditTriggers(QTreeView.NoEditTriggers) view.activated.connect(self.__onActivated) view.clicked.connect(self.__onActivated) view.entered.connect(self.__onEntered) view.installEventFilter(self) self.__view = view # type: QTreeView layout.addWidget(view) self.setLayout(layout) def setFlattened(self, flatten): # type: (bool) -> None """ Show the actions in a flattened view. """ if self.__flattened != flatten: self.__flattened = flatten if flatten: model = FlattenedTreeItemModel() model.setSourceModel(self.__model) else: model = self.__model self.__view.setModel(model) def flattened(self): # type: () -> bool """ Are actions shown in a flattened tree (a list). """ return self.__flattened def setModel(self, model): # type: (QAbstractItemModel) -> None if self.__model is not model: self.__model = model if self.__flattened: model = FlattenedTreeItemModel() model.setSourceModel(self.__model) self.__view.setModel(model) def model(self): # type: () -> QAbstractItemModel return self.__model def setRootIndex(self, index): # type: (QModelIndex) -> None """Set the root index """ self.__view.setRootIndex(index) def rootIndex(self): # type: () -> QModelIndex """Return the root index. """ return self.__view.rootIndex() def view(self): # type: () -> QTreeView """Return the QTreeView instance used. """ return self.__view def setActionRole(self, role): # type: (Qt.ItemDataRole) -> None """Set the action role. By default this is Qt.UserRole """ self.__actionRole = role def actionRole(self): # type: () -> Qt.ItemDataRole return self.__actionRole def __actionForIndex(self, index): # type: (QModelIndex) -> Optional[QAction] val = index.data(self.__actionRole) if isinstance(val, QAction): return val else: return None def __onActivated(self, index): # type: (QModelIndex) -> None """The item was activated, if index has an action we need to trigger it. """ if index.isValid(): action = self.__actionForIndex(index) if action is not None: action.trigger() self.triggered.emit(action) def __onEntered(self, index): # type: (QModelIndex) -> None if index.isValid(): action = self.__actionForIndex(index) if action is not None: action.hover() self.hovered.emit(action) def ensureCurrent(self): # type: () -> None """Ensure the view has a current item if one is available. """ model = self.__view.model() curr = self.__view.currentIndex() if not curr.isValid(): for i in range(model.rowCount()): index = model.index(i, 0) if index.flags() & Qt.ItemIsEnabled: self.__view.setCurrentIndex(index) break def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if obj is self.__view and event.type() == QEvent.KeyPress: key = event.key() space_activates = \ self.style().styleHint( QStyle.SH_Menu_SpaceActivatesItem, None, None) if key in [Qt.Key_Enter, Qt.Key_Return, Qt.Key_Select] or \ (key == Qt.Key_Space and space_activates): index = self.__view.currentIndex() if index.isValid() and index.flags() & Qt.ItemIsEnabled: # Emit activated on behalf of QTreeView. self.__view.activated.emit(index) return True return super().eventFilter(obj, event) class FlattenedTreeItemModel(QAbstractProxyModel): """An Proxy Item model containing a flattened view of a column in a tree like item model. """ class Mode(enum.IntEnum): Default = 1 InternalNodesDisabled = 2 LeavesOnly = 4 Default = Mode.Default InternalNodesDisabled = Mode.InternalNodesDisabled LeavesOnly = Mode.LeavesOnly def __init__(self, parent=None, **kwargs): # type: (QObject, Any) -> None super().__init__(parent, **kwargs) self.__sourceColumn = 0 self.__flatteningMode = FlattenedTreeItemModel.Default self.__sourceRootIndex = QModelIndex() self._source_key = [] # type: List[Tuple[int, ...]] self._source_offset = {} # type: Dict[Tuple[int, ...], int] def setSourceModel(self, model): # type: (QAbstractItemModel) -> None self.beginResetModel() curr_model = self.sourceModel() if curr_model is not None: curr_model.dataChanged.disconnect(self._sourceDataChanged) curr_model.rowsInserted.disconnect(self._sourceRowsInserted) curr_model.rowsRemoved.disconnect(self._sourceRowsRemoved) curr_model.rowsMoved.disconnect(self._sourceRowsMoved) super().setSourceModel(model) self._updateRowMapping() model.dataChanged.connect(self._sourceDataChanged) model.rowsInserted.connect(self._sourceRowsInserted) model.rowsRemoved.connect(self._sourceRowsRemoved) model.rowsMoved.connect(self._sourceRowsMoved) self.endResetModel() def setSourceColumn(self, column): raise NotImplementedError self.beginResetModel() self.__sourceColumn = column self._updateRowMapping() self.endResetModel() def sourceColumn(self): return self.__sourceColumn def setSourceRootIndex(self, rootIndex): # type: (QModelIndex) -> None """Set the source root index. """ self.beginResetModel() self.__sourceRootIndex = rootIndex self._updateRowMapping() self.endResetModel() def sourceRootIndex(self): # type: () -> QModelIndex """Return the source root index. """ return self.__sourceRootIndex def setFlatteningMode(self, mode): # type: (Mode) -> None """Set the flattening mode. """ if mode != self.__flatteningMode: self.beginResetModel() self.__flatteningMode = mode self._updateRowMapping() self.endResetModel() def flatteningMode(self): # type: () -> Mode """Return the flattening mode. """ return self.__flatteningMode def mapFromSource(self, sourceIndex): # type: (QModelIndex) -> QModelIndex if sourceIndex.isValid(): key = self._indexKey(sourceIndex) offset = self._source_offset[key] row = offset + sourceIndex.row() return self.index(row, 0) else: return sourceIndex def mapToSource(self, index): # type: (QModelIndex) -> QModelIndex if index.isValid(): row = index.row() source_key_path = self._source_key[row] return self._indexFromKey(source_key_path) else: return index def index(self, row, column=0, parent=QModelIndex()): # type: (int, int, QModelIndex) -> QModelIndex if not parent.isValid(): return self.createIndex(row, column, object=row) else: return QModelIndex() def parent(self, child): # type: ignore return QModelIndex() def rowCount(self, parent=QModelIndex()): # type: (QModelIndex) -> int if parent.isValid(): return 0 else: return len(self._source_key) def columnCount(self, parent=QModelIndex()): # type: (QModelIndex) -> int if parent.isValid(): return 0 else: return 1 def flags(self, index): # type: (QModelIndex) -> Qt.ItemFlags flags = super().flags(index) if self.__flatteningMode == self.InternalNodesDisabled: sourceIndex = self.mapToSource(index) sourceModel = self.sourceModel() if sourceModel is not None and \ sourceModel.rowCount(sourceIndex) > 0 and \ flags & Qt.ItemIsEnabled: # Internal node, enabled in the source model, disable it flags ^= Qt.ItemIsEnabled # type: ignore return flags def _indexKey(self, index): # type: (QModelIndex) -> Tuple[int, ...] """Return a key for `index` from the source model into the _source_offset map. The key is a tuple of row indices on the path from the top if the model to the `index`. """ key_path = [] parent = index while parent.isValid(): key_path.append(parent.row()) parent = parent.parent() return tuple(reversed(key_path)) def _indexFromKey(self, key_path): # type: (Tuple[int, ...]) -> QModelIndex """Return an source QModelIndex for the given key. """ model = self.sourceModel() if model is None: return QModelIndex() index = model.index(key_path[0], 0) for row in key_path[1:]: index = model.index(row, 0, index) return index def _updateRowMapping(self): # type: () -> None source = self.sourceModel() source_key = [] # type: List[Tuple[int, ...]] source_offset_map = {} # type: Dict[Tuple[int, ...], int] def create_mapping(model, index, key_path): # type: (QAbstractItemModel, QModelIndex, Tuple[int, ...]) -> None if model.rowCount(index) > 0: if self.__flatteningMode != self.LeavesOnly: source_offset_map[key_path] = len(source_offset_map) source_key.append(key_path) for i in range(model.rowCount(index)): create_mapping(model, model.index(i, 0, index), key_path + (i, )) else: source_offset_map[key_path] = len(source_offset_map) source_key.append(key_path) if source is not None: for i in range(source.rowCount()): create_mapping(source, source.index(i, 0), (i,)) self._source_key = source_key self._source_offset = source_offset_map def _sourceDataChanged(self, top, bottom): # type: (QModelIndex, QModelIndex) -> None changed_indexes = [] for i in range(top.row(), bottom.row() + 1): source_ind = top.sibling(i, 0) changed_indexes.append(source_ind) for ind in changed_indexes: self.dataChanged.emit(ind, ind) def _sourceRowsInserted(self, parent, start, end): # type: (QModelIndex, int, int) -> None self.beginResetModel() self._updateRowMapping() self.endResetModel() def _sourceRowsRemoved(self, parent, start, end): # type: (QModelIndex, int, int) -> None self.beginResetModel() self._updateRowMapping() self.endResetModel() def _sourceRowsMoved(self, sourceParent, sourceStart, sourceEnd, destParent, destRow): # type: (QModelIndex, int, int, QModelIndex, int) -> None self.beginResetModel() self._updateRowMapping() self.endResetModel() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/utils.py0000644000175100002000000005604114730024325021677 0ustar00runnerdocker""" Helper utilities """ import os import sys import traceback from typing import List import ctypes import ctypes.util import platform from contextlib import contextmanager from typing import Optional, Union from AnyQt.QtWidgets import ( QWidget, QMessageBox, QStyleOption, QStyle, QTextEdit, QScrollBar ) from AnyQt.QtGui import ( QGradient, QLinearGradient, QRadialGradient, QBrush, QPainter, QPaintEvent, QColor, QPixmap, QPixmapCache, QTextOption, QGuiApplication, QTextCharFormat, QFont ) from AnyQt.QtCore import Qt, QPointF, QPoint, QRect, QRectF, Signal, QEvent from AnyQt import sip @contextmanager def updates_disabled(widget): """Disable QWidget updates (using QWidget.setUpdatesEnabled) """ old_state = widget.updatesEnabled() widget.setUpdatesEnabled(False) try: yield finally: widget.setUpdatesEnabled(old_state) @contextmanager def signals_disabled(qobject): """Disables signals on an instance of QObject. """ old_state = qobject.signalsBlocked() qobject.blockSignals(True) try: yield finally: qobject.blockSignals(old_state) @contextmanager def disabled(qobject): """Disables a disablable QObject instance. """ if not (hasattr(qobject, "setEnabled") and hasattr(qobject, "isEnabled")): raise TypeError("%r does not have 'enabled' property" % qobject) old_state = qobject.isEnabled() qobject.setEnabled(False) try: yield finally: qobject.setEnabled(old_state) @contextmanager def disconnected(signal, slot, type=Qt.UniqueConnection): """ A context manager disconnecting a slot from a signal. :: with disconnected(scene.selectionChanged, self.onSelectionChanged): # Can change item selection in a scene without # onSelectionChanged being invoked. do_something() Warning ------- The relative order of the slot in signal's connections is not preserved. Raises ------ TypeError: If the slot was not connected to the signal """ signal.disconnect(slot) try: yield finally: signal.connect(slot, type) def StyledWidget_paintEvent(self, event): # type: (QWidget, QPaintEvent) -> None """A default styled QWidget subclass paintEvent function. """ opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) class StyledWidget(QWidget): """ """ paintEvent = StyledWidget_paintEvent # type: ignore class ScrollBar(QScrollBar): #: Emitted when the scroll bar receives a StyleChange event styleChange = Signal() def changeEvent(self, event: QEvent) -> None: if event.type() == QEvent.StyleChange: self.styleChange.emit() super().changeEvent(event) def is_transparency_supported(): # type: () -> bool """Is window transparency supported by the current windowing system. """ if sys.platform == "win32": return is_dwm_compositing_enabled() elif sys.platform == "cygwin": return False elif sys.platform == "darwin": if has_x11(): return is_x11_compositing_enabled() else: # Quartz compositor return True elif sys.platform.startswith("linux"): # TODO: wayland?? return is_x11_compositing_enabled() elif sys.platform.startswith("freebsd"): return is_x11_compositing_enabled() elif has_x11(): return is_x11_compositing_enabled() else: return False def has_x11(): # type: () -> bool """Is Qt build against X11 server. """ try: from AnyQt.QtX11Extras import QX11Info return True except ImportError: return False def is_x11_compositing_enabled(): # type: () -> bool """Is X11 compositing manager running. """ try: from AnyQt.QtX11Extras import QX11Info except ImportError: return False if hasattr(QX11Info, "isCompositingManagerRunning"): return QX11Info.isCompositingManagerRunning() else: # not available on Qt5 return False # ? def is_dwm_compositing_enabled(): # type: () -> bool """Is Desktop Window Manager compositing (Aero) enabled. """ enabled = ctypes.c_bool(False) try: DwmIsCompositionEnabled = \ ctypes.windll.dwmapi.DwmIsCompositionEnabled # type: ignore except (AttributeError, WindowsError): # dwmapi or DwmIsCompositionEnabled is not present return False rval = DwmIsCompositionEnabled(ctypes.byref(enabled)) return rval == 0 and enabled.value def windows_set_current_process_app_user_model_id(appid: str): """ On Windows set the AppUserModelID to `appid` for the current process. Does nothing on other systems """ if os.name != "nt": return from ctypes import windll try: windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) except AttributeError: pass def macos_set_nswindow_tabbing(enable=False): # type: (bool) -> None """ Disable/enable automatic NSWindow tabbing on macOS Sierra and higher. See QTBUG-61707 """ if sys.platform != "darwin": return ver, _, _ = platform.mac_ver() ver = tuple(map(int, ver.split(".")[:2])) if ver < (10, 12): return c_char_p, c_void_p, c_bool = ctypes.c_char_p, ctypes.c_void_p, ctypes.c_bool id = Sel = Class = c_void_p def annotate(func, restype, argtypes): func.restype = restype func.argtypes = argtypes return func try: libobjc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("libobjc")) # Load AppKit.framework which contains NSWindow class # pylint: disable=unused-variable AppKit = ctypes.cdll.LoadLibrary(ctypes.util.find_library("AppKit")) objc_getClass = annotate( libobjc.objc_getClass, Class, [c_char_p]) # A prototype for objc_msgSend for selector with a bool argument. # `(void *)(*)(void *, void *, bool)` objc_msgSend_bool = annotate( libobjc.objc_msgSend, id, [id, Sel, c_bool]) sel_registerName = annotate( libobjc.sel_registerName, Sel, [c_char_p]) class_getClassMethod = annotate( libobjc.class_getClassMethod, c_void_p, [Class, Sel]) except (OSError, AttributeError): return NSWindow = objc_getClass(b"NSWindow") if NSWindow is None: return setAllowsAutomaticWindowTabbing = sel_registerName( b'setAllowsAutomaticWindowTabbing:' ) # class_respondsToSelector does not work (for class methods) if class_getClassMethod(NSWindow, setAllowsAutomaticWindowTabbing): # [NSWindow setAllowsAutomaticWindowTabbing: NO] objc_msgSend_bool( NSWindow, setAllowsAutomaticWindowTabbing, c_bool(enable), ) def gradient_darker(grad, factor): # type: (QGradient, float) -> QGradient """Return a copy of the QGradient darkened by factor. .. note:: Only QLinearGradeint and QRadialGradient are supported. """ if type(grad) is QGradient: if grad.type() == QGradient.LinearGradient: grad = sip.cast(grad, QLinearGradient) elif grad.type() == QGradient.RadialGradient: grad = sip.cast(grad, QRadialGradient) if isinstance(grad, QLinearGradient): new_grad = QLinearGradient(grad.start(), grad.finalStop()) elif isinstance(grad, QRadialGradient): new_grad = QRadialGradient(grad.center(), grad.radius(), grad.focalPoint()) else: raise TypeError new_grad.setCoordinateMode(grad.coordinateMode()) for pos, color in grad.stops(): new_grad.setColorAt(pos, color.darker(factor)) return new_grad def brush_darker(brush: QBrush, factor: bool) -> QBrush: """Return a copy of the brush darkened by factor. """ grad = brush.gradient() if grad: return QBrush(gradient_darker(grad, factor)) else: brush = QBrush(brush) brush.setColor(brush.color().darker(factor)) return brush def create_gradient(base_color: QColor, stop=QPointF(0, 0), finalStop=QPointF(0, 1)) -> QLinearGradient: """ Create a default linear gradient using `base_color` . """ grad = QLinearGradient(stop, finalStop) grad.setStops([(0.0, base_color), (0.5, base_color), (0.8, base_color.darker(105)), (1.0, base_color.darker(110)), ]) grad.setCoordinateMode(QLinearGradient.ObjectBoundingMode) return grad def create_gradient_brush(color: QColor, stop=QPointF(0, 0), finalStop=QPointF(0, 1)) -> QBrush: """ Create a linear gradient brush using `color` as a base. """ grad = create_gradient(color, stop, finalStop) brush = QBrush(grad) brush.setColor(color) # also record the base color return brush def create_css_gradient(base_color: QColor, stop=QPointF(0, 0), finalStop=QPointF(0, 1)) -> str: """ Create a Qt css linear gradient fragment based on the `base_color`. """ gradient = create_gradient(base_color, stop, finalStop) return css_gradient(gradient) def css_gradient(gradient: QLinearGradient) -> str: """ Given an instance of a `QLinearGradient` return an equivalent qt css gradient fragment. """ stop, finalStop = gradient.start(), gradient.finalStop() x1, y1, x2, y2 = stop.x(), stop.y(), finalStop.x(), finalStop.y() stops = gradient.stops() stops = "\n".join(" stop: {0:f} {1}".format(stop, color.name()) for stop, color in stops) return ("qlineargradient(\n" " x1: {x1}, y1: {y1}, x2: {x2}, y2: {y2},\n" "{stops})").format(x1=x1, y1=y1, x2=x2, y2=y2, stops=stops) def luminance(color: QColor) -> float: """ Return the relative luminance of `color` https://en.wikipedia.org/wiki/Relative_luminance """ return (0.2126 * color.redF() + 0.7152 * color.greenF() + 0.0722 * color.blueF()) def merged_color(a: QColor, b: QColor, factor=0.5) -> QColor: """ Return a merge of colors `a` and `b` """ r = QColor() r.setRgbF( factor * a.redF() + (1. - factor) * b.redF(), factor * a.greenF() + (1. - factor) * b.greenF(), factor * a.blueF() + (1. - factor) * b.blueF() ) return r def message_critical(text, title=None, informative_text=None, details=None, buttons=None, default_button=None, exc_info=False, parent=None): """Show a critical message. """ if not text: text = "An unexpected error occurred." if title is None: title = "Error" return message(QMessageBox.Critical, text, title, informative_text, details, buttons, default_button, exc_info, parent) def message_warning(text, title=None, informative_text=None, details=None, buttons=None, default_button=None, exc_info=False, parent=None): """Show a warning message. """ if not text: import random text_candidates = ["Death could come at any moment.", "Murphy lurks about. Remember to save frequently." ] text = random.choice(text_candidates) if title is not None: title = "Warning" return message(QMessageBox.Warning, text, title, informative_text, details, buttons, default_button, exc_info, parent) def message_information(text, title=None, informative_text=None, details=None, buttons=None, default_button=None, exc_info=False, parent=None): """Show an information message box. """ if title is None: title = "Information" if not text: text = "I am not a number." return message(QMessageBox.Information, text, title, informative_text, details, buttons, default_button, exc_info, parent) def message_question(text, title, informative_text=None, details=None, buttons=None, default_button=None, exc_info=False, parent=None): """Show an message box asking the user to select some predefined course of action (set by buttons argument). """ return message(QMessageBox.Question, text, title, informative_text, details, buttons, default_button, exc_info, parent) def message(icon, text, title=None, informative_text=None, details=None, buttons=None, default_button=None, exc_info=False, parent=None): """Show a message helper function. """ if title is None: title = "Message" if not text: text = "I am neither a postman nor a doctor." if buttons is None: buttons = QMessageBox.Ok if details is None and exc_info: details = traceback.format_exc(limit=20) mbox = QMessageBox(icon, title, text, buttons, parent) if informative_text: mbox.setInformativeText(informative_text) if details: mbox.setDetailedText(details) dtextedit = mbox.findChild(QTextEdit) if dtextedit is not None: dtextedit.setWordWrapMode(QTextOption.NoWrap) if default_button is not None: mbox.setDefaultButton(default_button) return mbox.exec() def innerGlowBackgroundPixmap(color, size, radius=5): """ Draws radial gradient pixmap, then uses that to draw a rounded-corner gradient rectangle pixmap. Args: color (QColor): used as outer color (lightness 245 used for inner) size (QSize): size of output pixmap radius (int): radius of inner glow rounded corners """ key = "InnerGlowBackground " + \ color.name() + " " + \ str(radius) bg = QPixmapCache.find(key) if bg: return bg # set background colors for gradient color = color.toHsl() light_color = color.fromHsl(color.hslHue(), color.hslSaturation(), 245) dark_color = color # initialize radial gradient center = QPoint(radius, radius) pixRect = QRect(0, 0, radius * 2, radius * 2) gradientPixmap = QPixmap(radius * 2, radius * 2) gradientPixmap.fill(dark_color) # draw radial gradient pixmap pixPainter = QPainter(gradientPixmap) pixPainter.setPen(Qt.NoPen) gradient = QRadialGradient(QPointF(center), radius - 1) gradient.setColorAt(0, light_color) gradient.setColorAt(1, dark_color) pixPainter.setBrush(gradient) pixPainter.drawRect(pixRect) pixPainter.end() # set tl and br to the gradient's square-shaped rect tl = QPoint(0, 0) br = QPoint(size.width(), size.height()) # fragments of radial gradient pixmap to create rounded gradient outline rectangle frags = [ # top-left corner QPainter.PixmapFragment.create( QPointF(tl.x() + radius / 2, tl.y() + radius / 2), QRectF(0, 0, radius, radius) ), # top-mid 'linear gradient' QPainter.PixmapFragment.create( QPointF(tl.x() + (br.x() - tl.x()) / 2, tl.y() + radius / 2), QRectF(radius, 0, 1, radius), scaleX=(br.x() - tl.x() - 2 * radius) ), # top-right corner QPainter.PixmapFragment.create( QPointF(br.x() - radius / 2, tl.y() + radius / 2), QRectF(radius, 0, radius, radius) ), # left-mid 'linear gradient' QPainter.PixmapFragment.create( QPointF(tl.x() + radius / 2, tl.y() + (br.y() - tl.y()) / 2), QRectF(0, radius, radius, 1), scaleY=(br.y() - tl.y() - 2 * radius) ), # mid solid QPainter.PixmapFragment.create( QPointF(tl.x() + (br.x() - tl.x()) / 2, tl.y() + (br.y() - tl.y()) / 2), QRectF(radius, radius, 1, 1), scaleX=(br.x() - tl.x() - 2 * radius), scaleY=(br.y() - tl.y() - 2 * radius) ), # right-mid 'linear gradient' QPainter.PixmapFragment.create( QPointF(br.x() - radius / 2, tl.y() + (br.y() - tl.y()) / 2), QRectF(radius, radius, radius, 1), scaleY=(br.y() - tl.y() - 2 * radius) ), # bottom-left corner QPainter.PixmapFragment.create( QPointF(tl.x() + radius / 2, br.y() - radius / 2), QRectF(0, radius, radius, radius) ), # bottom-mid 'linear gradient' QPainter.PixmapFragment.create( QPointF(tl.x() + (br.x() - tl.x()) / 2, br.y() - radius / 2), QRectF(radius, radius, 1, radius), scaleX=(br.x() - tl.x() - 2 * radius) ), # bottom-right corner QPainter.PixmapFragment.create( QPointF(br.x() - radius / 2, br.y() - radius / 2), QRectF(radius, radius, radius, radius) ), ] # draw icon background to pixmap outPix = QPixmap(size.width(), size.height()) outPainter = QPainter(outPix) outPainter.setPen(Qt.NoPen) outPainter.drawPixmapFragments(frags, gradientPixmap, QPainter.OpaqueHint) outPainter.end() QPixmapCache.insert(key, outPix) return outPix def shadowTemplatePixmap(color, length): """ Returns 1 pixel wide, `length` pixels long linear-gradient. Args: color (QColor): shadow color length (int): length of cast shadow """ key = "InnerShadowTemplate " + \ color.name() + " " + \ str(length) # get cached template shadowPixmap = QPixmapCache.find(key) if shadowPixmap: return shadowPixmap shadowPixmap = QPixmap(1, length) shadowPixmap.fill(Qt.transparent) grad = QLinearGradient(0, 0, 0, length) grad.setColorAt(0, color) grad.setColorAt(1, Qt.transparent) painter = QPainter() painter.begin(shadowPixmap) painter.fillRect(shadowPixmap.rect(), grad) painter.end() # cache template QPixmapCache.insert(key, shadowPixmap) return shadowPixmap def innerShadowPixmap(color, size, pos, length=5): """ Args: color (QColor): shadow color size (QSize): size of pixmap pos (int): shadow position int flag, use bitwise operations 1 - top 2 - right 4 - bottom 8 - left length (int): length of cast shadow """ key = "InnerShadow " + \ color.name() + " " + \ str(size) + " " + \ str(pos) + " " + \ str(length) # get cached shadow if it exists finalShadow = QPixmapCache.find(key) if finalShadow: return finalShadow shadowTemplate = shadowTemplatePixmap(color, length) finalShadow = QPixmap(size) finalShadow.fill(Qt.transparent) shadowPainter = QPainter(finalShadow) shadowPainter.setCompositionMode(QPainter.CompositionMode_Darken) # top/bottom rect targetRect = QRect(0, 0, size.width(), length) # shadow on top if pos & 1: shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) # shadow on bottom if pos & 4: shadowPainter.save() shadowPainter.translate(QPointF(0, size.height())) shadowPainter.scale(1, -1) shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) shadowPainter.restore() # left/right rect targetRect = QRect(0, 0, size.height(), shadowTemplate.rect().height()) # shadow on the right if pos & 2: shadowPainter.save() shadowPainter.translate(QPointF(size.width(), 0)) shadowPainter.rotate(90) shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) shadowPainter.restore() # shadow on left if pos & 8: shadowPainter.save() shadowPainter.translate(0, size.height()) shadowPainter.rotate(-90) shadowPainter.drawPixmap(targetRect, shadowTemplate, shadowTemplate.rect()) shadowPainter.restore() shadowPainter.end() # cache shadow QPixmapCache.insert(key, finalShadow) return finalShadow def clipboard_has_format(mimetype): # type: (str) -> bool """Does the system clipboard contain data for mimetype?""" cb = QGuiApplication.clipboard() if cb is None: return False mime = cb.mimeData() if mime is None: return False return mime.hasFormat(mimetype) def clipboard_data(mimetype: str) -> Optional[bytes]: """Return the binary data of the system clipboard for mimetype.""" cb = QGuiApplication.clipboard() if cb is None: return None mime = cb.mimeData() if mime is None: return None if mime.hasFormat(mimetype): return bytes(mime.data(mimetype)) else: return None _Color = Union[QColor, QBrush, Qt.GlobalColor, QGradient] def update_char_format( baseformat: QTextCharFormat, color: Optional[_Color] = None, background: Optional[_Color] = None, weight: Optional[int] = None, italic: Optional[bool] = None, underline: Optional[bool] = None, font: Optional[QFont] = None ) -> QTextCharFormat: """ Return a copy of `baseformat` :class:`QTextCharFormat` with updated color, weight, background and font properties. """ charformat = QTextCharFormat(baseformat) if color is not None: charformat.setForeground(color) if background is not None: charformat.setBackground(background) if font is not None: assert weight is None and italic is None and underline is None charformat.setFont(font) else: if weight is not None: charformat.setFontWeight(weight) if italic is not None: charformat.setFontItalic(italic) if underline is not None: charformat.setFontUnderline(underline) return charformat def update_font( basefont: QFont, weight: Optional[int] = None, italic: Optional[bool] = None, underline: Optional[bool] = None, pixelSize: Optional[int] = None, pointSize: Optional[float] = None ) -> QFont: """ Return a copy of `basefont` :class:`QFont` with updated properties. """ font = QFont(basefont) if weight is not None: font.setWeight(weight) if italic is not None: font.setItalic(italic) if underline is not None: font.setUnderline(underline) if pixelSize is not None: font.setPixelSize(pixelSize) if pointSize is not None: font.setPointSizeF(pointSize) return font def screen_geometry(widget: QWidget, pos: Optional[QPoint] = None) -> QRect: screen = widget.screen() if pos is not None: sibling = screen.virtualSibling(pos) if sibling is not None: screen = sibling return screen.geometry() def available_screen_geometry(widget: QWidget, pos: Optional[QPoint] = None) -> QRect: screen = widget.screen() if pos is not None: sibling = screen.virtualSibling(pos) if sibling is not None: screen = sibling return screen.availableGeometry() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/gui/windowlistmanager.py0000644000175100002000000000776414730024325024305 0ustar00runnerdockerfrom typing import Sequence from AnyQt.QtCore import QObject, Signal, Slot, Qt from AnyQt.QtWidgets import QWidget, QAction, QActionGroup, QApplication from orangecanvas.utils import findf __all__ = [ "WindowListManager", ] class WindowListManager(QObject): """ An open windows list manager. Provides and manages actions for opened 'Windows' menu bar entries. """ #: Signal emitted when a widget/window is added windowAdded = Signal(QWidget, QAction) #: Signal emitted when a widget/window is removed windowRemoved = Signal(QWidget, QAction) __instance = None @staticmethod def instance() -> "WindowListManager": """Return the global WindowListManager instance.""" if WindowListManager.__instance is None: return WindowListManager() return WindowListManager.__instance def __init__(self, *args, **kwargs): if self.__instance is not None: raise RuntimeError super().__init__(*args, **kwargs) WindowListManager.__instance = self self.__group = QActionGroup( self, objectName="window-list-manager-action-group" ) self.__group.setExclusive(True) self.__windows = [] app = QApplication.instance() app.focusWindowChanged.connect( self.__focusWindowChanged, Qt.QueuedConnection ) def actionGroup(self) -> QActionGroup: """Return the QActionGroup containing the *Window* actions.""" return self.__group def addWindow(self, window: QWidget) -> None: """Add a `window` to the managed list.""" if window in self.__windows: raise ValueError(f"{window} already added") action = self.createActionForWindow(window) self.__windows.append(window) self.__group.addAction(action) self.windowAdded.emit(window, action) def removeWindow(self, window: QWidget) -> None: """Remove the `window` from the managed list.""" self.__windows.remove(window) act = self.actionForWindow(window) self.__group.removeAction(act) self.windowRemoved.emit(window, act) act.setData(None) act.setParent(None) act.deleteLater() def actionForWindow(self, window: QWidget) -> QAction: """Return the `QAction` representing the `window`.""" return findf(self.actions(), lambda a: a.data() is window) def createActionForWindow(self, window: QWidget) -> QAction: """Create the `QAction` instance for managing the `window`.""" action = QAction( window.windowTitle(), window, visible=window.isVisible(), checkable=True, objectName="action-canvas-window-list-manager-window-action" ) action.setData(window) handle = window.windowHandle() if not handle: # TODO: need better visible, title notify bypassing QWindow window.create() handle = window.windowHandle() action.setChecked(handle.isActive()) handle.visibleChanged.connect(action.setVisible) handle.windowTitleChanged.connect(action.setText) def activate(state): if not state: return handle: QWidget = action.data() handle.setVisible(True) if handle != QApplication.activeWindow(): # Do not re-activate when called from `focusWindowChanged`; # breaks macOS window cycling (CMD+`) order. handle.raise_() handle.activateWindow() action.toggled.connect(activate) return action def actions(self) -> Sequence[QAction]: """Return all actions representing managed windows.""" return self.__group.actions() @Slot() def __focusWindowChanged(self): window = QApplication.activeWindow() act = findf(self.actions(), lambda a: a.data() is window) if act is not None and not act.isChecked(): act.setChecked(True) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.790081 orange_canvas_core-0.2.5/orangecanvas/help/0000755000175100002000000000000014730024333020322 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/help/__init__.py0000644000175100002000000000010414730024325022427 0ustar00runnerdockerfrom .provider import HelpProvider from .manager import HelpManager ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/help/intersphinx.py0000644000175100002000000000432614730024325023255 0ustar00runnerdocker""" Parsers for intersphinx inventory files Taken from `sphinx.ext.intersphinx` """ import re import codecs import zlib b = str UTF8StreamReader = codecs.lookup('utf-8')[2] def read_inventory_v1(f, uri, join): f = UTF8StreamReader(f) invdata = {} line = f.next() projname = line.rstrip()[11:] line = f.next() version = line.rstrip()[11:] for line in f: name, type, location = line.rstrip().split(None, 2) location = join(uri, location) # version 1 did not add anchors to the location if type == 'mod': type = 'py:module' location += '#module-' + name else: type = 'py:' + type location += '#' + name invdata.setdefault(type, {})[name] = (projname, version, location, '-') return invdata def read_inventory_v2(f, uri, join, bufsize=16*1024): invdata = {} line = f.readline() projname = line.rstrip()[11:].decode('utf-8') line = f.readline() version = line.rstrip()[11:].decode('utf-8') line = f.readline().decode('utf-8') if 'zlib' not in line: raise ValueError def read_chunks(): decompressor = zlib.decompressobj() for chunk in iter(lambda: f.read(bufsize), b''): yield decompressor.decompress(chunk) yield decompressor.flush() def split_lines(chunkiter): buf = b'' for chunk in chunkiter: buf += chunk lineend = buf.find(b'\n') while lineend != -1: yield buf[:lineend].decode('utf-8') buf = buf[lineend + 1:] lineend = buf.find(b'\n') assert not buf for line in split_lines(read_chunks()): # be careful to handle names with embedded spaces correctly m = re.match(r'(?x)(.+?)\s+(\S*:\S*)\s+(\S+)\s+(\S+)\s+(.*)', line.rstrip()) if not m: continue name, type, prio, location, dispname = m.groups() if location.endswith('$'): location = location[:-1] + name location = join(uri, location) invdata.setdefault(type, {})[name] = (projname, version, location, dispname) return invdata ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/help/manager.py0000644000175100002000000002441414730024325022314 0ustar00runnerdocker""" """ import os import string import itertools import logging import urllib.parse import warnings from sysconfig import get_path import typing from typing import Dict, Optional, List, Tuple, Union, Callable, Sequence from AnyQt.QtCore import QObject, QUrl, QDir from ..utils.pkgmeta import get_dist_url, get_distribution, develop_root from . import provider if typing.TYPE_CHECKING: from ..registry import WidgetRegistry, WidgetDescription from ..utils.pkgmeta import Distribution, EntryPoint log = logging.getLogger(__name__) class HelpManager(QObject): def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) self._registry = None # type: Optional[WidgetRegistry] self._providers = {} # type: Dict[str, provider.HelpProvider] def set_registry(self, registry): # type: (Optional[WidgetRegistry]) -> None """ Set the widget registry for which the manager should provide help. """ if self._registry is not registry: self._registry = registry def registry(self): # type: () -> Optional[WidgetRegistry] """ Return the previously set with set_registry. """ return self._registry def initialize(self) -> None: warnings.warn( "`HelpManager.initialize` is deprecated and does nothing.", DeprecationWarning, stacklevel=2 ) return def get_provider(self, project: str) -> Optional[provider.HelpProvider]: """ Return a `HelpProvider` for the `project` name. """ provider = self._providers.get(project, None) if provider is None: try: dist = get_distribution(project) except ImportError: log.exception("Could not get distribution for '%s'", project) else: try: provider = get_help_provider_for_distribution(dist) except Exception: # noqa log.exception("Error while initializing help " "provider for %r", project) if provider: self._providers[project] = provider return provider def get_help(self, url): # type: (QUrl) -> QUrl """ """ if url.scheme() == "help" and url.authority() == "search": return self.search(qurl_query_items(url)) else: return url def description_by_id(self, desc_id): # type: (str) -> WidgetDescription reg = self._registry if reg is not None: return get_by_id(reg, desc_id) else: raise RuntimeError("No registry set. Cannot resolve") def search(self, query): # type: (Union[QUrl, Dict[str, str], Sequence[Tuple[str, str]]]) -> QUrl if isinstance(query, QUrl): query = qurl_query_items(query) query = dict(query) desc_id = query["id"] desc = self.description_by_id(desc_id) provider = None if desc.project_name: provider = self.get_provider(desc.project_name) if provider is not None: return provider.search(desc) else: raise KeyError(desc_id) async def search_async(self, query, timeout=2): if isinstance(query, QUrl): query = qurl_query_items(query) query = dict(query) desc_id = query["id"] desc = self.description_by_id(desc_id) provider = None if desc.project_name: provider = self.get_provider(desc.project_name) if provider is not None: return await provider.search_async(desc, timeout=timeout) else: raise KeyError(desc_id) def get_by_id(registry, descriptor_id): # type: (WidgetRegistry, str) -> WidgetDescription for desc in registry.widgets(): if desc.qualified_name == descriptor_id: return desc raise KeyError(descriptor_id) def qurl_query_items(url: QUrl) -> List[Tuple[str, str]]: if not url.hasQuery(): return [] querystr = url.query() return urllib.parse.parse_qsl(querystr) def _replacements_for_dist(dist): # type: (Distribution) -> Dict[str, str] replacements = {"PROJECT_NAME": dist.name, "PROJECT_NAME_LOWER": dist.name.lower(), "PROJECT_VERSION": dist.version, "DATA_DIR": get_path("data")} try: replacements["URL"] = get_dist_url(dist) except KeyError: pass if develop_root(dist) is not None: replacements["DEVELOP_ROOT"] = develop_root(dist) return replacements def qurl_from_path(urlpath): # type: (str) -> QUrl if QDir(urlpath).isAbsolute(): # deal with absolute paths including windows drive letters return QUrl.fromLocalFile(urlpath) return QUrl(urlpath, QUrl.TolerantMode) def create_intersphinx_provider(entry_point): # type: (EntryPoint) -> Optional[provider.IntersphinxHelpProvider] locations = entry_point.load() if entry_point.dist is not None: replacements = _replacements_for_dist(entry_point.dist) else: replacements = {} formatter = string.Formatter() for target, inventory in locations: # Extract all format fields format_iter = formatter.parse(target) if inventory: format_iter = itertools.chain(format_iter, formatter.parse(inventory)) # Names used in both target and inventory fields = {name for _, name, _, _ in format_iter if name} if not set(fields) <= set(replacements.keys()): continue target = formatter.format(target, **replacements) if inventory: inventory = formatter.format(inventory, **replacements) targeturl = qurl_from_path(target) if not targeturl.isValid(): continue if targeturl.isLocalFile(): if os.path.exists(os.path.join(target, "objects.inv")): inventory = QUrl.fromLocalFile( os.path.join(target, "objects.inv")) else: log.info("Local doc root '%s' does not exist.", target) continue else: if not inventory: # Default inventory location inventory = targeturl.resolved(QUrl("objects.inv")) if inventory is not None: return provider.IntersphinxHelpProvider( inventory=inventory, target=target) return None def create_html_provider(entry_point): # type: (EntryPoint) -> Optional[provider.SimpleHelpProvider] locations = entry_point.load() if entry_point.dist is not None: replacements = _replacements_for_dist(entry_point.dist) else: replacements = {} formatter = string.Formatter() for target in locations: # Extract all format fields format_iter = formatter.parse(target) fields = {name for _, name, _, _ in format_iter if name} if not set(fields) <= set(replacements.keys()): continue target = formatter.format(target, **replacements) targeturl = qurl_from_path(target) if not targeturl.isValid(): continue if targeturl.isLocalFile(): if not os.path.exists(target): log.info("Local doc root '%s' does not exist.", target) continue if target: return provider.SimpleHelpProvider( baseurl=QUrl.fromLocalFile(target)) return None def create_html_inventory_provider(entry_point): # type: (EntryPoint) -> Optional[provider.HtmlIndexProvider] locations = entry_point.load() if entry_point.dist is not None: replacements = _replacements_for_dist(entry_point.dist) else: replacements = {} formatter = string.Formatter() for target, xpathquery in locations: if isinstance(target, (tuple, list)): pass # Extract all format fields format_iter = formatter.parse(target) fields = {name for _, name, _, _ in format_iter if name} if not set(fields) <= set(replacements.keys()): continue target = formatter.format(target, **replacements) targeturl = qurl_from_path(target) if not targeturl.isValid(): continue if targeturl.isLocalFile(): if not os.path.exists(target): log.info("Local doc root '%s' does not exist", target) continue inventory = QUrl.fromLocalFile(target) else: inventory = QUrl(target) return provider.HtmlIndexProvider( inventory=inventory, xpathquery=xpathquery) return None _providers = { "intersphinx": create_intersphinx_provider, "html-simple": create_html_provider, "html-index": create_html_inventory_provider, } # type: Dict[str, Callable[[EntryPoint], Optional[provider.HelpProvider]]] _providers_cache = {} # type: Dict[str, provider.HelpProvider] def get_help_provider_for_distribution( dist: "Distribution" ) -> Optional[provider.HelpProvider]: """ Return a HelpProvider for the distribution. A 'orange.canvas.help' entry point is used to lookup one of the known provider classes, and the corresponding constructor factory is called with the entry point as the only parameter. Parameters ---------- dist : Distribution Returns ------- provider: Optional[provider.HelpProvider] """ if dist.name in _providers_cache: return _providers_cache[dist.name] eps = dist.entry_points entry_points = eps.select(group="orange.canvas.help") if not entry_points: # alternative name entry_points = eps.select(group="orangecanvas.help") provider = None for entry_point in entry_points: create = _providers.get(entry_point.name, None) if create: try: provider = create(entry_point) except Exception as ex: log.exception("Exception {}".format(ex)) if provider: log.info("Created %s provider for %s", type(provider), dist) break if provider is not None: _providers_cache[dist.name] = provider return provider ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/help/provider.py0000644000175100002000000003154714730024325022541 0ustar00runnerdocker""" """ from typing import TYPE_CHECKING, Dict, Optional, List, Tuple, IO, Callable import os import logging import io import codecs import asyncio from urllib.parse import urljoin from html import parser from xml.etree.ElementTree import TreeBuilder, Element from weakref import ref from concurrent.futures import Future from AnyQt.QtCore import QObject, QUrl, QSettings, pyqtSlot from AnyQt.QtNetwork import ( QNetworkAccessManager, QNetworkDiskCache, QNetworkRequest, QNetworkReply ) from .intersphinx import read_inventory_v1, read_inventory_v2 from ..utils import assocf from .. import config if TYPE_CHECKING: from ..registry import WidgetDescription log = logging.getLogger(__name__) class HelpProvider(QObject): _NETMANAGER_REF = None # type: Optional[ref[QNetworkAccessManager]] @classmethod def _networkAccessManagerInstance(cls): netmanager = cls._NETMANAGER_REF and cls._NETMANAGER_REF() settings = QSettings() settings.beginGroup(__name__) cache_dir = os.path.join(config.cache_dir(), "help", __name__) cache_size = settings.value( "cache_size_mb", defaultValue=50, type=int ) if netmanager is None: try: os.makedirs(cache_dir, exist_ok=True) except OSError: pass netmanager = QNetworkAccessManager() cache = QNetworkDiskCache() cache.setCacheDirectory(cache_dir) cache.setMaximumCacheSize(cache_size * 2 ** 20) netmanager.setCache(cache) cls._NETMANAGER_REF = ref(netmanager) return netmanager def search(self, description): # type: (WidgetDescription) -> QUrl raise NotImplementedError async def search_async(self, description, timeout=2) -> 'QUrl': return self.search(description) class BaseInventoryProvider(HelpProvider): def __init__(self, inventory, parent=None): super().__init__(parent) self.inventory = QUrl(inventory) if not self.inventory.scheme() and not self.inventory.isEmpty(): self.inventory.setScheme("file") self._error = None self._reply_f = Future() # type: Future[None] self._fetch_inventory(self.inventory) def _fetch_inventory(self, url: QUrl) -> None: self._reply_f.set_running_or_notify_cancel() if not url.isLocalFile(): # fetch and cache the inventory file. self._manager = manager = self._networkAccessManagerInstance() req = QNetworkRequest(url) req.setAttribute( QNetworkRequest.CacheLoadControlAttribute, QNetworkRequest.PreferCache ) req.setAttribute( QNetworkRequest.RedirectPolicyAttribute, QNetworkRequest.NoLessSafeRedirectPolicy ) req.setMaximumRedirectsAllowed(5) self._reply = manager.get(req) self._reply.finished.connect(self._on_finished) else: with open(url.toLocalFile(), "rb") as f: self._load_inventory(f) self._reply_f.set_result(None) @pyqtSlot() def _on_finished(self): # type: () -> None assert self._reply.isFinished() assert self.sender() is self._reply reply = self._reply # type: QNetworkReply if log.level <= logging.DEBUG: s = io.StringIO() print("\nGET:", reply.url().toString(), file=s) if reply.attribute(QNetworkRequest.SourceIsFromCacheAttribute): print(" (served from cache)", file=s) for name, val in reply.rawHeaderPairs(): print(bytes(name).decode("latin-1"), ":", bytes(val).decode("latin-1"), file=s) log.debug(s.getvalue()) if reply.error() != QNetworkReply.NoError: log.error("An error occurred while fetching " "help inventory '{0}'".format(self.inventory)) self._error = reply.error(), reply.errorString() else: contents = bytes(reply.readAll()) self._load_inventory(io.BytesIO(contents)) self._reply = None self._reply_f.set_result(None) reply.deleteLater() def _load_inventory(self, stream): # type: (IO[bytes]) -> None raise NotImplementedError() async def search_async(self, description, timeout=2) -> 'QUrl': reply_f = asyncio.wrap_future(self._reply_f) await asyncio.wait_for(reply_f, timeout) return self.search(description) class IntersphinxHelpProvider(BaseInventoryProvider): def __init__(self, inventory, target=None, parent=None): self.target = target self.items = None super().__init__(inventory, parent) def search(self, description): if description.help_ref: ref = description.help_ref else: ref = description.name if self.items is None: labels = {} else: labels = self.items.get("std:label", {}) entry = labels.get(ref.lower(), None) if entry is not None: _, _, url, _ = entry return QUrl(url) else: raise KeyError(ref) def _load_inventory(self, stream): version = stream.readline().rstrip() if self.inventory.isLocalFile(): target = QUrl.fromLocalFile(self.target).toString() else: target = self.target if version == b"# Sphinx inventory version 1": items = read_inventory_v1(stream, target, urljoin) elif version == b"# Sphinx inventory version 2": items = read_inventory_v2(stream, target, urljoin) else: log.error("Invalid/unknown intersphinx inventory format.") self._error = (ValueError, "{0} does not seem to be an intersphinx " "inventory file".format(self.target)) items = None self.items = items class SimpleHelpProvider(HelpProvider): def __init__(self, parent=None, baseurl=None): super().__init__(parent) self.baseurl = baseurl def search(self, description): # type: (WidgetDescription) -> QUrl if description.help_ref: ref = description.help_ref else: raise KeyError() url = QUrl(self.baseurl).resolved(QUrl(ref)) if url.isLocalFile(): path = url.toLocalFile() fragment = url.fragment() if os.path.isfile(path): return url elif os.path.isfile("{}.html".format(path)): url = QUrl.fromLocalFile("{}.html".format(path)) url.setFragment(fragment) return url elif os.path.isdir(path) and \ os.path.isfile(os.path.join(path, "index.html")): url = QUrl.fromLocalFile(os.path.join(path, "index.html")) url.setFragment(fragment) return url else: raise KeyError() else: if url.scheme() in ["http", "https"]: path = url.path() if not (path.endswith(".html") or path.endswith("/")): url.setPath(path + ".html") return url class HtmlIndexProvider(BaseInventoryProvider): """ Provide help links from an html help index page. """ class _XHTMLParser(parser.HTMLParser): # A helper class for parsing XHTML into an xml.etree.ElementTree def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.builder = TreeBuilder(element_factory=Element) def handle_starttag(self, tag, attrs): self.builder.start(tag, dict(attrs),) def handle_endtag(self, tag): self.builder.end(tag) def handle_data(self, data): self.builder.data(data) def __init__(self, inventory, parent=None, xpathquery=None): self.root = None self.items = {} # type: Dict[str, str] self.xpathquery = xpathquery # type: Optional[str] super().__init__(inventory, parent) def _load_inventory(self, stream): # type: (IO[bytes]) -> None try: contents = stream.read() except (IOError, ValueError): log.exception("Error reading help index.", exc_info=True) return # TODO: If contents are from a http response the charset from # content-type header should take precedence. try: charset = sniff_html_charset(contents) except UnicodeDecodeError: log.exception("Could not determine html charset from contents.") charset = "utf-8" try: self.items = self._parse(contents.decode(charset or "utf-8")) except Exception: log.exception("Error parsing") def _parse(self, stream): parser = HtmlIndexProvider._XHTMLParser(convert_charrefs=True) parser.feed(stream) self.root = parser.builder.close() # docutils < 0.17 use div tag, docutils >= 0.17 use section tags # use * instead of explicit tag path = self.xpathquery or ".//*[@id='widgets']//li/a" items = {} # type: Dict[str, str] for el in self.root.findall(path): href = el.attrib.get("href", None) name = el.text.lower() items[name] = href if not items: log.warning("No help references found. Wrong configuration??") return items def search(self, desc): # type: (WidgetDescription) -> QUrl if self.items is None: labels = {} # type: Dict[str, str] else: labels = self.items entry = labels.get(desc.name.lower(), None) if entry is not None: return self.inventory.resolved(QUrl(entry)) else: raise KeyError() def sniff_html_charset(content: bytes) -> Optional[str]: """ Parse html contents looking for a meta charset definition and return it. The contents should be encoded in an ascii compatible single byte encoding at least up to the actual meta charset definition, EXCEPT if the contents start with a UTF-16 byte order mark in which case 'utf-16' is returned without looking further. https://www.w3.org/International/questions/qa-html-encoding-declarations Parameters ---------- content : bytes Returns ------- charset: Optional[str] The specified charset if present in contents. """ def parse_content_type(value: str) -> 'Tuple[str, List[Tuple[str, str]]]': """limited RFC-2045 Content-Type header parser. >>> parse_content_type('text/plain') ('text/plain', []) >>> parse_content_type('text/plain; charset=cp1252') ('text/plain, [('charset', 'cp1252')]) """ ctype, _, rest = value.partition(';') params = [] rest = rest.strip() for param in map(str.strip, rest.split(";") if rest else []): key, _, value = param.partition("=") params.append((key.strip(), value.strip())) return ctype.strip(), params def cmp_casefold(s: str) -> Callable[[str], bool]: s = s.casefold() def f(s_: str) -> bool: return s_.casefold() == s return f class CharsetSniff(parser.HTMLParser): """ Parse html contents until encountering a meta charset definition. """ class Stop(BaseException): # Exception thrown with the result to stop the search. def __init__(self, result: str): super().__init__(result) self.result = result def handle_starttag( self, tag: str, attrs: 'List[Tuple[str, Optional[str]]]' ) -> None: if tag.lower() == "meta": attrs = [(k, v) for k, v in attrs if v is not None] charset = assocf(attrs, cmp_casefold("charset")) if charset is not None: raise CharsetSniff.Stop(charset[1]) http_equiv = assocf(attrs, cmp_casefold("http-equiv")) if http_equiv is not None \ and http_equiv[1].lower() == "content-type": content = assocf(attrs, cmp_casefold("content")) if content is not None: _, prms = parse_content_type(content[1]) else: prms = [] charset = assocf(prms, cmp_casefold("charset")) if charset is not None: raise CharsetSniff.Stop(charset[1]) if content.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)): return 'utf-16' csparser = CharsetSniff() try: csparser.feed(content.decode("latin-1")) except CharsetSniff.Stop as rv: return rv.result else: return None ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.790081 orange_canvas_core-0.2.5/orangecanvas/help/tests/0000755000175100002000000000000014730024333021464 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/help/tests/__init__.py0000644000175100002000000000000014730024325023564 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/help/tests/test_manager.py0000644000175100002000000000304014730024325024505 0ustar00runnerdockerimport os.path from unittest.mock import patch from types import SimpleNamespace as ns from orangecanvas.gui.test import QCoreAppTestCase from orangecanvas.help import HelpManager from orangecanvas.help.provider import HtmlIndexProvider from orangecanvas.registry.tests import small_testing_registry from orangecanvas.utils import pkgmeta class FakeDistribution(pkgmeta.Distribution): def read_text(self, filename): pass def locate_file(self, path): return os.path.join(os.path.devnull, path) _entry_points = None _name = None _version = None def __init__(self, name, version, eps): self._name = name self._version = version self._entry_points = eps @property def name(self): return self._name @property def version(self): return self._version @property def entry_points(self): return self._entry_points HELP_PATHS = ( ("https://example.com/help", ""), ) class TestHelpManager(QCoreAppTestCase): def test_manager(self): manager = HelpManager() reg = small_testing_registry() manager.set_registry(reg) ep = pkgmeta.EntryPoint("html-index", f"{__name__}:HELP_PATHS", "-") eps = ns(select=lambda *_, **__: [ep]) dist = FakeDistribution("foo", "0.0", eps) vars(ep).update(dist=dist) with patch("orangecanvas.help.manager.get_distribution", lambda *_: dist): provider = manager.get_provider("foobar") self.assertIsInstance(provider, HtmlIndexProvider) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/help/tests/test_provider.py0000644000175100002000000000555014730024325024735 0ustar00runnerdockerimport base64 import codecs import unittest from AnyQt.QtCore import QUrl from orangecanvas.gui.test import QCoreAppTestCase from orangecanvas.help.provider import sniff_html_charset, HtmlIndexProvider from orangecanvas.registry import WidgetDescription from orangecanvas.utils.asyncutils import get_event_loop from orangecanvas.utils.shtools import temp_named_file class TestUtils(unittest.TestCase): def test_sniff_html_charset(self): contents = ( b'\n' b'
              \n' b' \n' b'
              \n' b'' ) self.assertEqual(sniff_html_charset(contents), "cp1252") self.assertEqual(sniff_html_charset(contents[:-7]), "cp1252") self.assertEqual( sniff_html_charset(contents[:-7] + b'.<>>,<<.\xfe\xff<'), "cp1252" ) contents = ( b'\n' b'
              \n' b' \n' b'
              \n' b'' ) self.assertEqual(sniff_html_charset(contents), "utf-8") self.assertEqual(sniff_html_charset(codecs.BOM_UTF8 + contents), "utf-8") self.assertEqual(sniff_html_charset(b''), None) self.assertEqual(sniff_html_charset(b''), None) self.assertEqual( sniff_html_charset( codecs.BOM_UTF16_BE +"".encode("utf-16-be") ), 'utf-16' ) def data_url(mimetype, payload): # type: (str, bytes) -> str payload = base64.b64encode(payload).decode("ascii") return "data:{};base64,{}".format(mimetype, payload) class TestHtmlIndexProvider(QCoreAppTestCase): @classmethod def setUpClass(cls): super().setUpClass() cls.loop = get_event_loop() @classmethod def tearDownClass(cls): cls.loop.close() super().tearDownClass() def test(self): contents = ( b'\n' b'
              \n' b' \n' b'
              \n' b' <%s id="widgets">\n' b' \n' b' \n' b'' ) for tag in (b"section", b"div"): with temp_named_file((contents % tag).decode("ascii"),) as fname: url = QUrl.fromLocalFile(fname) p = HtmlIndexProvider(url) loop = get_event_loop() desc = WidgetDescription(name="aa", id="aa", qualified_name="aa") res = loop.run_until_complete(p.search_async(desc)) self.assertEqual(res, url.resolved(QUrl("a.html"))) self.assertEqual(p.items, {"aa": "a.html"}) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.794081 orange_canvas_core-0.2.5/orangecanvas/icons/0000755000175100002000000000000014730024333020505 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Arrow.svg0000644000175100002000000000141114730024325022316 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Back.svg0000644000175100002000000000152614730024325022073 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Document Info.svg0000644000175100002000000000251014730024325023657 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Documentation.svg0000644000175100002000000000404114730024325024037 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Dropdown.svg0000644000175100002000000000131214730024325023020 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Examples.svg0000644000175100002000000000624514730024325023014 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Get Started.svg0000644000175100002000000002606314730024325023344 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Grid.svg0000644000175100002000000000274514730024325022124 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Info.svg0000644000175100002000000000307514730024325022127 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Maximize Toolbar.svg0000644000175100002000000000140314730024325024373 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Minimize Toolbar.svg0000644000175100002000000000140114730024325024367 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/New.svg0000644000175100002000000000242514730024325021763 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Open.svg0000644000175100002000000000322014730024325022125 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Pause.svg0000644000175100002000000000136314730024325022307 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Recent.svg0000644000175100002000000000242114730024325022446 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Search.svg0000644000175100002000000000337714730024325022446 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Text Size.svg0000644000175100002000000000202414730024325023044 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/Tutorials.svg0000644000175100002000000000307114730024325023216 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/YouTube.svg0000644000175100002000000000404114730024325022622 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/arrow-right.svg0000644000175100002000000000113014730024325023467 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/default-category.svg0000644000175100002000000000176214730024325024474 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/default-widget.svg0000644000175100002000000000200714730024325024133 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/orange-canvas-core-splash.svg0000644000175100002000000000036314730024325026173 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/orange-canvas.svg0000644000175100002000000002315414730024325023760 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/icons/orange-splash-screen.png0000644000175100002000000010610314730024325025235 0ustar00runnerdockerPNG  IHDR,5AsRGB7MS pHYs  ~zTXtauthorxKI- < IDATx`ŽgKe[Ŗ{Wl1@(K/K !$/H!TCIPbMmlcl-[mYwVwٙٽ`m37@0a„ &L0a„ &L0a„ &L0a„ &L0a„ %&,˧,.0a&LX({„JE0a9\ 0aO^&oɮhˉH ;_<jGEA'|$*I_ӂq„YzhÐAG7 )^4rótGb #[w[}0Ӏ53U2iJe~c{Op߆zBAr8NTLt,3ݱ  s:ooEIeC2oigPNᚦut{y3W<wRw;n;ҙH'A5/\Ds\;"ۏ{EP(́gt5]z¦ m)q@'Q407ˣOu.z Ժ0sˍD v{ͭ-&n[f _k]U cWd3F nǥ U@' trikI 0GL#q0zF.x|\>?yս5ld}FpB)t҆$RPg;79ɤ7fHU5%^@]]X?9,Z~.e{OFP' @BW[4!H׉'):v$KMC+_@]])QCpf WseegVLɫvn\s5e/{L=h* w42$\ה+i<I=`?$"XN]y Fu$.~Sz7HćR]{ f)oY1Z)U#hj CЯ۾ {(`Kʙo+5Ɵt2uQ0G͆F/ql*JRlW=LWZ?:NC[i犉eUǖ:{D7 Ysg;u?gå%ydZ(\|yׅ aH@| *}NR{ԕ"xq@Dg훫VM(uiˆxh˦F{;HӦuutaXUw .eWߘr!d-'6|ky8$nZ~zp^gG!0uȂЅC* \_1 m" q8t^U׍Sӫ J_\3WsR~-M];Zj7ʶ]G@@{eiTy0 kfq]섗mvxdչ Bwpghԃ)U˦wިq^);x{_;QÔ'_'...G#m ̍!A_?)u5t#@{{a݃QG9!C;zz\ i:m-tv>t?xI\Ց LOĶ8` OuY=5|N(@}nB rpeMNN-"GEԹ v-qnYjxn'&Ր?'&rMLBqs:X@]68Ȍ>`>#Y<gQFp)˰@+N<72i%XZ-q<3OTeJlwtANuKY!}yIA (Yk8[lI]U6 [kW-cT;B~df'Yd5jhs/!SOv)DĘDOX8&=yhߑ4c-|y2cgܿ'w]YW c뎯 *e,nlz "U HfVX$mVL {<@G~O/_kL%e's{.3~eyѺ׷Z4@F]vT9fcQ*v@'U(n`:T3͒7:q+6Կa癷]v>,g50׃(Q\&9ҙلsdm9. 4`':-e;>kXѐWn<'QK|'ooмK.WU(/ c nӘG7;n,B;0C x;5 +kԩ83GG¡ZenQu nZJ=:i9KvZL&7YfTV=S F<%!\.kl  Ǻ?Ӳx667h1]]~tԬ<Ǥ[ۊz,NN³?d,rfԳ*l I'E S[2±Uj+^lu-0ԟFw~xg`ϒŊ(k[[XqU֠7eLCeC^}=wXaō3*&-[2iۏv~sWk_67vS{=o5x诮57 y@Τuwc9Ej}]}G W|$b]= +07R^@6'9iV4P84!n*'n߉?m<4wc{yCƙss ESvVizSZ[gZѺU*/y%Ef6Fr#ߵ 12Bn#-WGNN.8߹x+2]is+3 -`1cˢ^Q\Dȑ-8luR%,Pt⚳U2m@,c&: 6hc gQ;C܈H2ن2XϡJ5Z Zf 0' ."*QM΃]rd|E\?ku۹ ܪ3Q4͞6aΨIǑ;rJ6\n粅 l\gT충u>l'߽}۝N_FĻI[9i_Q^qM5̭-̪< gU.Y8lhsΏh4H;iZW.ݢb7L'kZ!ͥJg+\FP:>YԺC\b'lAg;j_;.nhi23yT9G#`lgT`?̏hlq3U-񵠘\i,.8ʕY.Uɗ~D^ȲO 7cAe z&ar0pApy򺑋/_:y׺x n9e)-$i]<}֕̚}r,ٿΠ֙RDz GO~s}Nm)$svu7, Q.vU)}}A;`7`Ϋ< gK9ҏDv6>LoGnx/}gڴ1gp ]K w'vg$\` |#Q+j/un'3ERAnw`H9Mo)T`շN[As+@?A{4}e+}GM{zp$ b@'j'ф+0qM?_{2XgvGT;2O.sԁ\Xef[8iT9IC[<ӋC ~CUf9EOuwj | &cxQ t@8j•%Fa7VZ6HY0 WU.@`iSAʱBۙMpFu s3RtvfP<0Ϗ=າ)0V)@.nsf[96/ˆmOkw4 &f[&81W:NQ3z/Nup.W{* `rԭ`ܮ:}PS]sw0{nLP:M Nu#/?hanu 8Wh\սmjeeuܾEN(wFY2Y9I9V6bWϪ\07U4PœT.- O2p- _kmN)Ψ7ys#W{ j0׍|ߓjn%΅0\j=gα?z9oS ^0Jq٢R\K#i9kG^z%mrK.pOj#L-P 9*lJ:Lɠ8YD,yT;7r|\Snsg̅ r)77]Q10+uuш*7 2ᅴ< FjZO}mY kr=mSжB/`.L@Թ=V>7th˃#޸}hD0hmW=!֨9v<͠N\_\`@,*xɱ@\:6%YUTœ$9ښt;9*V8xiiuκ]3#Z\Pl|66&1@CS1^kMZKp$w҇N psҬpnwLzg<²T?G" r.+0PnNma߶zeF $[7$cS` g OnQqG,Ulo58ccr=x7SuzC)U%>0]j?RcW]iRNux!c-3`ݑX_oq}K_z4k4fhf7H\:M">s#kS*smd;K 6D6<=Fٸл {?feIѶˌUC@q%M_JqA/BxJbp._ay-嘺VL +;zT~^LxVQʏN\K&(P-,AT+ޱgOtu &'M zC{Gpm``!sg_~\nе`GA=_4P E"QdԵ3x}u 07l=]c5=QpK0O{`wTuIyO^C~nwOU v5x;b&14N['f⎇ *@.u0Aw7* r̠M7rѮOL`r$۶X FI!oOQ:Id rgеwjat|/J,qNQ/ A~ *eUƨnI`c4 `ׂta3pXr60kT~,^}`cL]QοO_ϺZo?9̑!lZ{aM3$3\$IelUϸvᮙ<`9K˿MnY(ڂ #աY so_^B ogzҮ ȱ/loe> ;Sbwï[<"8o '{% f[ynhFuB Tlk>#u~̊E03US{aѳn_D\/6P{Gψ:OA6qwlUkU: ZSɆx< ,jI+'7hig#f^F u#L0;B}} UP\]55^$¤W3mW6n`?~&G!`9WAM 9!a<,b/~ P0t>%zmYhnm4soa'L 7<tN犇Aan2qP}zKF8ͪai}U]åCJ8C0 RaQ\r)"[^LqlwS9oybHOګN=7E&NP㜪fo+8DY-ӕ?R_<0g~OWK3B]F ]"Μ&a~;AX6,VZg&a5ޣ%p ̓`E-M"ʼ!*@ߪ9O<.NV\.⪣2.0(e g5ۙ,NO֗s8ñ0UPmMgm R, h=Vӛ1M/B)FӎGe181iA?(~yY^OWRR|anVnT6 LbË櫇<n֖ `s݋IeC˲ec:aM=RM (fKak \r7\1 0am*P9+t"ћM5"A<v<Ϩr&Y1]D&:VNoaյB (nwkwY< $;nY5E"TB<"BώL9WK Լb=ݣ OJ/t9<'85 i&9q<&({FM9\h-t ]=>7Qgl"-=ܥ{5ۦέqLQU?ˉYm(]ngiTuOz/d8?XTybF9^z@6q{- ̪tn7Zp(6W0\mIϑvgc~=e𔵊7uBFDl (3qE[2R,vau0xM0 VO,eEt?چ3ˠRֻEZB>J uՋ@Jٝv袡"W %nwbLq)`匊3rFk'MSsz!. ZCzU)C)_×R1Cj`o>V4}x2̑&nR%a3o޳̟otzu߸s7r\d/=PGG=\wis' k_M>2f{}θۡAJZ<h<4F&emP!y^0(g^31…^!M<~j>2Kǥ_9lFL` DLϭ-TAV1͸NB~*bt-yC`tGT%Cә8#>C[MOul$M FxkvO}ir'hv!YHʐqnr 4s\FOWL0䠩s?xe##^XƜi[O̕t0J G&w|yw!د5h׀d?n6JEϭn6(7iΦ;QC Mʟ0 `;䳰G6`=`d.nv86}m?~YBy=)9Uf*,4]gӞ8gdyŎlF|?*u+}Nx:XOr@tD3`7@@XhCUݞYQy,x\$cSfXd2f`xNBh(U42?gN݇N¦7pmk0i۪Na͞Sek`?|brIl6=QuEѧ>Z+tuFiv(6xT-Hw}L Gq*7:kiPRU0B8ulyRnW7ROܒI@.tJQ;əaGy&ڏ5Le9T)Յ z6ʺ> hyNRP”ngeP&~ԙX_ۆ.uY ,TTWn\X8yEw4%v;(w8>ڲ@OΙ>,aXmhw?]H-VniUܿ<{Y̙DPlȢ 8poqVsӂYRwmh>~q֖Znm!s4b@ In >ʤڇdѣJ}aTJFO"[Y99[2G~_Wnik.,#`IDhG {{2r0!t |Un@*s ͼվ9'[Ntt1m9i\Y3Y3eYRBPdAm¶ )&e_sI[ 60?z(CVC2=F̽eo'EG^e5mMM=I0>VZǜsAPS3?_Fcw>iDp5פ?cR6IEvh~`JeH`\@W̶}LTGٟ!)S.wWSLYRg~/7='\aTӤsqݏWas{W+@]x,NVr;ukk7^x[U:|7~e;R 9;.w3((uq1O[Er-T-G;_rG cTGۏ`@MZ$Ți`iS:o?`s Q+&^.XuwtP߅O]i)cem+0#F t>sGsɤ컝)tSGP]< ,Ko|oOkshw\~;x26DaTCE/.vy%Ks/ Aؾ_qY8_6Y;4e̜ LB_~D;ExE|z9T摏_'>OeǍFP87J ӹL6Tp㱿+䝋[ IDAT~\x)W=2T/b#h a`?7xQE-#,2=j/j(Ao{q9katS Ac A [{کwm sb(kP+**_uyt m b ,(yT0C2e:@ Bw͏@WZ n} huns[&Tڕ P0~OXlcC5C[es/d崷K_xOtL?+rOW!o"zius Pv."5ӷuo3O-W^pߕz *g fk^=,(tZz޲'Q`[⑔5@q'-\3_0 J'bfZR})\?ٞ@/(*u UwVuܯU[ %zBLJo" iqx-5'M 1tlO=On! ow<Gd8*RɹÜnХ$HQLgrK ;B%iĎ7pJ2C!lP܂q" -Ըl#:6a^-w*t:{.kV`T:|셗 ֮:7^{0ٌVa>\;e< <xkIv 2R;\G[va{[/%Iݣ=tٰ,lЉlyIm$lz:@GQc rF!fn\̞a.'{M&ـM 0k!'^_Wotv{@ ,; Dl)deBn=1{?L6BDUNv 3z B~F=*c8fn6o[RRQ҈E sm `<WqoX\~tI(Y]1 ]٩d `П3cρe ]aکoG6?'C1:ɥuI dBZʓN|Y%_«`eY0>E\GZ:'&cZ/(.4:K9VXVJi [;P:a6@*o\m'?xY}K,`.'\t5]u3'mD~@8<[9sWRZ?P sYന˯S``D0:N߶z׸ǍssE~b-y/8bLvϔal0s)A Z9'p rϠǩӨr8+S_(qw +ﰵOIȋN9I)k|M/><0| {?(0R+B(rߎw7X.pKn4Ҭ'^ĚBj+[?FYrB}+nąH(m# c~t]~<*'w{ `G'/Iu4`g_]]gccݪ>s]8x y\iKS{ȶ1̣ 6+ Jޝl f!'Bg*haF8O鐴4p_yčTzئsV(n0 #$97Ϛ6G?4qw{QB6\pc̡.2q vZZvRuN۽x7^h2 nE`.\]m9Bq[T:J@:I[n.@D;[ڬy(KYhimŽ]JKNK~ןaޫc~se`&!i9aWqNJZ5A^c>=@N!6%ݑXw}KK:\@N_nrq:EyJ1aNO3;%ԩ1 ai&hyϏ[xGϜӬce S^DAp(ec*1f޻hT.+L7s2z(I:i_Qm"iG&8,fzFf3k~o^ߋ#pq8nRuMAR!( ;%z%.w/R]C LO ucPF_E7_Hxprp.kޒ0x `x865e{,{>sb~aH:)uy5l>lEf'Iպ0 k؇{r@KKk$ݧ{?Wދ*ޡib~Ҙ zʛuHn&xm1޹M ;s"+%%P`yL,ũvU!C}QuuT;uv|gTV~5 j[P掘\]՟:͡3p[ Pj]sdb1ֿ Հtc~MPyn=w܉HKie513:.jG5K'` cЬ:A ]9Yf) _wknҨomԺp:`'>SX)?Uןڍa\V:ΡTB2@p6F]wB]ua|UZP;lή5 6wxIu&rՎ} -HRb]G_]=ΚJ5NʐQp 3EnA#xΎv_ ξJp%Wc~btsW{4P zǁS3@)@*Ywb':@PY:wÛ~xg՝͘M$ j.wأzqӱM ǔ8b@F9}ya>$U>.U5\gOC ;wzeyRO)݊0nsC\t$G3wΪÂ׿ õzbLvhpf!/y%_Ի{r6\n3}.a>LDo!3Q$#,I䏿{@ȵml>o_* 8kh%r7~ t]^V9T>qث{L!.*Bk, r-N}*LRaJ#ON؅jN3¼Xm= pY6zr7  t'`SGI|Ҁ# ::OQ> wm*7SF]2Ųټ Ju2E=:%Uɥ+s2SB) +uct@*vw@ܢ"7*HV]M.&?„>Y;t\Kpf}A5~0@@ļ_"||a 3@@77n?ݪSqai|v9f% pkϹgsl:UV[uo;uc$9N[{ΨqĶ=QpSu,.Ѕ0?'UAV [' [EwahUS1_>Um:j7j$閌,Pp7(f_ +c[$)im1 w7kG^Gk|oePg#W\>0g`U+V)`j1mY@džj2958% 1R>rm8mD:Իr7sP'Qv n\TUEߧFohayB{VuOJbD1GfO/}tѴ\-Y6~ebz2ۗx} ףYB%YP YQAoZU\8t/8C>$)Xx)pי)ۡm8{O~bu5067`:gid&krR7VO=bet5E;vu%3n?zP&1rϘs-qgա 7Nx^Q锳1G5iJjX"jux}@=2ݟǺoF7"@rvFs ,T~w4[@ t?E2H{σؕ,//]vD4]p]S$1e-QZ𓛏 4-{212c>&!L*K7ۮ3ݗlޥljkF2|ɗ(Ar4OU&ū6\NX{U>z-mi pW- R̚$`ZU-]Я-ced2o@Ȍ`v/v 6@\u(K5$eҶ67#,i915nZ؁u;*ޭ̪QlW(N} ZQo~g2.#@8( "n؉Tp8O/J3G6{@ncJd=|cH 9и%s&z(m@o?M r#s1+e^RQ|QAJsg?9@&k=Zz.\ 0 GQt]&>aرMܞZ-[!5nLD|9)>w96v>eƟHFaej! (v= [ÍC\Rb(F0 rAݒ_~y'WOlWPDYR,݊r*1Nu+)VIi -03 V Vn8r e_Y' w] 8m|2uNt~[q䮤zMd豓'ϟ\=`5(s6k\g 绗ӽM ٬lpnx ;Oݙ ½푐,&pSmZdgR0C;Ou5/Sc=T]5ŞT,u9=@ 牿dB& 3zw[pC};†Z$9-QZ㝑ctFP;E q|T{.\Ǝ(xqg/H6r`v?B'y.=Ȁ!L=@[[[5Az7.V uT"޵}ec;w;06z<ExmIJ C]d]f(0cq\Zve7b+PFcёic<1JexpT7膩dM2QesCvc!>dw_;)`7mvej. rDJ ;O?qHźNj_u5k&Nq"jR`zo,)F;xRam,HTH$ ՝7F]X$EZ_ MrĽRb;߯n۳㋃^FNY*ǏeUo_ 4]4:N=eyʾr,9#mlDƥ/ԡc'?ŋARnQjE;lpòAF6CkF]suTEI?4:ODzqǴiBuew|uB7R(t*5. mem: ةe ~5`T(Ҏe稁yyS&NXllnL*28 U81_BBŸ_L1NwW>FߟZNjk۷įlʉd&4R&Fw?@7RӸH9@)|;Z>P[{ɞD]#8ɓꉷbqדO ־B !gVg],Pry$_0ׂoh* %|NסMz׿&qś:gE;[H|`۳;o_'g/h'Hg8 f*kqpJQ;€sDy^EߓGum.OlZZٟ0սd߻dpZ$r >&u|Q)~4JAIY9شv y;֎AG0(/?C{9@.Ť&It^hIU2ғ0OSgW~HܨD>%:33t3}FS Pn:t@|&nU;[6^OƄ Z;ʧ'❆C}aeJKշ`7!/Ljo:͞3,;KÐxLH?ӕ5y,suzq~.tp?Ux^DtuN(y$)% 5uy'!5K}!D2zڲqEo_HL>QHCւӤ,,4dthI%]]Ɋ_eY$C>Ua`#07zG%X]QRåUQonmXO~ؼ^=V T:9n-#jsq?9 Qnp5BUe*tYR -.4XNEm?X݇{bGT #54?$)%8w"%g˘U@&F$dd) ,TYհnSMbg{@@2Ifֵ-tƿַ~Qnj`KOWtԖ V1z3M+eo8\ǁx>25US%>N 5(to,z_:>9ჾe')27}pFvrI Ǖ;,P2@ W7PXkg;iFQʄ:\s kydW CI[u|9IKiBc8JQ‘`a{˾͍~N̟ ͣr&ͿNw_"Z$@7jML RV(8xGVp;oQ3V(yĤpV\8ۃGQ+>8Ҫ,jW|!3j1yo^) L[vT-g7?\pOky/$pƂoa݁Ֆ 驉Ñ^|j՟u*Ip\J&ze)=`NƷI*H͕sMg0[d-(Ny ~ n⥧ 8W@!;TOm9nf*D[i(tR{(@Ӄ>0phxJȣҼIskf@21d)OL7-..kmL6r'`:$#DըsW|`L1 =2@DuGIϨǠMkǟ6ɗ7ސ*] W gڇf Fwku?8@e ,@eCU/O{ejգ˃#mVmk/k]}Q0qD)tqErB.6 )q}m[gO[rMIL˴C\i?`dy*:ѼVΥtux=D@z =S&*:QB}h'5'7.ƙ/Y_zo!HMJ3;\n]r֪*Oӄu1ς %.DY0 80Cmᮇ7o. zۧVm6ɮh7^=;^E2a vȠ%B="{ĥM/̴3JiGwm{Pxn OGۡ2ڹ suǁ|՗h7];׍ -k t fqOE~f˭㞯~uGZuJ 1::3`f. 5 {//ԥcٶ O}q2' uɀ݄ }d{xi.rH'+'FeGzl:Xwӆ)֒q\E8E0տ} jkz Aཷ[Lr>72>=ͰQ$!nݙ.(͍ ^^iJtGw־4݆RUq=lm k󭦾磿|ه[>$xɈ2E60fEH&Cm\QG`$|@͋+}.- EN\Cl&̗._9\uxmܰJF,kH\DN7FX7b)x?WWsTK)"NGl0fW-̩lw uN1j]z?1A;;c+nĩ(Q (rњLSԐѳuHJ:;{!S;|nXre7|kC7 =q.a9[j=n岺ۭVA:]ƕ\׻ C*؍qH@˗?w͜;Pѱܰ$SW݀wzgv<((pG֊M #̟QGӊ7=&/_8H 2K/e|lǴH@P0w8-m7VkwQH9ӽrN)%DVltı=KV\҉-]^?yAGa ݮt9iQݐjl&5~\] ,=/[k?֭_xKsrk`L Zrʱ`7Yd ;y̍O.PkTzi:Ux#C;t`/,Z*'M ;>fQzԈϪJ=xlp޾C6VjnPү\O;.(*^zٕ̜^o[;zB2t̽6`xq@o 4>jZ()Or15}wVӎ'{]!hLsaA B=8i;y3AH0nY&ݴx+xsr-}^Zyuؗ V+Z$n}mET n;6xbOS_S{U.fMa>ޅM8Vف2d:ưM4>fvl3sKڴhR䴚%;ܳU9V9y*|G`ּ߼glR\o0"\\\u_\?Y$9}a|3nCWTk3ƥm Ị ruևyzcŮ0cMX:e u L\ہ9}!-ݴr5sJ]x~~ʼ jk 6H-:FeT8tLZq%;u\zM̾-*\N_(Лʻ1jn u6; m=h -Zu1LSr\zİ1J&N/Л m޶zfɇtz[f釟WY.;;h*=nn2 s0O55 ;`|CY5I U@ǚb}k7vM_ʗcԨ䣏7]`gȃ@ ܕ]Qt.xZswb396潮_\>0"-RXe9ʹS>=qxOeKGͩvWH 8f*{Ŋ3`1XmzW1V-`,] lmMj( š67@aIx׻d\3#688AC_ `>h]JǔG7Ss#Rn~֖kT `ʼb產2Yt7\𘫓Rxvl W}R8PNiZBq 7r*u\+\7[3anB 5!,\q̺hTN6xsߴk{po*rS3οS0~+(#S_ b=Il' 榍?YXq+QN+YP+`HP)";U)v*E܆QtW29EcgU΅:A Ա`$=wӪ?;%^#] 3zz{_lik}Ce-T9(-Eˍv@ϱBsh[~ŚIw]br?6"hLsSÏϿ0(6d8J==+ΙpU)pMD7YĢ4XEVKs"n^LD yY)3&?qSJlq=?/=#̭dMB0.=7oq˗rU(wC`C}݉GK `ic&#0q>9xU .S^8 @NSMus W.+ջ7ɓ ubk7,.V%Æ]vS`.*dw퉮]u ecQ ӗrW/*++/).)-)7}17}@]֍M5t IDAT;nLkč:vx1w;(@(X=t {p(Ke`.vky MN0o=hwoq ĩMB(U:T(H}w?Hu^  xcz(}Du@&}x ]5f0Ew4;U'rM;[# ౛,1f=2͟t|y34NʁUSd\I.04 hb8E%_ڶh %T^:O'3G2TWASQ]ڹӛ:ځJ9j^70TKJ&V,'_|7^[̂3 {*\3ojii(UT*')ḙK+֨*4T-kddBwK5 ~&|a.PF62sVO*O 150S&VCCv$X6`NhxюyS^oRʕƖgj,\aa.9(RSBtTݩ;Ԍ rLwd7/d^zN*Wbk.uUS߱>@`@tkL%ՒENJ}0dc rok8^PZRrI~^j/sH`cG ݀s[ s1J][9ÁRc:ڄ<ۀ؝U9-+S\N%i0^sXoy@=G1E1|yMp]mÍ`?zC1˟y9Š}, Y8 C!ZpR28寽@/EQX;lN5/0V vw:na8󝭵u锍KXdלtT!HhʜtzLO sO)E׍dcP- oI90Ǡj0@O9FU}g("̥ɩ B 1}~etYo7[i_Un }uH7t5wںѱ@nꢎU98]p09I rI4Н#7oqN;8 9ޥzȑo!'`WICY\dry͇QSN$5MtgIvvn4FfX S@'<%pWUn}TUɃ:z;`QaetH6AS*4:wtz:5?钁KXs磒hjysйpV+ wu hxYwNY;U4nD>(Ɯ$7]bObhv*e`{Ai[R=9dO+ÅㆃѦ>C|_*yq!N:(*tz:RpL%)U `-N ê iq9N _P;OFDx/.9-jĵ[yʉVMMD`JPƧjϹr=!:[ QKuRNiIEMbY!9PtkTɍIT LJPoZghNwd$3.T` x솀sj:E:9QPk|)=V%=$zte$qigZfD)t ܝ\'`@= s1=e|f>4'.ej)1UϩE ~D+sn&MsI✨L ew=j vJN\:W GѺ`_x{2h32yЦMV&ATsKxb kL&B xt*$$6mRvJGSyra 6$URx&N%xkӦ-;NQHSY_ eJd6GRٟ(J$liӖ`v~yrT-ESqNy 1t\gy@צM]B 1cmX8|L6m烝 eܲ*>L'O4ȵikӦvUsat |ThR;6 tmڴ1t.7* tp <6 tmڴ) ;yL@!M]6mITq*sS P pmڴiKFC*ҩ6 tmڴ\ $2|hӦ- B `$4iM6=`Ҋ[)ЦM[+M6m6hxk6mڴ|aM6mڴiӦM6mڴiӦM6mڴiӦM6mڴiӦM6mڴi6F`W4IENDB`././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.798081 orange_canvas_core-0.2.5/orangecanvas/localization/0000755000175100002000000000000014730024333022062 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/localization/__init__.py0000644000175100002000000000763714730024325024211 0ustar00runnerdockerfrom functools import lru_cache import warnings import os import json import importlib try: from AnyQt.QtCore import QSettings, QLocale except ImportError: QSettings = QLocale = None def pl(n: int, forms: str) -> str: # pylint: disable=invalid-name """ Choose a singular/plural form for English - or create one, for regular nouns `forms` can be a string containing the singular and plural form, separated by "|", for instance `pl(n, "leaf|leaves")`. For nouns that are formed by adding an -s (e.g. tree -> trees), and for nouns that end with -y that is replaced by -ies (dictionary -> dictionaries), it suffices to pass the noun, e.g. `pl(n, "tree")`, `pl(n, "dictionary")`. Args: n: number forms: plural forms, separated by "|", or a single (regular) noun Returns: form corresponding to the given number """ plural = int(n != 1) if "|" in forms: return forms.split("|")[plural] if forms[-1] in "yY" and forms[-2] not in "aeiouAEIOU": word = [forms, forms[:-1] + "ies"][plural] else: word = forms + "s" * plural if forms.isupper(): word = word.upper() return word def _load_json(path): with open(path) as handle: return json.load(handle) @lru_cache def get_languages(package=None): if package is None: package = "orangecanvas" package_path = os.path.dirname(importlib.import_module(package).__file__) msgs_path = os.path.join(package_path, "i18n") if not os.path.exists(msgs_path): return {} names = {} for name, ext in map(os.path.splitext, os.listdir(msgs_path)): if ext == ".json": try: msgs = _load_json(os.path.join(msgs_path, name + ext)) except json.JSONDecodeError: warnings.warn("Invalid language file " + os.path.join(msgs_path, name + ext)) else: names[msgs[0]] = name return names if QLocale is not None: DEFAULT_LANGUAGE = QLocale().languageToString(QLocale().language()) if DEFAULT_LANGUAGE not in get_languages(): DEFAULT_LANGUAGE = "English" else: DEFAULT_LANGUAGE = "English" def language_changed(): assert QSettings is not None s = QSettings() lang = s.value("application/language", DEFAULT_LANGUAGE) last_lang = s.value("application/last-used-language", DEFAULT_LANGUAGE) return lang != last_lang def update_last_used_language(): assert QSettings is not None s = QSettings() lang = s.value("application/language", "English") s.setValue("application/last-used-language", lang) class _list(list): # Accept extra argument to allow for the original string def __getitem__(self, item): if isinstance(item, tuple): item = item[0] return super().__getitem__(item) class Translator: e = eval def __init__(self, package, organization="biolab.si", application="Orange"): if QSettings is not None: s = QSettings(QSettings.IniFormat, QSettings.UserScope, organization, application) lang = s.value("application/language", DEFAULT_LANGUAGE) else: lang = DEFAULT_LANGUAGE # For testing purposes (and potential fallback) # lang = os.environ.get("ORANGE_LANG", "English") package_path = os.path.dirname(importlib.import_module(package).__file__) lang_eng = get_languages().get(lang, lang) path = os.path.join(package_path, "i18n", f"{lang_eng}.json") if not os.path.exists(path): path = os.path.join(package_path, "i18n", f"{DEFAULT_LANGUAGE}.json") assert os.path.exists(path), f"Missing language file {path}" self.m = _list(_load_json(path)) # Extra argument(s) can give the original string or any other relevant data def c(self, idx, *_): return compile(self.m[idx], '', 'eval') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/localization/si.py0000644000175100002000000001014414730024325023050 0ustar00runnerdockerdef plsi(n: int, forms: str) -> str: """ Choose a plural form for Slovenian - or create one, for some rare cases `forms` can be a string containing the singular and plural form, separated by "|", for instance `"okno|okni|okna|oken". The number of forms must be 4 or 3. - The four forms are singular, dual, plural for 3 or 4, plural for >= 5 - Three forms are used for cases other than genitive, where plural is the same for all numbers >= 3 A single form can be given for nouns in genitive that conform to one of the following rules: - miza/mizi/mize/miz - korak/koraka/koraki/korakov The function does not speak Slovenian and cannot verify the conformance. :) Examples: - Four plural forms: f'Aktiven {nfilt} {plsi(n, "filter|filtra|filtri|filtrov")}' - Four forms, multiple words conjugated: f'V tabeli je {n} {plsi(n, "učni primer|učna primera|učni primeri|učnih primerov")}' - Three forms (non-nominative): f'Datoteka z {n} {plsi(n, "primerom|primeroma|primeri")}' - Single form, feminine, using pattern f'Najdena {nvars} {plsi(nvars, "spremenljivka")}' - Single form, masculine, using pattern f'Vsebina: {n} {plsi(n, "primer")' - Plural form used twice f'{plsi(n, "Ostalo je|Ostala sta|Ostali so")} še {n} {plsi(n, "primer")}' Args: n: number forms: plural forms, separated by "|", or a single (regular) noun Returns: form corresponding to the given number """ n = abs(n) % 100 if n == 4: n = 3 elif n == 0 or n >= 5: n = 4 n -= 1 if "|" in forms: forms = forms.split("|") # Don't use max: we want it to fail if there are just two forms if n == 3 and len(forms) == 3: n -= 1 return forms[n] if forms[-1] == "a": return forms[:-1] + ("a", "i", "e", "")[n] else: return forms + ("", "a", "i", "ov")[n] def plsi_sz(n: int) -> str: """ Returns proposition "s" or "z", depending on the number that will follow it. Args: n (int): number Returns: Proposition s or z """ # Cut of all groups of three, except the first one lead3 = f"{n:_}".split("_")[0] # handle 1, 1_XXX, 1_XXX_XXX ... because "ena" is not pronounced and we need # to match "tisoč", "milijon", ... "trilijarda" # https://sl.wikipedia.org/wiki/Imena_velikih_%C5%A1tevil if lead3 == "1": if n > 10 ** 63: # nobody knows their names return "z" return "zszzzzsssssssssszzzzzz"[len(str(n)) // 3] # This is pronounced sto...something if len(lead3) == 3 and lead3[0] == "1": return "s" # Take the first digit, or the second for two-digit number not divisible by 10 lead = lead3[len(lead3) == 2 and lead3[1] != "0"] return "zzzssssszz"[int(lead)] def z_besedo(n, case, gender, zero="nič"): if not 0 <= n <= 10: return str(n) if n == 0: return zero elif n == 1: return {"m": ("", "en", "enega", "enemu", "en", "enem", "enim"), "f": ("", "ena", "ene", "eni", "eno", "eni", "eno"), "n": ("", "eno", "enega", "enemu", "eno", "enem", "enim")}[gender][case] elif n == 2: return {"m": ("", "dva", "dveh", "dvema", "dva", "dveh", "dvema"), "f": ("", "dve", "dveh", "dvema", "dve", "dveh", "dvema"), "n": ("", "dve", "dveh", "dvema", "dve", "dveh", "dvema")}[gender][case] return (None, None, None, ("", "tri", "treh", "trem", "tri", "treh", "tremi"), ("", "štiri", "štirih", "štirim", "štiri", "štirih", "štirimi"), ("", "pet", "petih", "petim", "pet", "petih", "petimi"), ("", "šest", "šestih", "šestim", "šest", "šestih", "šestimi"), ("", "sedem", "sedmih", "sedmim", "sedem", "sedmih", "sedmimi"), ("", "osem", "osmih", "osmim", "osem", "osmih", "osmimi"), ("", "devet", "devetih", "devetim", "devet", "devetih", "devetimi"), ("", "deset", "desetih", "desetim", "deset", "desetih", "desetimi"))[n][case] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.798081 orange_canvas_core-0.2.5/orangecanvas/localization/tests/0000755000175100002000000000000014730024333023224 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/localization/tests/__init__.py0000644000175100002000000000000014730024325025324 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/localization/tests/test_localization.py0000644000175100002000000000264414730024325027334 0ustar00runnerdockerimport importlib import unittest import warnings from orangecanvas.localization import pl class TestLocalization(unittest.TestCase): def test_pl(self): for forms, singular, plural in (("word", "word", "words"), ("sun" , "sun", "suns"), ("day", "day", "days"), ("FEE", "FEE", "FEES"), ("daisy", "daisy", "daisies"), ("FEY", "FEY", "FEYS"), ("leaf|leaves", "leaf", "leaves")): self.assertEqual(pl(1, forms), singular) for n in (2, 5, 101, -1): self.assertEqual(pl(n, forms), plural, msg=f"for n={n}") def test_deprecated_import(self): warnings.simplefilter("always") # Imports must work, but with warning with self.assertWarns(DeprecationWarning): # unittest discovery may have already imported this file -> reload import orangecanvas.utils.localization importlib.reload(orangecanvas.utils.localization) self.assertIs(orangecanvas.utils.localization.pl, pl) with self.assertWarns(DeprecationWarning): # pylint: disable=unused-import from orangecanvas.utils.localization.si import plsi if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/localization/tests/test_si.py0000644000175100002000000001016714730024325025256 0ustar00runnerdockerimport os import sys import unittest # This test will usually not be run when the module `si` is embedded within # Orange structure, hence we manually insert its path from itertools import chain si_path = os.path.join(os.path.dirname(__file__), "..") sys.path.append(si_path) from si import plsi, plsi_sz, z_besedo class TestPlsi(unittest.TestCase): def test_plsi_4(self): self.assertEqual(plsi(0, "okno|okni|okna|oken"), "oken") self.assertEqual(plsi(1, "okno|okni|okna|oken"), "okno") self.assertEqual(plsi(2, "okno|okni|okna|oken"), "okni") self.assertEqual(plsi(3, "okno|okni|okna|oken"), "okna") self.assertEqual(plsi(4, "okno|okni|okna|oken"), "okna") self.assertEqual(plsi(5, "okno|okni|okna|oken"), "oken") self.assertEqual(plsi(11, "okno|okni|okna|oken"), "oken") self.assertEqual(plsi(100, "okno|okni|okna|oken"), "oken") self.assertEqual(plsi(101, "okno|okni|okna|oken"), "okno") self.assertEqual(plsi(102, "okno|okni|okna|oken"), "okni") self.assertEqual(plsi(103, "okno|okni|okna|oken"), "okna") self.assertEqual(plsi(105, "okno|okni|okna|oken"), "oken") self.assertEqual(plsi(1001, "okno|okni|okna|oken"), "okno") def test_plsi_3(self): self.assertEqual(plsi(0, "oknu|oknoma|oknom"), "oknom") self.assertEqual(plsi(1, "oknu|oknoma|oknom"), "oknu") self.assertEqual(plsi(2, "oknu|oknoma|oknom"), "oknoma") self.assertEqual(plsi(3, "oknu|oknoma|oknom"), "oknom") self.assertEqual(plsi(5, "oknu|oknoma|oknom"), "oknom") self.assertEqual(plsi(1, "oknu|oknoma|oknom"), "oknu") self.assertEqual(plsi(105, "oknu|oknoma|oknom"), "oknom") def test_plsi_1(self): self.assertEqual(plsi(0, "miza"), "miz") self.assertEqual(plsi(1, "miza"), "miza") self.assertEqual(plsi(2, "miza"), "mizi") self.assertEqual(plsi(3, "miza"), "mize") self.assertEqual(plsi(5, "miza"), "miz") self.assertEqual(plsi(101, "miza"), "miza") self.assertEqual(plsi(105, "miza"), "miz") self.assertEqual(plsi(0, "primer"), "primerov") self.assertEqual(plsi(1, "primer"), "primer") self.assertEqual(plsi(2, "primer"), "primera") self.assertEqual(plsi(3, "primer"), "primeri") self.assertEqual(plsi(5, "primer"), "primerov") self.assertEqual(plsi(50, "primer"), "primerov") self.assertEqual(plsi(101, "primer"), "primer") self.assertEqual(plsi(105, "primer"), "primerov") class TestPlsi_si(unittest.TestCase): def test_plsi_sz(self): for propn in "z0 z1 z2 s3 s4 s5 s6 s7 z8 z9 z10 " \ "z11 z12 s13 s14 s15 s16 s17 z18 z19 z20 " \ "z21 z22 s23 z31 z32 s35 s40 s50 s60 s70 z80 z90 " \ "z200 z22334 s3943 z832492 " \ "s100 s108 s1000 s13333 s122222 z1000000 " \ "z1000000000 z1000000000000".split(): self.assertEqual(plsi_sz(int(propn[1:])), propn[0], propn) class TestZBesedo(unittest.TestCase): def test_z_besedo(self): self.assertEqual( "\n".join( f"{z_besedo(n, 1, 'n')} " f"{plsi(n, 'zeleno drevo|zeleni drevesi|zelena drevesa|zelenih dreves')}" for n in chain(range(6), (11, ))), """nič zelenih dreves eno zeleno drevo dve zeleni drevesi tri zelena drevesa štiri zelena drevesa pet zelenih dreves 11 zelenih dreves""" ) self.assertEqual( "\n".join( f"{plsi_sz(n).upper()} {z_besedo(n, 6, 'n')} " f"{plsi(n, 'zelenim drevesom|zelenima drevesoma|zelenimi drevesi')}" for n in chain(range(1, 6), (11, 100, 101))), """Z enim zelenim drevesom Z dvema zelenima drevesoma S tremi zelenimi drevesi S štirimi zelenimi drevesi S petimi zelenimi drevesi Z 11 zelenimi drevesi S 100 zelenimi drevesi S 101 zelenim drevesom""") self.assertEqual(f"{z_besedo(0, 1, 'm', 'brez')} {plsi(0, 'drevesa|dreves|dreves')}", "brez dreves") sys.path.remove(si_path) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/main.py0000644000175100002000000004375614730024325020710 0ustar00runnerdocker""" """ import argparse import os import sys import gc import logging import pickle import shlex import warnings from typing import List, Optional, IO, Any, Iterable from urllib.request import getproxies from contextlib import ExitStack, closing from AnyQt.QtGui import QFont, QColor, QPalette from AnyQt.QtCore import Qt, QSettings, QTimer, QUrl, QDir from orangecanvas import localization from .utils.after_exit import run_after_exit from .styles import style_sheet, breeze_dark as _breeze_dark from .application.application import CanvasApplication from .application.canvasmain import CanvasMainWindow from .application.outputview import TextStream, ExceptHook, TerminalTextDocument from . import utils, config from .gui.splashscreen import SplashScreen from .gui.utils import macos_set_nswindow_tabbing as _macos_set_nswindow_tabbing from .registry import WidgetRegistry, set_global_registry from .registry.qt import QtRegistryHandler from .registry import cache log = logging.getLogger(__name__) class Main: """ A helper 'main' runner class. """ #: The default config namespace DefaultConfig: Optional[str] = None config: config.Config #: Arguments list (remaining after options parsing). arguments: List[str] = [] #: Parsed option arguments options: argparse.Namespace = None registry: WidgetRegistry = None application: CanvasApplication = None def __init__(self): self.options = argparse.Namespace() self.arguments: List[str] = [] def argument_parser(self) -> argparse.ArgumentParser: """ Construct an return an `argparse.ArgumentParser` instance """ return arg_parser() def parse_arguments(self, argv: List[str]): """ Parse the `argv` argument list. Initialize the options """ parser = self.argument_parser() options, argv_rest = parser.parse_known_args(argv[1:]) # Handle the deprecated args (let QApplication handle this) if options.style is not None: argv_rest = ["-style", options.style] + argv_rest if options.qt is not None: argv_rest = shlex.split(options.qt) + argv_rest self.options = options self.arguments = argv_rest def activate_default_config(self): """ Activate the default configuration (:mod:`config`) """ config_ns = self.DefaultConfig if self.options.config is not None: config_ns = self.options.config cfg = None if config_ns is not None: try: cfg_class = utils.name_lookup(config_ns) except (ImportError, AttributeError): pass else: cfg = cfg_class() if cfg is None: cfg = config.Default() self.config = cfg config.set_default(cfg) # Init config config.init() def show_splash_message(self, message: str, color=QColor()): """Display splash screen message""" splash = self.splash_screen() if splash is not None: splash.show() splash.showMessage(message, color=color) def close_splash_screen(self): """Close splash screen.""" splash = self.splash_screen() if splash is not None: splash.close() self.__splash_screen = None __splash_screen = None def splash_screen(self) -> SplashScreen: """Return the application splash screen""" if self.__splash_screen is not None: return self.__splash_screen[0] settings = QSettings() options = self.options want_splash = \ settings.value("startup/show-splash-screen", True, type=bool) and \ not options.no_splash if want_splash: pm, rect = self.config.splash_screen() splash_screen = SplashScreen(pixmap=pm, textRect=rect) splash_screen.setAttribute(Qt.WA_DeleteOnClose) splash_screen.setFont(QFont("Helvetica", 12)) palette = splash_screen.palette() color = QColor("#FFD39F") palette.setColor(QPalette.Text, color) splash_screen.setPalette(palette) else: splash_screen = None self.__splash_screen = (splash_screen,) return splash_screen def run_discovery(self) -> WidgetRegistry: """ Run the widget discovery and return the resulting registry. """ options = self.options language_changed = localization.language_changed() if not (options.force_discovery or language_changed): reg_cache = cache.registry_cache() else: reg_cache = None widget_registry = WidgetRegistry() handler = QtRegistryHandler(registry=widget_registry) handler.found_category.connect( lambda cd: self.show_splash_message(cd.name) ) widget_discovery = self.config.widget_discovery( handler, cached_descriptions=reg_cache ) cache_filename = os.path.join(config.cache_dir(), "widget-registry.pck") if options.no_discovery: with open(cache_filename, "rb") as f: widget_registry = pickle.load(f) widget_registry = WidgetRegistry(widget_registry) else: widget_discovery.run(self.config.widgets_entry_points()) # Store cached descriptions cache.save_registry_cache(widget_discovery.cached_descriptions) with open(cache_filename, "wb") as f: pickle.dump(WidgetRegistry(widget_registry), f) self.registry = widget_registry if language_changed: localization.update_last_used_language() self.close_splash_screen() return widget_registry def setup_application(self): # sys.argv[0] must be in QApplication's argv list. self.application = CanvasApplication(sys.argv[:1] + self.arguments) self.application.setWindowIcon(self.config.application_icon()) self.application.applicationPaletteChanged.connect(self.__reconfigure_stylesheet) # Update the arguments self.arguments = self.application.arguments()[1:] fix_set_proxy_env() def tear_down_application(self): gc.collect() self.application.processEvents() del self.application #: An exit stack to run cleanup at application exit. stack: ExitStack def run(self, argv: List[str]) -> int: if argv is None: argv = sys.argv fix_win_pythonw_std_stream() self.parse_arguments(argv) self.activate_default_config() with ExitStack() as stack: self.stack = stack self.setup_application() stack.callback(self.tear_down_application) self.setup_sys_redirections() stack.callback(self.tear_down_sys_redirections) self.setup_logging() stack.callback(self.tear_down_logging) paths = self.arguments log.debug("Loading paths from argv: %s", " ,".join(paths)) def record_path(url: QUrl): log.debug("Path from FileOpen event: %s", url.toLocalFile()) paths.append(url.toLocalFile()) self.application.fileOpenRequest.connect(record_path) registry = self.run_discovery() set_global_registry(registry) mainwindow = self.setup_main_window() mainwindow.show() if not paths: self.show_welcome_screen(mainwindow) else: self.open_files(paths) def open_request(url): path = url.toLocalFile() if os.path.exists(path) and not ( path.endswith("pydevd.py") or path.endswith("run_profiler.py") ): mainwindow.open_scheme_file(path) self.application.fileOpenRequest.connect(open_request) rv = self.application.exec() del mainwindow if rv == 96: log.info('Restarting via exit code 96.') run_after_exit([sys.executable, sys.argv[0]]) return rv def open_files(self, paths): _windows = [self.window] def _window(): if _windows: return _windows.pop(0) else: return self.window.create_new_window() for path in paths: w = _window() w.open_scheme_file(path) w.show() def setup_logging(self): level = self.options.log_level logformat = "%(asctime)s:%(levelname)s:%(name)s: %(message)s" # File handler should always be at least INFO level so we need # the application root level to be at least at INFO. root_level = min(level, logging.INFO) rootlogger = logging.getLogger() rootlogger.setLevel(root_level) # Standard output stream handler at the requested level stream_hander = make_stream_handler( level, fileobj=self.__stderr__, fmt=logformat ) rootlogger.addHandler(stream_hander) # Setup log capture for MainWindow/Log log_stream = TextStream(objectName="-log-stream") self.output.connectStream(log_stream) self.stack.push(closing(log_stream)) # close on exit log_handler = make_stream_handler( level, fileobj=log_stream, fmt=logformat ) rootlogger.addHandler(log_handler) # Also log to file file_handler = make_file_handler( root_level, os.path.join(config.log_dir(), "canvas.log"), mode="w", ) rootlogger.addHandler(file_handler) def tear_down_logging(self): pass def create_main_window(self) -> CanvasMainWindow: """Create the (initial) main window.""" return CanvasMainWindow() window: Optional[CanvasMainWindow] = None def setup_main_window(self): stylesheet = self.main_window_stylesheet() self.window = window = self.create_main_window() if sys.platform != "darwin": # on macOS transient document views do not have an icon. window.setWindowIcon(self.config.application_icon()) window.setStyleSheet(stylesheet) window.output_view().setDocument(self.output) window.set_widget_registry(self.registry) return window def main_window_stylesheet(self): """Return the stylesheet for the main window.""" options = self.options palette = self.application.palette() stylesheet = "orange.qss" if palette.color(QPalette.Window).value() < 127: log.info("Switching default stylesheet to darkorange") stylesheet = "darkorange.qss" if options.stylesheet is not None: stylesheet = options.stylesheet qss, paths = style_sheet(stylesheet) for prefix, path in paths: if path not in QDir.searchPaths(prefix): log.info("Adding search path %r for prefix, %r", path, prefix) QDir.addSearchPath(prefix, path) return qss def show_welcome_screen(self, parent: CanvasMainWindow): """Show the initial welcome screen.""" settings = QSettings() options = self.options want_welcome = settings.value( "startup/show-welcome-screen", True, type=bool ) and not options.no_welcome def trigger(): if not parent.is_transient(): return swp_loaded = parent.ask_load_swp_if_exists() if not swp_loaded and want_welcome: parent.welcome_action.trigger() # On a timer to allow FileOpen events to be delivered. If so # then do not show the welcome screen. QTimer.singleShot(0, trigger) __stdout__: Optional[IO] = None __stderr__: Optional[IO] = None __excepthook__: Optional[Any] = None output: TerminalTextDocument def setup_sys_redirections(self): self.output = doc = TerminalTextDocument() stdout = TextStream(objectName="-stdout") stderr = TextStream(objectName="-stderr") doc.connectStream(stdout) doc.connectStream(stderr, color=Qt.red) if sys.stdout is not None: stdout.stream.connect(sys.stdout.write, Qt.DirectConnection) self.__stdout__ = sys.stdout sys.stdout = stdout if sys.stderr is not None: stderr.stream.connect(sys.stderr.write, Qt.DirectConnection) self.__stderr__ = sys.stderr sys.stderr = stderr self.__excepthook__ = sys.excepthook sys.excepthook = ExceptHook(stream=stderr) self.stack.push(closing(stdout)) self.stack.push(closing(stderr)) def tear_down_sys_redirections(self): if self.__excepthook__ is not None: sys.excepthook = self.__excepthook__ if self.__stderr__ is not None: sys.stderr = self.__stderr__ if self.__stdout__ is not None: sys.stdout = self.__stdout__ def _main_windows(self) -> Iterable[CanvasMainWindow]: first = self.window return (*((first,) if first else ()), *CanvasMainWindow._instances) def __reconfigure_stylesheet(self) -> None: ssheet = self.main_window_stylesheet() for inst in self._main_windows(): if inst.styleSheet() != ssheet: inst.setStyleSheet(ssheet) def fix_win_pythonw_std_stream(): """ On windows when running without a console (using pythonw.exe without I/O redirection) the std[err|out] file descriptors are invalid (`http://bugs.python.org/issue706263`_). We `fix` this by setting the stdout/stderr to `os.devnull`. """ if sys.platform == "win32" and \ os.path.basename(sys.executable) == "pythonw.exe": if sys.stdout is None or sys.stdout.fileno() < 0: sys.stdout = open(os.devnull, "w", encoding="utf-8", errors="ignore") if sys.stderr is None or sys.stderr.fileno() < 0: sys.stderr = open(os.devnull, "w", encoding="utf-8", errors="ignore") default_proxies = None # TODO: Remove this def fix_set_proxy_env(): """ Set http_proxy/https_proxy environment variables (for requests, pip, ...) from user-specified settings or, if none, from system settings on OS X and from registry on Windos. """ warnings.warn( "fix_set_proxy_env is deprecated", DeprecationWarning, stacklevel=2 ) # save default proxies so that setting can be reset global default_proxies if default_proxies is None: default_proxies = getproxies() # can also read windows and macos settings settings = QSettings() proxies = getproxies() for scheme in set(["http", "https"]) | set(proxies): from_settings = settings.value("network/" + scheme + "-proxy", "", type=str) from_default = default_proxies.get(scheme, "") env_scheme = scheme + '_proxy' if from_settings: os.environ[env_scheme] = from_settings elif from_default: os.environ[env_scheme] = from_default # crucial for windows/macos support else: os.environ.pop(env_scheme, "") def fix_macos_nswindow_tabbing(): warnings.warn( f"'{__name__}.fix_macos_nswindow_tabbing()' is deprecated. Use " "'orangecanvas.gui.utils.macos_set_nswindow_tabbing()' instead", DeprecationWarning, stacklevel=2 ) _macos_set_nswindow_tabbing() # Used to be defined here now moved to styles. def breeze_dark(): warnings.warn( f"{__name__}'.breeze_dark()' has been moved to styles package.", DeprecationWarning, stacklevel=2 ) return _breeze_dark() def make_stream_handler(level, fileobj=None, fmt=None): handler = logging.StreamHandler(fileobj) handler.setLevel(level) if fmt: handler.setFormatter(logging.Formatter(fmt)) return handler def make_file_handler(level, filename, mode="w", fmt=None): handler = logging.FileHandler(filename, mode=mode, encoding="utf-8") handler.setLevel(level) if fmt: handler.setFormatter(logging.Formatter(fmt)) return handler LOG_LEVELS = [ logging.CRITICAL + 10, logging.CRITICAL, logging.ERROR, logging.WARN, logging.INFO, logging.DEBUG ] def arg_parser(): def log_level(value): if value in ("0", "1", "2", "3", "4", "5"): return LOG_LEVELS[int(value)] elif hasattr(logging, value.upper()): return getattr(logging, value.upper()) else: raise ValueError("Invalid log level {!r}".format(value)) parser = argparse.ArgumentParser( usage="usage: %(prog)s [options] [workflow_file]" ) parser.add_argument( "--no-discovery", action="store_true", help="Don't run widget discovery (use full cache instead)" ) parser.add_argument( "--force-discovery", action="store_true", help="Force full widget discovery (invalidate cache)" ) parser.add_argument( "--no-welcome", action="store_true", help="Don't show welcome dialog." ) parser.add_argument( "--no-splash", action="store_true", help="Don't show splash screen." ) parser.add_argument( "-l", "--log-level", help="Logging level (0, 1, 2, 3, 4)", type=log_level, default=logging.ERROR, ) parser.add_argument( "--stylesheet", help="Application level CSS style sheet to use", type=str, default=None ) parser.add_argument( "--config", help="Configuration namespace", type=str, default=None, ) deprecated = parser.add_argument_group("Deprecated") deprecated.add_argument( "--qt", help="Additional arguments for QApplication.\nDeprecated. " "List all arguments as normally to pass it to QApplication.", type=str, default=None ) deprecated.add_argument( "--style", help="QStyle to use (deprecated: use -style)", type=str, default=None ) return parser def main(argv=None): return Main().run(argv) if __name__ == "__main__": sys.exit(main()) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.798081 orange_canvas_core-0.2.5/orangecanvas/preview/0000755000175100002000000000000014730024333021053 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/__init__.py0000644000175100002000000000013414730024325023163 0ustar00runnerdocker""" """ from .previewbrowser import PreviewBrowser from .previewdialog import PreviewDialog ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/previewbrowser.py0000644000175100002000000002224514730024325024520 0ustar00runnerdocker""" Preview Browser Widget. """ import os from xml.sax.saxutils import escape from typing import Optional, Any from AnyQt.QtWidgets import QWidget, QLabel, QVBoxLayout, QHBoxLayout from AnyQt.QtSvg import QSvgWidget from AnyQt.QtCore import Qt, QByteArray, QModelIndex, QAbstractItemModel from AnyQt.QtCore import pyqtSignal as Signal from ..gui.dropshadow import DropShadowFrame from ..gui.iconview import LinearIconView from ..gui.textlabel import TextLabel from . import previewmodel NO_PREVIEW_SVG = """ """ # Default description template DESCRIPTION_TEMPLATE = """

              {name}

              {description}

              """ PREVIEW_SIZE = (440, 295) class PreviewBrowser(QWidget): """ A Preview Browser for recent/example workflow selection. """ # Emitted when the current previewed item changes currentIndexChanged = Signal(int) # Emitted when an item is double clicked in the preview list. activated = Signal(int) def __init__(self, *args, heading="", previewMargins=12, **kwargs): # type: (Any, str, int, Any) -> None super().__init__(*args, **kwargs) self.__model = None # type: Optional[QAbstractItemModel] self.__currentIndex = -1 self.__template = DESCRIPTION_TEMPLATE self.__margin = previewMargins vlayout = QVBoxLayout() vlayout.setContentsMargins(0, 0, 0, 0) top_layout = QVBoxLayout(objectName="top-layout") margin = self.__margin top_layout.setContentsMargins(margin, margin, margin, margin) # Optional heading label self.__heading = QLabel( self, objectName="heading", visible=False ) # Horizontal row with full text description and a large preview # image. hlayout = QHBoxLayout() hlayout.setContentsMargins(0, 0, 0, 0) self.__label = QLabel( self, objectName="description-label", wordWrap=True, alignment=Qt.AlignTop | Qt.AlignLeft ) self.__label.setWordWrap(True) self.__label.setFixedSize(220, PREVIEW_SIZE[1]) self.__label.setMinimumWidth(PREVIEW_SIZE[0] // 2) self.__label.setMaximumHeight(PREVIEW_SIZE[1]) self.__image = QSvgWidget(self, objectName="preview-image") self.__image.setFixedSize(*PREVIEW_SIZE) self.__imageFrame = DropShadowFrame(self) self.__imageFrame.setWidget(self.__image) hlayout.addWidget(self.__label) hlayout.addWidget(self.__image) # Path text below the description and image path_layout = QHBoxLayout() path_layout.setContentsMargins(0, 0, 0, 0) path_label = QLabel("{0!s}".format(self.tr("Path:")), self, objectName="path-label") self.__path = TextLabel(self, objectName="path-text") path_layout.addWidget(path_label) path_layout.addWidget(self.__path) top_layout.addWidget(self.__heading) top_layout.addLayout(hlayout) top_layout.addLayout(path_layout) vlayout.addLayout(top_layout) # An list view with small preview icons. self.__previewList = LinearIconView( objectName="preview-list-view", wordWrap=True ) self.__previewList.doubleClicked.connect(self.__onDoubleClicked) vlayout.addWidget(self.__previewList) self.setLayout(vlayout) self.setHeading(heading) def setHeading(self, text): # type: (str) -> None """ Set the heading text. Parameters ---------- text: str The new heading text. If empty the heading is hidden. """ self.__heading.setVisible(bool(text)) self.__heading.setText(text) def setPreviewMargins(self, margin): # type: (int) -> None """ Set the left, top and right margins of the top widget part (heading and description) Parameters ---------- margin : int Margin """ if margin != self.__margin: layout = self.layout().itemAt(0).layout() assert isinstance(layout, QVBoxLayout) assert layout.objectName() == "top-layout" layout.setContentsMargins(margin, margin, margin, 0) def setModel(self, model): # type: (QAbstractItemModel) -> None """ Set the item model for preview. Parameters ---------- model : QAbstractItemModel """ if self.__model != model: if self.__model: s_model = self.__previewList.selectionModel() s_model.selectionChanged.disconnect(self.__onSelectionChanged) self.__model.dataChanged.disconnect(self.__onDataChanged) self.__model = model self.__previewList.setModel(model) if model: s_model = self.__previewList.selectionModel() s_model.selectionChanged.connect(self.__onSelectionChanged) self.__model.dataChanged.connect(self.__onDataChanged) if model and model.rowCount(): self.setCurrentIndex(0) def model(self): # type: () -> Optional[QAbstractItemModel] """ Return the item model. """ return self.__model def setDescriptionTemplate(self, template): self.__template = template self.__update() def setCurrentIndex(self, index): # type: (int) -> None """ Set the selected preview item index. Parameters ---------- index : int The current selected index. """ if self.__model is not None and self.__model.rowCount(): index = min(index, self.__model.rowCount() - 1) index = self.__model.index(index, 0) sel_model = self.__previewList.selectionModel() # This emits selectionChanged signal and triggers # __onSelectionChanged, currentIndex is updated there. sel_model.select(index, sel_model.ClearAndSelect) elif self.__currentIndex != -1: self.__currentIndex = -1 self.__update() self.currentIndexChanged.emit(-1) def currentIndex(self): # type: () -> int """ Return the current selected index. """ return self.__currentIndex def __onSelectionChanged(self): # type: () -> None """Selected item in the preview list has changed. Set the new description and large preview image. """ rows = self.__previewList.selectedIndexes() if rows: index = rows[0] self.__currentIndex = index.row() else: self.__currentIndex = -1 self.__update() self.currentIndexChanged.emit(self.__currentIndex) def __onDataChanged(self, topLeft, bottomRight): # type: (QModelIndex, QModelIndex) -> None """Data changed, update the preview if current index in the changed range. """ if topLeft.row() <= self.__currentIndex <= bottomRight.row(): self.__update() def __onDoubleClicked(self, index): # type: (QModelIndex) -> None """Double click on an item in the preview item list. """ self.activated.emit(index.row()) def __update(self): # type: () -> None """Update the current description. """ if self.__currentIndex != -1 and self.__model is not None: index = self.__model.index(self.__currentIndex, 0) else: index = QModelIndex() if not index.isValid(): description = "" name = "" path = "" svg = NO_PREVIEW_SVG else: description = index.data(Qt.WhatsThisRole) if description: description = description else: description = "No description." description = escape(description) description = description.replace("\n", "
              ") name = index.data(Qt.DisplayRole) if name: name = name else: name = "Untitled" name = escape(name) path = str(index.data(Qt.StatusTipRole)) svg = str(index.data(previewmodel.ThumbnailSVGRole)) desc_text = self.__template.format(description=description, name=name) self.__label.setText(desc_text) self.__path.setText(contractuser(path)) if not svg: svg = NO_PREVIEW_SVG if svg: self.__image.load(QByteArray(svg.encode("utf-8"))) def contractuser(path): # type: (str) -> str """ Inverse of `expanduser(join("~", path))` Return the path unmodified if not under user's home dir. Parameters ---------- path : str Returns ------- path : str Examples -------- >>> contractuser(os.path.expanduser("~/hello")) '~/hello' """ home = os.path.expanduser("~/") pathnorm = os.path.normcase(os.path.normpath(path)) homenorm = os.path.normcase(os.path.normpath(home)) if pathnorm.startswith(homenorm): path = os.path.join("~", os.path.relpath(path, home)) return path ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/previewdialog.py0000644000175100002000000000744414730024325024300 0ustar00runnerdocker""" A dialog widget for selecting an item. """ from typing import Iterable, Optional, Any from AnyQt.QtWidgets import ( QDialog, QWidget, QVBoxLayout, QDialogButtonBox, QSizePolicy ) from AnyQt.QtCore import Qt, QStringListModel, QAbstractItemModel from AnyQt.QtCore import pyqtSignal as Signal from . import previewbrowser class PreviewDialog(QDialog): """A Dialog for selecting an item from a PreviewItem. """ currentIndexChanged = Signal(int) def __init__(self, parent=None, flags=Qt.WindowFlags(0), model=None, **kwargs): # type: (Optional[QWidget], int, Optional[QAbstractItemModel], Any) -> None super().__init__(parent, flags, **kwargs) self.__setupUi() if model is not None: self.setModel(model) def __setupUi(self): layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.setContentsMargins(0, 0, 0, 0) self.__browser = previewbrowser.PreviewBrowser( self, heading="

              {0}

              ".format(self.tr("Preview")) ) self.__buttons = QDialogButtonBox(QDialogButtonBox.Open | QDialogButtonBox.Cancel, Qt.Horizontal,) self.__buttons.button(QDialogButtonBox.Open).setAutoDefault(True) # Set the Open dialog as disabled until the current index changes self.__buttons.button(QDialogButtonBox.Open).setEnabled(False) # The QDialogButtonsWidget messes with the layout if it is # contained directly in the QDialog. So we create an extra # layer of indirection. buttons = QWidget(objectName="button-container") buttons_l = QVBoxLayout() buttons_l.setContentsMargins(12, 0, 12, 12) buttons.setLayout(buttons_l) buttons_l.addWidget(self.__buttons) layout.addWidget(self.__browser) layout.addWidget(buttons) self.__buttons.accepted.connect(self.accept) self.__buttons.rejected.connect(self.reject) self.__browser.currentIndexChanged.connect( self.__on_currentIndexChanged ) self.__browser.activated.connect(self.__on_activated) layout.setSizeConstraint(QVBoxLayout.SetFixedSize) self.setLayout(layout) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) def setItems(self, items): # type: (Iterable[str]) -> None """Set the items (a list of strings) for preview/selection. """ model = QStringListModel(items) self.setModel(model) def setModel(self, model): # type: (QAbstractItemModel) -> None """Set the model for preview/selection. """ self.__browser.setModel(model) def model(self): # type: () -> QAbstractItemModel """Return the model. """ return self.__browser.model() def currentIndex(self): # type: () -> int return self.__browser.currentIndex() def setCurrentIndex(self, index): # type: (int) -> None """Set the current selected (shown) index. """ self.__browser.setCurrentIndex(index) def setHeading(self, heading): # type: (str) -> None """Set `heading` as the heading string ('

              Preview

              ' by default). """ self.__browser.setHeading(heading) def heading(self): """Return the heading string. """ def __on_currentIndexChanged(self, index): # type: (int) -> None button = self.__buttons.button(QDialogButtonBox.Open) button.setEnabled(index >= 0) self.currentIndexChanged.emit(index) def __on_activated(self, index): # type: (int) -> None if self.currentIndex() != index: self.setCurrentIndex(index) self.accept() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/previewmodel.py0000644000175100002000000001140314730024325024127 0ustar00runnerdocker""" Preview item model. """ import os import logging from AnyQt.QtGui import ( QStandardItemModel, QStandardItem, QIcon ) from AnyQt.QtCore import Qt, QTimer from AnyQt.QtCore import pyqtSlot as Slot from ..gui.svgiconengine import SvgIconEngine from . import scanner log = logging.getLogger(__name__) # Preview Data Roles #################### # Name of the item, (same as `Qt.DisplayRole`) NameRole = Qt.DisplayRole # Items description (items long description) DescriptionRole = Qt.UserRole + 1 # Items url/path (where previewed resource is located). PathRole = Qt.UserRole + 2 # Items preview SVG contents string ThumbnailSVGRole = Qt.UserRole + 3 UNKNOWN_SVG = \ """ """ class PreviewModel(QStandardItemModel): """A model for preview items. """ def __init__(self, parent=None, items=None): super().__init__(parent) self.__preview_index = -1 if items is not None: self.insertColumn(0, items) self.__timer = QTimer(self) self.__timer.timeout.connect(self.__process_next) def delayedScanUpdate(self, delay=10): """Run a delayed preview item scan update. """ self.__preview_index = -1 self.__timer.start(delay) log.debug("delayedScanUpdate: Start") @Slot() def __process_next(self): index = self.__preview_index log.debug("delayedScanUpdate: Next %i", index + 1) if not 0 <= index + 1 < self.rowCount(): self.__timer.stop() log.debug("delayedScanUpdate: Stop") return self.__preview_index = index = index + 1 assert 0 <= index < self.rowCount() item = self.item(index) if os.path.isfile(item.path()): try: scanner.scan_update(item) except Exception: log.error("An unexpected error occurred while " "scanning '%s'.", item.text(), exc_info=True) item.setEnabled(False) class PreviewItem(QStandardItem): """A preview item. """ def __init__(self, name=None, description=None, thumbnail=None, icon=None, path=None): super().__init__() self.__name = "" if name is None: name = "Untitled" self.setName(name) if description is None: description = "No description." self.setDescription(description) if thumbnail is None: thumbnail = UNKNOWN_SVG self.setThumbnail(thumbnail) if icon is not None: self.setIcon(icon) if path is not None: self.setPath(path) def name(self): """Return the name (title) of the item (same as `text()`. """ return self.__name def setName(self, value): """Set the item name. `value` if not empty will be used as the items DisplayRole otherwise an 'untitled' placeholder will be used. """ self.__name = value if not value: self.setText("untitled") else: self.setText(value) def description(self): """Return the detailed description for the item. This is stored as `DescriptionRole`, if no data is set then return the string for `WhatsThisRole`. """ desc = self.data(DescriptionRole) if desc is not None: return str(desc) whatsthis = self.data(Qt.WhatsThisRole) if whatsthis is not None: return str(whatsthis) else: return "" def setDescription(self, description): self.setData(description, DescriptionRole) self.setWhatsThis(description) def thumbnail(self): """Return the thumbnail SVG string for the preview item. This is stored as `ThumbnailSVGRole` """ thumb = self.data(ThumbnailSVGRole) if thumb is not None: return str(thumb) else: return "" def setThumbnail(self, thumbnail): """Set the thumbnail SVG contents as a string. When set it also overrides the icon role. """ self.setData(thumbnail, ThumbnailSVGRole) engine = SvgIconEngine(thumbnail.encode("utf-8")) self.setIcon(QIcon(engine)) def path(self): """Return the path item data. """ return str(self.data(PathRole)) def setPath(self, path): """Set the path data of the item. .. note:: This also sets the Qt.StatusTipRole """ self.setData(path, PathRole) self.setStatusTip(path) self.setToolTip(path) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/scanner.py0000644000175100002000000001200514730024325023055 0ustar00runnerdocker""" Scheme file preview parser. """ import io import logging import typing from xml.sax import make_parser, handler, saxutils, SAXParseException from typing import BinaryIO, Tuple, List from ..scheme.readwrite import scheme_load if typing.TYPE_CHECKING: from .previewmodel import PreviewItem log = logging.getLogger(__name__) class PreviewHandler(handler.ContentHandler): def __init__(self): super().__init__() self._in_name = False self._in_description = False self._in_thumbnail = False self.name_data = [] self.title = None self.description = None self.description_data = [] self.thumbnail_data = [] def startElement(self, name, attrs): if name == "scheme": if attrs.get("version", "1.0") >= "2.0": self.title = attrs.get("title", None) self.description = attrs.get("description", None) elif name == "thumbnail": self._in_thumbnail = True def endElement(self, name): if name == "name": self._in_name = False elif name == "description": self._in_description = False elif name == "thumbnail": self._in_thumbnail = False def characters(self, content): if self._in_name: self.name_data.append(content) elif self._in_description: self.description_data.append(content) elif self._in_thumbnail: self.thumbnail_data.append(content) def preview_parse(scheme_file): # type: (str) -> Tuple[str, str, str] """Return the title, description, and thumbnail svg image data from a `scheme_file` (can be a file path or a file-like object). """ parser = make_parser() handler = PreviewHandler() parser.setContentHandler(handler) parser.parse(scheme_file) name_data = handler.title or "" description_data = handler.description or "" svg_data = "".join(handler.thumbnail_data) return (saxutils.unescape(name_data), saxutils.unescape(description_data), saxutils.unescape(svg_data)) def filter_properties(stream): # type: (BinaryIO) -> bytes """ Filter out the '' section from the .ows xml stream. Parameters ---------- stream : io.BinaryIO Returns ------- xml : bytes ows xml without the '' nodes. """ class PropertiesFilter(saxutils.XMLFilterBase): _in_properties = False def startElement(self, tag, attrs): if tag == "properties": self._in_properties = True else: super().startElement(tag, attrs) def characters(self, content): if self._in_properties: pass else: super().characters(content) def endElement(self, name): if name == "properties": self._in_properties = False else: super().endElement(name) buffer = io.BytesIO() writer = saxutils.XMLGenerator(out=buffer, encoding="utf-8") filter = PropertiesFilter(parent=make_parser()) filter.setContentHandler(writer) filter.parse(stream) return buffer.getvalue() def scheme_svg_thumbnail(scheme_file): # type: (str) -> str """Load the scheme scheme from a file and return its svg image representation. """ from ..scheme import Scheme from ..canvas import scene from ..registry import global_registry scheme = Scheme() scheme.set_loop_flags(scheme.AllowLoops | scheme.AllowSelfLoops) errors = [] # type: List[Exception] with open(scheme_file, "rb") as f: filtered_contents = filter_properties(f) scheme_load(scheme, io.BytesIO(filtered_contents), error_handler=errors.append) tmp_scene = scene.CanvasScene() tmp_scene.set_channel_names_visible(False) tmp_scene.set_registry(global_registry()) tmp_scene.set_node_animation_enabled(False) tmp_scene.set_scheme(scheme) # Force the anchor point layout. tmp_scene.anchor_layout().activate() # Last added node is auto-selected. Need to clear. tmp_scene.clearSelection() svg = scene.grab_svg(tmp_scene) tmp_scene.clear() tmp_scene.deleteLater() return svg def scan_update(item): # type: (PreviewItem) -> None """Given a preview item, scan the scheme file ('item.path') and update the item's contents. """ path = item.path() try: title, desc, svg = preview_parse(path) except SAXParseException as ex: log.error("%r is malformed (%r)", path, ex) item.setEnabled(False) item.setSelectable(False) return if not svg: try: svg = scheme_svg_thumbnail(path) except Exception: log.error("Could not render scheme preview for %r", title, exc_info=True) if item.name() != title: item.setName(title) if item.description() != desc: item.setDescription(desc) if svg: item.setThumbnail(svg) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.798081 orange_canvas_core-0.2.5/orangecanvas/preview/tests/0000755000175100002000000000000014730024333022215 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/tests/__init__.py0000644000175100002000000000000014730024325024315 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/tests/test_previewbrowser.py0000644000175100002000000000203414730024325026713 0ustar00runnerdocker""" Unittests for PrewiewBrowser widget. """ import pkgutil from ...gui import test from ..previewbrowser import PreviewBrowser from ..previewmodel import PreviewItem, PreviewModel from ... import config svg1 = pkgutil.get_data(config.__package__, "icons/default-category.svg") svg2 = pkgutil.get_data(config.__package__, "icons/default-widget.svg") def construct_test_preview_model(): items = [ ("Name1", "A preview item 1", svg1.decode("utf-8"), "~/bla", ), ("Name2", "A preview item 2" + "long text" * 5, svg2.decode("utf-8"), "~/item") ] items = [PreviewItem(*arg[:-1], path=arg[-1]) for arg in items] model = PreviewModel(items=items) return model class TestPreviewBrowser(test.QAppTestCase): def test_preview_browser(self): w = PreviewBrowser() model = construct_test_preview_model() model.delayedScanUpdate() w.setModel(model) w.show() def p(index): print(index) w.currentIndexChanged.connect(p) self.qWait() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/tests/test_previewdialog.py0000644000175100002000000000143614730024325026474 0ustar00runnerdocker""" Unittests for PrewiewDialog widget. """ from ...gui import test from ..previewdialog import PreviewDialog from .test_previewbrowser import construct_test_preview_model class TestPreviewDialog(test.QAppTestCase): def test_preview_dialog(self): w = PreviewDialog() model = construct_test_preview_model() w.setModel(model) w.show() current = [None] w.currentIndexChanged.connect(current.append) self.singleShot(50, w.close) status = w.exec() if status and len(current) > 1: self.assertIs(current[-1], w.currentIndex()) w.setItems(["A", "B"]) w.show() self.singleShot(50, w.close) status = w.exec() if status: self.assertTrue(w.currentIndex() != -1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/preview/tests/test_scanner.py0000644000175100002000000000266014730024325025264 0ustar00runnerdockerimport unittest import io from ..scanner import preview_parse, filter_properties test_ows = b"""\ random garbage random garbage """ class TestPreviewParse(unittest.TestCase): def test_filter_properties(self): stream = io.BytesIO(test_ows) filtered = filter_properties(stream) self.assertNotIn(b'', filtered) self.assertEqual(filtered.count(b' None # A list of (category, widgets_list) tuples ordered by priority. self.registry = [] # type: List[CategoryWidgetsPair] # tuples from 'registry' indexed by name self._categories_dict = {} # type: Dict[str, CategoryWidgetsPair] # WidgetDescriptions by qualified name self._widgets_dict = {} # type: Dict[str, WidgetDescription] if other is not None: if not isinstance(other, WidgetRegistry): raise TypeError("Expected a 'WidgetRegistry' got %r." \ % type(other).__name__) self.registry = list(other.registry) self._categories_dict = dict(other._categories_dict) self._widgets_dict = dict(other._widgets_dict) def categories(self): # type: () -> List[CategoryDescription] """ Return a list all top level :class:`CategoryDescription` instances ordered by `priority`. """ return [c for c, _ in self.registry] def category(self, name): # type: (str) -> CategoryDescription """ Find and return a :class:`CategoryDescription` by its `name`. .. note:: Categories are identified by `name` attribute in contrast with widgets which are identified by `qualified_name`. Parameters ---------- name : str Category name """ return self._categories_dict[name][0] def has_category(self, name): # type: (str) -> bool """ Return ``True`` if a category with `name` exist in this registry. Parameters ---------- name : str Category name """ return name in self._categories_dict def widgets(self, category=None): # type: (Union[CategoryDescription, str, None]) -> List[WidgetDescription] """ Return a list of all widgets in the registry. If `category` is specified return only widgets which belong to the category. Parameters ---------- category : :class:`CategoryDescription` or str, optional Return only descriptions of widgets belonging to the category. """ if category is None: categories = self.categories() elif isinstance(category, str): categories = [self.category(category)] else: categories = [category] widgets = [] for cat in categories: if isinstance(cat, str): cat = self.category(cat) cat_widgets = self._categories_dict[cat.name][1] widgets.extend(sorted(cat_widgets, key=attrgetter("priority"))) return widgets def widget(self, qualified_name): # type: (str) -> WidgetDescription """ Return a :class:`WidgetDescription` identified by `qualified_name`. Raise :class:`KeyError` if the description does not exist. Parameters ---------- qualified_name : str Widget description qualified name """ return self._widgets_dict[qualified_name] def has_widget(self, qualified_name): # type: (str) -> bool """ Return ``True`` if the widget with `qualified_name` exists in this registry. """ return qualified_name in self._widgets_dict def register_widget(self, desc): # type: (WidgetDescription) -> None """ Register a :class:`WidgetDescription` instance. """ if not isinstance(desc, description.WidgetDescription): raise TypeError("Expected a 'WidgetDescription' got %r." \ % type(desc).__name__) if self.has_widget(desc.qualified_name): raise ValueError("%r already exists in the registry." \ % desc.qualified_name) category = desc.category if category is None: category = "Unspecified" if self.has_category(category): cat_desc = self.category(category) else: log.warning("Creating a default category %r.", category) cat_desc = description.CategoryDescription(name=category) self.register_category(cat_desc) self._insert_widget(cat_desc, desc) def register_category(self, desc): # type: (CategoryDescription) -> None """ Register a :class:`CategoryDescription` instance. .. note:: It is always best to register the category before the widgets belonging to it. """ if not isinstance(desc, description.CategoryDescription): raise TypeError("Expected a 'CategoryDescription' got %r." \ % type(desc).__name__) name = desc.name if not name: log.info("Creating a default category name.") name = "default" if any(name == c.name for c in self.categories()): log.info("A category with %r name already exists" % name) return self._insert_category(desc) def _insert_category(self, desc): # type: (CategoryDescription) -> None """ Insert category description into 'registry' list """ priority = desc.priority priorities = [c.priority for c, _ in self.registry] insertion_i = bisect.bisect_right(priorities, priority) item = (desc, []) # type: CategoryWidgetsPair self.registry.insert(insertion_i, item) self._categories_dict[desc.name] = item def _insert_widget(self, category, desc): # type: (CategoryDescription, WidgetDescription) -> None """ Insert widget description `desc` into `category`. """ assert isinstance(category, description.CategoryDescription) _, widgets = self._categories_dict[category.name] priority = desc.priority priorities = [w.priority for w in widgets] insertion_i = bisect.bisect_right(priorities, priority) widgets.insert(insertion_i, desc) self._widgets_dict[desc.qualified_name] = desc ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/cache.py0000644000175100002000000000302614730024325022661 0ustar00runnerdocker""" Widget Registry cache. """ import os import pickle import logging from .. import config log = logging.getLogger(__name__) def registry_cache_filename(): """Return the pickled registry cache filename. Also make sure the containing directory is created if it does not exists. """ cache_dir = config.cache_dir() default = os.path.join(cache_dir, "registry-cache.pck") cache_filename = config.rc.get("registry.registry-cache", default) dirname = os.path.dirname(cache_filename) if not os.path.exists(dirname): log.info("Creating directory %r", dirname) os.makedirs(dirname) return cache_filename def registry_cache(): """Return the registry cache dictionary. """ filename = registry_cache_filename() log.debug("Loading widget registry cache (%r).", filename) if os.path.exists(filename): try: with open(filename, "rb") as f: return pickle.load(f) except Exception: log.error("Could not load registry cache.", exc_info=True) return {} def save_registry_cache(cache): """Save (pickle) the registry cache. Return True on success, False otherwise. """ filename = registry_cache_filename() log.debug("Saving widget registry cache with %i entries (%r).", len(cache), filename) try: with open(filename, "wb") as f: pickle.dump(cache, f) return True except Exception: log.error("Could not save registry cache", exc_info=True) return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/description.py0000644000175100002000000003606314730024325024150 0ustar00runnerdocker""" Widget meta description classes =============================== """ import sys import copy import warnings import typing from typing import Union, Optional, List, Tuple, Iterable, Sequence from orangecanvas.utils import qualified_name __all__ = [ "DescriptionError", "WidgetSpecificationError", "SignalSpecificationError", "CategorySpecificationError", "Single", "Multiple", "Default", "NonDefault", "Explicit", "Dynamic", "InputSignal", "OutputSignal", "WidgetDescription", "CategoryDescription", ] # Exceptions class DescriptionError(Exception): pass class WidgetSpecificationError(DescriptionError): pass class SignalSpecificationError(DescriptionError): pass class CategorySpecificationError(DescriptionError): pass ############### # Channel flags ############### # A single signal Single = 2 # Multiple signal (more then one input on the channel) Multiple = 4 # Default signal (default or primary input/output) Default = 8 NonDefault = 16 # Explicit - only connected if specifically requested or the only possibility Explicit = 32 # Dynamic type output signal Dynamic = 64 # Input/output signal (channel) description if typing.TYPE_CHECKING: #: A simple single type spec (a fully qualified name or a type instance) TypeSpecSimple = Union[str, type] #: A tuple of simple type specs indicating a union type. TypeSpecUnion = Tuple[TypeSpecSimple, ...] #: Specification of a input/output type TypeSpec = Union[TypeSpecSimple, TypeSpecUnion] class InputSignal(object): """ Description of an input channel. Parameters ---------- name : str Name of the channel. type : Union[str, type] or Tuple[Union[str, type]] Specify the type of the accepted input. This can be a `type` instance, a fully qualified type name or a tuple of such. If a tuple then the input type is a union of the passed types. .. versionchanged:: 0.1.5 Added `Union` type support. handler : str Name of the handler method for the signal. flags : int Channel flags. id : str, optional A unique id of the input signal. doc : str, optional A docstring documenting the channel. replaces : Iterable[str] A list of names this input replaces. """ name = "" # type: str type = "" # type: TypeSpec handler = "" # type: str id = None # type: Optional[str] doc = None # type: Optional[str] replaces = None # type: List[str] flags = None # type: int single = None # type: bool default = None # type: bool explicit = None # type: bool def __init__(self, name, type, handler, flags=Single + NonDefault, id=None, doc=None, replaces=()): # type: (str, TypeSpec, str, int, Optional[str], Optional[str], Iterable[str]) -> None self.name = name self.type = type self.handler = handler self.id = id self.doc = doc self.replaces = list(replaces) if not (flags & Single or flags & Multiple): flags += Single if not (flags & Default or flags & NonDefault): flags += NonDefault self.single = bool(flags & Single) self.default = bool(flags & Default) self.explicit = bool(flags & Explicit) self.flags = flags @property def types(self): # type: () -> Tuple[str, ...] """ The normalized type specification. This is a tuple of qualified type names that were passed to the constructor. .. versionadded:: 0.1.5 :type: Tuple[str, ...] """ return normalize_type(self.type) def __str__(self): fmt = ("{0.__name__}(name={name!r}, type={type!r}, " "handler={handler!r}, ...)") return fmt.format(type(self), **self.__dict__) __repr__ = __str__ def input_channel_from_args(args): if isinstance(args, tuple): return InputSignal(*args) elif isinstance(args, dict): return InputSignal(**args) elif isinstance(args, InputSignal): return copy.copy(args) else: raise TypeError("tuple, dict or InputSignal expected " "(got {0!r})".format(type(args))) class OutputSignal(object): """ Description of an output channel. Parameters ---------- name : str Name of the channel. type : Union[str, type] or Tuple[Union[str, type]] Specify the type of the output. This can be a `type` instance, a fully qualified type name or a tuple of such. If a tuple then the output type is a union of the passed types. .. versionchanged:: 0.1.5 Added `Union` type support. flags : int, optional Channel flags. id : str A unique id of the output signal. doc : str, optional A docstring documenting the channel. replaces : List[str] A list of names this output replaces. """ name = "" # type: str type = "" # type: TypeSpec id = None # type: Optional[str] doc = None # type: Optional[str] replaces = None # type: List[str] single = None # type: bool default = None # type: bool explicit = None # type: bool dynamic = None # type: bool def __init__(self, name, type, flags=NonDefault, id=None, doc=None, replaces=()): # type: (str, TypeSpec, int, Optional[str], Optional[str], Iterable[str]) -> None self.name = name self.type = type self.id = id self.doc = doc self.replaces = list(replaces) if not (flags & Single or flags & Multiple): flags += Single if not (flags & Default or flags & NonDefault): flags += NonDefault self.default = bool(flags & Default) self.explicit = bool(flags & Explicit) self.dynamic = bool(flags & Dynamic) self.flags = flags @property def types(self): # type: () -> Tuple[str, ...] """ The normalized type specification. This is a tuple of qualified type names that were passed to the constructor. .. versionadded:: 0.1.5 :type: Tuple[str, ...] """ return normalize_type(self.type) def __str__(self): fmt = ("{0.__name__}(name={name!r}, type={type!r}, " "...)") return fmt.format(type(self), **self.__dict__) __repr__ = __str__ def output_channel_from_args(args): # type: (...) -> OutputSignal if isinstance(args, tuple): return OutputSignal(*args) elif isinstance(args, dict): return OutputSignal(**args) elif isinstance(args, OutputSignal): return copy.copy(args) else: raise TypeError("tuple, dict or OutputSignal expected " "(got {0!r})".format(type(args))) def normalize_type_simple(type_): # type: (TypeSpecSimple) -> str if isinstance(type_, type): return qualified_name(type_) elif isinstance(type_, str): return type_ else: raise TypeError def normalize_type(type_): # type: (TypeSpec) -> Tuple[str, ...] if isinstance(type_, (type, str)): return (normalize_type_simple(type_), ) else: return tuple(map(normalize_type_simple, type_)) class WidgetDescription(object): """ Description of a widget. Parameters ---------- name : str A human readable name of the widget. id : str A unique identifier of the widget (in most situations this should be the full module name). category : str, optional A name of the category in which this widget belongs. version : str, optional Version of the widget. By default the widget inherits the project version. description : str, optional A short description of the widget, suitable for a tool tip. long_description : str, optional A longer description of the widget, suitable for a 'what's this?' role. qualified_name : str A qualified name (import name) of the class implementing the widget. package : str, optional A package name where the widget is implemented. project_name : str, optional The distribution name that provides the widget. inputs : Sequence[InputSignal] A list of input channels provided by the widget. outputs : Sequence[OutputSignal] A list of output channels provided by the widget. help : str, optional URL or an Resource template of a detailed widget help page. help_ref : str, optional A text reference id that can be used to identify the help page, for instance an intersphinx reference. author : str, optional Author name. author_email : str, optional Author email address. maintainer : str, optional Maintainer name maintainer_email : str, optional Maintainer email address. keywords : list-of-str, optional A list of keyword phrases. priority : int, optional Widget priority (the order of the widgets in a GUI presentation). icon : str, optional A filename of the widget icon (in relation to the package). background : str, optional Widget's background color (in the canvas GUI). replaces : list of `str`, optional A list of ids this widget replaces (optional). short_name: str, optional Short name for display where text would otherwise elide. """ name = "" # type: str id = "" # type: str qualified_name = None # type: str short_name = None # type: str description = "" # type: str category = None # type: Optional[str] project_name = None # type: Optional[str] inputs = [] # type: Sequence[InputSignal] outputs = [] # type: Sequence[OutputSignal] replaces = [] # type: Sequence[str] keywords = [] # type: Sequence[str] def __init__(self, name, id, category=None, version=None, description=None, long_description=None, qualified_name=None, package=None, project_name=None, inputs=None, outputs=None, author=None, author_email=None, maintainer=None, maintainer_email=None, help=None, help_ref=None, url=None, keywords=None, priority=sys.maxsize, icon=None, background=None, replaces=None, short_name=None, ): if inputs is None: inputs = [] if outputs is None: outputs = [] if keywords is None: keywords = [] if replaces is None: replaces = [] if not qualified_name: # TODO: Should also check that the name is real. raise ValueError("'qualified_name' must be supplied.") self.name = name self.id = id self.category = category self.version = version self.description = description self.long_description = long_description self.qualified_name = qualified_name self.package = package self.project_name = project_name self.short_name = short_name # Copy input/outputs and normalize the type to string. inputs = [ InputSignal( i.name, normalize_type(i.type), i.handler, i.flags, i.id, i.doc, i.replaces ) for i in inputs ] outputs = [ OutputSignal( o.name, normalize_type(o.type), o.flags, o.id, o.doc, o.replaces ) for o in outputs ] self.inputs = inputs self.outputs = outputs self.help = help self.help_ref = help_ref self.author = author self.author_email = author_email self.maintainer = maintainer self.maintainer_email = maintainer_email self.url = url self.keywords = keywords self.priority = priority self.icon = icon self.background = background self.replaces = list(replaces) def __str__(self): return ("WidgetDescription(name=%(name)r, id=%(id)r), " "category=%(category)r, ...)") % self.__dict__ def __repr__(self): return self.__str__() @classmethod def from_module(cls, module): warnings.warn( "'WidgetDescription.from_module' is deprecated", PendingDeprecationWarning, stacklevel=2 ) from .utils import widget_from_module_globals return widget_from_module_globals(module) class CategoryDescription(object): """ Description of a widget category. Parameters ---------- name : str A human readable name. version : str, optional Version string. description : str, optional A short description of the category, suitable for a tool tip. long_description : str, optional A longer description. qualified_name : str, Qualified name project_name : str A project name providing the category. priority : int Priority (order in the GUI). icon : str An icon filename (a resource name retrievable using `pkgutil.get_data` relative to `qualified_name`). background : str An background color for widgets in this category. hidden : bool Is this category (by default) hidden in the canvas gui. """ name = "" # type: str qualified_name = "" # type: str project_name = "" # type: str priority = None # type: int icon = "" # type: str def __init__(self, name=None, version=None, description=None, long_description=None, qualified_name=None, package=None, project_name=None, author=None, author_email=None, maintainer=None, maintainer_email=None, url=None, help=None, keywords=None, widgets=None, priority=sys.maxsize, icon=None, background=None, hidden=False ): self.name = name self.version = version self.description = description self.long_description = long_description self.qualified_name = qualified_name self.package = package self.project_name = project_name self.author = author self.author_email = author_email self.maintainer = maintainer self.maintainer_email = maintainer_email self.url = url self.help = help self.keywords = keywords self.widgets = widgets or [] self.priority = priority self.icon = icon self.background = background self.hidden = hidden def __str__(self): return "CategoryDescription(name=%(name)r, ...)" % self.__dict__ def __repr__(self): return self.__str__() @classmethod def from_package(cls, package): warnings.warn( "'CategoryDescription.from_package' is deprecated", DeprecationWarning, stacklevel=2 ) from .utils import category_from_package_globals return category_from_package_globals(package) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/discovery.py0000644000175100002000000004223414730024325023631 0ustar00runnerdocker""" Widget Discovery ================ Discover which widgets are installed/available. This module implements a discovery process """ import abc import os import sys import logging import types import pkgutil from collections import namedtuple from typing import Union from .description import ( WidgetDescription, CategoryDescription, WidgetSpecificationError, CategorySpecificationError ) from . import VERSION_HEX from . import cache, WidgetRegistry from . import utils from ..utils.pkgmeta import entry_points log = logging.getLogger(__name__) _CacheEntry = \ namedtuple( "_CacheEntry", ["mod_path", # Module path (filename) "name", # Module qualified import name "mtime", # Modified time "project_name", # distribution name (if available) "project_version", # distribution version (if available) "exc_type", # exception type when last trying to import "exc_val", # exception value (str of value) "description" # WidgetDescription instance ] ) def default_category_name_for_module(module): if isinstance(module, str): module = __import__(module, fromlist=[""]) path = module.__name__.split(".") name = path[-1] if name == "widgets" and len(path) > 1: name = path[-2].capitalize() return name def default_category_for_module(module): """ Return a default constructed :class:`CategoryDescription` for a `module`. """ if isinstance(module, str): module = __import__(module, fromlist=[""]) name = default_category_name_for_module(module) qualified_name = module.__name__ return CategoryDescription( name=name, qualified_name=qualified_name, package=module.__package__ ) class WidgetDiscovery(object): """ Base widget discovery runner. """ class Handler: @abc.abstractmethod def handle_category(self, category: CategoryDescription): pass @abc.abstractmethod def handle_widget(self, widget: WidgetDescription): pass class RegistryHandler(Handler): def __init__(self, registry: WidgetRegistry, **kwargs): super().__init__(**kwargs) self.registry = registry def handle_category(self, category: CategoryDescription) -> None: self.registry.register_category(category) def handle_widget(self, desc: WidgetDescription) -> None: self.registry.register_widget(desc) def __init__( self, registry: Union[WidgetRegistry, Handler, None] = None, cached_descriptions=None ) -> None: if isinstance(registry, WidgetRegistry): self.registry = registry self.handler = WidgetDiscovery.RegistryHandler(registry) elif isinstance(registry, WidgetDiscovery.Handler): self.handler = registry self.registry = None elif registry is None: self.handler = None self.registry = None else: raise TypeError("'WidgetRegistry', 'Handler' or None expected") self.cached_descriptions = cached_descriptions or {} version = (VERSION_HEX, ) if self.cached_descriptions.get("!VERSION") != version: self.cached_descriptions.clear() self.cached_descriptions["!VERSION"] = version def run(self, entry_points_iter): """ Run the widget discovery process from an entry point iterator (yielding :class:`importlib.metadata.EntryPoint` instances). As a convenience, if `entry_points_iter` is a string it will be used to retrieve the iterator using `importlib.metadata.entry_points`. """ if isinstance(entry_points_iter, str): entry_points_iter = entry_points(group=entry_points_iter) for entry_point in entry_points_iter: try: point = entry_point.load() except Exception: log.error("An exception occurred while loading " "entry point '%s'", entry_point, exc_info=True) continue try: if isinstance(point, types.ModuleType): if hasattr(point, "__path__"): # Entry point is a package (a widget category) self.process_category_package( point, name=entry_point.name, distribution=entry_point.dist ) else: # Entry point is a module (a single widget) self.process_widget_module( point, name=entry_point.name, distribution=entry_point.dist ) elif isinstance(point, (types.FunctionType, types.MethodType)): # Entry point is a callable loader function self.process_loader(point) elif isinstance(point, (list, tuple)): # An iterator yielding Category/WidgetDescriptor instances. self.process_iter(point) else: log.error("Cannot handle entry point %r", point) except Exception: log.error("An exception occurred while processing %r.", entry_point, exc_info=True) def process_widget_module(self, module, name=None, category_name=None, distribution=None): """ Process a widget module. """ try: desc = self.widget_description(module, widget_name=name, distribution=distribution) except (WidgetSpecificationError, Exception) as ex: log.info("Invalid widget specification.", exc_info=True) return self.handle_widget(desc) def process_category_package(self, category, name=None, distribution=None): """ Process a category package. """ cat_desc = None category = asmodule(category) if hasattr(category, "widget_discovery"): widget_discovery = getattr(category, "widget_discovery") self.process_loader(widget_discovery) return # The widget_discovery function handles all elif hasattr(category, "category_description"): category_description = getattr(category, "category_description") try: cat_desc = category_description() except Exception: log.error("Error calling 'category_description' in %r.", category, exc_info=True) cat_desc = default_category_for_module(category) else: try: cat_desc = utils.category_from_package_globals(category) except (CategorySpecificationError, Exception): log.info("Package %r does not describe a category.", category, exc_info=True) cat_desc = default_category_for_module(category) if cat_desc.name is None: if name is not None: cat_desc.name = name else: cat_desc.name = default_category_name_for_module(category) if distribution is not None: cat_desc.project_name = distribution.name self.handle_category(cat_desc) desc_iter = self.iter_widget_descriptions( category, category_name=cat_desc.name, distribution=distribution ) for desc in desc_iter: self.handle_widget(desc) def process_loader(self, callable): """ Process a callable loader function. """ try: callable(self) except Exception: log.error("Error calling %r", callable, exc_info=True) def process_iter(self, iter): """ """ for desc in iter: if isinstance(desc, CategoryDescription): self.handle_category(desc) elif isinstance(desc, WidgetDescription): self.handle_widget(desc) else: log.error("Category or Widget Description instance " "expected. Got %r.", desc) def handle_widget(self, desc): """ Handle a found widget description. Base implementation adds it to the registry supplied in the constructor. """ if self.handler: self.handler.handle_widget(desc) def handle_category(self, desc): """ Handle a found category description. Base implementation adds it to the registry supplied in the constructor. """ if self.handler: self.handler.handle_category(desc) def iter_widget_descriptions(self, package, category_name=None, distribution=None): """ Return an iterator over widget descriptions accessible from `package`. """ package = asmodule(package) for path in package.__path__: for _, mod_name, ispkg in pkgutil.iter_modules([path]): if ispkg: continue name = package.__name__ + "." + mod_name source_path = os.path.join(path, mod_name + ".py") desc = None # Check if the path can be ignored. if self.cache_can_ignore(source_path, distribution): log.info("Ignoring %r.", source_path) continue # Check if a source file for the module is available # and is already cached. if self.cache_has_valid_entry(source_path, distribution): desc = self.cache_get(source_path).description if desc is None: try: module = asmodule(name) except ImportError: log.info("Could not import %r.", name, exc_info=True) continue except Exception: log.warning("Error while importing %r.", name, exc_info=True) continue try: desc = self.widget_description( module, category_name=category_name, distribution=distribution ) except WidgetSpecificationError: self.cache_log_error( source_path, WidgetSpecificationError, distribution ) continue except Exception: log.warning("Problem parsing %r", name, exc_info=True) continue yield desc self.cache_insert(source_path, os.stat(source_path).st_mtime, desc, distribution) def widget_description(self, module, widget_name=None, category_name=None, distribution=None): """ Return a widget description from a module. """ if isinstance(module, str): module = __import__(module, fromlist=[""]) desc = None try: desc = utils.widget_from_module_globals(module) except WidgetSpecificationError: exc_info = sys.exc_info() if desc is None: # Raise the original exception. raise exc_info[1] if widget_name is not None: desc.name = widget_name if category_name is not None: desc.category = category_name if distribution is not None: desc.project_name = distribution.name return desc def cache_insert(self, module, mtime, description, distribution=None, error=None): """ Insert the description into the cache. """ if isinstance(module, types.ModuleType): mod_path = module.__file__ mod_name = module.__name__ else: mod_path = module mod_name = None mod_path = fix_pyext(mod_path) project_name = project_version = None if distribution is not None: project_name = distribution.name project_version = distribution.version exc_type = exc_val = None if error is not None: if isinstance(error, type): exc_type = error exc_val = None elif isinstance(error, Exception): exc_type = type(error) exc_val = repr(error.args) self.cached_descriptions[mod_path] = \ _CacheEntry(mod_path, mod_name, mtime, project_name, project_version, exc_type, exc_val, description) def cache_get(self, mod_path, distribution=None): """ Get the cache entry for `mod_path`. """ if isinstance(mod_path, types.ModuleType): mod_path = mod_path.__file__ mod_path = fix_pyext(mod_path) return self.cached_descriptions.get(mod_path) def cache_has_valid_entry(self, mod_path, distribution=None): """ Does the cache have a valid entry for `mod_path`. """ mod_path = fix_pyext(mod_path) if not os.path.exists(mod_path): return False if mod_path in self.cached_descriptions: entry = self.cache_get(mod_path) mtime = os.stat(mod_path).st_mtime if entry.mtime != mtime: return False if distribution is not None: if entry.project_name != distribution.name or \ entry.project_version != distribution.version: return False if entry.exc_type == WidgetSpecificationError: return False # All checks pass return True return False def cache_can_ignore(self, mod_path, distribution=None): """ Can the `mod_path` be ignored (i.e. it was determined that it could not contain a valid widget description, for instance the module does not have a valid description and was not changed from the last discovery run). """ mod_path = fix_pyext(mod_path) if not os.path.exists(mod_path): # Possible orphaned .py[co] file return True mtime = os.stat(mod_path).st_mtime if mod_path in self.cached_descriptions: entry = self.cached_descriptions[mod_path] return entry.mtime == mtime and \ entry.exc_type == WidgetSpecificationError else: return False def cache_log_error(self, mod_path, error, distribution=None): """ Cache that the `error` occurred while processing `mod_path`. """ mod_path = fix_pyext(mod_path) if not os.path.exists(mod_path): # Possible orphaned .py[co] file return mtime = os.stat(mod_path).st_mtime self.cache_insert(mod_path, mtime, None, distribution, error) def fix_pyext(mod_path): """ Fix a module filename path extension to always end with the modules source file (i.e. strip compiled/optimized .pyc, .pyo extension and replace it with .py). """ if mod_path[-4:] in [".pyo", "pyc"]: mod_path = mod_path[:-1] return mod_path def widget_descriptions_from_package(package): package = asmodule(package) desciptions = [] for _, name, ispkg in pkgutil.iter_modules( package.__path__, package.__name__ + "."): if ispkg: continue try: module = asmodule(name) except Exception: log.error("Error importing %r.", name, exc_info=True) continue desc = None try: desc = utils.widget_from_module_globals(module) except Exception: pass if not desc: log.info("Error in %r", name, exc_info=True) else: desciptions.append(desc) return desciptions def asmodule(module): """ Return the module references by `module` name. If `module` is already an imported module instance, return it as is. """ if isinstance(module, types.ModuleType): return module elif isinstance(module, str): return __import__(module, fromlist=[""]) else: raise TypeError(type(module)) def run_discovery(entry_point, cached=False): """ Run the default widget discovery and return a :class:`WidgetRegistry` instance. """ reg_cache = {} if cached: reg_cache = cache.registry_cache() registry = WidgetRegistry() discovery = WidgetDiscovery(registry, cached_descriptions=reg_cache) discovery.run() if cached: cache.save_registry_cache(reg_cache) return registry ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/qt.py0000644000175100002000000003031114730024325022237 0ustar00runnerdocker""" Qt Model classes for widget registry. """ import bisect import warnings from typing import Union from xml.sax.saxutils import escape from urllib.parse import urlencode from AnyQt.QtWidgets import QAction from AnyQt.QtGui import QStandardItemModel, QStandardItem, QColor, QBrush from AnyQt.QtCore import QObject, Qt from AnyQt.QtCore import pyqtSignal as Signal from ..utils import type_str from .discovery import WidgetDiscovery from .description import WidgetDescription, CategoryDescription from .base import WidgetRegistry from ..resources import icon_loader from . import cache, NAMED_COLORS, DEFAULT_COLOR class QtWidgetDiscovery(QObject, WidgetDiscovery): """ Qt interface class for widget discovery. """ # Discovery has started discovery_start = Signal() # Discovery has finished discovery_finished = Signal() # Processing widget with name discovery_process = Signal(str) # Found a widget with description found_widget = Signal(WidgetDescription) # Found a category with description found_category = Signal(CategoryDescription) def __init__(self, parent=None, registry=None, cached_descriptions=None): QObject.__init__(self, parent) WidgetDiscovery.__init__(self, registry, cached_descriptions) def run(self, entry_points_iter): self.discovery_start.emit() WidgetDiscovery.run(self, entry_points_iter) self.discovery_finished.emit() def handle_widget(self, description): self.discovery_process.emit(description.name) self.found_widget.emit(description) def handle_category(self, description): self.found_category.emit(description) class QtRegistryHandler(QObject, WidgetDiscovery.RegistryHandler): # Found a widget with description found_widget = Signal(WidgetDescription) # Found a category with description found_category = Signal(CategoryDescription) def handle_category(self, category): super().handle_category(category) self.found_category.emit(category) def handle_widget(self, desc): super().handle_widget(desc) self.found_widget.emit(desc) class QtWidgetRegistry(QObject, WidgetRegistry): """ A QObject wrapper for `WidgetRegistry` A QStandardItemModel instance containing the widgets in a tree (of depth 2). The items in a model can be quaries using standard roles (DisplayRole, BackgroundRole, DecorationRole ToolTipRole). They also have QtWidgetRegistry.CATEGORY_DESC_ROLE, QtWidgetRegistry.WIDGET_DESC_ROLE, which store Category/WidgetDescription respectfully. Furthermore QtWidgetRegistry.WIDGET_ACTION_ROLE stores an default QAction which can be used for widget creation action. """ CATEGORY_DESC_ROLE = Qt.UserRole + 1 """Category Description Role""" WIDGET_DESC_ROLE = Qt.UserRole + 2 """Widget Description Role""" WIDGET_ACTION_ROLE = Qt.UserRole + 3 """Widget Action Role""" BACKGROUND_ROLE = Qt.UserRole + 4 """Background color for widget/category in the canvas (different from Qt.BackgroundRole) """ category_added = Signal(str, CategoryDescription) """signal: category_added(name: str, desc: CategoryDescription) """ widget_added = Signal(str, str, WidgetDescription) """signal widget_added(category_name: str, widget_name: str, desc: WidgetDescription) """ reset = Signal() """signal: reset() """ def __init__(self, other_or_parent=None, parent=None): if isinstance(other_or_parent, QObject) and parent is None: parent, other_or_parent = other_or_parent, None QObject.__init__(self, parent) WidgetRegistry.__init__(self, other_or_parent) # Should the QStandardItemModel be subclassed? self.__item_model = QStandardItemModel(self) for i, desc in enumerate(self.categories()): cat_item = self._cat_desc_to_std_item(desc) self.__item_model.insertRow(i, cat_item) for j, wdesc in enumerate(self.widgets(desc.name)): widget_item = self._widget_desc_to_std_item(wdesc, desc) cat_item.insertRow(j, widget_item) def model(self): # type: () -> QStandardItemModel """ Return the widget descriptions in a Qt Item Model instance (QStandardItemModel). .. note:: The model should not be modified outside of the registry. """ return self.__item_model def item_for_widget(self, widget): # type: (Union[str, WidgetDescription]) -> QStandardItem """Return the QStandardItem for the widget. """ if isinstance(widget, str): widget = self.widget(widget) cat = self.category(widget.category or "Unspecified") cat_ind = self.categories().index(cat) cat_item = self.model().item(cat_ind) widget_ind = self.widgets(cat).index(widget) return cat_item.child(widget_ind) def action_for_widget(self, widget): # type: (Union[str, WidgetDescription]) -> QAction """ Return the QAction instance for the widget (can be a string or a WidgetDescription instance). """ item = self.item_for_widget(widget) return item.data(self.WIDGET_ACTION_ROLE) def create_action_for_item(self, item): # type: (QStandardItem) -> QAction """ Create a QAction instance for the widget description item. """ name = item.text() tooltip = item.toolTip() whatsThis = item.whatsThis() icon = item.icon() action = QAction( icon, name, self, toolTip=tooltip, whatsThis=whatsThis, statusTip=name ) widget_desc = item.data(self.WIDGET_DESC_ROLE) action.setData(widget_desc) action.setProperty("item", item) return action def _insert_category(self, desc): # type: (CategoryDescription) -> None """ Override to update the item model and emit the signals. """ priority = desc.priority priorities = [c.priority for c, _ in self.registry] insertion_i = bisect.bisect_right(priorities, priority) WidgetRegistry._insert_category(self, desc) cat_item = self._cat_desc_to_std_item(desc) self.__item_model.insertRow(insertion_i, cat_item) self.category_added.emit(desc.name, desc) def _insert_widget(self, category, desc): # type: (CategoryDescription, WidgetDescription) -> None """ Override to update the item model and emit the signals. """ assert isinstance(category, CategoryDescription) categories = self.categories() cat_i = categories.index(category) _, widgets = self._categories_dict[category.name] priorities = [w.priority for w in widgets] insertion_i = bisect.bisect_right(priorities, desc.priority) WidgetRegistry._insert_widget(self, category, desc) cat_item = self.__item_model.item(cat_i) widget_item = self._widget_desc_to_std_item(desc, category) cat_item.insertRow(insertion_i, widget_item) self.widget_added.emit(category.name, desc.name, desc) def _cat_desc_to_std_item(self, desc): # type: (CategoryDescription) -> QStandardItem """ Create a QStandardItem for the category description. """ item = QStandardItem() item.setText(desc.name) if desc.icon: icon = desc.icon else: icon = "icons/default-category.svg" icon = icon_loader.from_description(desc).get(icon) item.setIcon(icon) if desc.background: background = desc.background else: background = DEFAULT_COLOR background = NAMED_COLORS.get(background, background) brush = QBrush(QColor(background)) item.setData(brush, self.BACKGROUND_ROLE) tooltip = desc.description if desc.description else desc.name item.setToolTip(tooltip) item.setFlags(Qt.ItemIsEnabled) item.setData(desc, self.CATEGORY_DESC_ROLE) return item def _widget_desc_to_std_item(self, desc, category): # type: (WidgetDescription, CategoryDescription) -> QStandardItem """ Create a QStandardItem for the widget description. """ item = QStandardItem(desc.name) item.setText(desc.name) if desc.icon: icon = desc.icon else: icon = "icons/default-widget.svg" icon = icon_loader.from_description(desc).get(icon) item.setIcon(icon) # This should be inherited from the category. background = None if desc.background: background = desc.background elif category.background: background = category.background else: background = DEFAULT_COLOR if background is not None: background = NAMED_COLORS.get(background, background) brush = QBrush(QColor(background)) item.setData(brush, self.BACKGROUND_ROLE) tooltip = tooltip_helper(desc) style = "ul { margin-top: 1px; margin-bottom: 1px; }" tooltip = TOOLTIP_TEMPLATE.format(style=style, tooltip=tooltip) item.setToolTip(tooltip) item.setWhatsThis(whats_this_helper(desc)) item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) item.setData(desc, self.WIDGET_DESC_ROLE) # Create the action for the widget_item action = self.create_action_for_item(item) item.setData(action, self.WIDGET_ACTION_ROLE) return item TOOLTIP_TEMPLATE = """\ {tooltip} """ def tooltip_helper(desc): # type: (WidgetDescription) -> str """Widget tooltip construction helper. """ tooltip = [] tooltip.append("{name}".format(name=escape(desc.name))) if desc.project_name and desc.project_name != "Orange": tooltip[0] += " (from {0})".format(desc.project_name) if desc.description: tooltip.append("{0}".format( escape(desc.description))) inputs_fmt = "
            • {name} ({class_name})
            • " if desc.inputs: inputs = "".join(inputs_fmt.format(name=inp.name, class_name=type_str(inp.types)) for inp in desc.inputs) tooltip.append("Inputs:
                {0}
              ".format(inputs)) else: tooltip.append("No inputs") if desc.outputs: outputs = "".join(inputs_fmt.format(name=out.name, class_name=type_str(out.types)) for out in desc.outputs) tooltip.append("Outputs:
                {0}
              ".format(outputs)) else: tooltip.append("No outputs") return "
              ".join(tooltip) def whats_this_helper(desc, include_more_link=False): # type: (WidgetDescription, bool) -> str """ A `What's this` text construction helper. If `include_more_link` is True then the text will include a `more...` link. """ title = desc.name help_url = desc.help if not help_url: help_url = "help://search?" + urlencode({"id": desc.qualified_name}) description = desc.description long_description = desc.long_description template = ["

              {0}

              ".format(escape(title))] if description: template.append("

              {0}

              ".format(escape(description))) if long_description: template.append("

              {0}

              ".format(escape(long_description[:100]))) if help_url and include_more_link: template.append("
              more...".format(escape(help_url))) return "\n".join(template) def run_discovery(entry_points_iter, cached=False): warnings.warn( "run_discovery is deprecated and will be removed.", FutureWarning, stacklevel=2 ) reg_cache = {} if cached: reg_cache = cache.registry_cache() discovery = QtWidgetDiscovery(cached_descriptions=reg_cache) registry = QtWidgetRegistry() discovery.found_category.connect(registry.register_category) discovery.found_widget.connect(registry.register_widget) discovery.run() if cached: cache.save_registry_cache(reg_cache) return registry ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.802081 orange_canvas_core-0.2.5/orangecanvas/registry/tests/0000755000175100002000000000000014730024333022404 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/tests/__init__.py0000644000175100002000000001423514730024325024523 0ustar00runnerdocker""" """ import sys from types import ModuleType def make_module(name, package="", **namespace): mod = ModuleType(name) mod.__package__ = package or "" if package: mod.__name__ = "{}.{}".format(package, name) else: mod.__name = name mod.__dict__.update(namespace) return mod Test = make_module( "Test", __package__, NAME="Test", DESCRIPTION="This is a test.", LONG_DESCRIPTION="This. Is. A. Test.", PRIORITY=3 ) Test.__path__ = [] constants = make_module( "constants", __package__, NAME="Constants" ) constants.__path__ = [] constants.zero = make_module( "zero", constants.__name__, NAME="Zero", OUTPUTS=[("value", int)], CATEGORY="Constants", zero=None, ) constants.one = make_module( "one", constants.__name__, NAME="One", OUTPUTS=[("value", int)], CATEGORY="Constants", one=None, ) operators = make_module( "operators", __package__, NAME="Operators" ) operators.__path__ = [] operators.add = make_module( "add", operators.__name__, NAME="Add", INPUTS=[("left", int, "set_left"), ("right", int, "set_right")], OUTPUTS=[("value", int)], CATEGORY="Operators", add=None ) operators.sub = make_module( "sub", operators.__name__, NAME="Subtract", INPUTS=[("left", int, "set_left"), ("right", int, "set_right")], OUTPUTS=[("value", int)], CATEGORY="Operators", sub=None, ) operators.mult = make_module( "mult", operators.__name__, NAME="Multiply", INPUTS=[("left", int, "set_left"), ("right", int, "set_right")], OUTPUTS=[("value", int)], CATEGORY="Operators", mult=None, ) operators.div = make_module( "div", operators.__name__, NAME="Divide", INPUTS=[("left", int, "set_left"), ("right", int, "set_right")], OUTPUTS=[("value", int)], CATEGORY="Operators", div=None ) def set_up_package(package): sys.modules[package.__name__] = package for val in package.__dict__.values(): if isinstance(val, ModuleType): sys.modules[val.__name__] = val def tear_down_package(package): for val in package.__dict__.values(): if isinstance(val, ModuleType): if val.__name__ in sys.modules: del sys.modules[val.__name__] if package.__name__ in sys.modules: del sys.modules[package.__name__] def set_up_modules(): set_up_package(constants) set_up_package(operators) def tear_down_modules(): tear_down_package(constants) tear_down_package(operators) def small_testing_registry(): """Return a small registry with a few widgets for testing. """ from ..description import ( WidgetDescription, CategoryDescription, InputSignal, OutputSignal ) from .. import WidgetRegistry registry = WidgetRegistry() const_cat = CategoryDescription( "Constants", background="light-orange") zero = WidgetDescription( "zero", "zero", "Constants", qualified_name="zero", package=__package__, outputs=[OutputSignal("value", "int", id="val")]) one = WidgetDescription( "one", "one", "Constants", qualified_name="one", package=__package__, outputs=[OutputSignal("value", "int")]) unit = WidgetDescription( "unit", "unit", "Constants", qualified_name="unit", package=__package__, outputs=[OutputSignal("value", "tuple")]) op_cat = CategoryDescription( "Operators", background="grass") add = WidgetDescription( "add", "add", "Operators", qualified_name="add", package=__package__, inputs=[InputSignal("left", "int", "set_left"), InputSignal("right", "int", "set_right", id="droite")], outputs=[OutputSignal("result", "int")] ) sub = WidgetDescription( "sub", "sub", "Operators", qualified_name="sub", package=__package__, inputs=[InputSignal("left", "int", "set_left"), InputSignal("right", "int", "set_right")], outputs=[OutputSignal("result", "int")] ) mult = WidgetDescription( "mult", "mult", "Operators", qualified_name="mult", package=__package__, inputs=[InputSignal("left", "int", "set_left"), InputSignal("right", "int", "set_right")], outputs=[OutputSignal("result", "int")] ) div = WidgetDescription( "div", "div", "Operators", qualified_name="div", package=__package__, inputs=[InputSignal("left", "int", "set_left"), InputSignal("right", "int", "set_right")], outputs=[OutputSignal("result", "int")] ) negate = WidgetDescription( "negate", "negate", "Operators", qualified_name="negate", package=__package__, inputs=[InputSignal("value", "int", "set_value")], outputs=[OutputSignal("result", "int")], ) struct_cat = CategoryDescription( "Structure", background="red") cons = WidgetDescription( "cons", "cons", "Structure", qualified_name="cons", package=__package__, inputs=[InputSignal("first", "object", "set_first"), InputSignal("second", "object", "set_second")], outputs=[OutputSignal("cons", "tuple")] ) decons = WidgetDescription( "decons", "decons", "Structure", qualified_name="decons", package=__package__, inputs=[InputSignal("cons", "tuple", "set_cons")], outputs=[OutputSignal("first", "object", doc="First matched"), OutputSignal("second", "object", doc="Second matched"), OutputSignal("empty", "tuple", doc="No match")] ) registry.register_category(const_cat) registry.register_category(op_cat) registry.register_category(struct_cat) registry.register_widget(zero) registry.register_widget(one) registry.register_widget(unit) registry.register_widget(add) registry.register_widget(sub) registry.register_widget(mult) registry.register_widget(div) registry.register_widget(negate) registry.register_widget(cons) registry.register_widget(decons) return registry ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/tests/test_base.py0000644000175100002000000001073514730024325024736 0ustar00runnerdocker""" Test WidgetRegistry. """ import logging from operator import attrgetter import unittest from orangecanvas.registry import InputSignal, OutputSignal from ..base import WidgetRegistry from .. import description from ..utils import category_from_package_globals, widget_from_module_globals class TestRegistry(unittest.TestCase): def setUp(self): logging.basicConfig() from . import set_up_modules set_up_modules() from . import constants from . import operators self.constants = constants self.operators = operators def tearDown(self): from . import tear_down_modules tear_down_modules() def test_registry_const(self): reg = WidgetRegistry() const_cat = category_from_package_globals(self.constants.__name__) reg.register_category(const_cat) zero_desc = widget_from_module_globals(self.constants.zero.__name__) reg.register_widget(zero_desc) self.assertTrue(reg.has_widget(zero_desc.qualified_name)) self.assertSequenceEqual(reg.widgets(self.constants.NAME), [zero_desc]) self.assertIs(reg.widget(zero_desc.qualified_name), zero_desc) # ValueError adding a description with the same qualified name with self.assertRaises(ValueError): desc = description.WidgetDescription( name="A name", id=zero_desc.id, qualified_name=zero_desc.qualified_name ) reg.register_widget(desc) one_desc = widget_from_module_globals(self.constants.one) reg.register_widget(one_desc) self.assertTrue(reg.has_widget(one_desc.qualified_name)) self.assertIs(reg.widget(one_desc.qualified_name), one_desc) self.assertSetEqual(set(reg.widgets(self.constants.NAME)), set([zero_desc, one_desc])) op_cat = category_from_package_globals(self.operators.__name__) reg.register_category(op_cat) self.assertTrue(reg.has_category(op_cat.name)) self.assertIs(reg.category(op_cat.name), op_cat) self.assertSetEqual(set(reg.categories()), set([const_cat, op_cat])) add_desc = widget_from_module_globals(self.operators.add) reg.register_widget(add_desc) self.assertTrue(reg.has_widget(add_desc.qualified_name)) self.assertIs(reg.widget(add_desc.qualified_name), add_desc) self.assertSequenceEqual(reg.widgets(self.operators.NAME), [add_desc]) sub_desc = widget_from_module_globals(self.operators.sub) reg.register_widget(sub_desc) # Test copy constructor reg1 = WidgetRegistry(reg) self.assertTrue(reg1.has_category(const_cat.name)) self.assertTrue(reg1.has_category(op_cat.name)) self.assertSequenceEqual(reg.categories(), reg1.categories()) # Test 'widgets()' self.assertSetEqual(set(reg1.widgets()), set([zero_desc, one_desc, add_desc, sub_desc])) # Test ordering by priority self.assertSequenceEqual( reg.widgets(op_cat.name), sorted([add_desc, sub_desc], key=attrgetter("priority")) ) self.assertTrue(all(isinstance(desc.priority, int) for desc in [one_desc, zero_desc, sub_desc, add_desc]) ) def test_input_signal(self): isig_1 = InputSignal("A", str, "aa", id="sig-a") isig_2 = InputSignal("A", 'builtins.str', "aa", id="sig-a") self.assertTupleEqual(isig_1.types, isig_2.types) self.assertTupleEqual(isig_1.types, ('builtins.str',)) isig_1 = InputSignal("A", (str, int), "aa", id="sig-a") isig_2 = InputSignal("A", ('builtins.str', "builtins.int",), "aa", id="sig-a") self.assertTupleEqual(isig_1.types, isig_2.types) def test_output_signal(self): osig_1 = OutputSignal("A", str, id="sig-a") osig_2 = OutputSignal("A", 'builtins.str', id="sig-a") self.assertTupleEqual(osig_1.types, osig_2.types) self.assertTupleEqual(osig_1.types, ('builtins.str',)) osig_1 = OutputSignal("A", (str, int), id="sig-a") osig_2 = OutputSignal("A", ('builtins.str', "builtins.int",), id="sig-a") self.assertTupleEqual(osig_1.types, osig_2.types) self.assertTupleEqual(osig_1.types, ('builtins.str', "builtins.int",)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/tests/test_discovery.py0000644000175100002000000000561114730024325026030 0ustar00runnerdocker""" Test widget discovery """ import logging import types import unittest from unittest.mock import patch from ..discovery import WidgetDiscovery, widget_descriptions_from_package from ..description import CategoryDescription, WidgetDescription from ..utils import category_from_package_globals from ...utils.pkgmeta import get_distribution class TestDiscovery(unittest.TestCase): def setUp(self): logging.basicConfig() from . import set_up_modules, operators, constants set_up_modules() self.operators = operators self.constants = constants def tearDown(self): from . import tear_down_modules tear_down_modules() def discovery_class(self): return WidgetDiscovery() def test_handle(self): disc = self.discovery_class() desc = CategoryDescription(name="C", qualified_name="M.C") disc.handle_category(desc) desc = WidgetDescription(name="SomeWidget", id="some.widget", qualified_name="Some.Widget", category="C",) disc.handle_widget(desc) def test_process_module(self): disc = self.discovery_class() dist = get_distribution("orange-canvas-core") disc.process_category_package(self.operators.__name__, distribution=dist) disc.process_widget_module(self.constants.one.__name__, distribution=dist) def test_process_loader(self): disc = self.discovery_class() def callable(discovery): desc = CategoryDescription( name="Data", qualified_name="Data") discovery.handle_category(desc) desc = WidgetDescription( name="CSV", id="some.id", qualified_name="Some.widget", inputs=[], category="Data", ) discovery.handle_widget(desc) disc.process_loader(callable) def test_process_iter(self): disc = self.discovery_class() cat_desc = category_from_package_globals( self.operators.__name__, ) modules = [ (None, self.operators.add.__name__, False) ] with patch("pkgutil.iter_modules", lambda *_, **__: modules): wid_desc = widget_descriptions_from_package( self.operators.__name__, ) disc.process_iter([cat_desc] + wid_desc) def test_process_category_package(self): disc = self.discovery_class() dist = get_distribution("orange-canvas-core") modules = [ (None, self.operators.add.__name__, False) ] self.operators.__path__ = ["aaa"] with patch("pkgutil.iter_modules", lambda *_, **__: modules): disc.process_category_package(self.operators, distribution=dist) def test_run(self): disc = self.discovery_class() disc.run("example.does.not.exist.but.it.does.not.matter.") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/registry/utils.py0000644000175100002000000001366714730024325022772 0ustar00runnerdocker""" Widget Discovery Utilities ========================== """ import sys from .description import ( WidgetDescription, WidgetSpecificationError, CategoryDescription, CategorySpecificationError, InputSignal, input_channel_from_args, OutputSignal, output_channel_from_args ) def widget_from_module_globals(module): """ Get the :class:`WidgetDescription` by inspecting the `module`'s global namespace. The module is inspected for global variables (upper case versions of :class:`WidgetDescription` parameters, i.e. NAME global variable is used as a `name` parameter). Parameters ---------- module : `module` or str A module to inspect for widget description. Can be passed as a string (a qualified import name). """ if isinstance(module, str): module = __import__(module, fromlist=[""]) module_name = module.__name__.rsplit(".", 1)[-1] if module.__package__: package_name = module.__package__.rsplit(".", 1)[-1] else: package_name = None # Default widget class name unless otherwise specified is the # module name, and default category the package name default_cls_name = module_name default_cat_name = package_name if package_name else "" widget_cls_name = getattr(module, "WIDGET_CLASS", default_cls_name) try: widget_class = getattr(module, widget_cls_name) name = getattr(module, "NAME") except AttributeError: # The module does not have a widget class implementation or the # widget name. raise WidgetSpecificationError qualified_name = "%s.%s" % (module.__name__, widget_cls_name) id = getattr(module, "ID", module_name) inputs = getattr(module, "INPUTS", []) outputs = getattr(module, "OUTPUTS", []) category = getattr(module, "CATEGORY", default_cat_name) version = getattr(module, "VERSION", None) description = getattr(module, "DESCRIPTION", name) long_description = getattr(module, "LONG_DESCRIPTION", None) author = getattr(module, "AUTHOR", None) author_email = getattr(module, "AUTHOR_EMAIL", None) maintainer = getattr(module, "MAINTAINER", None) maintainer_email = getattr(module, "MAINTAINER_EMAIL", None) help = getattr(module, "HELP", None) help_ref = getattr(module, "HELP_REF", None) url = getattr(module, "URL", None) icon = getattr(module, "ICON", None) priority = getattr(module, "PRIORITY", sys.maxsize) keywords = getattr(module, "KEYWORDS", None) background = getattr(module, "BACKGROUND", None) replaces = getattr(module, "REPLACES", None) inputs = list(map(input_channel_from_args, inputs)) outputs = list(map(output_channel_from_args, outputs)) return WidgetDescription( name=name, id=id, category=category, version=version, description=description, long_description=long_description, qualified_name=qualified_name, package=module.__package__, inputs=inputs, outputs=outputs, author=author, author_email=author_email, maintainer=maintainer, maintainer_email=maintainer_email, help=help, help_ref=help_ref, url=url, keywords=keywords, priority=priority, icon=icon, background=background, replaces=replaces) def category_from_package_globals(package): """ Get the :class:`CategoryDescription` from a package. The package global namespace is inspected for global variables (upper case versions of :class:`CategoryDescription` parameters) Parameters ---------- package : `module` or `str` A package containing the category. Can be passed as a string (qualified import name). """ if isinstance(package, str): package = __import__(package, fromlist=[""]) package_name = package.__name__ qualified_name = package_name name = getattr(package, "NAME", None) description = getattr(package, "DESCRIPTION", None) long_description = getattr(package, "LONG_DESCRIPTION", None) author = getattr(package, "AUTHOR", None) author_email = getattr(package, "AUTHOR_EMAIL", None) maintainer = getattr(package, "MAINTAINER", None) maintainer_email = getattr(package, "MAINTAINER_MAIL", None) url = getattr(package, "URL", None) help = getattr(package, "HELP", None) keywords = getattr(package, "KEYWORDS", None) widgets = getattr(package, "WIDGETS", None) priority = getattr(package, "PRIORITY", sys.maxsize - 1) icon = getattr(package, "ICON", None) background = getattr(package, "BACKGROUND", None) hidden = getattr(package, "HIDDEN", None) if priority == sys.maxsize - 1 \ and name is not None and name.lower() == "prototypes": priority = sys.maxsize return CategoryDescription( name=name, qualified_name=qualified_name, package=qualified_name, description=description, long_description=long_description, help=help, author=author, author_email=author_email, maintainer=maintainer, maintainer_email=maintainer_email, url=url, keywords=keywords, widgets=widgets, priority=priority, icon=icon, background=background, hidden=hidden) def search_filter_query_helper(desc: WidgetDescription, query: str) -> bool: """ Does the `desc` match a user supplied text `query`. This is a helper function to implement consistent search/filter GUI. """ query = query.lstrip().lower() name = desc.name.lower() keywords = [k.lower() for k in desc.keywords] for k in keywords[:]: if '-' in k: keywords.append(k.replace('-', '')) keywords.append(k.replace('-', ' ')) # match name and keywords return (not query or query in name or query in name.replace(' ', '') or any(k.startswith(query) for k in keywords)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/resources.py0000644000175100002000000001561314730024325021765 0ustar00runnerdocker""" Orange Canvas Resource Loader """ import os import glob import pkgutil from typing import Tuple, Dict, Optional, List, IO from AnyQt.QtCore import QObject from AnyQt.QtGui import QIcon from orangecanvas.gui.iconengine import SymbolIconEngine from orangecanvas.gui.svgiconengine import StyledSvgIconEngine def package_dirname(package): """Return the directory path where package is located. """ if isinstance(package, str): package = __import__(package, fromlist=[""]) filename = package.__file__ dirname = os.path.dirname(filename) return dirname def package(qualified_name): """Return the enclosing package name where qualified_name is located. `qualified_name` can be a module inside the package or even an object inside the module. If a package name itself is provided it is returned. """ try: module = __import__(qualified_name, fromlist=[""]) except ImportError: # qualified_name could name an object inside a module/package if "." in qualified_name: qualified_name, attr_name = qualified_name.rsplit(".", 1) module = __import__(qualified_name, fromlist=[attr_name]) else: raise if module.__package__: # the module's enclosing package return module.__package__ else: # 'qualified_name' is itself the package assert module.__name__ == qualified_name return qualified_name dirname = os.path.abspath(os.path.dirname(__file__)) DEFAULT_SEARCH_PATHS = [("", dirname)] del dirname def default_search_paths(): return DEFAULT_SEARCH_PATHS def add_default_search_paths(search_paths): DEFAULT_SEARCH_PATHS.extend(search_paths) def search_paths_from_description(desc): """Return the search paths for the Category/WidgetDescription. """ paths = [] if desc.package: dirname = package_dirname(desc.package) paths.append(("", dirname)) elif desc.qualified_name: dirname = package_dirname(package(desc.qualified_name)) paths.append(("", dirname)) if hasattr(desc, "search_paths"): paths.extend(desc.search_paths) return paths class resource_loader(object): package = None def __init__(self, search_paths=[]): self._search_paths = [] self.add_search_paths(search_paths) @classmethod def from_description(cls, desc): """Construct an resource from a Widget or Category description. """ paths = search_paths_from_description(desc) loader = icon_loader(search_paths=paths) loader.package = desc.package return loader def add_search_paths(self, paths): """Add `paths` to the list of search paths. """ self._search_paths.extend(paths) def search_paths(self): """Return a list of all search paths. """ return self._search_paths + default_search_paths() def split_prefix(self, path): # type: (str) -> Tuple[str, str] """Split prefixed path. """ if self.is_valid_prefixed(path) and ":" in path: prefix, path = path.split(":", 1) else: prefix = "" return prefix, path def is_valid_prefixed(self, path): # type: (str) -> bool i = path.find(":") return i != 1 def find(self, name): # type: (str) -> Optional[str] """Find a resource matching `name`. """ prefix, path = self.split_prefix(name) if prefix == "" and self.match(path): return path elif self.is_valid_prefixed(path): for pp, search_path in self.search_paths(): if pp == prefix and \ self.match(os.path.join(search_path, path)): return os.path.join(search_path, path) return None def match(self, path): # type: (str) -> bool return os.path.exists(path) def get(self, name): # type: (str) -> bytes return self.load(name) def load(self, name): # type: (str) -> bytes return self.open(name).read() def open(self, name): # type: (str) -> IO[bytes] path = self.find(name) if path is not None: return open(path, "rb") else: raise FileNotFoundError("Cannot find %r" % name) class icon_loader(resource_loader): _icon_cache = {} # type: Dict[Tuple[str, ...], QIcon] DEFAULT_ICON = "icons/default-widget.svg" def match(self, path): # type: (str) -> bool if super().match(path): return True return self.is_icon_glob(path) def icon_glob(self, path): # type: (str) -> List[str] name, ext = os.path.splitext(path) pattern = name + "_*" + ext return glob.glob(pattern) def is_icon_glob(self, path): # type: (str) -> bool name, ext = os.path.splitext(path) pattern = name + "_*" + ext return bool(glob.glob(pattern)) def get(self, name, default=None): # type: (str, Optional[str]) -> QIcon if name: path = self.find(name) else: path = None if path is None: path = self.find(self.DEFAULT_ICON if default is None else default) if path is None: return QIcon() if self.is_icon_glob(path): icons = self.icon_glob(path) else: icons = [path] cache_key = tuple(icons) icon = QIcon() if cache_key in self._icon_cache: return QIcon(self._icon_cache[cache_key]) if len(icons) == 1 and icons[0].lower().endswith(".svg"): if self.package is not None: try: contents = pkgutil.get_data(self.package, name) except FileNotFoundError: pass else: if b'current-color-scheme' in contents: icon = QIcon(StyledSvgIconEngine(contents)) self._icon_cache[cache_key] = icon return icon if icons: if cache_key not in self._icon_cache: for path in icons: icon.addFile(path) icon = QIcon(SymbolIconEngine(icon)) self._icon_cache[cache_key] = icon else: icon = self._icon_cache[cache_key] return QIcon(icon) def open(self, name): raise NotImplementedError def load(self, name): # type: (str) -> QIcon return self.get(name) def load_styled_svg_icon( name: str, styleobject: Optional[QObject] = None ) -> QIcon: """ Load a styled svg icon from the `icons` resource directory. .. seealso:: StyledSvgIconEngine """ try: c = pkgutil.get_data(__name__, f"icons/{name}") except Exception: return QIcon() return QIcon(StyledSvgIconEngine(c, styleObject=styleobject)) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.802081 orange_canvas_core-0.2.5/orangecanvas/scheme/0000755000175100002000000000000014730024333020636 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/__init__.py0000644000175100002000000000176014730024325022754 0ustar00runnerdocker""" ====== Scheme ====== The scheme package implements and defines the underlying workflow model. The :class:`.Scheme` class represents the workflow and is composed of a set of :class:`.SchemeNode` connected with :class:`.SchemeLink`, defining an directed acyclic graph (DAG). Additionally instances of :class:`.SchemeArrowAnnotation` or :class:`.SchemeTextAnnotation` can be inserted into the scheme. """ from .node import SchemeNode from .link import SchemeLink, compatible_channels, can_connect, possible_links from .scheme import Scheme from .annotations import ( BaseSchemeAnnotation, SchemeArrowAnnotation, SchemeTextAnnotation ) from .errors import * from .events import * #: Alias for SchemeNode Node = SchemeNode #: Alias for SchemeLink Link = SchemeLink #: Alias for Scheme Workflow = Scheme #: Alias for BaseSchemeAnnotation Annotation = BaseSchemeAnnotation #: Alias for SchemeArrowAnnotation Arrow = SchemeArrowAnnotation #: Alias for SchemeTextAnnotation Text = SchemeTextAnnotation ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/annotations.py0000644000175100002000000001672014730024325023554 0ustar00runnerdocker""" ================== Scheme Annotations ================== """ from typing import Tuple, Optional, Any from AnyQt.QtCore import QObject from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from ..utils import check_type Pos = Tuple[float, float] Rect = Tuple[float, float, float, float] class BaseSchemeAnnotation(QObject): """ Base class for scheme annotations. """ # Signal emitted when the geometry of the annotation changes geometry_changed = Signal() class SchemeArrowAnnotation(BaseSchemeAnnotation): """ An arrow annotation in the scheme. """ color_changed = Signal(str) def __init__(self, start_pos, end_pos, color="red", anchor=None, parent=None): # type: (Pos, Pos, str, Any, Optional[QObject]) -> None super().__init__(parent) self.__start_pos = start_pos self.__end_pos = end_pos self.__color = color self.__anchor = anchor def set_line(self, start_pos, end_pos): # type: (Pos, Pos) -> None """ Set arrow lines start and end position (``(x, y)`` tuples). """ if self.__start_pos != start_pos or self.__end_pos != end_pos: self.__start_pos = start_pos self.__end_pos = end_pos self.geometry_changed.emit() def _start_pos(self): # type: () -> Pos """ Start position of the arrow (base point). """ return self.__start_pos start_pos: Pos start_pos = Property(tuple, _start_pos) # type: ignore def _end_pos(self): """ End position of the arrow (arrow head points toward the end). """ return self.__end_pos end_pos: Pos end_pos = Property(tuple, _end_pos) # type: ignore def set_geometry(self, geometry): # type: (Tuple[Pos, Pos]) -> None """ Set the geometry of the arrow as a start and end position tuples (e.g. ``set_geometry(((0, 0), (100, 0))``). """ (start_pos, end_pos) = geometry self.set_line(start_pos, end_pos) def _geometry(self): # type: () -> Tuple[Pos, Pos] """ Return the start and end positions of the arrow. """ return (self.start_pos, self.end_pos) geometry: Tuple[Pos, Pos] geometry = Property(tuple, _geometry, set_geometry) # type: ignore def set_color(self, color): # type: (str) -> None """ Set the fill color for the arrow as a string (`#RGB`, `#RRGGBB`, `#RRRGGGBBB`, `#RRRRGGGGBBBB` format or one of SVG color keyword names). """ if self.__color != color: self.__color = color self.color_changed.emit(color) def _color(self): # type: () -> str """ The arrow's fill color. """ return self.__color color: str color = Property(str, _color, set_color) # type: ignore def __getstate__(self): return self.__start_pos, \ self.__end_pos, \ self.__color, \ self.__anchor, \ self.parent() def __setstate__(self, state): self.__init__(*state) class SchemeTextAnnotation(BaseSchemeAnnotation): """ Text annotation in the scheme. """ # Signal emitted when the annotation content change. content_changed = Signal(str, str) # Signal emitted when the annotation text changes. text_changed = Signal(str) # Signal emitted when the annotation text font changes. font_changed = Signal(dict) def __init__(self, rect, text="", content_type="text/plain", font=None, anchor=None, parent=None): # type: (Rect, str, str, Optional[dict], Any, Optional[QObject]) -> None super().__init__(parent) self.__rect = rect # type: Rect self.__content = text self.__content_type = content_type self.__font = {} if font is None else font self.__anchor = anchor def set_rect(self, rect): # type: (Rect) -> None """ Set the text geometry bounding rectangle (``(x, y, width, height)`` tuple). """ if self.__rect != rect: self.__rect = rect self.geometry_changed.emit() def _rect(self): # type: () -> Rect """ Text bounding rectangle """ return self.__rect rect: Rect rect = Property(tuple, _rect, set_rect) # type: ignore def set_geometry(self, rect): # type: (Rect) -> None """ Set the text geometry (same as ``set_rect``) """ self.set_rect(rect) def _geometry(self): # type: () -> Rect """ Text annotation geometry (same as ``rect``) """ return self.__rect geometry: Rect geometry = Property(tuple, _geometry, set_geometry) # type: ignore def set_text(self, text): # type: (str) -> None """ Set the annotation text. Same as `set_content(text, "text/plain")` """ self.set_content(text, "text/plain") def _text(self): # type: () -> str """ Annotation text. .. deprecated:: Use `content` instead. """ return self.__content text: str text = Property(str, _text, set_text) # type: ignore @property def content_type(self): # type: () -> str """ Return the annotations' content type. Currently this will be 'text/plain', 'text/html' or 'text/rst'. """ return self.__content_type @property def content(self): # type: () -> str """ The annotation content. How the content is interpreted/displayed depends on `content_type`. """ return self.__content def set_content(self, content, content_type="text/plain"): # type: (str, str) -> None """ Set the annotation content. Parameters ---------- content : str The content. content_type : str Content type. Currently supported are 'text/plain' 'text/html' (subset supported by `QTextDocument`) and `text/rst`. """ if self.__content != content or self.__content_type != content_type: text_changed = self.__content != content self.__content = content self.__content_type = content_type self.content_changed.emit(content, content_type) if text_changed: self.text_changed.emit(content) def set_font(self, font): # type: (dict) -> None """ Set the annotation's default font as a dictionary of font properties (at the moment only family and size are used). >>> annotation.set_font({"family": "Helvetica", "size": 16}) """ check_type(font, dict) font = dict(font) if self.__font != font: self.__font = font self.font_changed.emit(font) def _font(self): # type: () -> dict """ Annotation's font property dictionary. """ return dict(self.__font) font: dict font = Property(object, _font, set_font) # type: ignore def __getstate__(self): return self.__rect, \ self.__content, \ self.__content_type, \ self.__font, \ self.__anchor, \ self.parent() def __setstate__(self, state): self.__init__(*state) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/errors.py0000644000175100002000000000114414730024325022525 0ustar00runnerdocker""" Scheme Errors ============= """ class SchemeTopologyError(Exception): """ A general scheme topology error. """ pass class SchemeCycleError(SchemeTopologyError): """ A link would create a cycle in the scheme. """ pass class SinkChannelError(SchemeTopologyError): """ Sink channel already connected. """ class DuplicatedLinkError(SchemeTopologyError): """ A link duplicates another link already present in the scheme. """ class IncompatibleChannelTypeError(TypeError): """ Source and sink channels do not have compatible types """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/events.py0000644000175100002000000002027614730024325022524 0ustar00runnerdocker""" ============================ Workflow Events (``events``) ============================ Here defined are events dispatched to and from an Scheme workflow instance. """ import typing from typing import Any, Union, cast from AnyQt.QtCore import QEvent if typing.TYPE_CHECKING: from orangecanvas.scheme import SchemeLink, SchemeNode, BaseSchemeAnnotation __all__ = [ "WorkflowEvent", "NodeEvent", "LinkEvent", "AnnotationEvent", "WorkflowEnvChanged" ] _EType = Union[int, QEvent.Type] class WorkflowEvent(QEvent): #: Delivered to Scheme when a node has been added (:class:`NodeEvent`) NodeAdded = QEvent.Type(QEvent.registerEventType()) #: Delivered to Scheme when a node has been removed (:class:`NodeEvent`) NodeRemoved = QEvent.Type(QEvent.registerEventType()) #: A Link has been added to the scheme (:class:`LinkEvent`) LinkAdded = QEvent.Type(QEvent.registerEventType()) #: A Link has been removed from the scheme (:class:`LinkEvent`) LinkRemoved = QEvent.Type(QEvent.registerEventType()) #: An input Link has been added to a node (:class:`LinkEvent`) InputLinkAdded = QEvent.Type(QEvent.registerEventType()) #: An output Link has been added to a node (:class:`LinkEvent`) OutputLinkAdded = QEvent.Type(QEvent.registerEventType()) #: An input Link has been removed from a node (:class:`LinkEvent`) InputLinkRemoved = QEvent.Type(QEvent.registerEventType()) #: An output Link has been removed from a node (:class:`LinkEvent`) OutputLinkRemoved = QEvent.Type(QEvent.registerEventType()) #: Node's (runtime) state has changed (:class:`NodeEvent`) NodeStateChange = QEvent.Type(QEvent.registerEventType()) #: Link's (runtime) state has changed (:class:`LinkEvent`) LinkStateChange = QEvent.Type(QEvent.registerEventType()) #: Input link's (runtime) state has changed (:class:`LinkEvent`) InputLinkStateChange = QEvent.Type(QEvent.registerEventType()) #: Output link's (runtime) state has changed (:class:`LinkEvent`) OutputLinkStateChange = QEvent.Type(QEvent.registerEventType()) #: Request for Node's runtime initialization (e.g. #: load required data, establish connection, ...) NodeInitialize = QEvent.Type(QEvent.registerEventType()) #: Restore the node from serialized state NodeRestore = QEvent.Type(QEvent.registerEventType()) NodeSaveStateRequest = QEvent.Type(QEvent.registerEventType()) #: Node user activate request (e.g. on double click in the #: canvas GUI) NodeActivateRequest = QEvent.Type(QEvent.registerEventType()) # Workflow runtime changed (Running/Paused/Stopped, ...) RuntimeStateChange = QEvent.Type(QEvent.registerEventType()) #: Workflow resource changed (e.g. work directory, env variable) WorkflowEnvironmentChange = QEvent.Type(QEvent.registerEventType()) WorkflowResourceChange = WorkflowEnvironmentChange #: Workflow is about to close. WorkflowAboutToClose = QEvent.Type(QEvent.registerEventType()) WorkflowClose = QEvent.Type(QEvent.registerEventType()) AnnotationAdded = QEvent.Type(QEvent.registerEventType()) AnnotationRemoved = QEvent.Type(QEvent.registerEventType()) AnnotationChange = QEvent.Type(QEvent.registerEventType()) #: Request activation (show and raise) of the window containing #: the workflow view ActivateParentRequest = QEvent.Type(QEvent.registerEventType()) def __init__(self, etype): # type: (_EType) -> None super().__init__(QEvent.Type(etype)) class NodeEvent(WorkflowEvent): """ An event notifying the receiver of an workflow link change. This event is used with: * :data:`WorkflowEvent.NodeAdded` * :data:`WorkflowEvent.NodeRemoved` * :data:`WorkflowEvent.NodeStateChange` * :data:`WorkflowEvent.NodeActivateRequest` * :data:`WorkflowEvent.ActivateParentRequest` * :data:`WorkflowEvent.OutputLinkRemoved` Parameters ---------- etype: QEvent.Type node: SchemeNode pos: int """ def __init__(self, etype, node, pos=-1): # type: (_EType, SchemeNode, int) -> None super().__init__(etype) self.__node = node self.__pos = pos def node(self): # type: () -> SchemeNode """ Return ------ node : SchemeNode The node instance. """ return self.__node def pos(self) -> int: """ For NodeAdded/NodeRemoved events this is the position into which the node is inserted or removed from (is -1 if not applicable)) .. versionadded:: 0.1.16 """ return self.__pos class LinkEvent(WorkflowEvent): """ An event notifying the receiver of an workflow link change. This event is used with: * :data:`WorkflowEvent.LinkAdded` * :data:`WorkflowEvent.LinkRemoved` * :data:`WorkflowEvent.InputLinkAdded` * :data:`WorkflowEvent.InputLinkRemoved` * :data:`WorkflowEvent.OutputLinkAdded` * :data:`WorkflowEvent.OutputLinkRemoved` * :data:`WorkflowEvent.InputLinkStateChange` * :data:`WorkflowEvent.OutputLinkStateChange` Parameters ---------- etype: QEvent.Type link: SchemeLink The link subject to change pos: int The link position index. """ def __init__(self, etype, link, pos=-1): # type: (_EType, SchemeLink, int) -> None super().__init__(etype) self.__link = link self.__pos = pos def link(self): # type: () -> SchemeLink """ Return ------ link : SchemeLink The link instance. """ return self.__link def pos(self) -> int: """ The index position into which the link was inserted. For LinkAdded/LinkRemoved this is the index in the `Scheme.links` sequence from which the link was removed or was inserted into. For InputLinkAdded/InputLinkRemoved it is the sequential position in the input links to the sink node. For OutputLinkAdded/OutputLinkRemoved it is the sequential position in the output links from the source node. .. versionadded:: 0.1.16 """ return self.__pos class AnnotationEvent(WorkflowEvent): """ An event notifying the receiver of an workflow annotation changes This event is used with: * :data:`WorkflowEvent.AnnotationAdded` * :data:`WorkflowEvent.AnnotationRemoved` Parameters ---------- etype: QEvent.Type annotation: BaseSchemeAnnotation The annotation that is a subject of change. pos: int """ def __init__(self, etype, annotation, pos=-1): # type: (_EType, BaseSchemeAnnotation, int) -> None super().__init__(etype) self.__annotation = annotation self.__pos = pos def annotation(self): # type: () -> BaseSchemeAnnotation """ Return ------ annotation : BaseSchemeAnnotation The annotation instance. """ return self.__annotation def pos(self) -> int: """ The index position of the annotation in the `Scheme.annotations` .. versionadded:: 0.1.16 """ return self.__pos class WorkflowEnvChanged(WorkflowEvent): """ An event notifying the receiver of a workflow environment change. Parameters ---------- name: str The name of the environment property that was changed newValue: Any The new value oldValue: Any The old value See Also -------- Scheme.runtime_env """ def __init__(self, name, newValue, oldValue): # type: (str, Any, Any) -> None super().__init__(WorkflowEvent.WorkflowEnvironmentChange) self.__name = name self.__oldValue = oldValue self.__newValue = newValue def name(self): # type: () -> str """ The name of the environment property. """ return self.__name def oldValue(self): # type: () -> Any """ The old value. """ return self.__oldValue def newValue(self): # type: () -> Any """ The new value. """ return self.__newValue ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/link.py0000644000175100002000000003254114730024325022153 0ustar00runnerdocker""" =========== Scheme Link =========== """ import enum import warnings import typing from traceback import format_exception_only, format_exception from typing import List, Tuple, Union, Optional, Iterable from AnyQt.QtCore import QObject, QCoreApplication from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from ..registry.description import normalize_type_simple from ..utils import type_lookup from .errors import IncompatibleChannelTypeError from .events import LinkEvent if typing.TYPE_CHECKING: from ..registry import OutputSignal as Output, InputSignal as Input from . import SchemeNode as Node def resolve_types(types): # type: (Iterable[str]) -> Tuple[Optional[type], ...] """ Resolve the fully qualified names to python types. If a name fails to resolve to a type then the corresponding entry in output is replaced with a None. Parameters ---------- types: Iterable[str] Names of types to resolve Returns ------- type: Tuple[Optional[type], ...] The `type` instances in the same order as input `types` with `None` replacing any type that cannot be resolved. """ rt = [] # type: List[Optional[type]] for t in types: try: rt.append(type_lookup(t)) except Exception as err: warnings.warn( "An unexpected error while resolving type {!r}:\n{}".format( t, "".join(format_exception_only(type(err), err))), RuntimeWarning, stacklevel=2 ) rt.append(None) return tuple(rt) def resolved_valid_types(types): # type: (Iterable[str]) -> Tuple[type, ...] """ Resolve fully qualified names to python types, omiting all types that fail to resolve. Parameters ---------- types: Iterable[str] Returns ------- type: Tuple[type, ...] """ return tuple(filter(None, resolve_types(types))) def compatible_channels(source_channel, sink_channel): # type: (Output, Input) -> bool """ Do the source and sink channels have compatible types, i.e. can they be connected based on their specified types. """ strict, dynamic = _classify_connection(source_channel, sink_channel) return strict or dynamic def _classify_connection(source, sink): # type: (Output, Input) -> Tuple[bool, bool] """ Classify the source -> sink connection type check. Returns ------- rval : Tuple[bool, bool] A `(strict, dynamic)` tuple where `strict` is True if connection passes a strict type check, and `dynamic` is True if the `source.dynamic` is True and at least one of the sink types is a subtype of the source types. """ source_types = resolved_valid_types(source.types) sink_types = resolved_valid_types(sink.types) if not source_types or not sink_types: return False, False # Are all possible source types subtypes of the sink_types. strict = all(issubclass(source_t, sink_types) for source_t in source_types) if source.dynamic: # Is at least one of the possible sink types a subtype of # the source_types. dynamic = any(issubclass(sink_t, source_types) for sink_t in sink_types) else: dynamic = False return strict, dynamic def can_connect(source_node, sink_node): # type: (Node, Node) -> bool """ Return True if any output from `source_node` can be connected to any input of `sink_node`. """ return bool(possible_links(source_node, sink_node)) def possible_links(source_node, sink_node): # type: (Node, Node) -> List[Tuple[Output, Input]] """ Return a list of (OutputSignal, InputSignal) tuples, that can connect the two nodes. """ possible = [] for source in source_node.output_channels(): for sink in sink_node.input_channels(): if compatible_channels(source, sink): possible.append((source, sink)) return possible def _get_first_type(arg, newname): # type: (Union[str, type, Tuple[Union[str, type], ...]], str) -> type if isinstance(arg, tuple): if len(arg) > 1: warnings.warn( "Multiple types specified, but using only the first. " "Use `{newname}` instead.".format(newname=newname), RuntimeWarning, stacklevel=3 ) if arg: arg0 = normalize_type_simple(arg[0]) return type_lookup(arg0) else: raise ValueError("no type spec") if isinstance(arg, type): return arg rv = type_lookup(arg) if rv is not None: return rv else: raise TypeError("{!r} does not resolve to a type") class SchemeLink(QObject): """ A instantiation of a link between two :class:`.SchemeNode` instances in a :class:`.Scheme`. Parameters ---------- source_node : :class:`.SchemeNode` Source node. source_channel : :class:`OutputSignal` The source widget's signal. sink_node : :class:`.SchemeNode` The sink node. sink_channel : :class:`InputSignal` The sink widget's input signal. properties : `dict` Additional link properties. """ #: The link enabled state has changed enabled_changed = Signal(bool) #: The link dynamic enabled state has changed. dynamic_enabled_changed = Signal(bool) #: Runtime link state has changed state_changed = Signal(int) class State(enum.IntEnum): """ Flags indicating the runtime state of a link """ #: The link has no associated state (e.g. is not associated with any #: execution contex) NoState = 0 #: A link is empty when it has no value on it. Empty = 1 #: A link is active when the source node provides a value on output. Active = 2 #: A link is pending when it's sink node has not yet been notified #: of a change (note that Empty|Pending is a valid state) Pending = 4 #: The link's source node has invalidated the source channel. #: The execution manager should not propagate this links source value #: until this flag is cleared. #: #: .. versionadded:: 0.1.8 Invalidated = 8 NoState = State.NoState Empty = State.Empty Active = State.Active Pending = State.Pending Invalidated = State.Invalidated def __init__(self, source_node, source_channel, sink_node, sink_channel, enabled=True, properties=None, parent=None): # type: (Node, Output, Node, Input, bool, dict, QObject) -> None super().__init__(parent) self.source_node = source_node if isinstance(source_channel, str): source_channel = source_node.output_channel(source_channel) elif source_channel not in source_node.output_channels(): raise ValueError("%r not in in nodes output channels." \ % source_channel) self.source_channel = source_channel self.sink_node = sink_node if isinstance(sink_channel, str): sink_channel = sink_node.input_channel(sink_channel) elif sink_channel not in sink_node.input_channels(): raise ValueError("%r not in in nodes input channels." \ % source_channel) self.sink_channel = sink_channel if not compatible_channels(source_channel, sink_channel): raise IncompatibleChannelTypeError( "Cannot connect %r to %r" % (source_channel.type, sink_channel.type) ) self.__enabled = enabled self.__dynamic_enabled = False self.__state = SchemeLink.NoState # type: Union[SchemeLink.State, int] self.__tool_tip = "" self.properties = properties or {} def source_type(self): # type: () -> type """ Return the type of the source channel. .. deprecated:: 0.1.5 Use :func:`source_types` instead. """ warnings.warn( "`source_type()` is deprecated. Use `source_types()`.", DeprecationWarning, stacklevel=2 ) return _get_first_type(self.source_channel.type, "source_types") def source_types(self): # type: () -> Tuple[type, ...] """ Return the type(s) of the source channel. """ return resolved_valid_types(self.source_channel.types) def sink_type(self): # type: () -> type """ Return the type of the sink channel. .. deprecated:: 0.1.5 Use :func:`sink_types` instead. """ warnings.warn( "`sink_type()` is deprecated. Use `sink_types()`.", DeprecationWarning, stacklevel=2 ) return _get_first_type(self.sink_channel.types, "sink_types") def sink_types(self): # type: () -> Tuple[type, ...] """ Return the type(s) of the sink channel. """ return resolved_valid_types(self.sink_channel.types) def is_dynamic(self): # type: () -> bool """ Is this link dynamic. """ sink_types = self.sink_types() source_types = self.source_types() if self.source_channel.dynamic: strict, dynamic = _classify_connection( self.source_channel, self.sink_channel) # If the connection type checks (strict) then supress the dynamic # state. return not strict and dynamic else: return False def set_enabled(self, enabled): # type: (bool) -> None """ Enable/disable the link. """ if self.__enabled != enabled: self.__enabled = enabled self.enabled_changed.emit(enabled) def is_enabled(self): # type: () -> bool """ Is this link enabled. """ return self.__enabled enabled: bool enabled = Property(bool, is_enabled, set_enabled) # type: ignore def set_dynamic_enabled(self, enabled): # type: (bool) -> None """ Enable/disable the dynamic link. Has no effect if the link is not dynamic. """ if self.is_dynamic() and self.__dynamic_enabled != enabled: self.__dynamic_enabled = enabled self.dynamic_enabled_changed.emit(enabled) def is_dynamic_enabled(self): # type: () -> bool """ Is this a dynamic link and is `dynamic_enabled` set to `True` """ return self.is_dynamic() and self.__dynamic_enabled dynamic_enabled: bool dynamic_enabled = Property( # type: ignore bool, is_dynamic_enabled, set_dynamic_enabled) def set_runtime_state(self, state): # type: (Union[State, int]) -> None """ Set the link's runtime state. Parameters ---------- state : SchemeLink.State """ if self.__state != state: self.__state = state ev = LinkEvent(LinkEvent.InputLinkStateChange, self) QCoreApplication.sendEvent(self.sink_node, ev) ev = LinkEvent(LinkEvent.OutputLinkStateChange, self) QCoreApplication.sendEvent(self.source_node, ev) self.state_changed.emit(state) def runtime_state(self): # type: () -> Union[State, int] """ Returns ------- state : SchemeLink.State """ return self.__state def set_runtime_state_flag(self, flag, on): # type: (State, bool) -> None """ Set/unset runtime state flag. Parameters ---------- flag: SchemeLink.State on: bool """ if on: state = self.__state | flag else: state = self.__state & ~flag self.set_runtime_state(state) def test_runtime_state(self, flag): # type: (State) -> bool """ Test if runtime state flag is on/off Parameters ---------- flag: SchemeLink.State State flag to test Returns ------- on: bool True if `flag` is set; False otherwise. """ return bool(self.__state & flag) def set_tool_tip(self, tool_tip): # type: (str) -> None """ Set the link tool tip. """ if self.__tool_tip != tool_tip: self.__tool_tip = tool_tip def _tool_tip(self): # type: () -> str """ Link tool tip. """ return self.__tool_tip tool_tip: str tool_tip = Property(str, _tool_tip, set_tool_tip) # type: ignore def __str__(self): return "{0}(({1}, {2}) -> ({3}, {4}))".format( type(self).__name__, self.source_node.title, self.source_channel.name, self.sink_node.title, self.sink_channel.name ) def __getstate__(self): return self.source_node, \ self.source_channel.name, \ self.sink_node, \ self.sink_channel.name, \ self.__enabled, \ self.properties, \ self.parent() def __setstate__(self, state): mutable_state = list(state) # correct source channel mutable_state[1] = state[0].output_channel(state[1]) # correct sink channel mutable_state[3] = state[2].input_channel(state[3]) self.__init__(*mutable_state) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/node.py0000644000175100002000000002674614730024325022155 0ustar00runnerdocker""" =========== Scheme Node =========== """ import enum import warnings from typing import Optional, Dict, Any, List, Tuple, Iterable, Union from AnyQt.QtCore import QObject, QCoreApplication from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from ..registry import WidgetDescription, InputSignal, OutputSignal from .events import NodeEvent class UserMessage(object): """ A user message that should be displayed in a scheme view. Parameters ---------- contents : str Message text. severity : int Message severity. message_id : str Message id. data : dict A dictionary with optional extra data. """ #: Severity flags Info, Warning, Error = 1, 2, 3 def __init__(self, contents, severity=Info, message_id="", data={}): # type: (str, int, str, Dict[str, Any]) -> None self.contents = contents self.severity = severity self.message_id = message_id self.data = dict(data) class SchemeNode(QObject): """ A node in a :class:`.Scheme`. Parameters ---------- description : :class:`WidgetDescription` Node description instance. title : str, optional Node title string (if None `description.name` is used). position : tuple (x, y) tuple of floats for node position in a visual display. properties : dict Additional extra instance properties (settings, widget geometry, ...) parent : :class:`QObject` Parent object. """ class State(enum.IntEnum): """ A workflow node's runtime state flags """ #: The node has no state. NoState = 0 #: The node is running (i.e. executing a task). Running = 1 #: The node has invalidated inputs. This flag is set when: #: #: * An input link is added or removed #: * An input link is marked as pending #: #: It is set/cleared by the execution manager when the inputs are #: propagated to the node. Pending = 2 #: The node has invalidated outputs. Execution manager should not #: propagate this node's existing outputs to dependent nodes until #: this flag is cleared. Invalidated = 4 #: The node is in a state where it does not accept new signals. #: The execution manager should not propagate inputs to this node #: until this flag is cleared. NotReady = 8 NoState = State.NoState Running = State.Running Pending = State.Pending Invalidated = State.Invalidated NotReady = State.NotReady def __init__(self, description, title=None, position=None, properties=None, parent=None): # type: (WidgetDescription, str, Tuple[float, float], dict, QObject) -> None super().__init__(parent) self.description = description if title is None: title = description.name self.__title = title self.__position = position or (0, 0) self.__progress = -1 self.__processing_state = 0 self.__tool_tip = "" self.__status_message = "" self.__state_messages = {} # type: Dict[str, UserMessage] self.__state = SchemeNode.NoState # type: Union[SchemeNode.State, int] self.properties = properties or {} def input_channels(self): # type: () -> List[InputSignal] """ Return a list of input channels (:class:`InputSignal`) for the node. """ return list(self.description.inputs) def output_channels(self): # type: () -> List[OutputSignal] """ Return a list of output channels (:class:`OutputSignal`) for the node. """ return list(self.description.outputs) def input_channel(self, name): # type: (str) -> InputSignal """ Return the input channel matching `name`. Raise a `ValueError` if not found. """ for channel in self.input_channels(): if channel.id == name: return channel # Fallback to channel names for backward compatibility for channel in self.input_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid input channel for %r." % \ (name, self.description.name)) def output_channel(self, name): # type: (str) -> OutputSignal """ Return the output channel matching `name`. Raise an `ValueError` if not found. """ for channel in self.output_channels(): if channel.id == name: return channel # Fallback to channel names for backward compatibility for channel in self.output_channels(): if channel.name == name: return channel raise ValueError("%r is not a valid output channel for %r." % \ (name, self.description.name)) #: The title of the node has changed title_changed = Signal(str) def set_title(self, title): """ Set the node title. """ if self.__title != title: self.__title = title self.title_changed.emit(self.__title) def _title(self): """ The node title. """ return self.__title title: str title = Property(str, _title, set_title) # type: ignore #: Position of the node in the scheme has changed position_changed = Signal(tuple) def set_position(self, pos): """ Set the position (``(x, y)`` tuple) of the node. """ if self.__position != pos: self.__position = pos self.position_changed.emit(pos) def _get_position(self): """ ``(x, y)`` tuple containing the position of the node in the scheme. """ return self.__position position: Tuple[float, float] position = Property(tuple, _get_position, set_position) # type: ignore #: Node's progress value has changed. progress_changed = Signal(float) def set_progress(self, value): """ Set the progress value. """ if self.__progress != value: self.__progress = value self.progress_changed.emit(value) def _progress(self): """ The current progress value. -1 if progress is not set. """ return self.__progress progress: float progress = Property(float, _progress, set_progress) # type: ignore #: Node's processing state has changed. processing_state_changed = Signal(int) def set_processing_state(self, state): """ Set the node processing state. """ self.set_state_flags(SchemeNode.Running, bool(state)) def _processing_state(self): """ The node processing state, 0 for not processing, 1 the node is busy. """ return int(bool(self.state() & SchemeNode.Running)) processing_state: int processing_state = Property( # type: ignore int, _processing_state, set_processing_state) def set_tool_tip(self, tool_tip): if self.__tool_tip != tool_tip: self.__tool_tip = tool_tip def _tool_tip(self): return self.__tool_tip tool_tip: str tool_tip = Property(str, _tool_tip, set_tool_tip) # type: ignore #: The node's status tip has changes status_message_changed = Signal(str) def set_status_message(self, text): # type: (str) -> None """Set a short status message.""" if self.__status_message != text: self.__status_message = text self.status_message_changed.emit(text) def status_message(self): # type: () -> str """A short status message summarizing the current node state.""" return self.__status_message #: The node's state message has changed state_message_changed = Signal(UserMessage) def set_state_message(self, message): # type: (UserMessage) -> None """ Set a message to be displayed by a scheme view for this node. """ if message.message_id is not None: self.__state_messages[message.message_id] = message self.state_message_changed.emit(message) else: warnings.warn( "'message' with no id was ignored. " "This will raise an error in the future.", FutureWarning, stacklevel=2 ) def clear_state_message(self, message_id): # type: (str) -> None """ Clear (remove) a message with `message_id`. :attr:`state_message_changed` signal will be emitted with a empty message for the `message_id`. """ if message_id in self.__state_messages: # emit an empty message m = self.__state_messages[message_id] m = UserMessage("", m.severity, m.message_id) self.__state_messages[message_id] = m self.state_message_changed.emit(m) del self.__state_messages[message_id] def state_message(self, message_id): # type: (str) -> Optional[UserMessage] """ Return a message with `message_id` or None if a message with that id does not exist. """ return self.__state_messages.get(message_id, None) def state_messages(self): # type: () -> Iterable[UserMessage] """ Return a list of all state messages. """ return self.__state_messages.values() state_changed = Signal(int) def set_state(self, state): # type: (Union[State, int]) -> None """ Set the node runtime state flags Parameters ---------- state: SchemeNode.State """ if self.__state != state: curr = self.__state self.__state = state QCoreApplication.sendEvent( self, NodeEvent(NodeEvent.NodeStateChange, self) ) self.state_changed.emit(state) if curr & SchemeNode.Running != state & SchemeNode.Running: self.processing_state_changed.emit( int(bool(state & SchemeNode.Running)) ) def state(self): # type: () -> Union[State, int] """ Return the node runtime state flags. """ return self.__state def set_state_flags(self, flags, on): # type: (Union[State, int], bool) -> None """ Set the specified state flags on/off. Parameters ---------- flags: SchemeNode.State Flag to modify on: bool Turn the flag on or off """ if on: state = self.__state | flags else: state = self.__state & ~flags self.set_state(state) def test_state_flags(self, flag): # type: (State) -> bool """ Return True/False if the runtime state flag is set. Parameters ---------- flag: SchemeNode.State Returns ------- val: bool """ return bool(self.__state & flag) def __str__(self): return "SchemeNode(description_id=%r, title=%r, ...)" % \ (str(self.description.id), self.title) def __repr__(self): return str(self) def __getstate__(self): return self.description, \ self.__title, \ self.__position, \ self.properties, \ self.parent() def __setstate__(self, state): self.__init__(*state) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/readwrite.py0000644000175100002000000007027014730024325023205 0ustar00runnerdocker""" Scheme save/load routines. """ import numbers import base64 import binascii import itertools import math from xml.etree.ElementTree import TreeBuilder, Element, ElementTree, parse from collections import defaultdict from itertools import chain import pickle import json import pprint import ast from ast import literal_eval import logging from typing import ( NamedTuple, Dict, Tuple, List, Union, Any, Optional, AnyStr, IO ) from typing_extensions import TypeGuard from . import SchemeNode, SchemeLink from .annotations import SchemeTextAnnotation, SchemeArrowAnnotation from .errors import IncompatibleChannelTypeError from ..registry import global_registry, WidgetRegistry from ..registry import WidgetDescription, InputSignal, OutputSignal from ..utils import findf log = logging.getLogger(__name__) # protocol v4 is supported since Python 3.4, protocol v5 since Python 3.8 PICKLE_PROTOCOL = 4 class UnknownWidgetDefinition(Exception): pass def _ast_parse_expr(source): # type: (str) -> ast.Expression node = ast.parse(source, "", mode="eval") assert isinstance(node, ast.Expression) return node def string_eval(source): # type: (str) -> str """ Evaluate a python string literal `source`. Raise ValueError if `source` is not a string literal. >>> string_eval("'a string'") a string """ node = _ast_parse_expr(source) body = node.body if not _is_constant(body, (str,)): raise ValueError("%r is not a string literal" % source) return body.value def tuple_eval(source): # type: (str) -> tuple """ Evaluate a python tuple literal `source` where the elements are constrained to be int, float or string. Raise ValueError if not a tuple literal. >>> tuple_eval("(1, 2, '3')") (1, 2, '3') """ node = _ast_parse_expr(source) if not isinstance(node.body, ast.Tuple): raise ValueError("%r is not a tuple literal" % source) if not all(_is_constant(el, (str, float, complex, int)) or # allow signed number literals in Python3 (i.e. -1|+1|-1.0) (isinstance(el, ast.UnaryOp) and isinstance(el.op, (ast.UAdd, ast.USub)) and _is_constant(el.operand, (float, complex, int))) for el in node.body.elts): raise ValueError("Can only contain numbers or strings") return literal_eval(source) def terminal_eval(source): # type: (str) -> Union[str, bytes, int, float, complex, bool, None] """ Evaluate a python 'constant' (string, number, None, True, False) `source`. Raise ValueError is not a terminal literal. >>> terminal_eval("True") True """ node = _ast_parse_expr(source) return _terminal_value(node.body) def _terminal_value(node): # type: (ast.AST) -> Union[str, bytes, int, float, complex, None] if _is_constant(node, (str, bytes, int, float, complex, type(None))): return node.value raise ValueError("Not a terminal") def _is_constant( node: ast.AST, types: Tuple[type, ...] ) -> TypeGuard[ast.Constant]: return isinstance(node, ast.Constant) and isinstance(node.value, types) # Intermediate scheme representation _scheme = NamedTuple( "_scheme", [ ("title", str), ("version", str), ("description", str), ("nodes", List['_node']), ("links", List['_link']), ("annotations", List['_annotation']), ("session_state", '_session_data') ] ) _node = NamedTuple( "_node", [ ("id", str), ("title", str), ("name", str), ("position", Tuple[float, float]), ("project_name", str), ("qualified_name", str), ("version", str), ("data", Optional['_data']) ] ) _data = NamedTuple( "_data", [ ("format", str), ("data", Union[bytes, str]) ] ) _link = NamedTuple( "_link", [ ("id", str), ("source_node_id", str), ("sink_node_id", str), ("source_channel", str), ("source_channel_id", str), ("sink_channel", str), ("sink_channel_id", str), ("enabled", bool), ] ) _annotation = NamedTuple( "_annotation", [ ("id", str), ("type", str), ("params", Union['_text_params', '_arrow_params']), ] ) _text_params = NamedTuple( "_text_params", [ ("geometry", Tuple[float, float, float, float]), ("text", str), ("font", Dict[str, Any]), ("content_type", str), ] ) _arrow_params = NamedTuple( "_arrow_params", [ ("geometry", Tuple[Tuple[float, float], Tuple[float, float]]), ("color", str), ]) _window_group = NamedTuple( "_window_group", [ ("name", str), ("default", bool), ("state", List[Tuple[str, bytes]]) ] ) _session_data = NamedTuple( "_session_data", [ ("groups", List[_window_group]) ] ) def parse_ows_etree_v_2_0(tree): # type: (ElementTree) -> _scheme """ Parset an xml.etree.ElementTree struct into a intermediate workflow representation. """ scheme = tree.getroot() version = scheme.get("version") nodes, links, annotations = [], [], [] # First collect all properties properties = {} # type: Dict[str, _data] for property in tree.findall("node_properties/properties"): node_id = property.get("node_id") # type: str format = property.get("format") if version == "2.0" and "data" in property.attrib: data_str = property.get("data", default="") else: data_str = property.text or "" properties[node_id] = _data(format, data_str) # Collect all nodes for node in tree.findall("nodes/node"): node_id = node.get("id") _px, _py = tuple_eval(node.get("position")) nodes.append( _node( # type: ignore id=node_id, title=node.get("title"), name=node.get("name"), position=(_px, _py), project_name=node.get("project_name"), qualified_name=node.get("qualified_name"), version=node.get("version", ""), data=properties.get(node_id, None) ) ) for link in tree.findall("links/link"): params = _link( id=link.get("id"), source_node_id=link.get("source_node_id"), sink_node_id=link.get("sink_node_id"), source_channel=link.get("source_channel"), source_channel_id=link.get("source_channel_id", ""), sink_channel=link.get("sink_channel"), sink_channel_id=link.get("sink_channel_id", ""), enabled=link.get("enabled") == "true", ) links.append(params) for annot in tree.findall("annotations/*"): if annot.tag == "text": rect = tuple_eval(annot.get("rect", "(0.0, 0.0, 20.0, 20.0)")) font_family = annot.get("font-family", "").strip() font_size = annot.get("font-size", "").strip() font = {} # type: Dict[str, Any] if font_family: font["family"] = font_family if font_size: font["size"] = int(font_size) content_type = annot.get("type", "text/plain") annotation = _annotation( id=annot.get("id"), type="text", params=_text_params( # type: ignore rect, annot.text or "", font, content_type), ) elif annot.tag == "arrow": start = tuple_eval(annot.get("start", "(0, 0)")) end = tuple_eval(annot.get("end", "(0, 0)")) color = annot.get("fill", "red") annotation = _annotation( id=annot.get("id"), type="arrow", params=_arrow_params((start, end), color) # type: ignore ) else: log.warning("Unknown annotation '%s'. Skipping.", annot.tag) continue annotations.append(annotation) window_presets = [] for window_group in tree.findall("session_state/window_groups/group"): name = window_group.get("name") # type: str default = window_group.get("default", "false") == "true" state = [] for state_ in window_group.findall("window_state"): node_id = state_.get("node_id") text_ = state_.text if text_ is not None: try: data = base64.decodebytes(text_.encode("ascii")) except (binascii.Error, UnicodeDecodeError): data = b'' else: data = b'' state.append((node_id, data)) window_presets.append(_window_group(name, default, state)) session_state = _session_data(window_presets) return _scheme( version=version, title=scheme.get("title", ""), description=scheme.get("description"), nodes=nodes, links=links, annotations=annotations, session_state=session_state, ) class InvalidFormatError(ValueError): pass class UnsupportedFormatVersionError(ValueError): pass def parse_ows_stream(stream): # type: (Union[AnyStr, IO]) -> _scheme doc = parse(stream) scheme_el = doc.getroot() if scheme_el.tag != "scheme": raise InvalidFormatError( "Invalid Orange Workflow Scheme file" ) version = scheme_el.get("version", None) if version is None: # Check for "widgets" tag - old Orange<2.7 format if scheme_el.find("widgets") is not None: raise UnsupportedFormatVersionError( "Cannot open Orange Workflow Scheme v1.0. This format is no " "longer supported" ) else: raise InvalidFormatError( "Invalid Orange Workflow Scheme file (missing version)." ) if version in {"2.0", "2.1"}: return parse_ows_etree_v_2_0(doc) else: raise UnsupportedFormatVersionError( f"Unsupported format version {version}") def resolve_replaced(scheme_desc: _scheme, registry: WidgetRegistry) -> _scheme: widgets = registry.widgets() nodes_by_id = {} # type: Dict[str, _node] replacements = {} replacements_channels = {} # type: Dict[str, Tuple[dict, dict]] # collect all the replacement mappings for desc in widgets: # type: WidgetDescription if desc.replaces: for repl_qname in desc.replaces: replacements[repl_qname] = desc.qualified_name input_repl = {} for idesc in desc.inputs or []: # type: InputSignal for repl_qname in idesc.replaces or []: # type: str input_repl[repl_qname] = idesc.name output_repl = {} for odesc in desc.outputs: # type: OutputSignal for repl_qname in odesc.replaces or []: # type: str output_repl[repl_qname] = odesc.name replacements_channels[desc.qualified_name] = (input_repl, output_repl) # replace the nodes nodes = scheme_desc.nodes for i, node in list(enumerate(nodes)): if not registry.has_widget(node.qualified_name) and \ node.qualified_name in replacements: qname = replacements[node.qualified_name] desc = registry.widget(qname) nodes[i] = node._replace(qualified_name=desc.qualified_name, project_name=desc.project_name) nodes_by_id[node.id] = nodes[i] # replace links links = scheme_desc.links for i, link in list(enumerate(links)): nsource = nodes_by_id[link.source_node_id] nsink = nodes_by_id[link.sink_node_id] _, source_rep = replacements_channels.get( nsource.qualified_name, ({}, {})) sink_rep, _ = replacements_channels.get( nsink.qualified_name, ({}, {})) if link.source_channel in source_rep: link = link._replace( source_channel=source_rep[link.source_channel]) if link.sink_channel in sink_rep: link = link._replace( sink_channel=sink_rep[link.sink_channel]) links[i] = link return scheme_desc._replace(nodes=nodes, links=links) def scheme_load(scheme, stream, registry=None, error_handler=None): desc = parse_ows_stream(stream) # type: _scheme if registry is None: registry = global_registry() if error_handler is None: def error_handler(exc): raise exc desc = resolve_replaced(desc, registry) nodes_not_found = [] nodes = [] nodes_by_id = {} links = [] annotations = [] scheme.title = desc.title scheme.description = desc.description for node_d in desc.nodes: try: w_desc = registry.widget(node_d.qualified_name) except KeyError as ex: error_handler(UnknownWidgetDefinition(*ex.args)) nodes_not_found.append(node_d.id) else: node = SchemeNode( w_desc, title=node_d.title, position=node_d.position) data = node_d.data if data: try: properties = loads(data.data, data.format) except Exception: log.error("Could not load properties for %r.", node.title, exc_info=True) else: node.properties = properties nodes.append(node) nodes_by_id[node_d.id] = node for link_d in desc.links: source_id = link_d.source_node_id sink_id = link_d.sink_node_id if source_id in nodes_not_found or sink_id in nodes_not_found: continue source = nodes_by_id[source_id] sink = nodes_by_id[sink_id] try: source_channel = _find_source_channel(source, link_d) sink_channel = _find_sink_channel(sink, link_d) link = SchemeLink(source, source_channel, sink, sink_channel, enabled=link_d.enabled) except (ValueError, IncompatibleChannelTypeError) as ex: error_handler(ex) else: links.append(link) for annot_d in desc.annotations: params = annot_d.params if annot_d.type == "text": annot = SchemeTextAnnotation( params.geometry, params.text, params.content_type, params.font ) elif annot_d.type == "arrow": start, end = params.geometry annot = SchemeArrowAnnotation(start, end, params.color) else: log.warning("Ignoring unknown annotation type: %r", annot_d.type) continue annotations.append(annot) for node in nodes: scheme.add_node(node) for link in links: scheme.add_link(link) for annot in annotations: scheme.add_annotation(annot) if desc.session_state.groups: groups = [] for g in desc.session_state.groups: # type: _window_group # resolve node_id -> node state = [(nodes_by_id[node_id], data) for node_id, data in g.state if node_id in nodes_by_id] groups.append(Scheme.WindowGroup(g.name, g.default, state)) scheme.set_window_group_presets(groups) return scheme def _find_source_channel(node: SchemeNode, link: _link) -> OutputSignal: source_channel: Optional[OutputSignal] = None if link.source_channel_id: source_channel = findf( node.output_channels(), lambda c: c.id == link.source_channel_id, ) if source_channel is not None: return source_channel source_channel = findf( node.output_channels(), lambda c: c.name == link.source_channel, ) if source_channel is not None: return source_channel raise ValueError( f"{link.source_channel!r} is not a valid output channel " f"for {node.description.name!r}." ) def _find_sink_channel(node: SchemeNode, link: _link) -> InputSignal: sink_channel: Optional[InputSignal] = None if link.sink_channel_id: sink_channel = findf( node.input_channels(), lambda c: c.id == link.sink_channel_id, ) if sink_channel is not None: return sink_channel sink_channel = findf( node.input_channels(), lambda c: c.name == link.sink_channel, ) if sink_channel is not None: return sink_channel raise ValueError( f"{link.sink_channel!r} is not a valid input channel " f"for {node.description.name!r}." ) def scheme_to_etree(scheme, data_format="literal", pickle_fallback=False): """ Return an `xml.etree.ElementTree` representation of the `scheme`. """ builder = TreeBuilder(element_factory=Element) builder.start("scheme", {"version": "2.0", "title": scheme.title or "", "description": scheme.description or ""}) # Nodes node_ids = defaultdict(lambda c=itertools.count(): next(c)) builder.start("nodes", {}) for node in scheme.nodes: # type: SchemeNode desc = node.description attrs = {"id": str(node_ids[node]), "name": desc.name, "qualified_name": desc.qualified_name, "project_name": desc.project_name or "", "version": desc.version or "", "title": node.title, } if node.position is not None: attrs["position"] = str(node.position) if type(node) is not SchemeNode: attrs["scheme_node_type"] = "%s.%s" % (type(node).__name__, type(node).__module__) builder.start("node", attrs) builder.end("node") builder.end("nodes") # Links link_ids = defaultdict(lambda c=itertools.count(): next(c)) builder.start("links", {}) for link in scheme.links: source = link.source_node sink = link.sink_node source_id = node_ids[source] sink_id = node_ids[sink] attrs = {"id": str(link_ids[link]), "source_node_id": str(source_id), "sink_node_id": str(sink_id), "source_channel": link.source_channel.name, "sink_channel": link.sink_channel.name, "enabled": "true" if link.enabled else "false", } if link.source_channel.id: attrs["source_channel_id"] = link.source_channel.id if link.sink_channel.id: attrs["sink_channel_id"] = link.sink_channel.id builder.start("link", attrs) builder.end("link") builder.end("links") # Annotations annotation_ids = defaultdict(lambda c=itertools.count(): next(c)) builder.start("annotations", {}) for annotation in scheme.annotations: annot_id = annotation_ids[annotation] attrs = {"id": str(annot_id)} data = None if isinstance(annotation, SchemeTextAnnotation): tag = "text" attrs.update({"type": annotation.content_type}) attrs.update({"rect": repr(annotation.rect)}) # Save the font attributes font = annotation.font attrs.update({"font-family": font.get("family", None), "font-size": font.get("size", None)}) attrs = [(key, value) for key, value in attrs.items() if value is not None] attrs = dict((key, str(value)) for key, value in attrs) data = annotation.content elif isinstance(annotation, SchemeArrowAnnotation): tag = "arrow" attrs.update({"start": repr(annotation.start_pos), "end": repr(annotation.end_pos), "fill": annotation.color}) data = None else: log.warning("Can't save %r", annotation) continue builder.start(tag, attrs) if data is not None: builder.data(data) builder.end(tag) builder.end("annotations") builder.start("thumbnail", {}) builder.end("thumbnail") # Node properties/settings builder.start("node_properties", {}) for node in scheme.nodes: data = None if node.properties: try: data, format = dumps(node.properties, format=data_format, pickle_fallback=pickle_fallback) except Exception: log.error("Error serializing properties for node %r", node.title, exc_info=True) if data is not None: builder.start("properties", {"node_id": str(node_ids[node]), "format": format}) builder.data(data) builder.end("properties") builder.end("node_properties") builder.start("session_state", {}) builder.start("window_groups", {}) for g in scheme.window_group_presets(): builder.start( "group", {"name": g.name, "default": str(g.default).lower()} ) for node, data in g.state: if node not in node_ids: continue builder.start("window_state", {"node_id": str(node_ids[node])}) builder.data(base64.encodebytes(data).decode("ascii")) builder.end("window_state") builder.end("group") builder.end("window_group") builder.end("session_state") builder.end("scheme") root = builder.close() tree = ElementTree(root) return tree def scheme_to_ows_stream(scheme, stream, pretty=False, pickle_fallback=False): """ Write scheme to a a stream in Orange Scheme .ows (v 2.0) format. Parameters ---------- scheme : :class:`.Scheme` A :class:`.Scheme` instance to serialize. stream : file-like object A file-like object opened for writing. pretty : bool, optional If `True` the output xml will be pretty printed (indented). pickle_fallback : bool, optional If `True` allow scheme node properties to be saves using pickle protocol if properties cannot be saved using the default notation. """ tree = scheme_to_etree(scheme, data_format="literal", pickle_fallback=pickle_fallback) if pretty: indent(tree.getroot(), 0) tree.write(stream, encoding="utf-8", xml_declaration=True) def indent(element, level=0, indent="\t"): """ Indent an instance of a :class:`Element`. Based on (http://effbot.org/zone/element-lib.htm#prettyprint). """ def empty(text): return not text or not text.strip() def indent_(element, level, last): child_count = len(element) if child_count: if empty(element.text): element.text = "\n" + indent * (level + 1) if empty(element.tail): element.tail = "\n" + indent * (level + (-1 if last else 0)) for i, child in enumerate(element): indent_(child, level + 1, i == child_count - 1) else: if empty(element.tail): element.tail = "\n" + indent * (level + (-1 if last else 0)) return indent_(element, level, True) def dumps(obj, format="literal", prettyprint=False, pickle_fallback=False): """ Serialize `obj` using `format` ('json' or 'literal') and return its string representation and the used serialization format ('literal', 'json' or 'pickle'). If `pickle_fallback` is True and the serialization with `format` fails object's pickle representation will be returned """ if format == "literal": try: return (literal_dumps(obj, indent=1 if prettyprint else None), "literal") except (ValueError, TypeError) as ex: if not pickle_fallback: raise log.warning("Could not serialize to a literal string", exc_info=True) elif format == "json": try: return (json.dumps(obj, indent=1 if prettyprint else None), "json") except (ValueError, TypeError): if not pickle_fallback: raise log.warning("Could not serialize to a json string", exc_info=True) elif format == "pickle": return base64.encodebytes(pickle.dumps(obj, protocol=PICKLE_PROTOCOL)). \ decode('ascii'), "pickle" else: raise ValueError("Unsupported format %r" % format) if pickle_fallback: log.warning("Using pickle fallback") return base64.encodebytes(pickle.dumps(obj, protocol=PICKLE_PROTOCOL)). \ decode('ascii'), "pickle" else: raise Exception("Something strange happened.") def loads(string, format): if format == "literal": return literal_eval(string) elif format == "json": return json.loads(string) elif format == "pickle": return pickle.loads(base64.decodebytes(string.encode('ascii'))) else: raise ValueError("Unknown format") # This is a subset of PyON serialization. def literal_dumps(obj, indent=None, relaxed_types=True): """ Write obj into a string as a python literal. Note ---- :class:`set` objects are not supported as the empty set is not representable as a literal. Parameters ---------- obj : Any indent : Optional[int] If not None then it is the indent for the pretty printer. relaxed_types : bool Relaxed type checking. In addition to exact builtin numeric types, the numbers.Integer, numbers.Real are checked and allowed if their repr matches that of the builtin. .. warning:: The exact type of the values will be lost. Returns ------- repr : str String representation of `obj` See Also -------- ast.literal_eval Raises ------ TypeError If obj contains non builtin types that cannot be represented as a literal value. ValueError If obj is a recursive structure. """ memo = {} # non compounds builtins = {int, float, bool, type(None), str, bytes} # sequences builtins_seq = {list, tuple} # mappings builtins_mapping = {dict} def check(obj): if type(obj) == float and not math.isfinite(obj): raise TypeError("Non-finite values can not be " "serialized as a python literal") if type(obj) in builtins: return True if id(obj) in memo: raise ValueError("{0} is a recursive structure".format(obj)) memo[id(obj)] = obj if type(obj) in builtins_seq: return all(map(check, obj)) elif type(obj) in builtins_mapping: return all(map(check, chain(obj.keys(), obj.values()))) else: raise TypeError("{0} can not be serialized as a python " "literal".format(type(obj))) def check_relaxed(obj): if isinstance(obj, numbers.Real) and not math.isfinite(obj): raise TypeError("Non-finite values can not be " "serialized as a python literal") if type(obj) in builtins: return True if id(obj) in memo: raise ValueError("{0} is a recursive structure".format(obj)) memo[id(obj)] = obj if type(obj) in builtins_seq: return all(map(check_relaxed, obj)) elif type(obj) in builtins_mapping: return all(map(check_relaxed, chain(obj.keys(), obj.values()))) # numpy.int, uint, ... elif isinstance(obj, numbers.Integral): if repr(obj) == repr(int(obj)): return True # numpy.float, ... elif isinstance(obj, numbers.Real): if repr(obj) == repr(float(obj)): return True raise TypeError("{0} can not be serialized as a python " "literal".format(type(obj))) if relaxed_types: check_relaxed(obj) else: check(obj) if indent is not None: return pprint.pformat(obj, width=80 * 2, indent=indent, compact=True) else: return repr(obj) literal_loads = literal_eval from .scheme import Scheme # pylint: disable=all ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/scheme.py0000644000175100002000000006615714730024325022474 0ustar00runnerdocker""" =============== Scheme Workflow =============== The :class:`Scheme` class defines a DAG (Directed Acyclic Graph) workflow. """ import types import logging from contextlib import ExitStack from operator import itemgetter from collections import deque import typing from typing import List, Tuple, Optional, Set, Dict, Any, Mapping from AnyQt.QtCore import QObject, QCoreApplication from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property from .node import SchemeNode from .link import SchemeLink, compatible_channels, _classify_connection from .annotations import BaseSchemeAnnotation from ..utils import check_arg, findf from .errors import ( SchemeCycleError, IncompatibleChannelTypeError, SinkChannelError, DuplicatedLinkError ) from .events import NodeEvent, LinkEvent, AnnotationEvent, WorkflowEnvChanged from ..registry import WidgetDescription, InputSignal, OutputSignal if typing.TYPE_CHECKING: T = typing.TypeVar("T") log = logging.getLogger(__name__) Node = SchemeNode Link = SchemeLink Annotation = BaseSchemeAnnotation class Scheme(QObject): """ An :class:`QObject` subclass representing the scheme widget workflow with annotations. Parameters ---------- parent : :class:`QObject` A parent QObject item (default `None`). title : str The scheme title. description : str A longer description of the scheme. env: Mapping[str, Any] Extra workflow environment definition (application defined). """ # Flags indicating if loops are allowed in the workflow. NoLoops, AllowLoops, AllowSelfLoops = 0, 1, 2 # Signal emitted when a `node` is added to the scheme. node_added = Signal(SchemeNode) # Signal emitted when a `node` is inserted to the scheme. node_inserted = Signal(int, Node) # Signal emitted when a `node` is removed from the scheme. node_removed = Signal(SchemeNode) # Signal emitted when a `link` is added to the scheme. link_added = Signal(SchemeLink) # Signal emitted when a `link` is added to the scheme. link_inserted = Signal(int, Link) # Signal emitted when a `link` is removed from the scheme. link_removed = Signal(SchemeLink) # Signal emitted when a `annotation` is added to the scheme. annotation_added = Signal(BaseSchemeAnnotation) # Signal emitted when a `annotation` is added to the scheme. annotation_inserted = Signal(int, BaseSchemeAnnotation) # Signal emitted when a `annotation` is removed from the scheme. annotation_removed = Signal(BaseSchemeAnnotation) # Signal emitted when the title of scheme changes. title_changed = Signal(str) # Signal emitted when the description of scheme changes. description_changed = Signal(str) # Signal emitted when the associated runtime environment changes runtime_env_changed = Signal(str, object, object) # Signal emitted by subclass upon a detected settings change node_properties_changed = Signal() def __init__(self, parent=None, title="", description="", env={}, **kwargs): # type: (Optional[QObject], str, str, Mapping[str, Any], Any) -> None super().__init__(parent, **kwargs) #: Workflow title (empty string by default). self.__title = title or "" #: Workflow description (empty string by default). self.__description = description or "" self.__annotations = [] # type: List[BaseSchemeAnnotation] self.__nodes = [] # type: List[SchemeNode] self.__links = [] # type: List[SchemeLink] self.__loop_flags = Scheme.NoLoops self.__env = dict(env) # type: Dict[str, Any] @property def nodes(self): # type: () -> List[SchemeNode] """ A list of all nodes (:class:`.SchemeNode`) currently in the scheme. """ return list(self.__nodes) @property def links(self): # type: () -> List[SchemeLink] """ A list of all links (:class:`.SchemeLink`) currently in the scheme. """ return list(self.__links) @property def annotations(self): # type: () -> List[BaseSchemeAnnotation] """ A list of all annotations (:class:`.BaseSchemeAnnotation`) in the scheme. """ return list(self.__annotations) def set_loop_flags(self, flags): self.__loop_flags = flags def loop_flags(self): return self.__loop_flags def set_title(self, title): # type: (str) -> None """ Set the scheme title text. """ if self.__title != title: self.__title = title self.title_changed.emit(title) def _title(self): """ The title (human readable string) of the scheme. """ return self.__title title: str title = Property(str, _title, set_title) # type: ignore def set_description(self, description): # type: (str) -> None """ Set the scheme description text. """ if self.__description != description: self.__description = description self.description_changed.emit(description) def _description(self): """ Scheme description text. """ return self.__description description: str description = Property(str, _description, set_description) # type: ignore def add_node(self, node): # type: (SchemeNode) -> None """ Add a node to the scheme. An error is raised if the node is already in the scheme. Parameters ---------- node : :class:`.SchemeNode` Node instance to add to the scheme. """ self.insert_node(len(self.__nodes), node) def insert_node(self, index: int, node: Node): """ Insert `node` into self.nodes at the specified position `index` """ assert isinstance(node, SchemeNode) check_arg(node not in self.__nodes, "Node already in scheme.") self.__nodes.insert(index, node) ev = NodeEvent(NodeEvent.NodeAdded, node, index) QCoreApplication.sendEvent(self, ev) log.info("Added node %r to scheme %r." % (node.title, self.title)) self.node_added.emit(node) self.node_inserted.emit(index, node) def new_node(self, description, title=None, position=None, properties=None): # type: (WidgetDescription, str, Tuple[float, float], dict) -> SchemeNode """ Create a new :class:`.SchemeNode` and add it to the scheme. Same as:: scheme.add_node(SchemeNode(description, title, position, properties)) Parameters ---------- description : :class:`WidgetDescription` The new node's description. title : str, optional Optional new nodes title. By default `description.name` is used. position : Tuple[float, float] Optional position in a 2D space. properties : dict, optional A dictionary of optional extra properties. See also -------- .SchemeNode, Scheme.add_node """ if isinstance(description, WidgetDescription): node = SchemeNode(description, title=title, position=position, properties=properties) else: raise TypeError("Expected %r, got %r." % \ (WidgetDescription, type(description))) self.add_node(node) return node def remove_node(self, node): # type: (SchemeNode) -> SchemeNode """ Remove a `node` from the scheme. All links into and out of the `node` are also removed. If the node in not in the scheme an error is raised. Parameters ---------- node : :class:`.SchemeNode` Node instance to remove. """ check_arg(node in self.__nodes, "Node is not in the scheme.") self.__remove_node_links(node) index = self.__nodes.index(node) self.__nodes.pop(index) ev = NodeEvent(NodeEvent.NodeRemoved, node, index) QCoreApplication.sendEvent(self, ev) log.info("Removed node %r from scheme %r." % (node.title, self.title)) self.node_removed.emit(node) return node def __remove_node_links(self, node): # type: (SchemeNode) -> None """ Remove all links for node. """ links_in, links_out = [], [] for link in self.__links: if link.source_node is node: links_out.append(link) elif link.sink_node is node: links_in.append(link) for link in links_out + links_in: self.remove_link(link) def insert_link(self, index: int, link: Link): """ Insert `link` into `self.links` at the specified position `index`. """ assert isinstance(link, SchemeLink) self.check_connect(link) self.__links.insert(index, link) source_index, _ = findf( enumerate(self.find_links(source_node=link.source_node)), lambda t: t[1] == link, default=(-1, None) ) sink_index, _ = findf( enumerate(self.find_links(sink_node=link.sink_node)), lambda t: t[1] == link, default=(-1, None) ) assert sink_index != -1 and source_index != -1 QCoreApplication.sendEvent( link.source_node, LinkEvent(LinkEvent.OutputLinkAdded, link, source_index) ) QCoreApplication.sendEvent( link.sink_node, LinkEvent(LinkEvent.InputLinkAdded, link, sink_index) ) QCoreApplication.sendEvent( self, LinkEvent(LinkEvent.LinkAdded, link, index) ) log.info("Added link %r (%r) -> %r (%r) to scheme %r." % \ (link.source_node.title, link.source_channel.name, link.sink_node.title, link.sink_channel.name, self.title) ) self.link_inserted.emit(index, link) self.link_added.emit(link) def add_link(self, link): # type: (SchemeLink) -> None """ Add a `link` to the scheme. Parameters ---------- link : :class:`.SchemeLink` An initialized link instance to add to the scheme. """ self.insert_link(len(self.__links), link) def new_link(self, source_node, source_channel, sink_node, sink_channel): # type: (SchemeNode, OutputSignal, SchemeNode, InputSignal) -> SchemeLink """ Create a new :class:`.SchemeLink` from arguments and add it to the scheme. The new link is returned. Parameters ---------- source_node : :class:`.SchemeNode` Source node of the new link. source_channel : :class:`.OutputSignal` Source channel of the new node. The instance must be from ``source_node.output_channels()`` sink_node : :class:`.SchemeNode` Sink node of the new link. sink_channel : :class:`.InputSignal` Sink channel of the new node. The instance must be from ``sink_node.input_channels()`` See also -------- .SchemeLink, Scheme.add_link """ link = SchemeLink(source_node, source_channel, sink_node, sink_channel) self.add_link(link) return link def remove_link(self, link): # type: (SchemeLink) -> None """ Remove a link from the scheme. Parameters ---------- link : :class:`.SchemeLink` Link instance to remove. """ check_arg(link in self.__links, "Link is not in the scheme.") source_index, _ = findf( enumerate(self.find_links(source_node=link.source_node)), lambda t: t[1] == link, default=(-1, None) ) sink_index, _ = findf( enumerate(self.find_links(sink_node=link.sink_node)), lambda t: t[1] == link, default=(-1, None) ) assert sink_index != -1 and source_index != -1 index = self.__links.index(link) self.__links.pop(index) QCoreApplication.sendEvent( link.sink_node, LinkEvent(LinkEvent.InputLinkRemoved, link, sink_index) ) QCoreApplication.sendEvent( link.source_node, LinkEvent(LinkEvent.OutputLinkRemoved, link, source_index) ) QCoreApplication.sendEvent( self, LinkEvent(LinkEvent.LinkRemoved, link, index) ) log.info("Removed link %r (%r) -> %r (%r) from scheme %r." % \ (link.source_node.title, link.source_channel.name, link.sink_node.title, link.sink_channel.name, self.title) ) self.link_removed.emit(link) def check_connect(self, link): # type: (SchemeLink) -> None """ Check if the `link` can be added to the scheme and raise an appropriate exception. Can raise: - :class:`.SchemeCycleError` if the `link` would introduce a loop in the graph which does not allow loops. - :class:`.IncompatibleChannelTypeError` if the channel types are not compatible - :class:`.SinkChannelError` if a sink channel has a `Single` flag specification and the channel is already connected. - :class:`.DuplicatedLinkError` if a `link` duplicates an already present link. """ if not self.loop_flags() & Scheme.AllowSelfLoops and \ link.source_node is link.sink_node: raise SchemeCycleError("Cannot create self cycle in the scheme") elif not self.loop_flags() & Scheme.AllowLoops and \ self.creates_cycle(link): raise SchemeCycleError("Cannot create cycles in the scheme") if not self.compatible_channels(link): raise IncompatibleChannelTypeError( "Cannot connect %r to %r." \ % (link.source_channel.type, link.sink_channel.type) ) links = self.find_links(source_node=link.source_node, source_channel=link.source_channel, sink_node=link.sink_node, sink_channel=link.sink_channel) if links: raise DuplicatedLinkError( "A link from %r (%r) -> %r (%r) already exists" \ % (link.source_node.title, link.source_channel.name, link.sink_node.title, link.sink_channel.name) ) if link.sink_channel.single: links = self.find_links(sink_node=link.sink_node, sink_channel=link.sink_channel) if links: raise SinkChannelError( "%r is already connected." % link.sink_channel.name ) def creates_cycle(self, link): # type: (SchemeLink) -> bool """ Return `True` if `link` would introduce a cycle in the scheme. Parameters ---------- link : :class:`.SchemeLink` """ assert isinstance(link, SchemeLink) source_node, sink_node = link.source_node, link.sink_node upstream = self.upstream_nodes(source_node) upstream.add(source_node) return sink_node in upstream def compatible_channels(self, link): # type: (SchemeLink) -> bool """ Return `True` if the channels in `link` have compatible types. Parameters ---------- link : :class:`.SchemeLink` """ assert isinstance(link, SchemeLink) return compatible_channels(link.source_channel, link.sink_channel) def can_connect(self, link): # type: (SchemeLink) -> bool """ Return `True` if `link` can be added to the scheme. See also -------- Scheme.check_connect """ assert isinstance(link, SchemeLink) try: self.check_connect(link) return True except (SchemeCycleError, IncompatibleChannelTypeError, SinkChannelError, DuplicatedLinkError): return False def upstream_nodes(self, start_node): # type: (SchemeNode) -> Set[SchemeNode] """ Return a set of all nodes upstream from `start_node` (i.e. all ancestor nodes). Parameters ---------- start_node : :class:`.SchemeNode` """ visited = set() # type: Set[SchemeNode] queue = deque([start_node]) while queue: node = queue.popleft() snodes = [link.source_node for link in self.input_links(node)] for source_node in snodes: if source_node not in visited: queue.append(source_node) visited.add(node) visited.remove(start_node) return visited def downstream_nodes(self, start_node): # type: (SchemeNode) -> Set[SchemeNode] """ Return a set of all nodes downstream from `start_node`. Parameters ---------- start_node : :class:`.SchemeNode` """ visited = set() # type: Set[SchemeNode] queue = deque([start_node]) while queue: node = queue.popleft() snodes = [link.sink_node for link in self.output_links(node)] for source_node in snodes: if source_node not in visited: queue.append(source_node) visited.add(node) visited.remove(start_node) return visited def is_ancestor(self, node, child): # type: (SchemeNode, SchemeNode) -> bool """ Return True if `node` is an ancestor node of `child` (is upstream of the child in the workflow). Both nodes must be in the scheme. Parameters ---------- node : :class:`.SchemeNode` child : :class:`.SchemeNode` """ return child in self.downstream_nodes(node) def children(self, node): # type: (SchemeNode) -> Set[SchemeNode] """ Return a set of all children of `node`. """ return set(link.sink_node for link in self.output_links(node)) def parents(self, node): # type: (SchemeNode) -> Set[SchemeNode] """ Return a set of all parents of `node`. """ return set(link.source_node for link in self.input_links(node)) def input_links(self, node): # type: (SchemeNode) -> List[SchemeLink] """ Return a list of all input links (:class:`.SchemeLink`) connected to the `node` instance. """ return self.find_links(sink_node=node) def output_links(self, node): # type: (SchemeNode) -> List[SchemeLink] """ Return a list of all output links (:class:`.SchemeLink`) connected to the `node` instance. """ return self.find_links(source_node=node) def find_links(self, source_node=None, source_channel=None, sink_node=None, sink_channel=None): # type: (Optional[SchemeNode], Optional[OutputSignal], Optional[SchemeNode], Optional[InputSignal]) -> List[SchemeLink] # TODO: Speedup - keep index of links by nodes and channels result = [] def match(query, value): # type: (Optional[T], T) -> bool return query is None or value == query for link in self.__links: if match(source_node, link.source_node) and \ match(sink_node, link.sink_node) and \ match(source_channel, link.source_channel) and \ match(sink_channel, link.sink_channel): result.append(link) return result def propose_links( self, source_node: SchemeNode, sink_node: SchemeNode, source_signal: Optional[OutputSignal] = None, sink_signal: Optional[InputSignal] = None ) -> List[Tuple[OutputSignal, InputSignal, int]]: """ Return a list of ordered (:class:`OutputSignal`, :class:`InputSignal`, weight) tuples that could be added to the scheme between `source_node` and `sink_node`. .. note:: This can depend on the links already in the scheme. """ if source_node is sink_node and \ not self.loop_flags() & Scheme.AllowSelfLoops: # Self loops are not enabled return [] elif not self.loop_flags() & Scheme.AllowLoops and \ self.is_ancestor(sink_node, source_node): # Loops are not enabled. return [] outputs = [source_signal] if source_signal \ else source_node.output_channels() inputs = [sink_signal] if sink_signal \ else sink_node.input_channels() # Get existing links to sink channels that are Single. links = self.find_links(None, None, sink_node) already_connected_sinks = [link.sink_channel for link in links \ if link.sink_channel.single] def weight(out_c, in_c): # type: (OutputSignal, InputSignal) -> int if out_c.explicit or in_c.explicit: weight = -1 # Negative weight for explicit links else: check = [in_c not in already_connected_sinks, bool(in_c.default), bool(out_c.default)] weights = [2 ** i for i in range(len(check), 0, -1)] weight = sum([w for w, c in zip(weights, check) if c]) return weight proposed_links = [] for out_c in outputs: for in_c in inputs: if compatible_channels(out_c, in_c): proposed_links.append((out_c, in_c, weight(out_c, in_c))) return sorted(proposed_links, key=itemgetter(-1), reverse=True) def insert_annotation(self, index: int, annotation: Annotation) -> None: """ Insert `annotation` into `self.annotations` at the specified position `index`. """ assert isinstance(annotation, BaseSchemeAnnotation) if annotation in self.__annotations: raise ValueError("Cannot add the same annotation multiple times") self.__annotations.insert(index, annotation) ev = AnnotationEvent(AnnotationEvent.AnnotationAdded, annotation, index) QCoreApplication.sendEvent(self, ev) self.annotation_inserted.emit(index, annotation) self.annotation_added.emit(annotation) def add_annotation(self, annotation): # type: (BaseSchemeAnnotation) -> None """ Add an annotation (:class:`BaseSchemeAnnotation` subclass) instance to the scheme. """ self.insert_annotation(len(self.__annotations), annotation) def remove_annotation(self, annotation): # type: (BaseSchemeAnnotation) -> None """ Remove the `annotation` instance from the scheme. """ index = self.__annotations.index(annotation) self.__annotations.pop(index) ev = AnnotationEvent(AnnotationEvent.AnnotationRemoved, annotation, index) QCoreApplication.sendEvent(self, ev) self.annotation_removed.emit(annotation) def clear(self): # type: () -> None """ Remove all nodes, links, and annotation items from the scheme. """ def is_terminal(node): # type: (SchemeNode) -> bool return not bool(self.find_links(source_node=node)) while self.nodes: terminal_nodes = filter(is_terminal, self.nodes) for node in terminal_nodes: self.remove_node(node) for annotation in self.annotations: self.remove_annotation(annotation) assert not (self.nodes or self.links or self.annotations) def sync_node_properties(self): # type: () -> None """ Called before saving, allowing a subclass to update/sync. The default implementation does nothing. """ pass def save_to(self, stream, pretty=True, **kwargs): """ Save the scheme as an xml formatted file to `stream` See also -------- readwrite.scheme_to_ows_stream """ with ExitStack() as exitstack: if isinstance(stream, str): stream = exitstack.enter_context(open(stream, "wb")) self.sync_node_properties() readwrite.scheme_to_ows_stream(self, stream, pretty, **kwargs) def load_from(self, stream, *args, **kwargs): """ Load the scheme from xml formatted `stream`. Any extra arguments are passed to `readwrite.scheme_load` See Also -------- readwrite.scheme_load """ if self.__nodes or self.__links or self.__annotations: raise ValueError("Scheme is not empty.") with ExitStack() as exitstack: if isinstance(stream, str): stream = exitstack.enter_context(open(stream, "rb")) readwrite.scheme_load(self, stream, *args, **kwargs) def set_runtime_env(self, key, value): # type: (str, Any) -> None """ Set a runtime environment variable `key` to `value` """ oldvalue = self.__env.get(key, None) if value != oldvalue: self.__env[key] = value QCoreApplication.sendEvent( self, WorkflowEnvChanged(key, value, oldvalue) ) self.runtime_env_changed.emit(key, value, oldvalue) def get_runtime_env(self, key, default=None): # type: (str, Any) -> Any """ Return a runtime environment variable for `key`. """ return self.__env.get(key, default) def runtime_env(self): # type: () -> Mapping[str, Any] """ Return (a view to) the full runtime environment. The return value is a types.MappingProxyType of the underlying environment dictionary. Changes to the env. will be reflected in it. """ return types.MappingProxyType(self.__env) class WindowGroup(types.SimpleNamespace): name = None # type: str default = None # type: bool state = None # type: List[Tuple[SchemeNode, bytes]] def __init__(self, name="", default=False, state=[]): super().__init__(name=name, default=default, state=state) window_group_presets_changed = Signal() def window_group_presets(self): # type: () -> List[WindowGroup] """ Return a collection of preset window groups and their encoded states. The base implementation returns an empty list. """ return self.property("_presets") or [] def set_window_group_presets(self, groups): # type: (List[WindowGroup]) -> None self.setProperty("_presets", groups) self.window_group_presets_changed.emit() from . import readwrite ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/signalmanager.py0000644000175100002000000013256014730024325024030 0ustar00runnerdocker""" ================================= SignalManager (``signalmanager``) ================================= A SignalManager instance handles the runtime signal propagation between widgets in a scheme workflow. """ import os import logging import warnings import enum import functools from collections import defaultdict from operator import attrgetter from functools import partial, reduce from itertools import chain import typing from typing import ( Any, Optional, List, NamedTuple, Set, Dict, Callable, Sequence, Union, DefaultDict, Type ) from AnyQt.QtCore import QObject, QTimer, QSettings, QEvent from AnyQt.QtCore import pyqtSignal, pyqtSlot as Slot from . import LinkEvent from ..utils import unique, mapping_get, group_by_all from ..registry import OutputSignal, InputSignal from .scheme import Scheme, SchemeNode, SchemeLink from ..utils.graph import traverse_bf, strongly_connected_components if typing.TYPE_CHECKING: V = typing.TypeVar("V") K = typing.TypeVar("K") log = logging.getLogger(__name__) class Signal( NamedTuple( "Signal", ( ("link", SchemeLink), ("value", Any), ("id", Any), ("index", int), )) ): """ A signal sent via a link between two nodes. Attributes ---------- link : SchemeLink The link on which the signal is sent value : Any The signal value id : Any .. deprecated:: 0.1.19 index: int Position of the link in sink_node's input links at the time the signal is enqueued or -1 if not applicable. See also -------- InputSignal.flags, OutputSignal.flags """ def __new__(cls, link: SchemeLink, value: Any, id: Any = None, index: int = -1): return super().__new__(cls, link, value, id, index) @property def channel(self) -> InputSignal: """Alias for `self.link.sink_channel`""" return self.link.sink_channel New: 'Type[New]' Update: 'Type[Update]' Close: 'Type[Close]' class New(Signal): ... class Update(Signal): ... class Close(Signal): ... Signal.New = New Signal.Update = Update Signal.Close = Close is_enabled = attrgetter("enabled") class _LazyValueType: """ LazyValue is an abstract type for wrapper for lazy evaluation of signals. LazyValue is intended for situations in which computation of outputs is reasonably fast, but we won't to compute it only if the output is connected to some input, in order to save memory. Assume the widget has a method `commit` that outputs the sum of two objects, `self.a` and `self.b`. The output signal is names `signal_name` and its type is `SomeType`. ``` def commit(self): self.send(self.Outputs.signal_name, self.a + self.b) ``` To use lazy values, we modify the method as follows. ``` def commit(self): def f(): return self.a + self.b self.send(self.Outputs.signal_name, LazySignal[SomeType](f)) ``` The lazy function receives no arguments, so `commit` will often prepare some data accessible through closure or default arguments. After calling the function, LazyValue will release the reference to function, which in turn releases references to any data from closure or arguments. LazyValue is a singleton, used in similar way as generic classes from typing. "Indexing" returns an instance of (internal) class `LazyValue_`. Indexing is cached; `LazyValue[SomeType]` always returns the same object. LazySignal[SomeType] (that is: LazyValue_) has a constructor that expects the following arguments. - A function that computes the actual value. This function must expect no arguments, but will usually get data (for instance `self`, in the above example) from closure. - An optional function that can be called to interrupt the computation. This function is called when the signal is deleted. - Optional extra arguments that are stored as LazyValue's attributes. These are not accessible by the above function and are primarily intended to be used in output summaries. Properties: - `is_cached()`, which returns `True` if the value is already computed. Functions for output summaries can use this to show more information if the value is available, and avoid computing it when not. Methods: - `get_value()` returns the actual value by calling the function, if the value has not been computed yet, or providing the cached value. - `type()` returns the type of the lazy signal (e.g. `SomeType`, in above case. """ class LazyValueMeta(type): def __repr__(cls): """ Pretty-prints the LazyValue[SomeType] as "LazyValue[SomeType]" instead of generic `LazyValue_`. """ return f"LazyValue[{cls.type().__name__}]" @classmethod def is_lazy(cls, value): """ Tells whether the given value is lazy. ``` >>> def f(): ... return 12 ... >>> lazy = LazyValue[int](f) >>> eager = f() >>> LazyValue.is_lazy(lazy) True >>> LazyValue.is_lazy(eager) False ``` """ return isinstance(type(value), cls.LazyValueMeta) @classmethod @functools.lru_cache(maxsize=None) def __getitem__(cls, type_): # This is cached, so that it always returns the same class for the # same type. # >>> t1 = LazyValue[int] # >>> t2 = LazyValue[int] # >>> t1 is t2 # True class LazyValue_(metaclass=cls.LazyValueMeta): __type = type_ def __init__(self, func: Callable, interrupt=None, **extra_attrs): self.__func = func self.__cached = None self.interrupt = interrupt self.__dict__.update(extra_attrs) def __del__(self): if self.interrupt is not None: self.interrupt() @property def is_cached(self): return self.__func is None @classmethod def type(cls): return cls.__type def get_value(self): if self.__func is not None: self.__cached = self.__func() # This frees any references to closure and arguments self.__func = None return self.__cached return LazyValue_ LazyValue = _LazyValueType() class _OutputState: """Output state for a single node/channel""" __slots__ = ('flags', 'outputs') #: Flag indicating the output on the channel is invalidated. Invalidated = 1 def __init__(self): self.outputs = defaultdict() self.flags = 0 def __repr__(self): return "State(flags={}, outputs={!r})".format( self.flags, dict(self.outputs) ) __str__ = __repr__ class _LinkExtra: """Extra data tracked for a SchemeLink""" __slots__ = ("flags",) DidScheduleNew = 1 def __init__(self, flags=0): self.flags = flags class SignalManager(QObject): """ SignalManager handles the runtime signal propagation for a :class:`.Scheme` instance. Note ---- If a scheme instance is passed as a parent to the constructor it is also set as the workflow model. """ class State(enum.IntEnum): """ SignalManager state flags. .. seealso:: :func:`SignalManager.state()` """ #: The manager is running, i.e. it propagates signals Running = 0 #: The manager is stopped. It does not track node output changes, #: and does not deliver signals to dependent nodes Stopped = 1 #: The manager is paused. It still tracks node output changes, but #: does not deliver new signals to dependent nodes. The pending signals #: will be delivered once it enters Running state again Paused = 2 #: The manager is running, i.e. it propagates signals Running = State.Running #: The manager is stopped. It does not track node ouput changes, #: and does not deliver signals to dependent nodes Stopped = State.Stopped #: The manager is paused. It still tracks node output changes, but #: does not deliver new signals to dependent nodes. The pending signals #: will be delivered once it enters Running state again Paused = State.Paused # unused; back-compatibility Error = 3 class RuntimeState(enum.IntEnum): """ SignalManager runtime state. See Also -------- SignalManager.runtime_state """ #: Waiting, idle state. The signal queue is empty Waiting = 0 #: ... Processing = 1 Waiting = RuntimeState.Waiting Processing = RuntimeState.Processing #: Emitted when the state of the signal manager changes. stateChanged = pyqtSignal(int) #: Emitted when signals are added to the queue. updatesPending = pyqtSignal() #: Emitted right before a `SchemeNode` instance has its inputs updated. processingStarted = pyqtSignal([], [SchemeNode]) #: Emitted right after a `SchemeNode` instance has had its inputs updated. processingFinished = pyqtSignal([], [SchemeNode]) #: Emitted when `SignalManager`'s runtime state changes. runtimeStateChanged = pyqtSignal(int) #: Emitted when the execution finishes (there are no more nodes that #: need to run). Note: the nodes can activate again due to user #: interaction or other scheduled events, i.e. finished is not a definitive #: state. Use at your own discretion. finished = pyqtSignal() #: Emitted when starting initial execution and when resuming after already #: emitting `finished`. started = pyqtSignal() def __init__(self, parent=None, *, max_running=None, **kwargs): # type: (Optional[QObject], Optional[int], Any) -> None super().__init__(parent, **kwargs) self.__workflow = None # type: Optional[Scheme] self.__input_queue = [] # type: List[Signal] # mapping a node to its current outputs self.__node_outputs = {} # type: Dict[SchemeNode, DefaultDict[OutputSignal, _OutputState]] #: Extra link state self.__link_extra = defaultdict(_LinkExtra) # type: DefaultDict[SchemeLink, _LinkExtra] self.__state = SignalManager.Running self.__runtime_state = SignalManager.Waiting self.__update_timer = QTimer(self, interval=100, singleShot=True) self.__update_timer.timeout.connect(self.__process_next) self.__max_running = max_running self.__has_finished = True if isinstance(parent, Scheme): self.set_workflow(parent) def _can_process(self): # type: () -> bool """ Return a bool indicating if the manger can enter the main processing loop. """ return self.__state not in [SignalManager.Error, SignalManager.Stopped] def workflow(self): # type: () -> Optional[Scheme] """ Return the :class:`Scheme` instance. """ return self.__workflow #: Alias scheme = workflow def set_workflow(self, workflow): # type: (Scheme) -> None """ Set the workflow model. Parameters ---------- workflow : Scheme """ if workflow is self.__workflow: return if self.__workflow is not None: for node in self.__workflow.nodes: node.state_changed.disconnect(self._update) node.removeEventFilter(self) for link in self.__workflow.links: self.__on_link_removed(link) self.__workflow.node_added.disconnect(self.__on_node_added) self.__workflow.node_removed.disconnect(self.__on_node_removed) self.__workflow.link_added.disconnect(self.__on_link_added) self.__workflow.link_removed.disconnect(self.__on_link_removed) self.__workflow.removeEventFilter(self) self.__node_outputs = {} self.__input_queue = [] self.__workflow = workflow if workflow is not None: workflow.node_added.connect(self.__on_node_added) workflow.node_removed.connect(self.__on_node_removed) workflow.link_added.connect(self.__on_link_added) workflow.link_removed.connect(self.__on_link_removed) for node in workflow.nodes: self.__node_outputs[node] = defaultdict(_OutputState) node.state_changed.connect(self._update) node.installEventFilter(self) for link in workflow.links: self.__on_link_added(link) workflow.installEventFilter(self) def has_pending(self): # type: () -> bool """ Does the manager have any signals to deliver? """ return bool(self.__input_queue) def start(self): # type: () -> None """ Start the update loop. Note ---- The updates will not happen until the control reaches the Qt event loop. """ if self.__state != SignalManager.Running: self.__state = SignalManager.Running self.stateChanged.emit(SignalManager.Running) self._update() def stop(self): # type: () -> None """ Stop the update loop. Note ---- If the `SignalManager` is currently in `process_queues` it will still update all current pending signals, but will not re-enter until `start()` is called again. """ if self.__state != SignalManager.Stopped: self.__state = SignalManager.Stopped self.stateChanged.emit(SignalManager.Stopped) self.__update_timer.stop() def pause(self): # type: () -> None """ Pause the delivery of signals. """ if self.__state != SignalManager.Paused: self.__state = SignalManager.Paused self.stateChanged.emit(SignalManager.Paused) self.__update_timer.stop() def resume(self): # type: () -> None """ Resume the delivery of signals. """ if self.__state == SignalManager.Paused: self.__state = SignalManager.Running self.stateChanged.emit(self.__state) self._update() def step(self): # type: () -> None """ Deliver signals to a single node (only applicable while the `state()` is `Paused`). """ if self.__state == SignalManager.Paused: self.process_queued() def state(self): # type: () -> State """ Return the current state. Return ------ state : SignalManager.State """ return self.__state def _set_runtime_state(self, state): # type: (Union[RuntimeState, int]) -> None """ Set the runtime state. Should only be called by `SignalManager` implementations. """ state = SignalManager.RuntimeState(state) if self.__runtime_state != state: self.__runtime_state = state self.runtimeStateChanged.emit(self.__runtime_state) def runtime_state(self): # type: () -> RuntimeState """ Return the runtime state. This can be `SignalManager.Waiting` or `SignalManager.Processing`. """ return self.__runtime_state def __on_node_removed(self, node): # type: (SchemeNode) -> None # remove all pending input signals for node so we don't get # stale references in process_node. # NOTE: This does not remove output signals for this node. In # particular the final 'None' will be delivered to the sink # nodes even after the source node is no longer in the scheme. log.info("Removing pending signals for '%s'.", node.title) self.remove_pending_signals(node) del self.__node_outputs[node] node.state_changed.disconnect(self._update) node.removeEventFilter(self) def __on_node_added(self, node): # type: (SchemeNode) -> None self.__node_outputs[node] = defaultdict(_OutputState) # schedule update pass on state change node.state_changed.connect(self._update) node.installEventFilter(self) def __on_link_added(self, link): # type: (SchemeLink) -> None # push all current source values to the sink link.set_runtime_state(SchemeLink.Empty) state = self.__node_outputs[link.source_node][link.source_channel] link.set_runtime_state_flag( SchemeLink.Invalidated, bool(state.flags & _OutputState.Invalidated) ) signals: List[Signal] = [Signal.New(*s) for s in self.signals_on_link(link)] if not link.is_enabled(): # Send New signals even if disabled. This is changed behaviour # from <0.1.19 where signals were only sent when link was enabled. # Because we need to maintain input consistency we cannot use the # current signal value so replace it with None. signals = [s._replace(value=None) for s in signals] log.info("Scheduling signal data update for '%s'.", link) self._schedule(signals) link.enabled_changed.connect(self.__on_link_enabled_changed) def __on_link_removed(self, link): # type: (SchemeLink) -> None link.enabled_changed.disconnect(self.__on_link_enabled_changed) self.__link_extra.pop(link, None) def eventFilter(self, recv: QObject, event: QEvent) -> bool: etype = event.type() if etype == LinkEvent.InputLinkRemoved: event = typing.cast(LinkEvent, event) link = event.link() log.info("Scheduling close signal (%s).", link) signals: List[Signal] = [Signal.Close(link, None, id, event.pos()) for id in self.link_contents(link)] self._schedule(signals) return super().eventFilter(recv, event) def __on_link_enabled_changed(self, enabled): if enabled: link = self.sender() log.info("Link %s enabled. Scheduling signal data update.", link) self._update_link(link) def signals_on_link(self, link): # type: (SchemeLink) -> List[Signal] """ Return :class:`Signal` instances representing the current values present on the `link`. """ if self.__workflow is None: return [] items = self.link_contents(link) links_in = self.__workflow.find_links(sink_node=link.sink_node) index = links_in.index(link) return [Signal(link, value, key, index=index) for key, value in items.items()] def link_contents(self, link): # type: (SchemeLink) -> Dict[Any, Any] """ Return the contents on the `link`. """ node, channel = link.source_node, link.source_channel if node in self.__node_outputs: return self.__node_outputs[node][channel].outputs else: # if the the node was already removed its tracked outputs in # __node_outputs are cleared, however the final 'None' signal # deliveries for the link are left in the _input_queue. pending = [sig for sig in self.__input_queue if sig.link is link] return {sig.id: sig.value for sig in pending} def send(self, node, channel, value, *args, **kwargs): # type: (SchemeNode, OutputSignal, Any, Any, Any) -> None """ Send the `value` on the output `channel` from `node`. Schedule the signal delivery to all dependent nodes Parameters ---------- node : SchemeNode The originating node. channel : OutputSignal The nodes output on which the value is sent. value : Any The value to send, id : Any Signal id. .. deprecated:: 0.1.19 """ if self.__workflow is None: raise RuntimeError("'send' called with no workflow!.") # parse deprecated id parameter from *args, **kwargs. def _id_(id): return id try: id = _id_(*args, **kwargs) except TypeError: id = None else: warnings.warn( "`id` parameter is deprecated and will be removed in v0.2", FutureWarning, stacklevel=2 ) log.debug("%r sending %r (id: %r) on channel %r", node.title, type(value), id, channel.name) scheme = self.__workflow state = self.__node_outputs[node][channel] if state.outputs and id not in state.outputs: raise RuntimeError( "Sending multiple values on the same output channel via " "different ids is no longer supported." ) sigtype: Type[Signal] if id in state.outputs: sigtype = Signal.Update else: sigtype = Signal.New state.outputs[id] = value assert len(state.outputs) == 1 # clear invalidated flag if state.flags & _OutputState.Invalidated: log.debug("%r clear invalidated flag on channel %r", node.title, channel.name) state.flags &= ~_OutputState.Invalidated links = scheme.find_links(source_node=node, source_channel=channel) signals = [] for link in links: extra = self.__link_extra[link] links_in = scheme.find_links(sink_node=link.sink_node) index = links_in.index(link) if not link.is_enabled() and not extra.flags & _LinkExtra.DidScheduleNew: # Send Signal.New with None value. Proper update will be done # when/if the link is re-enabled. signal = Signal.New(link, None, id, index=index) elif link.is_enabled(): signal = sigtype(link, value, id, index=index) else: continue signals.append(signal) link.set_runtime_state_flag(SchemeLink.Invalidated, False) self._schedule(signals) def invalidate(self, node, channel): # type: (SchemeNode, OutputSignal) -> None """ Invalidate the `channel` on `node`. The channel is effectively considered changed but unavailable until a new value is sent via `send`. While this state is set the dependent nodes will not be updated. All links originating with this node/channel will be marked with `SchemeLink.Invalidated` flag until a new value is sent with `send`. Parameters ---------- node: SchemeNode The originating node. channel: OutputSignal The channel to invalidate. .. versionadded:: 0.1.8 """ log.debug("%r invalidating channel %r", node.title, channel.name) self.__node_outputs[node][channel].flags |= _OutputState.Invalidated if self.__workflow is None: return links = self.__workflow.find_links( source_node=node, source_channel=channel ) for link in links: link.set_runtime_state(link.runtime_state() | link.Invalidated) def purge_link(self, link): # type: (SchemeLink) -> None """ Purge the link (send None for all ids currently present) .. deprecated:: 0.1.19 """ warnings.warn( "`purge_link` is deprecated.", DeprecationWarning, stacklevel=2 ) self._schedule([Signal(link, None, id) for id in self.link_contents(link)]) def _schedule(self, signals): # type: (List[Signal]) -> None """ Schedule a list of :class:`Signal` for delivery. """ self.__input_queue.extend(signals) for sig in signals: if isinstance(sig, Signal.New): extra = self.__link_extra[sig.link] extra.flags |= _LinkExtra.DidScheduleNew for link in {sig.link for sig in signals}: # update the SchemeLink's runtime state flags contents = self.link_contents(link) if any(value is not None for value in contents.values()): state = SchemeLink.Active else: state = SchemeLink.Empty link.set_runtime_state(state | SchemeLink.Pending) for node in {sig.link.sink_node for sig in signals}: # type: SchemeNode # update the SchemeNodes's runtime state flags node.set_state_flags(SchemeNode.Pending, True) if signals: self.updatesPending.emit() self._update() def _update_link(self, link): # type: (SchemeLink) -> None """ Schedule update of a single link. """ self._schedule([Signal.Update(*s) for s in self.signals_on_link(link)]) def process_queued(self, max_nodes=None): # type: (Any) -> None """ Process queued signals. Take the first eligible node from the pending input queue and deliver all scheduled signals. """ if not (max_nodes is None or max_nodes == 1): warnings.warn( "`max_nodes` is deprecated and will be removed in the future", FutureWarning, stacklevel=2) if self.__runtime_state == SignalManager.Processing: raise RuntimeError("Cannot re-enter 'process_queued'") if not self._can_process(): raise RuntimeError("Can't process in state %i" % self.__state) self.process_next() def process_next(self): # type: () -> bool """ Process queued signals. Take the first eligible node from the pending input queue and deliver all scheduled signals for it and return `True`. If no node is eligible for update do nothing and return `False`. """ return self.__process_next_helper(use_max_active=False) def process_node(self, node): # type: (SchemeNode) -> None """ Process pending input signals for `node`. """ assert self.__runtime_state != SignalManager.Processing signals_in = self.pending_input_signals(node) self.remove_pending_signals(node) signals_in = self.compress_signals(signals_in) log.debug("Processing %r, sending %i signals.", node.title, len(signals_in)) # Clear the link's pending flag. for link in {sig.link for sig in signals_in}: link.set_runtime_state(link.runtime_state() & ~SchemeLink.Pending) def process_dynamic(signals): # type: (List[Signal]) -> List[Signal] """ Process dynamic signals; Update the link's dynamic_enabled flag if the value is valid; replace values that do not type check with `None` """ res = [] for sig in signals: # Check and update the dynamic link state link = sig.link if sig.link.is_dynamic(): enabled = can_enable_dynamic(link, sig.value) link.set_dynamic_enabled(enabled) if not enabled: # Send None instead (clear the link) sig = sig._replace(value=None) res.append(sig) return res signals_in = process_dynamic(signals_in) assert ({sig.link for sig in self.__input_queue} .intersection({sig.link for sig in signals_in}) == set([])) self._set_runtime_state(SignalManager.Processing) self.processingStarted.emit() self.processingStarted[SchemeNode].emit(node) try: self.send_to_node(node, signals_in) finally: node.set_state_flags(SchemeNode.Pending, False) self.processingFinished.emit() self.processingFinished[SchemeNode].emit(node) self._set_runtime_state(SignalManager.Waiting) def compress_signals(self, signals): # type: (List[Signal]) -> List[Signal] """ Compress a list of :class:`Signal` instances to be delivered. Before the signal values are delivered to the sink node they can be optionally `compressed`, i.e. values can be merged or dropped depending on the execution semantics. The input list is in the order that the signals were enqueued. The base implementation returns the list unmodified. Parameters ---------- signals : List[Signal] Return ------ signals : List[Signal] """ return signals def send_to_node(self, node, signals): # type: (SchemeNode, List[Signal]) -> None """ Abstract. Reimplement in subclass. Send/notify the `node` instance (or whatever object/instance it is a representation of) that it has new inputs as represented by the `signals` list). Parameters ---------- node : SchemeNode signals : List[Signal] """ raise NotImplementedError def is_pending(self, node): # type: (SchemeNode) -> bool """ Is `node` (class:`SchemeNode`) scheduled for processing (i.e. it has incoming pending signals). Parameters ---------- node : SchemeNode Returns ------- pending : bool """ return node in [signal.link.sink_node for signal in self.__input_queue] def pending_nodes(self): # type: () -> List[SchemeNode] """ Return a list of pending nodes. The nodes are returned in the order they were enqueued for signal delivery. Returns ------- nodes : List[SchemeNode] """ return list(unique(sig.link.sink_node for sig in self.__input_queue)) def pending_input_signals(self, node): # type: (SchemeNode) -> List[Signal] """ Return a list of pending input signals for node. """ return [signal for signal in self.__input_queue if node is signal.link.sink_node] def remove_pending_signals(self, node): # type: (SchemeNode) -> None """ Remove pending signals for `node`. """ for signal in self.pending_input_signals(node): try: self.__input_queue.remove(signal) except ValueError: pass def __nodes(self): # type: () -> Sequence[SchemeNode] return self.__workflow.nodes if self.__workflow else [] def blocking_nodes(self): # type: () -> List[SchemeNode] """ Return a list of nodes in a blocking state. """ return [node for node in self.__nodes() if self.is_blocking(node)] def invalidated_nodes(self): # type: () -> List[SchemeNode] """ Return a list of invalidated nodes. .. versionadded:: 0.1.8 """ return [node for node in self.__nodes() if self.has_invalidated_outputs(node) or self.is_invalidated(node)] def active_nodes(self): # type: () -> List[SchemeNode] """ Return a list of active nodes. .. versionadded:: 0.1.8 """ return [node for node in self.__nodes() if self.is_active(node)] def is_blocking(self, node): # type: (SchemeNode) -> bool """ Is the node in `blocking` state. Is it currently in a state where will produce new outputs and therefore no signals should be delivered to dependent nodes until it does so. Also no signals will be delivered to the node until it exits this state. The default implementation returns False. .. deprecated:: 0.1.8 Use a combination of `is_invalidated` and `is_ready`. """ return False def is_ready(self, node: SchemeNode) -> bool: """ Is the node in a state where it can receive inputs. Re-implement this method in as subclass to prevent specific nodes from being considered for input update (e.g. they are still initializing runtime resources, executing a non-interruptable task, ...) Note that whenever the implicit state changes the `post_update_request` should be called. The default implementation returns the state of the node's `SchemeNode.NotReady` flag. Parameters ---------- node: SchemeNode """ return not node.test_state_flags(SchemeNode.NotReady) def is_invalidated(self, node: SchemeNode) -> bool: """ Is the node marked as invalidated. Parameters ---------- node : SchemeNode Returns ------- state: bool """ return node.test_state_flags(SchemeNode.Invalidated) def has_invalidated_outputs(self, node): # type: (SchemeNode) -> bool """ Does node have any explicitly invalidated outputs. Parameters ---------- node: SchemeNode Returns ------- state: bool See also -------- invalidate .. versionadded:: 0.1.8 """ out = self.__node_outputs.get(node) if out is not None: return any(state.flags & _OutputState.Invalidated for state in out.values()) else: return False def has_invalidated_inputs(self, node): # type: (SchemeNode) -> bool """ Does the node have any immediate ancestor with invalidated outputs. Parameters ---------- node : SchemeNode Returns ------- state: bool Note ---- The node's ancestors are only computed over enabled links. .. versionadded:: 0.1.8 """ if self.__workflow is None: return False workflow = self.__workflow return any(self.has_invalidated_outputs(link.source_node) for link in workflow.find_links(sink_node=node) if link.is_enabled()) def is_active(self, node): # type: (SchemeNode) -> bool """ Is the node considered active (executing a task). Parameters ---------- node: SchemeNode Returns ------- active: bool """ return bool(node.state() & SchemeNode.Running) def node_update_front(self): # type: () -> Sequence[SchemeNode] """ Return a list of nodes on the update front, i.e. nodes scheduled for an update that have no ancestor which is either itself scheduled for update or is in a blocking state). Note ---- The node's ancestors are only computed over enabled links. """ if self.__workflow is None: return [] workflow = self.__workflow expand = partial(expand_node, workflow) components = strongly_connected_components(workflow.nodes, expand) node_scc = {node: scc for scc in components for node in scc} def isincycle(node): # type: (SchemeNode) -> bool return len(node_scc[node]) > 1 def dependents(node): # type: (SchemeNode) -> List[SchemeNode] return dependent_nodes(workflow, node) # A list of all nodes currently active/executing a non-interruptable # task. blocking_nodes = set(self.blocking_nodes()) # nodes marked as having invalidated outputs (not yet available) invalidated_nodes = set(self.invalidated_nodes()) #: transitive invalidated nodes (including the legacy self.is_blocked #: behaviour - blocked nodes are both invalidated and cannot receive #: new inputs) invalidated_ = reduce( set.union, map(dependents, invalidated_nodes | blocking_nodes), set([]), ) # type: Set[SchemeNode] pending = self.pending_nodes() pending_ = set() for n in pending: depend = set(dependents(n)) if isincycle(n): # a pending node in a cycle would would have a circular # dependency on itself, preventing any progress being made # by the workflow execution. cc = node_scc[n] depend -= set(cc) pending_.update(depend) def has_invalidated_ancestor(node): # type: (SchemeNode) -> bool return node in invalidated_ def has_pending_ancestor(node): # type: (SchemeNode) -> bool return node in pending_ #: nodes that are eligible for update. ready = list(filter( lambda node: not has_pending_ancestor(node) and not has_invalidated_ancestor(node) and not self.is_blocking(node), pending )) return ready @Slot() def __process_next(self): if not self.__state == SignalManager.Running: log.debug("Received 'UpdateRequest' while not in 'Running' state") return if self.__runtime_state == SignalManager.Processing: # This happens if QCoreApplication.processEvents is called from # the input handlers. A `__process_next` must be rescheduled when # exiting process_queued. log.warning("Received 'UpdateRequest' while in 'process_queued'. " "An update will be re-scheduled when exiting the " "current update.") return if not self.__input_queue: return if self.__has_finished: self.__has_finished = False self.started.emit() if self.__process_next_helper(use_max_active=True): # Schedule another update (will be a noop if nothing to do). self._update() def __process_next_helper(self, use_max_active=True) -> bool: eligible = [n for n in self.node_update_front() if self.is_ready(n)] if not eligible: return False max_active = self.max_active() nactive = len(set(self.active_nodes()) | set(self.blocking_nodes())) log.debug( "Process next, queued signals: %i, nactive: %i " "(max_active: %i)", len(self.__input_queue), nactive, max_active ) _ = lambda nodes: list(map(attrgetter('title'), nodes)) log.debug("Pending nodes: %s", _(self.pending_nodes())) log.debug("Blocking nodes: %s", _(self.blocking_nodes())) log.debug("Invalidated nodes: %s", _(self.invalidated_nodes())) log.debug("Nodes ready for update: %s", _(eligible)) # Select an node that is already running (effectively cancelling # already executing tasks that are immediately updatable) selected_node = None # type: Optional[SchemeNode] for node in eligible: if self.is_active(node): selected_node = node break # Return if over committed, except in the case that the selected_node # is already active. if use_max_active and nactive >= max_active and selected_node is None: return False if selected_node is None: selected_node = eligible[0] self.process_node(selected_node) self.__maybe_emit_finished() return True def _update(self): # type: () -> None """ Schedule processing at a later time. """ if self.__state == SignalManager.Running and \ not self.__update_timer.isActive(): self.__update_timer.start() def __maybe_emit_finished(self): if self.__has_finished: # already emitted finished return if any(chain(self.active_nodes(), self.blocking_nodes(), self.pending_nodes())): return self.__has_finished = True self.finished.emit() def post_update_request(self): """ Schedule an update pass. Call this method whenever: * a node's outputs change (note that this is already done by `send`) * any change in the node that influences its eligibility to be picked for an input update (is_ready, is_blocking ...). Multiple update requests are merged into one. """ self._update() def set_max_active(self, val: int) -> None: if self.__max_running != val: self.__max_running = val self._update() def max_active(self) -> int: value = self.__max_running # type: Optional[int] if value is None: value = mapping_get(os.environ, "MAX_ACTIVE_NODES", int, None) if value is None: s = QSettings() s.beginGroup(__name__) value = s.value("max-active-nodes", defaultValue=1, type=int) if value < 0: ccount = os.cpu_count() if ccount is None: return 1 else: return max(1, ccount + value) else: return max(1, value) def can_enable_dynamic(link, value): # type: (SchemeLink, Any) -> bool """ Can the a dynamic `link` (:class:`SchemeLink`) be enabled for`value`. """ if LazyValue.is_lazy(value): value = value.get_value() return isinstance(value, link.sink_types()) def compress_signals(signals: List[Signal]) -> List[Signal]: """ Compress a list of signals by dropping 'stale' signals. * Multiple consecutive updates are dropped - preserving only the latest, except when one of the updates had `None` value in which case the `None` update signal is preserved (by historical convention this meant a reset of the input for pending nodes). So for instance if a link had: `1, 2, None, 3` scheduled updates then the list would be compressed to `None, 3`. * Updates preceding a Close signal are dropped - only Close is preserved. See Also -------- SignalManager.compress_signals """ # group by key in reverse order (to preserve order of last update) groups = group_by_all(reversed(signals), key=lambda sig: (sig.link, sig.id)) out: List[Signal] = [] id_to_index = {id(s): i for i, s in enumerate(signals)} for _, signals_rev in groups: signals = compress_single(list(reversed(signals_rev))) out.extend(reversed(signals)) out = list(reversed(out)) assert all(id(s) in id_to_index for s in out), 'Must preserve signal id' # maintain relative order of (surviving) signals return sorted(out, key=lambda s: id_to_index[id(s)]) def compress_single(signals: List[Signal]) -> List[Signal]: def is_none_update(signal: 'Optional[Signal]') -> bool: return is_update(signal) and signal is not None and signal.value is None def is_update(signal: 'Optional[Signal]') -> bool: return isinstance(signal, Update) or type(signal) is Signal def is_close(signal: 'Optional[Signal]') -> bool: return isinstance(signal, Close) out: List[Signal] = [] # 1.) Merge all consecutive updates for i, sig in enumerate(signals): prev = out[-1] if out else None prev_prev = out[-2] if len(out) > 1 else None if is_none_update(prev_prev) and is_update(prev) and is_none_update(sig): # ..., None, X, None --> ..., None out[-2:] = [sig] elif is_none_update(prev_prev) and is_update(prev) and is_update(sig): # ..., None, X, Y -> ..., None, Y out[-1] = sig elif is_none_update(prev) and is_none_update(sig): # ..., None, None -> ..., None out[-1] = sig elif is_none_update(prev) and is_update(sig): # ..., None, X -> ..., None, X out.append(sig) elif is_update(prev) and is_update(sig): # ..., X, Y -> ..., Y out[-1] = sig else: # ..., X -> ..., X out.append(sig) signals = out # Sanity check. There cannot be more then 2 consecutive updates in the # compressed signals queue. for i in range(len(signals) - 3): assert not all(map(is_update, signals[i: i + 3])) out: List[Signal] = [] # 2.) Drop all Update preceding a Close for i, sig in enumerate(signals): prev = out[-1] if out else None prev_prev = out[-2] if len(out) > 1 else None if is_update(prev_prev) and is_update(prev) and is_close(sig): # ..., Y, X, Close --> ..., Close assert is_none_update(prev_prev) out[-2:] = [sig] elif is_update(prev) and is_close(sig): # ..., X, Close -> ..., Close out[-1] = sig else: # ..., X -> ..., X out.append(sig) return out def expand_node(workflow, node): # type: (Scheme, SchemeNode) -> List[SchemeNode] return [link.sink_node for link in workflow.find_links(source_node=node) if link.enabled] def dependent_nodes(scheme, node): # type: (Scheme, SchemeNode) -> List[SchemeNode] """ Return a list of all nodes (in breadth first order) in `scheme` that are dependent on `node`, Note ---- This does not include nodes only reachable by disables links. """ nodes = list(traverse_bf(node, partial(expand_node, scheme))) assert nodes[0] is node # Remove the first item (`node`). return nodes[1:] ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.802081 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/0000755000175100002000000000000014730024333022000 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/__init__.py0000644000175100002000000000521314730024325024113 0ustar00runnerdocker""" Scheme tests """ from AnyQt.QtCore import QObject, QEventLoop, QTimer, QCoreApplication, QEvent from typing import List class EventSpy(QObject): """ A testing utility class (similar to QSignalSpy) to record events delivered to a QObject instance. Note ---- Only event types can be recorded (as QEvent instances are deleted on delivery). Note ---- Can only be used with a QCoreApplication running. Parameters ---------- object : QObject An object whose events need to be recorded. etype : Union[QEvent.Type, Sequence[QEvent.Type] A event type (or types) that should be recorded """ def __init__(self, object: QObject, etype, **kwargs): super().__init__(**kwargs) if not isinstance(object, QObject): raise TypeError self.__object = object try: len(etype) except TypeError: etypes = {etype} else: etypes = set(etype) self.__etypes = etypes self.__record = [] self.__loop = QEventLoop() self.__timer = QTimer(self, singleShot=True) self.__timer.timeout.connect(self.__loop.quit) self.__object.installEventFilter(self) def wait(self, timeout=5000): """ Start an event loop that runs until a spied event or a timeout occurred. Parameters ---------- timeout : int Timeout in milliseconds. Returns ------- res : bool True if the event occurred and False otherwise. Example ------- >>> app = QCoreApplication.instance() or QCoreApplication([]) >>> obj = QObject() >>> spy = EventSpy(obj, QEvent.User) >>> app.postEvent(obj, QEvent(QEvent.User)) >>> spy.wait() True >>> print(spy.events()) [1000] """ count = len(self.__record) self.__timer.stop() self.__timer.setInterval(timeout) self.__timer.start() self.__loop.exec() self.__timer.stop() return len(self.__record) != count def eventFilter(self, reciever: QObject, event: QEvent) -> bool: if reciever is self.__object and event.type() in self.__etypes: self.__record.append(event.type()) if self.__loop.isRunning(): self.__loop.quit() return super().eventFilter(reciever, event) def events(self) -> List[QEvent.Type]: """ Return a list of all (listened to) event types that occurred. Returns ------- events : List[QEvent.Type] """ return list(self.__record) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/test_annotations.py0000644000175100002000000000257714730024325025762 0ustar00runnerdocker""" Tests for scheme annotations. """ import unittest from .. import SchemeArrowAnnotation, SchemeTextAnnotation class TestAnnotations(unittest.TestCase): def test_arrow(self): arrow = SchemeArrowAnnotation((0, 0), (10, 10)) self.assertTrue(arrow.start_pos == (0, 0)) self.assertTrue(arrow.end_pos == (10, 10)) def count(): count.i += 1 count.i = 0 arrow.geometry_changed.connect(count) arrow.set_line((10, 10), (0, 0)) self.assertTrue(arrow.start_pos == (10, 10)) self.assertTrue(arrow.end_pos == (0, 0)) self.assertTrue(count.i == 1) def test_text(self): text = SchemeTextAnnotation((0, 0, 10, 100), "--") self.assertEqual(text.rect, (0, 0, 10, 100)) self.assertEqual(text.text, "--") def count(): count.i += 1 count.i = 0 text.geometry_changed.connect(count) text.set_rect((9, 9, 30, 30)) self.assertEqual(text.rect, (9, 9, 30, 30)) self.assertEqual(count.i, 1) text.rect = (4, 4, 4, 4) self.assertEqual(count.i, 2) count.i = 0 text.text_changed.connect(count) text.set_text("...") self.assertEqual(text.text, "...") self.assertTrue(count.i == 1) text.text = '==' self.assertEqual(text.text, "==") self.assertEqual(count.i, 2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/test_links.py0000644000175100002000000000534114730024325024535 0ustar00runnerdocker""" Tests for SchemeLink """ import warnings from ...gui import test from ...registry.tests import small_testing_registry from ...registry.description import InputSignal, OutputSignal, Dynamic from .. import SchemeNode, SchemeLink, IncompatibleChannelTypeError from ..link import resolved_valid_types, _classify_connection class A: pass class B(A): pass class C: pass class TestSchemeLink(test.QAppTestCase): def test_link(self): reg = small_testing_registry() one_desc = reg.widget("one") add_desc = reg.widget("add") unit_desc = reg.widget("unit") one_node = SchemeNode(one_desc) add_node = SchemeNode(add_desc) unit_node = SchemeNode(unit_desc) link1 = SchemeLink(one_node, one_node.output_channel("value"), add_node, add_node.input_channel("left")) self.assertEqual(link1.source_types(), (int, )) self.assertEqual(link1.sink_types(), (int, )) with self.assertRaises(ValueError): SchemeLink(add_node, "right", one_node, "$$$[") with self.assertRaises(IncompatibleChannelTypeError): SchemeLink(unit_node, "value", add_node, "right") def test_utils(self): with warnings.catch_warnings(record=True): self.assertTupleEqual( resolved_valid_types(("int", __name__ + ".NoSuchType", "str")), (int, str), ) source = OutputSignal("A", (A,)) source_d = OutputSignal("A", (A,), flags=Dynamic) source_ac_d = OutputSignal("A", (A, C), flags=Dynamic) source_ac = OutputSignal("A", (B, C)) sink_a = InputSignal("A", (A,), "a") sink_b = InputSignal("B", (B,), "b") sink_c = InputSignal("C", (C,), "c") sink_bc = InputSignal("C", (B, C,), "c") t1, t2 = _classify_connection(source, sink_a) self.assertTrue(t1) self.assertFalse(t2) t1, t2 = _classify_connection(source, sink_b) self.assertFalse(t1) self.assertFalse(t2) t1, t2 = _classify_connection(source_d, sink_b) self.assertFalse(t1) self.assertTrue(t2) t1, t2 = _classify_connection(source_d, sink_a) self.assertTrue(t1) self.assertTrue(t2) t1, t2 = _classify_connection(source_d, sink_c) self.assertFalse(t1) self.assertFalse(t2) t1, t2 = _classify_connection(source_d, sink_bc) self.assertFalse(t1) self.assertTrue(t2) t1, t2 = _classify_connection(source_ac_d, sink_bc) self.assertFalse(t1) self.assertTrue(t2) t1, t2 = _classify_connection(source_ac, sink_bc) self.assertTrue(t1) self.assertFalse(t2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/test_nodes.py0000644000175100002000000000333214730024325024523 0ustar00runnerdocker""" """ from ...gui import test from ...registry.tests import small_testing_registry from ...registry import InputSignal, OutputSignal from .. import SchemeNode class TestScheme(test.QAppTestCase): def test_node(self): """Test SchemeNode. """ reg = small_testing_registry() one_desc = reg.widget("one") node = SchemeNode(one_desc) inputs = node.input_channels() self.assertSequenceEqual(inputs, one_desc.inputs) for ch in inputs: channel = node.input_channel(ch.name) self.assertIsInstance(channel, InputSignal) self.assertTrue(channel in inputs) self.assertRaises(ValueError, node.input_channel, "%%&&&$$()[()[") outputs = node.output_channels() self.assertSequenceEqual(outputs, one_desc.outputs) for ch in outputs: channel = node.output_channel(ch.name) self.assertIsInstance(channel, OutputSignal) self.assertTrue(channel in outputs) self.assertRaises(ValueError, node.output_channel, "%%&&&$$()[()[") def test_channels_by_name_or_id(self): reg = small_testing_registry() zero_desc = reg.widget("zero") node = SchemeNode(zero_desc) self.assertIs(node.output_channel("value"), zero_desc.outputs[0]) self.assertIs(node.output_channel("val"), zero_desc.outputs[0]) add_desc = reg.widget("add") node = SchemeNode(add_desc) self.assertIs(node.input_channel("left"), add_desc.inputs[0]) self.assertIs(node.input_channel("right"), add_desc.inputs[1]) self.assertIs(node.input_channel("droite"), add_desc.inputs[1]) self.assertRaises(ValueError, node.input_channel, "gauche") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/test_readwrite.py0000644000175100002000000001616514730024325025411 0ustar00runnerdocker""" Test read write """ import io from xml.etree import ElementTree as ET from ...gui import test from ...registry import WidgetRegistry, WidgetDescription, CategoryDescription from ...registry import tests as registry_tests from .. import Scheme, SchemeNode, SchemeLink, \ SchemeArrowAnnotation, SchemeTextAnnotation from .. import readwrite class TestReadWrite(test.QAppTestCase): def test_io(self): reg = registry_tests.small_testing_registry() zero_desc = reg.widget("zero") one_desc = reg.widget("one") add_desc = reg.widget("add") negate = reg.widget("negate") scheme = Scheme() zero_node = SchemeNode(zero_desc) one_node = SchemeNode(one_desc) add_node = SchemeNode(add_desc) negate_node = SchemeNode(negate) scheme.add_node(zero_node) scheme.add_node(one_node) scheme.add_node(add_node) scheme.add_node(negate_node) scheme.add_link(SchemeLink(zero_node, "value", add_node, "left")) scheme.add_link(SchemeLink(one_node, "value", add_node, "right")) scheme.add_link(SchemeLink(add_node, "result", negate_node, "value")) scheme.add_annotation(SchemeArrowAnnotation((0, 0), (10, 10))) scheme.add_annotation(SchemeTextAnnotation((0, 100, 200, 200), "$$")) stream = io.BytesIO() readwrite.scheme_to_ows_stream(scheme, stream, pretty=True) stream.seek(0) scheme_1 = readwrite.scheme_load(Scheme(), stream, reg) self.assertTrue(len(scheme.nodes) == len(scheme_1.nodes)) self.assertTrue(len(scheme.links) == len(scheme_1.links)) self.assertTrue(len(scheme.annotations) == len(scheme_1.annotations)) for n1, n2 in zip(scheme.nodes, scheme_1.nodes): self.assertEqual(n1.position, n2.position) self.assertEqual(n1.title, n2.title) for link1, link2 in zip(scheme.links, scheme_1.links): self.assertEqual(link1.source_type(), link2.source_type()) self.assertEqual(link1.sink_type(), link2.sink_type()) self.assertEqual(link1.source_channel.name, link2.source_channel.name) self.assertEqual(link1.sink_channel.name, link2.sink_channel.name) self.assertEqual(link1.enabled, link2.enabled) for annot1, annot2 in zip(scheme.annotations, scheme_1.annotations): self.assertIs(type(annot1), type(annot2)) if isinstance(annot1, SchemeTextAnnotation): self.assertEqual(annot1.text, annot2.text) self.assertEqual(annot1.rect, annot2.rect) else: self.assertEqual(annot1.start_pos, annot2.start_pos) self.assertEqual(annot1.end_pos, annot2.end_pos) def test_safe_evals(self): s = readwrite.string_eval(r"'\x00\xff'") self.assertEqual(s, chr(0) + chr(255)) with self.assertRaises(ValueError): readwrite.string_eval("3") with self.assertRaises(ValueError): readwrite.string_eval("[1, 2]") t = readwrite.tuple_eval("(1, 2.0, 'a')") self.assertEqual(t, (1, 2.0, 'a')) with self.assertRaises(ValueError): readwrite.tuple_eval("u'string'") with self.assertRaises(ValueError): readwrite.tuple_eval("(1, [1, [2, ]])") self.assertIs(readwrite.terminal_eval("True"), True) self.assertIs(readwrite.terminal_eval("False"), False) self.assertIs(readwrite.terminal_eval("None"), None) self.assertEqual(readwrite.terminal_eval("42"), 42) self.assertEqual(readwrite.terminal_eval("42."), 42.) self.assertEqual(readwrite.terminal_eval("'42'"), '42') self.assertEqual(readwrite.terminal_eval(r"b'\xff\x00'"), b'\xff\x00') with self.assertRaises(ValueError): readwrite.terminal_eval("...") with self.assertRaises(ValueError): readwrite.terminal_eval("{}") def test_literal_dump(self): struct = {1: [{(1, 2): ""}], True: 1.0, None: None} s = readwrite.literal_dumps(struct) self.assertEqual(readwrite.literal_loads(s), struct) with self.assertRaises(ValueError): recur = [1] recur.append(recur) readwrite.literal_dumps(recur) with self.assertRaises(TypeError): readwrite.literal_dumps(self) with self.assertRaises(TypeError): readwrite.literal_dumps(float("nan")) def test_resolve_replaced(self): tree = ET.parse(io.BytesIO(FOOBAR_v20.encode())) parsed = readwrite.parse_ows_etree_v_2_0(tree) self.assertIsInstance(parsed, readwrite._scheme) self.assertEqual(parsed.version, "2.0") self.assertTrue(len(parsed.nodes) == 2) self.assertTrue(len(parsed.links) == 2) qnames = [node.qualified_name for node in parsed.nodes] self.assertSetEqual(set(qnames), set(["package.foo", "package.bar"])) reg = foo_registry() parsed = readwrite.resolve_replaced(parsed, reg) qnames = [node.qualified_name for node in parsed.nodes] self.assertSetEqual(set(qnames), set(["package.foo", "frob.bar"])) projects = [node.project_name for node in parsed.nodes] self.assertSetEqual(set(projects), set(["Foo", "Bar"])) def foo_registry(): reg = WidgetRegistry() reg.register_category(CategoryDescription("Quack")) reg.register_widget( WidgetDescription( name="Foo", id="foooo", qualified_name="package.foo", project_name="Foo", category="Quack", ) ) reg.register_widget( WidgetDescription( name="Bar", id="barrr", qualified_name="frob.bar", project_name="Bar", replaces=["package.bar"], category="Quack", ) ) return reg FOOBAR_v10 = """ """ FOOBAR_v20 = """ """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/test_scheme.py0000644000175100002000000001307014730024325024657 0ustar00runnerdocker""" Tests for Scheme """ from AnyQt.QtTest import QSignalSpy from ...gui import test from ...registry.tests import small_testing_registry from .. import ( Scheme, SchemeNode, SchemeLink, SchemeTextAnnotation, SchemeArrowAnnotation, SchemeTopologyError, SinkChannelError, DuplicatedLinkError, IncompatibleChannelTypeError ) class TestScheme(test.QCoreAppTestCase): def test_scheme(self): reg = small_testing_registry() one_desc = reg.widget("one") add_desc = reg.widget("add") cons_desc = reg.widget("cons") # Create the scheme scheme = Scheme() self.assertEqual(scheme.title, "") self.assertEqual(scheme.description, "") nodes_added = [] links_added = [] annotations_added = [] scheme.node_added.connect(nodes_added.append) scheme.node_removed.connect(nodes_added.remove) scheme.link_added.connect(links_added.append) scheme.link_removed.connect(links_added.remove) scheme.annotation_added.connect(annotations_added.append) scheme.annotation_removed.connect(annotations_added.remove) w1 = scheme.new_node(one_desc) self.assertTrue(len(nodes_added) == 1) self.assertTrue(isinstance(nodes_added[-1], SchemeNode)) self.assertTrue(nodes_added[-1] is w1) w2 = scheme.new_node(add_desc) self.assertTrue(len(nodes_added) == 2) self.assertTrue(isinstance(nodes_added[-1], SchemeNode)) self.assertTrue(nodes_added[-1] is w2) w3 = scheme.new_node(cons_desc) self.assertTrue(len(nodes_added) == 3) self.assertTrue(isinstance(nodes_added[-1], SchemeNode)) self.assertTrue(nodes_added[-1] is w3) self.assertTrue(len(links_added) == 0) l1 = SchemeLink(w1, "value", w2, "left") scheme.add_link(l1) self.assertTrue(len(links_added) == 1) self.assertTrue(isinstance(links_added[-1], SchemeLink)) self.assertTrue(links_added[-1] is l1) l2 = SchemeLink(w1, "value", w3, "first") scheme.add_link(l2) self.assertTrue(len(links_added) == 2) self.assertTrue(isinstance(links_added[-1], SchemeLink)) self.assertTrue(links_added[-1] is l2) # Test find_links. found = scheme.find_links(w1, None, w2, None) self.assertSequenceEqual(found, [l1]) found = scheme.find_links(None, None, w3, None) self.assertSequenceEqual(found, [l2]) scheme.remove_link(l2) self.assertTrue(l2 not in links_added) # Add a link to itself. self.assertRaises(SchemeTopologyError, scheme.new_link, w2, "result", w2, "right") # Add an link with incompatible types self.assertRaises(IncompatibleChannelTypeError, scheme.new_link, w3, "cons", w2, "right") # Add a link to a node with no input channels self.assertRaises(ValueError, scheme.new_link, w2, "result", w1, "foo") # add back l2 for the following checks scheme.add_link(l2) # Add a duplicate link self.assertRaises(DuplicatedLinkError, scheme.new_link, w1, "value", w3, "first") # Add a link to an already connected sink channel self.assertRaises(SinkChannelError, scheme.new_link, w2, "result", w3, "first") text_annot = SchemeTextAnnotation((0, 0, 100, 20), "Text") scheme.add_annotation(text_annot) self.assertSequenceEqual(annotations_added, [text_annot]) self.assertSequenceEqual(scheme.annotations, annotations_added) arrow_annot = SchemeArrowAnnotation((0, 100), (100, 100)) scheme.add_annotation(arrow_annot) self.assertSequenceEqual(annotations_added, [text_annot, arrow_annot]) self.assertSequenceEqual(scheme.annotations, annotations_added) scheme.remove_annotation(text_annot) self.assertSequenceEqual(annotations_added, [arrow_annot]) self.assertSequenceEqual(scheme.annotations, annotations_added) def test_insert_node(self): reg = small_testing_registry() one_desc = reg.widget("one") n1, n2 = SchemeNode(one_desc), SchemeNode(one_desc) w = Scheme() spy = QSignalSpy(w.node_inserted) w.add_node(n1) w.insert_node(0, n2) self.assertSequenceEqual(list(spy), [[0, n1], [0, n2]]) self.assertSequenceEqual(w.nodes, [n2, n1]) def test_insert_link(self): reg = small_testing_registry() one_desc = reg.widget("one") add_desc = reg.widget("add") n1, n2, n3 = SchemeNode(one_desc), SchemeNode(one_desc), SchemeNode(add_desc) w = Scheme() spy = QSignalSpy(w.link_inserted) w.add_node(n1) w.add_node(n2) w.add_node(n3) l1 = SchemeLink(n1, "value", n3, "left") l2 = SchemeLink(n2, "value", n3, "right") w.add_link(l1) w.insert_link(0, l2) self.assertSequenceEqual(list(spy), [[0, l1], [0, l2]]) self.assertSequenceEqual(w.links, [l2, l1]) def test_insert_annotation(self): w = Scheme() a1 = SchemeTextAnnotation((0, 0, 1, 1), "a1") a2 = SchemeTextAnnotation((0, 0, 1, 1), "a2") a3 = SchemeTextAnnotation((0, 0, 1, 1), "a3") spy = QSignalSpy(w.annotation_inserted) w.insert_annotation(0, a1) w.insert_annotation(1, a2) w.insert_annotation(0, a3) self.assertSequenceEqual(w.annotations, [a3, a1, a2]) self.assertSequenceEqual(list(spy), [[0, a1], [1, a2], [0, a3]]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/test_signalmanager.py0000644000175100002000000003477114730024325026236 0ustar00runnerdockerimport sys import unittest from unittest.mock import Mock from AnyQt.QtTest import QSignalSpy from orangecanvas.scheme import Scheme, SchemeNode, SchemeLink from orangecanvas.scheme.signalmanager import ( SignalManager, Signal, compress_signals, compress_single, LazyValue ) from orangecanvas.registry import tests as registry_tests from orangecanvas.gui.test import QCoreAppTestCase class TestLazyValue(unittest.TestCase): def test_singletonnes(self): i1 = LazyValue[int] i2 = LazyValue[int] f1 = LazyValue[float] self.assertIs(i1, i2) self.assertIsNot(i1, f1) self.assertIsNot(i2, f1) def test_repr(self): self.assertEqual(repr(LazyValue[int]), "LazyValue[int]") def test_get_value_and_cached(self): f = Mock(return_value=42) lazy = LazyValue[int](f) f.assert_not_called() self.assertFalse(lazy.is_cached) self.assertEqual(lazy.get_value(), 42) f.assert_called_once() self.assertTrue(lazy.is_cached) self.assertEqual(lazy.get_value(), 42) f.assert_called_once() self.assertTrue(lazy.is_cached) def test_type(self): self.assertIs(LazyValue[int].type(), int) self.assertIs(LazyValue[float].type(), float) def test_release_closure(self): deleted = Mock() def commit(): class S: def __del__(self): deleted() s = S() def f(): return 42 + bool(s) return LazyValue[int](f) lazy = commit() deleted.assert_not_called() lazy.get_value() deleted.assert_called_once() def test_interrupt(self): interrupt = Mock() lazy = LazyValue[int](Mock(), interrupt) del lazy interrupt.assert_called_once() def test_extra_args(self): lazy = LazyValue[int](Mock(), a=1, b=2) self.assertEqual(lazy.a, 1) self.assertEqual(lazy.b, 2) class TestingSignalManager(SignalManager): def is_blocking(self, node): return bool(node.property("-blocking")) def send_to_node(self, node, signals): for sig in signals: name = sig.link.sink_channel.name node.setProperty("-input-" + name, sig.value) for out in node.description.outputs: self.send(node, out, "hello") class TestSignalManager(QCoreAppTestCase): def setUp(self): super().setUp() reg = registry_tests.small_testing_registry() scheme = Scheme() zero = scheme.new_node(reg.widget("zero")) one = scheme.new_node(reg.widget("one")) add = scheme.new_node(reg.widget("add")) scheme.new_link(zero, "value", add, "left") scheme.new_link(one, "value", add, "right") sm = TestingSignalManager() sm.set_workflow(scheme) sm.start() self.reg = reg self.scheme = scheme self.signal_manager = sm def test(self): workflow = self.scheme sm = TestingSignalManager() sm.set_workflow(workflow) sm.set_workflow(workflow) sm.set_workflow(None) sm.set_workflow(workflow) sm.start() self.assertFalse(sm.has_pending()) sm.stop() sm.pause() sm.resume() n0, n1, n3 = workflow.nodes sm.send(n0, n0.description.outputs[0], 'hello') sm.send(n1, n1.description.outputs[0], 'hello') spy = QSignalSpy(sm.processingFinished[SchemeNode]) self.assertTrue(spy.wait()) self.assertSequenceEqual(list(spy), [[n3]]) self.assertEqual(n3.property("-input-left"), 'hello') self.assertEqual(n3.property("-input-right"), 'hello') self.assertFalse(sm.has_pending()) workflow.remove_link(workflow.links[0]) self.assertTrue(sm.has_pending()) spy = QSignalSpy(sm.processingFinished[SchemeNode]) self.assertTrue(spy.wait()) self.assertEqual(n3.property("-input-left"), None) self.assertEqual(n3.property("-input-right"), 'hello') def test_add_link_disabled(self): workflow = self.scheme sm = self.signal_manager n0, n1, n2 = workflow.nodes l0, l1 = workflow.links workflow.remove_link(l0) sm.send(n0, n0.description.outputs[0], 1) sm.send(n1, n1.description.outputs[0], 2) sm.process_queued() self.assertFalse(sm.has_pending()) l0.set_enabled(False) workflow.insert_link(0, l0) self.assertSequenceEqual( sm.pending_input_signals(n2), [Signal.New(l0, None, None, 0)] ) l0.set_enabled(True) self.assertSequenceEqual( sm.pending_input_signals(n2), [ Signal.New(l0, None, None, 0), Signal.Update(l0, 1, None, 0), ] ) def test_link_new_dispatch_after_enable(self): workflow = self.scheme sm = self.signal_manager n0, n1, n2 = workflow.nodes l0, l1 = workflow.links l0.set_enabled(False) sm.send(n0, n0.description.outputs[0], -42) sm.send(n0, n0.description.outputs[0], 42) self.assertSequenceEqual( sm.pending_input_signals(n2), [Signal.New(l0, None, None, 0)] ) sm.process_queued() l0.set_enabled(True) self.assertSequenceEqual( sm.pending_input_signals(n2), [Signal.Update(l0, 42, None, 0)] ) sm.process_queued() def test_invalidated_flags(self): workflow = self.scheme sm = self.signal_manager n0, n1, n2 = workflow.nodes[:3] l0, l1 = workflow.links[:2] self.assertFalse(l0.runtime_state() & SchemeLink.Invalidated) sm.send(n0, n0.description.outputs[0], 'hello') self.assertFalse(l0.runtime_state() & SchemeLink.Invalidated) self.assertIn(n2, sm.node_update_front()) sm.invalidate(n0, n0.description.outputs[0]) self.assertTrue(l0.runtime_state() & SchemeLink.Invalidated) self.assertTrue(sm.has_invalidated_outputs(n0)) self.assertTrue(sm.has_invalidated_inputs(n2)) self.assertNotIn(n2, sm.node_update_front()) sm.send(n0, n0.description.outputs[0], 'hello') self.assertFalse(l0.runtime_state() & SchemeLink.Invalidated) self.assertFalse(sm.has_invalidated_outputs(n0)) self.assertFalse(sm.has_invalidated_inputs(n2)) self.assertIn(n2, sm.node_update_front()) sm.invalidate(n1, n1.description.outputs[0]) n3 = workflow.new_node(self.reg.widget('add')) l2 = workflow.new_link( n1, n1.output_channel('value'), n3, n3.input_channel('left') ) self.assertTrue(l2.test_runtime_state(SchemeLink.Invalidated)) self.assertTrue(sm.has_invalidated_inputs(n3)) self.assertNotIn(n3, sm.node_update_front()) workflow.remove_link(l2) self.assertFalse(sm.has_invalidated_inputs(n3)) self.assertNotIn(n3, sm.node_update_front()) # invalidated must not propagate via disabled links self.assertNotIn(n2, sm.node_update_front()) l1.set_enabled(False) self.assertIn(n2, sm.node_update_front()) self.assertFalse(sm.has_invalidated_inputs(n2)) l1.set_enabled(True) self.assertNotIn(n2, sm.node_update_front()) self.assertTrue(sm.has_invalidated_inputs(n2)) def test_pending_flags(self): workflow = self.scheme sm = self.signal_manager n0, n1, n3 = workflow.nodes[:3] l0, l1 = workflow.links[:2] self.assertFalse(n3.test_state_flags(SchemeNode.Pending)) self.assertFalse(l0.runtime_state() & SchemeLink.Pending) sm.send(n0, n0.description.outputs[0], 'hello') self.assertTrue(n3.test_state_flags(SchemeNode.Pending)) self.assertTrue(l0.runtime_state() & SchemeLink.Pending) spy = QSignalSpy(sm.processingFinished) assert spy.wait() self.assertFalse(n3.test_state_flags(SchemeNode.Pending)) self.assertFalse(l0.runtime_state() & SchemeLink.Pending) def test_ready_flags(self): workflow = self.scheme sm = self.signal_manager n0, n1, n3 = workflow.nodes[:3] sm.send(n0, n0.output_channel("value"), 'hello') sm.send(n1, n1.output_channel("value"), 'hello') self.assertIn(n3, sm.node_update_front()) n3.set_state_flags(SchemeNode.NotReady, True) spy = QSignalSpy(sm.processingStarted[SchemeNode]) sm.process_next() self.assertNotIn([n3], list(spy)) n3.set_state_flags(SchemeNode.NotReady, False) assert spy.wait() self.assertIn([n3], list(spy)) def test_start_finished(self): workflow = self.scheme sm = self.signal_manager n0, n1, n3 = workflow.nodes[:3] start_spy = QSignalSpy(sm.started) fin_spy = QSignalSpy(sm.finished) sm.send(n0, n0.output_channel("value"), 'hello') sm.send(n1, n1.output_channel("value"), 'hello') assert fin_spy.wait() self.assertEqual(len(start_spy), 1) self.assertEqual(len(fin_spy), 1) sm.send(n1, n1.output_channel("value"), 'hello') assert fin_spy.wait() self.assertEqual(len(start_spy), 2) self.assertEqual(len(fin_spy), 2) def test_compress_signals(self): workflow = self.scheme link = workflow.links[0] self.assertSequenceEqual(compress_signals([]), []) signals_in = [ Signal(link, 1, None), Signal(link, 3, None), Signal(link, 2, None), ] self.assertSequenceEqual( compress_signals(signals_in), signals_in[-1:] ) signals_in = [ Signal(link, None, None), Signal(link, 3, None), Signal(link, 2, None), ] self.assertSequenceEqual( compress_signals(signals_in), [signals_in[0], signals_in[-1]] ) signals_in = [ Signal(link, None, 1), Signal(link, 3, 1), Signal(link, 2, 2), ] self.assertSequenceEqual( compress_signals(signals_in), signals_in, ) signals_in = [ Signal(link, 1, 1), Signal(link, None, 1), Signal(link, 2, 2), ] self.assertSequenceEqual( compress_signals(signals_in), signals_in[1:], ) signals_in = [ Signal(link, None, 1), Signal(link, None, 1), ] self.assertSequenceEqual( compress_signals(signals_in), signals_in[1:], ) def test_compress_signals_single(self): New, Update, Close = Signal.New, Signal.Update, Signal.Close workflow = self.scheme link = workflow.links[0] self.assertSequenceEqual( compress_single([]), [] ) signals = [Update(link, None, 1)] self.assertSequenceEqual( compress_single(signals), signals ) signals = [Update(link, None, 1), Update(link, 1, 1)] self.assertSequenceEqual( compress_single(signals), signals ) signals = [Update(link, 1, 1), Update(link, None, 1)] self.assertSequenceEqual( compress_single(signals), [signals[-1]] ) signals = [ Update(link, None, 1), Update(link, 1, 1), Update(link, None, 1), ] self.assertSequenceEqual( compress_single(signals), [signals[-1]] ) signals = [ Update(link, None, 1), Update(link, 1, 1), Update(link, 2, 1), ] self.assertSequenceEqual( compress_single(signals), [signals[0], signals[-1]] ) signals = [New(link, None, 1), Close(link, None, 1)] self.assertSequenceEqual( compress_single(signals), signals, ) signals = [ New(link, 1, 1), Update(link, 2, 1), Close(link, None, 1) ] self.assertSequenceEqual( compress_single(signals), [signals[0], signals[-1]] ) signals = [ New(link, 1, 1), Update(link, 1, 1), Close(link, None, 1), New(link, 1, 1) ] self.assertSequenceEqual( compress_single(signals), [signals[0], *signals[2:]] ) signals = [ Update(link, 1, 1), Update(link, 2, 1), Close(link, None, 1) ] self.assertSequenceEqual( compress_single(signals), [signals[-1]] ) signals = [ Update(link, 1, 1), Update(link, None, 1), Update(link, 2, 1), Close(link, None, 1) ] self.assertSequenceEqual( compress_single(signals), [signals[-1]], ) signals = [ Update(link, 1, 1), Update(link, 2, 1), Close(link, None, 1), ] self.assertSequenceEqual( compress_single(signals), [signals[-1]] ) signals = [ Update(link, 1, 1), Update(link, 2, 1), Close(link, None, 1), New(link, None, 1), ] self.assertSequenceEqual( compress_single(signals), signals[-2:] ) def test_compress_signals_typed(self): l1, l2 = self.scheme.links[0], self.scheme.links[1] New, Update, Close = Signal.New, Signal.Update, Signal.Close signals = [ New(l1, 1, index=0), Update(l1, 2, index=0), New(l2, "a", index=0), Update(l2, 2, index=0), Close(l1, None, index=1), New(l1, None, index=1), Update(l2, "b", index=0) ] # must preserve relative order of New/Close self.assertSequenceEqual( compress_signals(signals), [ New(l1, 1, index=0), New(l2, "a", index=0), Close(l1, None, index=1), New(l1, None, index=1), Update(l2, "b", index=0) ], ) signals = [ Update(l1, 2, index=0), New(l2, "a", index=0), Update(l2, 2, index=0), Close(l1, None, index=1), ] self.assertSequenceEqual( compress_signals(signals), [ New(l2, "a", index=0), Update(l2, 2, index=0), Close(l1, None, index=1), ], ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/tests/test_widgetmanager.py0000644000175100002000000002504414730024325026235 0ustar00runnerdockerimport unittest import weakref from collections import Counter from AnyQt.QtCore import QEvent from AnyQt.QtWidgets import QWidget, QApplication, QAction from AnyQt.QtTest import QSignalSpy from orangecanvas.gui.windowlistmanager import WindowListManager from orangecanvas.scheme import ( Scheme, NodeEvent, SchemeLink, LinkEvent, WorkflowEvent ) from orangecanvas.scheme.widgetmanager import WidgetManager from orangecanvas.registry import tests as registry_tests from orangecanvas.scheme.tests import EventSpy class TestingWidgetManager(WidgetManager): def create_widget_for_node(self, node): return QWidget() def delete_widget_for_node(self, node, widget): widget.deleteLater() def save_widget_geometry(self, node, widget): return widget.saveGeometry() def restore_widget_geometry(self, node, widget, state): return widget.restoreGeometry(state) class TestWidgetManager(unittest.TestCase): @classmethod def setUpClass(cls): app = QApplication.instance() if app is None: app = QApplication([]) cls.app = app @classmethod def tearDownClass(cls): cls.app = None def setUp(self): super().setUp() reg = registry_tests.small_testing_registry() scheme = Scheme() zero = scheme.new_node(reg.widget("zero")) one = scheme.new_node(reg.widget("one")) add = scheme.new_node(reg.widget("add")) scheme.new_link(zero, "value", add, "left") scheme.new_link(one, "value", add, "right") self.scheme = scheme def tearDown(self) -> None: self.scheme.clear() del self.scheme super().tearDown() def test_create_immediate(self): wm = TestingWidgetManager() wm.set_creation_policy(TestingWidgetManager.Immediate) spy = QSignalSpy(wm.widget_for_node_added) wm.set_workflow(self.scheme) nodes = self.scheme.nodes self.assertEqual(len(spy), 3) self.assertSetEqual({n for n, _ in spy}, set(nodes)) spy = QSignalSpy(wm.widget_for_node_removed) self.scheme.clear() self.assertEqual(len(spy), 3) self.assertSetEqual({n for n, _ in spy}, set(nodes)) def test_create_normal(self): workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_creation_policy(TestingWidgetManager.Normal) spy = QSignalSpy(wm.widget_for_node_added) wm.set_workflow(workflow) self.assertEqual(len(spy), 0) w = wm.widget_for_node(nodes[0]) self.assertEqual(list(spy), [[nodes[0], w]]) w = wm.widget_for_node(nodes[2]) self.assertEqual(list(spy)[1:], [[nodes[2], w]]) spy = QSignalSpy(wm.widget_for_node_added) self.assertTrue(spy.wait()) w = wm.widget_for_node(nodes[1]) self.assertEqual(list(spy), [[nodes[1], w]]) spy = QSignalSpy(wm.widget_for_node_removed) workflow.clear() self.assertEqual(len(spy), 3) self.assertSetEqual({n for n, _ in spy}, set(nodes)) def test_create_on_demand(self): workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_creation_policy(WidgetManager.OnDemand) spy = QSignalSpy(wm.widget_for_node_added) wm.set_workflow(workflow) self.assertEqual(len(spy), 0) self.assertFalse(spy.wait(30)) self.assertEqual(len(spy), 0) w = wm.widget_for_node(nodes[0]) self.assertEqual(list(spy), [[nodes[0], w]]) # transition to normal spy = QSignalSpy(wm.widget_for_node_added) wm.set_creation_policy(WidgetManager.Normal) self.assertTrue(spy.wait()) self.assertEqual(spy[0][0], nodes[1]) def test_mappings(self): workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_workflow(workflow) w = wm.widget_for_node(nodes[0]) n = wm.node_for_widget(w) self.assertIs(n, nodes[0]) def test_save_geometry(self): workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_workflow(workflow) n = nodes[0] w = wm.widget_for_node(n) state = wm.save_widget_geometry(n, w) self.assertTrue(wm.restore_widget_geometry(n, w, state)) wm.activate_widget_for_node(n, w) state = wm.save_window_state() self.assertEqual(len(state), 1) self.assertIs(state[0][0], n) self.assertEqual(state[0][1], wm.save_widget_geometry(n, w)) QApplication.sendEvent( nodes[1], NodeEvent(NodeEvent.NodeActivateRequest, nodes[1]) ) wm.raise_widgets_to_front() wm.restore_window_state(state) def test_set_model(self): workflow = self.scheme wm = TestingWidgetManager() wm.set_workflow(workflow) wm.set_workflow(workflow) wm.set_creation_policy(WidgetManager.Immediate) wm.set_workflow(Scheme()) def test_event_dispatch(self): workflow = self.scheme nodes = workflow.nodes links = workflow.links class Widget(QWidget): def __init__(self, *a): self._evt = [] super().__init__(*a) def event(self, event): # record all event types self._evt.append(event.type()) return super().event(event) class WidgetManager(TestingWidgetManager): def create_widget_for_node(self, node): w = Widget() w._evt = [] return w wm = WidgetManager() wm.set_creation_policy(WidgetManager.OnDemand) wm.set_workflow(workflow) n1, n2, n3 = nodes[:3] l1, l2 = links[:2] w1 = wm.widget_for_node(n1) self.assertInWithCount(NodeEvent.OutputLinkAdded, w1._evt, 1) w1._evt.clear() workflow.remove_link(l1) self.assertInWithCount(NodeEvent.OutputLinkRemoved, w1._evt, 1) w3 = wm.widget_for_node(n3) w3._evt.clear() workflow.add_link(l1) self.assertInWithCount(NodeEvent.OutputLinkAdded, w1._evt, 1) self.assertInWithCount(NodeEvent.InputLinkAdded, w3._evt, 1) w1._evt.clear() workflow.set_runtime_env("tt", "aaa") self.assertInWithCount(NodeEvent.WorkflowEnvironmentChange, w1._evt, 1) w3._evt.clear() l1.set_runtime_state(SchemeLink.Pending) self.assertInWithCount(LinkEvent.InputLinkStateChange, w3._evt, 1) self.assertInWithCount(LinkEvent.OutputLinkStateChange, w1._evt, 1) def assertInWithCount(self, member, container, expected): counter = Counter(container) count = counter[member] if count != expected: msg = "Count of %s in %s is %i; expected %i" % ( member, container, count, expected ) self.fail(msg) def test_activation_on_delayed_creation_policy(self): workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_creation_policy(WidgetManager.Normal) wm.set_workflow(workflow) n1, n2 = nodes[0], nodes[1] spy = QSignalSpy(wm.widget_for_node_added) QApplication.sendEvent( n1, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) self.assertEqual(len(spy), 1) self.assertIs(spy[0][0], n1) wm.set_creation_policy(WidgetManager.OnDemand) spy = QSignalSpy(wm.widget_for_node_added) QApplication.sendEvent( n2, WorkflowEvent(WorkflowEvent.NodeActivateRequest)) self.assertEqual(len(spy), 1) self.assertIs(spy[0][0], n2) def test_garbage_collect_widgets(self): workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_creation_policy(WidgetManager.Immediate) wm.set_workflow(workflow) w1 = wm.widget_for_node(nodes[0]) w2 = wm.widget_for_node(nodes[1]) w1_ref = weakref.ref(w1) w2_ref = weakref.ref(w2) workflow.remove_node(nodes[0]) del w1 self.assertIsNone(w1_ref()) workflow.remove_node(nodes[1]) del w2 self.assertIsNone(w2_ref()) def test_actions(self): def action_ancestors(widget: QWidget) -> QAction: return widget.findChild(QAction, "action-canvas-raise-ancestors") def action_descendants(widget: QWidget) -> QAction: return widget.findChild(QAction, "action-canvas-raise-descendants") workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_creation_policy(WidgetManager.Immediate) wm.set_workflow(workflow) w = wm.widget_for_node(nodes[0]) w2 = wm.widget_for_node(nodes[2]) espy = EventSpy(w2, QEvent.WindowActivate) ac = action_ancestors(w) self.assertFalse(ac.isEnabled()) ac = action_descendants(w) self.assertTrue(ac.isEnabled()) ac.trigger() if not espy.events(): self.assertTrue(espy.wait(1000)) self.assertTrue(w2.isActiveWindow()) ac = action_descendants(w2) self.assertFalse(ac.isEnabled()) ac = action_ancestors(w2) self.assertTrue(ac.isEnabled()) espy = EventSpy(w, QEvent.Show) ac.trigger() if not espy.events(): self.assertTrue(espy.wait(1000)) self.assertTrue(w.isVisible()) workflow.remove_link( workflow.find_links(source_node=nodes[0], sink_node=nodes[2])[0] ) self.assertFalse(action_descendants(w).isEnabled()) self.assertTrue(action_ancestors(w2).isEnabled()) workflow.remove_link( workflow.find_links(source_node=nodes[1], sink_node=nodes[2])[0] ) self.assertFalse(action_ancestors(w2).isEnabled()) def test_window_list_actions(self): workflow = self.scheme nodes = workflow.nodes wm = TestingWidgetManager() wm.set_creation_policy(WidgetManager.Immediate) windowlist = WindowListManager.instance() wm.set_workflow(workflow) w1 = wm.widget_for_node(nodes[0]) w2 = wm.widget_for_node(nodes[1]) ac1 = windowlist.actionForWindow(w1) self.assertTrue(all(ac in w1.actions() for ac in windowlist.actions())) self.assertIn(ac1, w2.actions()) spy = EventSpy(w1, QEvent.Show) ac1.setChecked(True) self.assertIn(QEvent.Show, spy.events()) workflow.remove_node(nodes[0]) self.assertNotIn(ac1, w2.actions()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/scheme/widgetmanager.py0000644000175100002000000006150014730024325024031 0ustar00runnerdockerimport enum import itertools import weakref from functools import partial import logging import sys import traceback from collections import deque from xml.sax.saxutils import escape from typing import Iterable, Dict, Deque, Optional, List, Tuple from AnyQt.QtCore import Qt, QObject, QEvent, QTimer, QCoreApplication from AnyQt.QtCore import Slot, Signal from AnyQt.QtGui import QKeySequence from AnyQt.QtWidgets import QWidget, QLabel, QAction from orangecanvas.resources import icon_loader from orangecanvas.scheme import SchemeNode, Scheme, NodeEvent, LinkEvent, Link from orangecanvas.scheme.events import WorkflowEvent from orangecanvas.scheme.node import UserMessage from orangecanvas.gui.windowlistmanager import WindowListManager from orangecanvas.utils.qinvoke import connect_with_context log = logging.getLogger(__name__) __all__ = ["WidgetManager"] Workflow = Scheme Node = SchemeNode class Item: def __init__(self, node, widget, activation_order=-1, errorwidget=None): # type: (SchemeNode, Optional[QWidget], int, Optional[QWidget]) -> None self.node = node self.widget = widget self.activation_order = activation_order self.errorwidget = errorwidget class WidgetManager(QObject): """ WidgetManager class is responsible for creation, tracking and deletion of UI elements constituting an interactive workflow. It does so by reacting to changes in the underlying workflow model, creating and destroying the components when needed. This is an abstract class, subclassed MUST reimplement at least :func:`create_widget_for_node` and :func:`delete_widget_for_node`. The widgets created with :func:`create_widget_for_node` will automatically receive dispatched events: * :attr:`.WorkflowEvent.InputLinkAdded` - when a new input link is added to the workflow. * :attr:`.WorkflowEvent.InputLinkRemoved` - when a input link is removed. * :attr:`.WorkflowEvent.OutputLinkAdded` - when a new output link is added to the workflow. * :attr:`.WorkflowEvent.OutputLinkRemoved` - when a output link is removed. * :attr:`.WorkflowEvent.InputLinkStateChange` - when the input link's runtime state changes. * :attr:`.WorkflowEvent.OutputLinkStateChange` - when the output link's runtime state changes. * :attr:`.WorkflowEvent.NodeStateChange` - when the node's runtime state changes. * :attr:`.WorkflowEvent.WorkflowEnvironmentChange` - when the workflow environment changes. .. seealso:: :func:`.Scheme.add_link()`, :func:`Scheme.remove_link`, :func:`.Scheme.runtime_env`, :class:`NodeEvent`, :class:`LinkEvent` """ #: A new QWidget was created and added by the manager. widget_for_node_added = Signal(SchemeNode, QWidget) #: A QWidget was removed, hidden and will be deleted when appropriate. widget_for_node_removed = Signal(SchemeNode, QWidget) __init_queue = None # type: Deque[SchemeNode] class CreationPolicy(enum.Enum): """ Widget Creation Policy. """ #: Widgets are scheduled to be created from the event loop, or when #: first accessed with `widget_for_node` Normal = "Normal" #: Widgets are created immediately when a node is added to the #: workflow model. Immediate = "Immediate" #: Widgets are created only when first accessed with `widget_for_node` #: (e.g. when activated in the view). OnDemand = "OnDemand" Normal = CreationPolicy.Normal Immediate = CreationPolicy.Immediate OnDemand = CreationPolicy.OnDemand def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__workflow = None # type: Optional[Scheme] self.__creation_policy = WidgetManager.OnDemand self.__float_widgets_on_top = False self.__item_for_node = {} # type: Dict[SchemeNode, Item] self.__item_for_widget = {} # type: Dict[QWidget, Item] self.__init_queue = deque() self.__init_timer = QTimer(self, singleShot=True) self.__init_timer.timeout.connect(self.__process_init_queue) self.__activation_monitor = ActivationMonitor(self) self.__activation_counter = itertools.count() self.__activation_monitor.activated.connect(self.__mark_activated) self.__windows_list_head = QAction( None, objectName="action-canvas-windows-list-head", ) self.__windows_list_head.setSeparator(True) def set_workflow(self, workflow): # type: (Scheme) -> None """ Set the workflow. """ if workflow is self.__workflow: return if self.__workflow is not None: # cleanup for node in self.__workflow.nodes: self.__remove_node(node) self.__workflow.node_added.disconnect(self.__on_node_added) self.__workflow.node_removed.disconnect(self.__on_node_removed) self.__workflow.removeEventFilter(self) self.__workflow = workflow workflow.node_added.connect( self.__on_node_added, Qt.UniqueConnection) workflow.node_removed.connect( self.__on_node_removed, Qt.UniqueConnection) workflow.installEventFilter(self) for node in workflow.nodes: self.__add_node(node) def workflow(self): # type: () -> Optional[Workflow] return self.__workflow scheme = workflow set_scheme = set_workflow def set_creation_policy(self, policy): # type: (CreationPolicy) -> None """ Set the widget creation policy. """ if self.__creation_policy != policy: self.__creation_policy = policy if self.__creation_policy == WidgetManager.Immediate: self.__init_timer.stop() # create all if self.__workflow is not None: for node in self.__workflow.nodes: self.ensure_created(node) elif self.__creation_policy == WidgetManager.Normal: if not self.__init_timer.isActive() and self.__init_queue: self.__init_timer.start() elif self.__creation_policy == WidgetManager.OnDemand: self.__init_timer.stop() else: assert False def creation_policy(self): """ Return the current widget creation policy. """ return self.__creation_policy def create_widget_for_node(self, node): # type: (SchemeNode) -> QWidget """ Create and initialize a widget for node. This is an abstract method. Subclasses must reimplemented it. """ raise NotImplementedError() def delete_widget_for_node(self, node, widget): # type: (SchemeNode, QWidget) -> None """ Remove and delete widget for node. This is an abstract method. Subclasses must reimplemented it. """ raise NotImplementedError() def node_for_widget(self, widget): # type: (QWidget) -> Optional[SchemeNode] """ Return the node for widget. """ item = self.__item_for_widget.get(widget) if item is not None: return item.node else: return None def widget_for_node(self, node): # type: (SchemeNode) -> Optional[QWidget] """ Return the widget for node. """ self.ensure_created(node) item = self.__item_for_node.get(node) return item.widget if item is not None else None def __add_widget_for_node(self, node): # type: (SchemeNode) -> None item = self.__item_for_node.get(node) if item is not None: return if self.__workflow is None: return if node not in self.__workflow.nodes: return if node in self.__init_queue: self.__init_queue.remove(node) item = Item(node, None, -1) # Insert on the node -> item mapping. self.__item_for_node[node] = item log.debug("Creating widget for node %s", node) try: w = self.create_widget_for_node(node) except Exception: # pylint: disable=broad-except log.critical("", exc_info=True) lines = traceback.format_exception(*sys.exc_info()) text = "".join(lines) errorwidget = QLabel( textInteractionFlags=Qt.TextSelectableByMouse, wordWrap=True, objectName="widgetmanager-error-placeholder", text="
              " + escape(text) + "
              " ) item.errorwidget = errorwidget node.set_state_message( UserMessage(text, UserMessage.Error, "") ) raise else: item.widget = w self.__item_for_widget[w] = item self.__set_float_on_top_flag(w) if w.windowIcon().isNull(): desc = node.description w.setWindowIcon( icon_loader.from_description(desc).get(desc.icon) ) if not w.windowTitle(): w.setWindowTitle(node.title) w.installEventFilter(self.__activation_monitor) raise_canvas = QAction( self.tr("Raise Canvas to Front"), w, objectName="action-canvas-raise-canvas", toolTip=self.tr("Raise containing canvas workflow window"), shortcut=QKeySequence("Ctrl+Up") ) raise_canvas.triggered.connect(self.__on_activate_parent) raise_descendants = QAction( self.tr("Raise Descendants"), w, objectName="action-canvas-raise-descendants", toolTip=self.tr("Raise all immediate descendants of this node"), shortcut=QKeySequence("Ctrl+Shift+Right"), enabled=False, ) raise_descendants.triggered.connect( partial(self.__on_raise_descendants, node) ) raise_ancestors = QAction( self.tr("Raise Ancestors"), w, objectName="action-canvas-raise-ancestors", toolTip=self.tr("Raise all immediate ancestors of this node"), shortcut=QKeySequence("Ctrl+Shift+Left"), enabled=False, ) raise_ancestors.triggered.connect( partial(self.__on_raise_ancestors, node) ) w.addActions([raise_canvas, raise_descendants, raise_ancestors]) w.addAction(self.__windows_list_head) windowmanager = WindowListManager.instance() windowmanager.addWindow(w) w.addActions(windowmanager.actions()) w_ref = weakref.ref(w) # avoid ref cycles in connection closures def addWindowAction(_, a: QAction): w = w_ref() if w is not None: w.addAction(a) def removeWindowAction(_, a: QAction): w = w_ref() if w is not None: w.removeAction(a) connect_with_context( windowmanager.windowAdded, w, addWindowAction, ) connect_with_context( windowmanager.windowRemoved, w, removeWindowAction, ) # send all the post creation notification events workflow = self.__workflow assert workflow is not None inputs = workflow.find_links(sink_node=node) raise_ancestors.setEnabled(bool(inputs)) for i, link in enumerate(inputs): ev = LinkEvent(LinkEvent.InputLinkAdded, link, i) QCoreApplication.sendEvent(w, ev) outputs = workflow.find_links(source_node=node) raise_descendants.setEnabled(bool(outputs)) for i, link in enumerate(outputs): ev = LinkEvent(LinkEvent.OutputLinkAdded, link, i) QCoreApplication.sendEvent(w, ev) self.widget_for_node_added.emit(node, w) def ensure_created(self, node): # type: (SchemeNode) -> None """ Ensure that the widget for node is created. """ if self.__workflow is None: return if node not in self.__workflow.nodes: return item = self.__item_for_node.get(node) if item is None: self.__add_widget_for_node(node) def __on_node_added(self, node): # type: (SchemeNode) -> None assert self.__workflow is not None assert node in self.__workflow.nodes assert node not in self.__item_for_node self.__add_node(node) def __add_node(self, node): # type: (SchemeNode) -> None # add node for tracking node.installEventFilter(self) if self.__creation_policy == WidgetManager.Immediate: self.ensure_created(node) else: self.__init_queue.append(node) if self.__creation_policy == WidgetManager.Normal: self.__init_timer.start() def __on_node_removed(self, node): # type: (SchemeNode) -> None assert self.__workflow is not None assert node not in self.__workflow.nodes self.__remove_node(node) def __remove_node(self, node): # type: (SchemeNode) -> None # remove the node and its widget from tracking. node.removeEventFilter(self) if node in self.__init_queue: self.__init_queue.remove(node) item = self.__item_for_node.get(node) if item is not None and item.widget is not None: widget = item.widget assert widget in self.__item_for_widget del self.__item_for_widget[widget] widget.removeEventFilter(self.__activation_monitor) windowmanager = WindowListManager.instance() windowmanager.removeWindow(widget) item.widget = None self.widget_for_node_removed.emit(node, widget) self.delete_widget_for_node(node, widget) if item is not None: del self.__item_for_node[node] @Slot() def __process_init_queue(self): if self.__init_queue: node = self.__init_queue.popleft() assert self.__workflow is not None assert node in self.__workflow.nodes log.debug("__process_init_queue: '%s'", node.title) try: self.ensure_created(node) finally: if self.__init_queue: self.__init_timer.start() def __mark_activated(self, widget): # type: (QWidget) -> None # Update the tracked stacking order for `widget` item = self.__item_for_widget.get(widget) if item is not None: item.activation_order = next(self.__activation_counter) def activate_widget_for_node(self, node, widget): # type: (SchemeNode, QWidget) -> None """ Activate the widget for node (show and raise above other) """ if widget.windowState() == Qt.WindowMinimized: widget.showNormal() widget.setVisible(True) widget.raise_() widget.activateWindow() def activate_window_group(self, group): # type: (Scheme.WindowGroup) -> None self.restore_window_state(group.state) def raise_widgets_to_front(self): """ Raise all current visible widgets to the front. The widgets will be stacked by activation order. """ workflow = self.__workflow if workflow is None: return items = filter( lambda item: ( item.widget.isVisible() if item is not None and item.widget is not None else False) , map(self.__item_for_node.get, workflow.nodes)) self.__raise_and_activate(items) def set_float_widgets_on_top(self, float_on_top): """ Set `Float Widgets on Top` flag on all widgets. """ self.__float_widgets_on_top = float_on_top for item in self.__item_for_node.values(): if item.widget is not None: self.__set_float_on_top_flag(item.widget) def save_window_state(self): # type: () -> List[Tuple[SchemeNode, bytes]] """ Save current open window arrangement. """ if self.__workflow is None: return [] workflow = self.__workflow # type: Scheme state = [] for node in workflow.nodes: # type: SchemeNode item = self.__item_for_node.get(node, None) if item is None: continue stackorder = item.activation_order if item.widget is not None and not item.widget.isHidden(): data = self.save_widget_geometry(node, item.widget) state.append((stackorder, node, data)) return [(node, data) for _, node, data in sorted(state, key=lambda t: t[0])] def restore_window_state(self, state): # type: (List[Tuple[Node, bytes]]) -> None """ Restore the window state. """ assert self.__workflow is not None workflow = self.__workflow # type: Scheme visible = {node for node, _ in state} # first hide all other widgets for node in workflow.nodes: if node not in visible: # avoid creating widgets if not needed item = self.__item_for_node.get(node, None) if item is not None and item.widget is not None: item.widget.hide() allnodes = set(workflow.nodes) # restore state for visible group; windows are stacked as they appear # in the state list. w = None for node, node_state in filter(lambda t: t[0] in allnodes, state): w = self.widget_for_node(node) # also create it if needed if w is not None: w.show() self.restore_widget_geometry(node, w, node_state) w.raise_() self.__mark_activated(w) # activate (give focus to) the last window if w is not None: w.activateWindow() def save_widget_geometry(self, node, widget): # type: (SchemeNode, QWidget) -> bytes """ Save and return the current geometry and state for node. """ return b'' def restore_widget_geometry(self, node, widget, state): # type: (SchemeNode, QWidget, bytes) -> bool """ Restore the widget geometry and state for node. Return True if the geometry was restored successfully. The default implementation does nothing. """ return False @Slot(SchemeNode) def __on_raise_ancestors(self, node): # type: (SchemeNode) -> None """ Raise all the ancestor widgets of `widget`. """ item = self.__item_for_node.get(node) if item is not None: scheme = self.scheme() assert scheme is not None ancestors = [self.__item_for_node.get(p) for p in scheme.parents(item.node)] self.__raise_and_activate(filter(None, reversed(ancestors))) @Slot(SchemeNode) def __on_raise_descendants(self, node): # type: (SchemeNode) -> None """ Raise all the descendants widgets of `widget`. """ item = self.__item_for_node.get(node) if item is not None: scheme = self.scheme() assert scheme is not None descendants = [self.__item_for_node.get(p) for p in scheme.children(item.node)] self.__raise_and_activate(filter(None, reversed(descendants))) def __raise_and_activate(self, items): # type: (Iterable[Item]) -> None """Show and raise a set of widgets.""" # preserve the tracked stacking order items = sorted(items, key=lambda item: item.activation_order) w = None for item in items: if item.widget is not None: w = item.widget elif item.errorwidget is not None: w = item.errorwidget else: continue w.show() w.raise_() if w is not None: # give focus to the last activated top window w.activateWindow() def __activate_widget_for_node(self, node): # type: (SchemeNode) -> None # activate the widget for the node. self.ensure_created(node) item = self.__item_for_node.get(node) if item is None: return if item.widget is not None: self.activate_widget_for_node(node, item.widget) elif item.errorwidget is not None: item.errorwidget.show() item.errorwidget.raise_() item.errorwidget.activateWindow() def __on_activate_parent(self): event = WorkflowEvent(WorkflowEvent.ActivateParentRequest) QCoreApplication.sendEvent(self.scheme(), event) def __on_link_added_removed(self, link: Link): source = link.source_node sink = link.sink_node item = self.__item_for_node.get(source) if item is not None and item.widget is not None: self.__update_actions_state(item) item = self.__item_for_node.get(sink) if item is not None and item.widget is not None: self.__update_actions_state(item) def __update_actions_state(self, item: Item) -> None: widget = item.widget workflow = self.__workflow if widget is None or workflow is None: return node = item.node inputs = workflow.find_links(sink_node=node) outputs = workflow.find_links(source_node=node) action = widget.findChild(QAction, "action-canvas-raise-ancestors") action.setEnabled(bool(inputs)) action = widget.findChild(QAction, "action-canvas-raise-descendants") action.setEnabled(bool(outputs)) def eventFilter(self, recv, event): # type: (QObject, QEvent) -> bool if isinstance(recv, SchemeNode): if event.type() == NodeEvent.NodeActivateRequest: self.__activate_widget_for_node(recv) self.__dispatch_events(recv, event) elif event.type() == WorkflowEvent.WorkflowEnvironmentChange \ and recv is self.__workflow: for node in self.__item_for_node: self.__dispatch_events(node, event) elif event.type() in (LinkEvent.LinkAdded, LinkEvent.LinkRemoved): self.__on_link_added_removed(event.link()) return False def __dispatch_events(self, node: Node, event: QEvent) -> None: """ Dispatch relevant workflow events to the GUI widget """ if event.type() in ( WorkflowEvent.InputLinkAdded, WorkflowEvent.InputLinkRemoved, WorkflowEvent.InputLinkStateChange, WorkflowEvent.OutputLinkAdded, WorkflowEvent.OutputLinkRemoved, WorkflowEvent.OutputLinkStateChange, WorkflowEvent.NodeStateChange, WorkflowEvent.WorkflowEnvironmentChange, ): item = self.__item_for_node.get(node) if item is not None and item.widget is not None: QCoreApplication.sendEvent(item.widget, event) def __set_float_on_top_flag(self, widget): # type: (QWidget) -> None """Set or unset widget's float on top flag""" should_float_on_top = self.__float_widgets_on_top float_on_top = bool(widget.windowFlags() & Qt.WindowStaysOnTopHint) if float_on_top == should_float_on_top: return widget_was_visible = widget.isVisible() if should_float_on_top: widget.setWindowFlags( widget.windowFlags() | Qt.WindowStaysOnTopHint) else: widget.setWindowFlags( widget.windowFlags() & ~Qt.WindowStaysOnTopHint) # Changing window flags hid the widget if widget_was_visible: widget.show() def actions_for_context_menu(self, node): # type: (SchemeNode) -> List[QAction] """ Return a list of extra actions that can be inserted into context menu in the workflow editor. Subclasses can reimplement this method to extend the default context menu. Parameters ---------- node: SchemeNode The node for which the context menu is requested. Return ------ actions: List[QAction] Actions that are appended to the default menu. """ return [] # Utility class used to preserve window stacking order. class ActivationMonitor(QObject): """ An event filter for monitoring QWidgets for `WindowActivation` events. """ #: Signal emitted with the `QWidget` instance that was activated. activated = Signal(QWidget) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.WindowActivate and isinstance(obj, QWidget): self.activated.emit(obj) return False ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.806081 orange_canvas_core-0.2.5/orangecanvas/styles/0000755000175100002000000000000014730024333020715 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/__init__.py0000644000175100002000000001321414730024325023030 0ustar00runnerdocker""" """ import os import pkgutil import re from functools import wraps from typing import Mapping, Callable, Tuple, List, TypeVar from AnyQt.QtCore import Qt from AnyQt.QtGui import QPalette, QColor _T = TypeVar("_T") def _make_palette( base: QColor, text: QColor, window: QColor, highlight: QColor, highlight_disabled: QColor, text_disabled: QColor, link: QColor, light: QColor, mid: QColor, dark: QColor, shadow: QColor ): palette = QPalette() palette.setColor(QPalette.Window, window) palette.setColor(QPalette.WindowText, text) palette.setColor(QPalette.Disabled, QPalette.WindowText, text_disabled) palette.setColor(QPalette.Base, base) palette.setColor(QPalette.AlternateBase, window) palette.setColor(QPalette.ToolTipBase, window) palette.setColor(QPalette.ToolTipText, text) palette.setColor(QPalette.Text, text) palette.setColor(QPalette.Disabled, QPalette.Text, text_disabled) palette.setColor(QPalette.Button, window) palette.setColor(QPalette.ButtonText, text) palette.setColor(QPalette.Disabled, QPalette.ButtonText, text_disabled) palette.setColor(QPalette.BrightText, Qt.white) palette.setColor(QPalette.Highlight, highlight) palette.setColor(QPalette.Disabled, QPalette.Highlight, highlight_disabled) palette.setColor(QPalette.HighlightedText, text) palette.setColor(QPalette.Light, light) palette.setColor(QPalette.Mid, mid) palette.setColor(QPalette.Dark, dark) palette.setColor(QPalette.Shadow, shadow) palette.setColor(QPalette.Link, link) return palette def _once(f: _T) -> _T: palette = None @wraps(f) def wrapper(): nonlocal palette if palette is None: palette = f() return QPalette(palette) return wrapper @_once def breeze_light() -> QPalette: # 'Breeze-Light' color scheme from KDE. return _make_palette( text=QColor("#30363C"), text_disabled=QColor("#888786"), window=QColor("#EFF0F1"), base=QColor("#FCFCFC"), highlight=QColor("#00B0EF"), highlight_disabled=QColor("#00B0EF"), link=QColor("#0057B4"), light=QColor("#ffffff"), mid=QColor("#c4c9cd"), dark=QColor("#888e93"), shadow=QColor("#474a4c"), ) @_once def breeze_dark() -> QPalette: # 'Breeze Dark' color scheme from KDE. return _make_palette( text=QColor(239, 240, 241), text_disabled=QColor(98, 108, 118), window=QColor(49, 54, 59), base=QColor(35, 38, 41), highlight=QColor(61, 174, 233), highlight_disabled=QColor(61, 174, 233), link=QColor(41, 128, 185), light=QColor(69, 76, 84), mid=QColor(43, 47, 52), dark=QColor(28, 31, 34), shadow=QColor(20, 23, 25), ) @_once def zion_reversed() -> QPalette: # 'Zion Reversed' color scheme from KDE. window = QColor(16, 16, 16) return _make_palette( text=QColor(Qt.white), text_disabled=QColor(85, 85, 85), window=window, base=QColor(Qt.black), highlight=QColor(0, 49, 110), highlight_disabled=window, link=QColor(128, 181, 255), light=QColor(174, 174, 174), mid=QColor(89, 89, 89), dark=QColor(118, 118, 118), shadow=QColor(141, 141, 141), ) @_once def dark(): window = QColor(0x30, 0x30, 0x30) return _make_palette( text=QColor(Qt.white), base=QColor(0x20, 0x20, 0x20), window=QColor(0x30, 0x30, 0x30), text_disabled=QColor(0x9B, 0x9B, 0x9B), highlight=QColor(0x2E, 0x93, 0xFF), highlight_disabled=window, link=QColor(0x2E, 0x93, 0xFF), light=QColor(174, 174, 174), mid=QColor(89, 89, 89), dark=QColor(118, 118, 118), shadow=QColor(141, 141, 141), ) colorthemes = { "breeze-light": breeze_light, "breeze-dark": breeze_dark, "zion-reversed": zion_reversed, "dark": dark } # type: Mapping[str, Callable[[],QPalette]] def style_sheet(stylesheet: str) -> Tuple[str, List[Tuple[str, str]]]: """ Load and return a stylesheet string from path. Extract special `@prefix: subdirname` 'directives' and return the (prefix, dirname) tuples. These should be added to `QDir.searchPath` in order to locate resources. Parameters ---------- stylesheet: str A path to a css (Qt's stylesheet) file. Can be a relative path w.r.t. this package's directory. Returns ------- stylesheet: str searchpaths: List[Tuple[str, str]] """ def process_qss(content: str, base: str): pattern = re.compile( r"^\s*@([a-zA-Z0-9_]+?)\s*:\s*([a-zA-Z0-9_/]+?);\s*$", flags=re.MULTILINE ) matches = pattern.findall(content) paths = [] for prefix, search_path in matches: paths.append((prefix, os.path.join(base, search_path))) content = pattern.sub("", content) return content, paths stylesheet_string = None try: with open(stylesheet, "r", encoding="utf-8") as f: stylesheet_string = f.read() except (OSError, UnicodeDecodeError): pass else: return process_qss(stylesheet_string, os.path.basename(stylesheet)) if not os.path.splitext(stylesheet)[1]: # no extension stylesheet = os.path.extsep.join([stylesheet, "qss"]) resource = stylesheet try: stylesheet_string = pkgutil.get_data(__package__, resource) except FileNotFoundError: return stylesheet_string, [] else: return process_qss(stylesheet_string.decode("utf-8"), os.path.dirname(__file__)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/darkorange.qss0000644000175100002000000000035314730024325023564 0ustar00runnerdocker @canvas_icons: orange; CanvasToolDock QToolBar QToolButton:menu-indicator { height: 8px; width: 8px; } CollapsibleDockWidget QWidget#canvas-quick-dock QToolBar QToolButton:menu-indicator { height: 8px; width: 8px; } ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.806081 orange_canvas_core-0.2.5/orangecanvas/styles/orange/0000755000175100002000000000000014730024333022170 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Arrow.svg0000644000175100002000000000370714730024325024013 0ustar00runnerdocker image/svg+xml././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Document Info.svg0000644000175100002000000000637114730024325025353 0ustar00runnerdocker image/svg+xml././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Dropdown.svg0000644000175100002000000000103214730024325024502 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Grid.svg0000644000175100002000000000431314730024325023600 0ustar00runnerdocker image/svg+xml././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Info.svg0000644000175100002000000000261414730024325023610 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Pause.svg0000644000175100002000000000107514730024325023772 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Search.svg0000644000175100002000000001140614730024325024121 0ustar00runnerdocker image/svg+xml././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange/Text Size.svg0000644000175100002000000000153614730024325024536 0ustar00runnerdocker ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/styles/orange.qss0000644000175100002000000002007714730024325022727 0ustar00runnerdocker/* Default Orange stylesheet */ /* * Icon search paths relative to this files directory. * (main.py script will add this to QDir.searchPaths) */ @canvas_icons: orange; /* Main window background color */ CanvasMainWindow { background-color: #E9EFF2; } CanvasMainWindow::separator { width: 1px; /* when vertical */ height: 1px; /* when horizontal */ } /* The widget buttons in the dock tool box */ WidgetToolBox WidgetToolGrid QToolButton { border: none; background-color: #F2F2F2; border-radius: 8px; color: #333; } /* Dock widget tool box tab buttons (categories) */ WidgetToolBox QToolButton#toolbox-tab-button { /* nativeStyling property overrides the QStyle and uses a fixed drawing routine */ qproperty-nativeStyling_: "false"; font-size: 14px; color: #333; border: none; border-bottom: 1px solid #B5B8B8; background: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #F2F2F2, stop: 0.5 #F2F2F2, stop: 0.8 #EBEBEB, stop: 1.0 #DBDBDB ); } WidgetToolBox QToolButton#toolbox-tab-button:hover { background-color: palette(light); } WidgetToolBox QToolButton#toolbox-tab-button:checked { background-color: palette(dark); } WidgetToolBox QToolButton#toolbox-tab-button:focus { background-color: palette(highlight); border: 1px solid #609ED7 } WidgetToolBox ToolGrid { background-color: #F2F2F2; } WidgetToolBox QWidget#toolbox-contents { background-color: #F2F2F2; /* match the ToolGrid's background */ } WidgetToolBox ToolGrid QToolButton[last-column] { border-right: none; } WidgetToolBox ToolGrid QToolButton { font-size: 10px; } WidgetToolBox ToolGrid QToolButton:focus { background-color: palette(window); } WidgetToolBox ToolGrid QToolButton:hover { background-color: palette(light); } WidgetToolBox ToolGrid QToolButton:pressed { background-color: palette(dark); } /* QuickCategoryToolbar popup menus */ CategoryPopupMenu { background-color: #E9EFF2; } CategoryPopupMenu ToolTree QTreeView::item { height: 25px; border-bottom: 1px solid #e9eff2; } CategoryPopupMenu QTreeView::item:selected { background: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #688EF6, stop: 0.5 #4047f4, stop: 1.0 #2D68F3 ); color: white; } /* Canvas Dock Header */ CollapsibleDockWidget::title { background: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #808080, stop: 1.0 #666 ); } /* Dock widget title bar button icons (icon size for both must be set). * The buttons define the title bar height. */ CollapsibleDockWidget::close-button, CollapsibleDockWidget::float-button { padding: 1px; icon-size: 11px; } CanvasToolDock WidgetToolBox { border: 1px solid #B5B8B8; } /* Toolbar at the bottom of the dock widget when in in expanded state */ CanvasToolDock QToolBar { height: 28; spacing: 1; border: none; color: white; background-color: #898989; } CanvasToolDock QToolBar QToolButton { border: none; color: white; background: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #808080, stop: 1.0 #666 ); } CanvasToolDock QToolBar QToolButton:menu-indicator { image: url(canvas_icons:/Dropdown.svg); subcontrol-position: top right; height: 8px; width: 8px; } CanvasToolDock QToolBar QToolButton:checked, CanvasToolDock QToolBar QToolButton:pressed { background-color: #FFA840; } /* Toolbar in the dock when in collapsed state. */ CollapsibleDockWidget QWidget#canvas-quick-dock QToolBar { spacing: 1; border: none; background-color: #898989; } CollapsibleDockWidget QWidget#canvas-quick-dock QToolBar QToolButton { border: none; color: white; background: qlineargradient( x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #808080, stop: 1.0 #666 ); } CollapsibleDockWidget QWidget#canvas-quick-dock QToolBar QToolButton:menu-indicator { image: url(canvas_icons:/Dropdown.svg); subcontrol-position: top right; height: 8px; width: 8px; } CollapsibleDockWidget QWidget#canvas-quick-dock QToolBar QToolButton:checked, CollapsibleDockWidget QWidget#canvas-quick-dock QToolBar QToolButton:pressed { background-color: #FFA840; } /* Splitter between the widget toolbox and quick help. */ CanvasToolDock QSplitter::handle { border: 1px solid #B5B8B8; background: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #D4D4D4, stop: 0.05 #EDEDED, stop: 0.5 #F2F2F2, stop: 0.95 #EDEDED, stop: 1.0 #E0E0E0 ); } /* Scheme Info Dialog */ SchemeInfoDialog { background-color: #E9EFF2; } SchemeInfoDialog SchemeInfoEdit QLabel { font-weight: bold; font-size: 16px; color: black; } SchemeInfoDialog QLabel#heading { font-size: 21px; color: #515151; } SchemeInfoDialog StyledWidget#auto-show-container * { font-size: 12px; color: #1A1A1A; } SchemeInfoDialog StyledWidget#auto-show-container { border-top: 1px solid #C1C2C3; } SchemeInfoDialog SchemeInfoEdit QLineEdit { padding: 4px; font-size: 12px; color: #1A1A1A; } SchemeInfoDialog SchemeInfoEdit QTextEdit { padding: 4px; background-color: white; font-size: 12px; color: #1A1A1A; } /* Preview Dialog (Recent Schemes and Tutorials) */ PreviewDialog { background-color: #E9EFF2; } PreviewDialog QLabel#heading { font-weight: bold; font-size: 21px; color: #515151; } PreviewDialog PreviewBrowser * { color: #1A1A1A; } PreviewDialog PreviewBrowser TextLabel#path-text { font-size: 12px; } PreviewDialog PreviewBrowser QLabel#path-label { font-size: 12px; } PreviewDialog DropShadowFrame { qproperty-radius_: 10; qproperty-color_: rgb(0, 0, 0, 100); } /* Welcome Screen Dialog */ WelcomeDialog { background-color: #E9EFF2; } WelcomeDialog QToolButton { font-size: 13px; color: #333; } WelcomeDialog QWidget#bottom-bar { border-top: 1px solid #C1C2C3; } WelcomeDialog QWidget#bottom-bar QCheckBox { color: #333; } /* SchemeEditWidget */ SchemeEditWidget { font-size: 12px; } /* Quick Menu */ QuickMenu { background-color: #E9EFF2; } QuickMenu QFrame#menu-frame { border: 1px solid #9CACB4; border-radius: 3px; background-color: #E9EFF2; } /* separating border */ QuickMenu QTreeView::item { border-bottom: 1px solid #e9eff2; } QuickMenu QTreeView::item:selected { background: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #688EF6, stop: 0.5 #4047f4, stop: 1.0 #2D68F3 ); color: white; } /* split 'shortcut' hint item spacing */ QuickMenu QTreeView::item:selected:first { margin-right: 0px; padding-right: 0px; } QuickMenu QTreeView::item:selected:last { margin-left: -1px; padding-left: -1px; } QuickMenu TabBarWidget QToolButton { height: 27px; width: 27px; qproperty-iconSize: 20px; qproperty-showMenuIndicator_: false; qproperty-shadowLength_: 3; } QuickMenu TabBarWidget QToolButton:menu-indicator { image: url(canvas_icons:/arrow-right.svg); subcontrol-position: center right; height: 8px; width: 8px; } /* Quick Menu search line edit */ QuickMenu SearchWidget { height: 22px; margin: 0px; padding: 0px; border: 1px solid #9CACB4; border-radius: 3px; background-color: white; } QuickMenu QLineEdit:focus { border: 2px solid #9CACB4; border-radius: 2px; } QuickMenu QLineEdit QToolButton { qproperty-flat_: false; qproperty-shadowLength_: 3; qproperty-shadowColor_: #454C4F; qproperty-shadowPosition_: 15; border: 1px solid #9CACB4; border-top-left-radius: 3px; border-bottom-left-radius: 3px; background-color: #8E9CA4; padding: 0px; margin: 0px; icon-size: 18px; } QuickMenu QLineEdit QToolButton[checked="true"] { qproperty-shadowPosition_: 0; background-color: #9CACB4; } /* Notifications */ NotificationWidget { margin: 10px; qproperty-dismissMargin_: 10; background: #626262; border: 1px solid #999999; border-radius: 8px; } NotificationWidget QLabel#text-label { color: white; } NotificationWidget QLabel#title-label { color: white; font-weight: bold; } ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.810081 orange_canvas_core-0.2.5/orangecanvas/utils/0000755000175100002000000000000014730024333020532 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/__init__.py0000644000175100002000000002111214730024325022641 0ustar00runnerdockerimport enum import operator import types from functools import reduce import typing from typing import ( Iterable, Set, Any, Optional, Union, Tuple, Callable, Mapping, List, Dict, SupportsInt, cast, overload ) from AnyQt.QtCore import Qt from AnyQt.QtGui import QMouseEvent, QWheelEvent from AnyQt.QtWidgets import QSizePolicy from .qtcompat import toPyObject __all__ = [ "dotted_getattr", "qualified_name", "name_lookup", "type_lookup", "type_lookup_", "asmodule", "check_type", "check_arg", "check_subclass", "unique", "assocv", "assocf", "group_by_all", "mapping_get", "findf", "set_flag", "is_flag_set", "qsizepolicy_is_expanding", "qsizepolicy_is_shrinking", "is_event_source_mouse", "UNUSED", ] if typing.TYPE_CHECKING: H = typing.TypeVar("H", bound=typing.Hashable) A = typing.TypeVar("A") B = typing.TypeVar("B") C = typing.TypeVar("C") K = typing.TypeVar("K") V = typing.TypeVar("V") KV = Tuple[K, V] F = typing.TypeVar("F", bound=int) def dotted_getattr(obj, name): # type: (Any, str) -> Any """ `getattr` like function accepting a dotted name for attribute lookup. """ return reduce(getattr, name.split("."), obj) def qualified_name(obj): # type: (Union[types.FunctionType, type]) -> str """ Return a qualified name for `obj` (type or function). """ if obj.__name__ == "builtins": return obj.__name__ else: return "%s.%s" % (obj.__module__, obj.__name__) def type_str(type_name): # type: (Union[str, Tuple[str, ...]]) -> str if isinstance(type_name, tuple): if len(type_name) == 1: return type_str(type_name[0]) else: return "Union[" + ", ".join(type_str(t) for t in type_name) + "]" elif type_name.startswith("builtin."): return type_name[len("builtin."):] else: return type_name def name_lookup(qualified_name): # type: (str) -> Any """ Return the object referenced by a qualified name (dotted name). """ if "." not in qualified_name: qualified_name = "builtins." + qualified_name module_name, class_name = qualified_name.rsplit(".", 1) module = __import__(module_name, fromlist=[class_name]) return getattr(module, class_name) def type_lookup(qualified_name): # type: (str) -> type """ Return the type referenced by a qualified name. Parameters ---------- qualified_name : str Returns ------- type: type Raises ------ TypeError: If the object referenced by `qualified_name` is not a type """ rval = name_lookup(qualified_name) if not isinstance(rval, type): raise TypeError( "'{}' is a {!r} not a type".format(qualified_name, type(rval)) ) return rval def type_lookup_(tspec): # type: (Union[str, type]) -> Optional[type] if isinstance(tspec, str): return type_lookup(tspec) else: return tspec def asmodule(module): # type: (Union[str, types.ModuleType]) -> types.ModuleType """ Return the :class:`module` instance named by `module`. If `module` is already a module instance and not a string, return it unchanged. """ if isinstance(module, str): return __import__(module, fromlist=[]) else: return module def check_type(obj, type_or_tuple): if not isinstance(obj, type_or_tuple): raise TypeError("Expected %r. Got %r" % (type_or_tuple, type(obj))) def check_subclass(cls, class_or_tuple): # type: (type, Union[type, Tuple[type, ...]]) -> None if not issubclass(cls, class_or_tuple): raise TypeError("Expected %r. Got %r" % (class_or_tuple, type(cls))) def check_arg(pred, value): # type: (bool, str) -> None if not pred: raise ValueError(value) @overload def unique(iterable: Iterable['H']) -> Iterable['H']: ... @overload def unique(iterable: Iterable['A'], key: Callable[['A'], 'H']) -> Iterable['A']: ... def unique(iterable, key=None): # type: (Iterable[A], Optional[Callable[[A], H]]) -> Iterable[A] """ Return unique elements of `iterable` while preserving their order. If `key` is supplied it is used as a substitute for determining 'uniqueness' of elements. Parameters ---------- iterable : Iterable[A] key : Callable[[A], Hashable] Returns ------- unique : Iterable[A] Unique elements from `iterable`. """ seen = set() # type: Set[Union[H, A]] if key is None: key = cast('Callable[[A], H]', lambda t: t) for el in iterable: el_key = key(el) if el_key not in seen: seen.add(el_key) yield el def assocv(seq, key, eq=operator.eq): # type: (Iterable[KV], C, Callable[[K, C], bool]) -> Optional[KV] """ Find and return the first pair `p` in `seq` where `eq(p[0], key) is True` Return None if not found. Parameters ---------- seq: Iterable[Tuple[K, V]] key: C eq: Callable[[K, C], bool] Returns ------- pair: Optional[Tuple[K, V]] """ for k, v in seq: if eq(k, key): return k, v return None def assocf(seq, predicate): # type: (Iterable[KV], Callable[[K], bool]) -> Optional[KV] """ Find and return the first pair `p` in `seq` where `predicate(p[0]) is True` Return None if not found. Parameters ---------- seq: Iterable[Tuple[K, V]] predicate: Callable[[K], bool] Returns ------- pair: Optional[Tuple[K, V]] """ for k, v in seq: if predicate(k): return k, v return None def group_by_all(sequence, key=None): # type: (Iterable[V], Callable[[V], K]) -> List[Tuple[K, List[V]]] order_seen = [] groups = {} # type: Dict[K, List[V]] for item in sequence: if key is not None: item_key = key(item) else: item_key = item # type: ignore if item_key in groups: groups[item_key].append(item) else: groups[item_key] = [item] order_seen.append(item_key) return [(key, groups[key]) for key in order_seen] def mapping_get( mapping, # type: Mapping[K, V] key, # type: K type, # type: Callable[[V], A] default, # type: B ): # type: (...) -> Union[A, B] try: val = mapping[key] except KeyError: return default try: return type(val) except (TypeError, ValueError): return default def findf(iterable, predicate, default=None): # type: (Iterable[A], Callable[[A], bool], B) -> Union[A, B] """ Find and return the first element in iterable where `predicate(el)` is True. Return default if no such element is found. """ for item in iterable: if predicate(item): return item return typing.cast('Union[A, B]', default) def set_flag(flags, mask, on=True): # type: (F, Union[SupportsInt, enum.Flag], bool) -> F if not isinstance(mask, enum.Flag): mask = int(mask) if on: return type(flags)(flags | mask) else: return type(flags)(flags & ~mask) def enum_as_int(value: Union[int, enum.Enum]) -> int: """ Return a `enum.Enum` value as an `int. This is function intended for extracting underlying Qt5/6 enum values specifically with PyQt6 where most Qt enums are represented with `enum.Enum` and lose their numerical value. >>> from PyQt6.QtCore import Qt >>> enum_as_int(Qt.Alignment.AlignLeft) 1 """ if isinstance(value, enum.Enum): return int(value.value) else: return int(value) def is_flag_set(flags, mask): flags_i = enum_as_int(flags) mask_i = enum_as_int(mask) return bool(flags_i & mask_i) def qsizepolicy_is_expanding(policy: QSizePolicy.Policy) -> bool: return is_flag_set(policy, QSizePolicy.ExpandFlag) def qsizepolicy_is_shrinking(policy: QSizePolicy.Policy) -> bool: return is_flag_set(policy, QSizePolicy.ShrinkFlag) def is_event_source_mouse(event: Union[QWheelEvent, QMouseEvent]) -> bool: """ Does th event originate from a mouse type device or from another source (touchpad/screen). """ try: return event.source() != Qt.MouseEventNotSynthesized except AttributeError: # PyQt6 from AnyQt.QtGui import QInputDevice return event.device().type() == QInputDevice.DeviceType.Mouse def UNUSED(*_unused_args) -> None: """ *Mark* the function arguments as unused for a code checker Examples -------- >>> def foo(bar, baz): ... UNUSED(bar, baz) ... return True """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/after_exit.py0000644000175100002000000000531314730024325023241 0ustar00runnerdockerimport argparse import logging import os import sys import subprocess import threading from typing import Sequence, Optional, List import orangecanvas.utils.shtools as sh def run_after_exit( command: Sequence[str] = (), log: Optional[str] = None ) -> None: """ Run the `command` after this process exits. """ # pass read end of a pipe to subprocess. It blocks to read from it # and will not succeed until the write end is closed which will happen at # this process's exit (assuming `w` is not leaked in a fork). command = ["--arg=" + c for c in command] if log is not None: command.append("--log=" + log) command = ["-m", __name__, *command] with __write_fds_lock: r, w = os.pipe() __write_fds.append(w) p = sh.python_process( command, stdin=r, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # close the read end of the pipe os.close(r) # Popen warns in __del__ if child did not complete yet (should # double fork to do this right, but since we exit immediately anyways). __run_after_exit_processes.append(p) __run_after_exit_processes: List[subprocess.Popen] = [] __write_fds: List[int] = [] __write_fds_lock = threading.Lock() def __close_write_fd_after_fork(): while __write_fds: w = __write_fds.pop() try: os.close(w) except OSError: pass __write_fds_lock.release() if hasattr(os, "register_at_fork"): os.register_at_fork( before=__write_fds_lock.acquire, after_in_child=__close_write_fd_after_fork, after_in_parent=__write_fds_lock.release, ) def main(argv): ap = argparse.ArgumentParser() ap.add_argument("-f", default=0, type=int) ap.add_argument("-a", "--arg", action='append', default=[]) ap.add_argument("--log", help="Log file", type=argparse.FileType("w")) ns, rest = ap.parse_known_args(argv) if ns.log is not None: logging.basicConfig(level=logging.INFO, stream=ns.log) log = logging.getLogger(__name__) if ns.f is not None: readfd = int(ns.f) else: readfd = 0 # read form readfd (an os.pipe read end) until EOF indicating parent # closed the pipe (i.e. did exit) log.info("Blocking on read from fd: %d", readfd) c = os.read(readfd, 1) if c != b"": log.error("Unexpected content %r from parent", c) else: log.info("Parent closed fd; %d") if ns.arg: log.info("Starting new process with cmd: %r", ns.arg) p = sh.create_process( ns.arg, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) main.p = p return 0 if __name__ == "__main__": sys.exit(main(sys.argv)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/asyncutils.py0000644000175100002000000000307614730024325023311 0ustar00runnerdockerimport asyncio from concurrent import futures from typing import Optional from AnyQt.QtCore import QCoreApplication, QThread import qasync def get_event_loop() -> asyncio.AbstractEventLoop: """ Get the asyncio.AbstractEventLoop for the main Qt application thread. The QCoreApplication instance must already have been created. Must only be called from the main Qt application thread. """ try: # Python >= 3.7 get_running_loop = asyncio.get_running_loop # type: ignore except AttributeError: get_running_loop = asyncio._get_running_loop # type: ignore app = QCoreApplication.instance() if app is None: raise RuntimeError("QCoreApplication is not running") if app.thread() is not QThread.currentThread(): raise RuntimeError("Called from non-main thread") loop: Optional[asyncio.AbstractEventLoop] try: loop = get_running_loop() except RuntimeError: loop = None else: if loop is not None: return loop if loop is None: loop = qasync.QEventLoop(app) # Do not use qasync.QEventLoop's default executor which uses QThread # based pool and exhibits https://github.com/numpy/numpy/issues/11551 loop.set_default_executor(futures.ThreadPoolExecutor()) try: # qasync>=0.24.2 no longer sets the running loop in QEventLoop # constructor get_running_loop() except RuntimeError: asyncio.events._set_running_loop(loop) assert get_running_loop() is not None return loop ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/graph.py0000644000175100002000000000462714730024325022217 0ustar00runnerdockerimport itertools from collections import deque from typing import TypeVar, Iterable, Callable, Set, List, Hashable __all__ = [ "traverse_bf", "strongly_connected_components" ] H = TypeVar("H", bound=Hashable) def traverse_bf(start, expand): # type: (H, Callable[[H], Iterable[H]]) -> Iterable[H] """ Breadth first traversal of a DAG starting from `start`. Parameters ---------- start : H A starting node expand : (H) -> Iterable[H] A function returning children of a node. """ queue = deque([start]) visited = set() # type: Set[H] while queue: item = queue.popleft() if item not in visited: yield item visited.add(item) queue.extend(expand(item)) def strongly_connected_components(nodes, expand): # type: (Iterable[H], Callable[[H], Iterable[H]]) -> List[List[H]] """ Return a list of strongly connected components. Implementation of Tarjan's SCC algorithm. """ # SCC found components = [] # type: List[List[H]] # node stack in BFS stack = [] # type: List[H] # == set(stack) : a set of all nodes in stack (for faster lookup) stackset = set() # node -> int increasing node numbering as encountered in DFS traversal index = {} # node -> int the lowest node index reachable from a node lowlink = {} indexgen = itertools.count() def push_node(v): # type: (H) -> None """Push node onto the stack.""" stack.append(v) stackset.add(v) index[v] = lowlink[v] = next(indexgen) def pop_scc(v): # type: (H) -> List[H] """Pop from the stack a SCC rooted at node v.""" i = stack.index(v) scc = stack[i:] del stack[i:] stackset.difference_update(scc) return scc def isvisited(node): # type: (H) -> bool return node in index def strong_connect(v): # type: (H) -> None push_node(v) for w in expand(v): if not isvisited(w): strong_connect(w) lowlink[v] = min(lowlink[v], lowlink[w]) elif w in stackset: lowlink[v] = min(lowlink[v], index[w]) if index[v] == lowlink[v]: scc = pop_scc(v) components.append(scc) for node in nodes: if not isvisited(node): strong_connect(node) return components ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/image.py0000644000175100002000000000652014730024325022172 0ustar00runnerdockerfrom typing import Sequence import numpy as np from AnyQt.QtGui import QImage, QColor def qrgb( r: Sequence[int], g: Sequence[int], b: Sequence[int] ) -> Sequence[int]: """A vectorized `qRgb`.""" r, g, b = map(lambda a: np.asarray(a, dtype=np.uint32), (r, g, b)) return (0xff << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff) def qrgba( r: Sequence[int], g: Sequence[int], b: Sequence[int], a: Sequence[int] ) -> Sequence[int]: """A vectorized `qRgba`.""" r, g, b, a = map(lambda a: np.asarray(a, dtype=np.uint32), (r, g, b, a)) return ((a & 0xff) << 24) | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff) def qgray( r: Sequence[int], g: Sequence[int], b: Sequence[int] ) -> Sequence[int]: """A vectorized `qGray`.""" r, g, b = map(lambda a: np.asarray(a, dtype=np.uint16), (r, g, b)) return (r * 11 + g * 16 + b * 5) // 32 def qred(rgb: Sequence[int]) -> Sequence[int]: """A vectorized `qRed`.""" rgb = np.asarray(rgb, np.uint32) return (rgb >> 16) & 0xff def qgreen(rgb: Sequence[int]) -> Sequence[int]: """A vectorized `qGreen`.""" rgb = np.asarray(rgb, np.uint32) return (rgb >> 8) & 0xff def qblue(rgb: Sequence[int]) -> Sequence[int]: """A vectorized `qBlue`.""" rgb = np.asarray(rgb, np.uint32) return rgb & 0xff def qalpha(rgb: Sequence[int]) -> Sequence[int]: """A vectorized `qAlpha`.""" rgb = np.asarray(rgb, np.uint32) return (rgb >> 24) & 0xff def grayscale_invert( src: QImage, foreground: QColor, background: QColor ) -> QImage: """ Convert the `src` image to grayscale and invert it into background to foreground (gray) range. Parameters ---------- src: QImage foreground: QColor background: QColor Returns ------- image: QImage """ image = src.convertToFormat(QImage.Format_ARGB32) size = image.size() w, h = shape = size.width(), size.height() buffer = image.bits().asarray(w * h * 4) view = np.frombuffer(buffer, np.uint32).reshape(shape) r, g, b, a = qred(view), qgreen(view), qblue(view), qalpha(view) gray = qgray(r, g, b) factor = gray / 255 foreground = qgray(foreground.red(), foreground.blue(), foreground.green()) background = qgray(background.red(), background.blue(), background.green()) if foreground > background: minv_, maxv_ = background, foreground else: minv_, maxv_ = foreground, background inv = (1 - factor) * (maxv_ - minv_) + minv_ inv = np.asarray(inv, np.uint8) rgba = qrgba(inv, inv, inv, a) res = QImage(w, h, QImage.Format_ARGB32) return qimage_copy_from_buffer(res, rgba) def qimage_copy_from_buffer(image: QImage, data: np.ndarray) -> QImage: """ Copy the `data` to `image`. Parameters ---------- image: QImage The destination image. data: np.ndarray The raw source data in the same format as the `image`. """ w, h = image.width(), image.height() if data.shape != (w, h): raise ValueError( f"Wrong data.shape (expected ({w}, {h}) got {data.shape})" ) d = image.depth() // 8 dtype = { 1: np.uint8, 2: np.uint16, 4: np.uint32, 8: np.uint64 }[d] dest = image.bits().asarray(w * h * d) dest = np.frombuffer(dest, dtype).reshape((w, h)) dest[:] = data return image ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.810081 orange_canvas_core-0.2.5/orangecanvas/utils/localization/0000755000175100002000000000000014730024333023222 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/localization/__init__.py0000644000175100002000000000032314730024325025332 0ustar00runnerdockerimport warnings from orangecanvas.localization import * # pylint: disable=unused-import warnings.warn( "import 'orangecanvas.localization', not 'orangecanvas.utils.localization'", DeprecationWarning) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/localization/si.py0000644000175100002000000000033514730024325024211 0ustar00runnerdockerimport warnings from orangecanvas.localization.si import * # pylint: disable=unused-import warnings.warn( "import 'orangecanvas.localization.si', not 'orangecanvas.utils.localization.si'", DeprecationWarning) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/markup.py0000644000175100002000000000437014730024325022410 0ustar00runnerdockerfrom collections import OrderedDict from xml.sax.saxutils import escape from typing import Mapping, Callable import docutils.core def render_plain(content: str) -> str: """ Return a html fragment for a plain pre-formatted text Parameters ---------- content : str Plain text content Returns ------- html : str """ return '

              ' + escape(content) + "

              " def render_html(content: str) -> str: """ Return a html fragment unchanged. Parameters ---------- content : str Html text. Returns ------- html : str """ return content def render_markdown(content: str) -> str: """ Return a html fragment from markdown text content Parameters ---------- content : str A markdown formatted text Returns ------- html : str """ # commonmark >= 0.8.1; but only optionally. Many other packages may pin it # to <0.8 due to breaking changes. try: import commonmark except ImportError: return render_plain(content) else: return commonmark.commonmark(content) def render_rst(content: str) -> str: """ Return a html fragment from a RST text content Parameters ---------- content : str A RST formatted text content Returns ------- html : str """ overrides = { "report_level": 10, # suppress errors from appearing in the html "output-encoding": "utf-8" } html = docutils.core.publish_string( content, writer_name="html", settings_overrides=overrides ) return html.decode("utf-8") ContentRenderer = OrderedDict([ ("text/plain", render_plain), ("text/rst", render_rst), ("text/x-rst", render_rst), ("text/markdown", render_markdown), ("text/html", render_html), ]) # type: Mapping[str, Callable[[str], str]] def render_as_rich_text(content: str, content_type="text/plain") -> str: # split off the parameters (not supported) content_type, _, _ = content_type.partition(";") renderer = ContentRenderer.get(content_type.lower(), render_plain) try: return renderer(content) except (ImportError, ValueError): return render_plain(content) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/overlay.py0000644000175100002000000006625514730024325022604 0ustar00runnerdockerimport enum import functools import logging import operator import sys from collections import namedtuple from AnyQt.QtCore import Signal, Qt, QSize, Slot, QObject, Property, QRect, QEvent, QPoint from AnyQt.QtGui import QIcon, QPixmap, QPainter, QPalette from AnyQt.QtWidgets import QAbstractButton, QHBoxLayout, QPushButton, QStyle, QWidget, \ QVBoxLayout, QLabel, QSizePolicy, QStyleOption, QFocusFrame, QStylePainter, QStyleOptionButton from orangecanvas.gui.stackedwidget import StackLayout from orangecanvas.utils import qsizepolicy_is_expanding log = logging.getLogger(__name__) class StandardButton(enum.IntEnum): NoButton, Ok, Close, Dismiss = 0x0, 0x1, 0x2, 0x4 class ButtonRole(enum.IntEnum): InvalidRole, AcceptRole, RejectRole, DismissRole = 0, 1, 2, 3 class Notification(QObject): """ Notification data bean used to construct NotificationWidget instances. Pass an instance of this class to NotificationServer.registerNotification(). Args: title (str) text (str) accept_button_label (str) reject_button_label (str) iconPath (str): Relative to Orange directory """ InvalidRole, AcceptRole, RejectRole, DismissRole = list(ButtonRole) # upon calling NotificationServer.registerNotification, # the following signals are connected to the instantiated NotificationWidgets' clicked = Signal(ButtonRole) accepted = Signal() rejected = Signal() dismissed = Signal() def __init__(self, title, text, accept_button_label=None, reject_button_label=None, icon=None, *args, **kwargs): super().__init__(*args, **kwargs) self.title = title self.text = text self.accept_button_label = accept_button_label self.reject_button_label = reject_button_label self.icon = icon class OverlayWidget(QWidget): """ A widget positioned on top of another widget. """ def __init__(self, parent=None, alignment=Qt.AlignCenter, **kwargs): super().__init__(parent, **kwargs) self.setContentsMargins(0, 0, 0, 0) self.__alignment = alignment self.__widget = None def setWidget(self, widget): """ Set the widget over which this overlay should be displayed (anchored). :type widget: QWidget """ if self.__widget is not None: self.__widget.removeEventFilter(self) self.__widget.destroyed.disconnect(self.__on_destroyed) self.__widget = widget if self.__widget is not None: self.__widget.installEventFilter(self) self.__widget.destroyed.connect(self.__on_destroyed) if self.__widget is None: self.hide() else: self.__layout() def widget(self): """ Return the overlaid widget. :rtype: QWidget | None """ return self.__widget def setAlignment(self, alignment): """ Set overlay alignment. :type alignment: Qt.Alignment """ if self.__alignment != alignment: self.__alignment = alignment if self.__widget is not None: self.__layout() def alignment(self): """ Return the overlay alignment. :rtype: Qt.Alignment """ return self.__alignment def eventFilter(self, recv, event): # reimplemented if recv is self.__widget: if event.type() == QEvent.Resize or event.type() == QEvent.Move: self.__layout() elif event.type() == QEvent.Show: self.show() elif event.type() == QEvent.Hide: self.hide() return super().eventFilter(recv, event) def event(self, event): # reimplemented if event.type() == QEvent.LayoutRequest: self.__layout() return True else: return super().event(event) def paintEvent(self, event): opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) def showEvent(self, event): super().showEvent(event) # Force immediate re-layout on show self.__layout() def __layout(self): # position itself over `widget` # pylint: disable=too-many-branches widget = self.__widget if widget is None: return alignment = self.__alignment policy = self.sizePolicy() if widget.window() is self.window() and not self.isWindow(): if widget.isWindow(): bounds = widget.rect() else: bounds = QRect(widget.mapTo(widget.window(), QPoint(0, 0)), widget.size()) tl = self.parent().mapFrom(widget.window(), bounds.topLeft()) bounds = QRect(tl, widget.size()) else: if widget.isWindow(): bounds = widget.geometry() else: bounds = QRect(widget.mapToGlobal(QPoint(0, 0)), widget.size()) if self.isWindow(): bounds = bounds else: bounds = QRect(self.parent().mapFromGlobal(bounds.topLeft()), bounds.size()) sh = self.sizeHint() minsh = self.minimumSizeHint() minsize = self.minimumSize() if minsize.isNull(): minsize = minsh maxsize = bounds.size().boundedTo(self.maximumSize()) minsize = minsize.boundedTo(maxsize) effectivesh = sh.expandedTo(minsize).boundedTo(maxsize) hpolicy = policy.horizontalPolicy() vpolicy = policy.verticalPolicy() if not effectivesh.isValid(): effectivesh = QSize(0, 0) vpolicy = hpolicy = QSizePolicy.Ignored def getsize(hint, minimum, maximum, policy): if policy == QSizePolicy.Ignored: return maximum elif qsizepolicy_is_expanding(policy): return maximum else: return max(hint, minimum) width = getsize(effectivesh.width(), minsize.width(), maxsize.width(), hpolicy) heightforw = self.heightForWidth(width) if heightforw > 0: height = getsize(heightforw, minsize.height(), maxsize.height(), vpolicy) else: height = getsize(effectivesh.height(), minsize.height(), maxsize.height(), vpolicy) size = QSize(width, height) if alignment & Qt.AlignLeft: x = bounds.x() elif alignment & Qt.AlignRight: x = bounds.x() + bounds.width() - size.width() else: x = bounds.x() + max(0, bounds.width() - size.width()) // 2 if alignment & Qt.AlignTop: y = bounds.y() elif alignment & Qt.AlignBottom: y = bounds.y() + bounds.height() - size.height() else: y = bounds.y() + max(0, bounds.height() - size.height()) // 2 geom = QRect(QPoint(x, y), size) self.setGeometry(geom) @Slot() def __on_destroyed(self): self.__widget = None if self.isVisible(): self.hide() class NotificationMessageWidget(QWidget): #: Emitted when a button with the AcceptRole is clicked accepted = Signal() #: Emitted when a button with the RejectRole is clicked rejected = Signal() #: Emitted when a button is clicked clicked = Signal(QAbstractButton) NoButton, Ok, Close = list(StandardButton)[:3] InvalidRole, AcceptRole, RejectRole = list(ButtonRole)[:3] _Button = namedtuple("_Button", ["button", "role", "stdbutton"]) def __init__(self, parent=None, icon=QIcon(), title="", text="", wordWrap=False, textFormat=Qt.PlainText, standardButtons=NoButton, acceptLabel="Ok", rejectLabel="No", **kwargs): super().__init__(parent, **kwargs) self._title = title self._text = text self._icon = QIcon() self._wordWrap = wordWrap self._standardButtons = NotificationMessageWidget.NoButton self._buttons = [] self._acceptLabel = acceptLabel self._rejectLabel = rejectLabel self._iconlabel = QLabel(objectName="icon-label") self._titlelabel = QLabel(objectName="title-label", text=title, wordWrap=wordWrap, textFormat=textFormat) self._textlabel = QLabel(objectName="text-label", text=text, wordWrap=wordWrap, textFormat=textFormat) self._textlabel.setTextInteractionFlags(Qt.TextBrowserInteraction) self._textlabel.setOpenExternalLinks(True) if sys.platform == "darwin": self._titlelabel.setAttribute(Qt.WA_MacSmallSize) self._textlabel.setAttribute(Qt.WA_MacSmallSize) layout = QHBoxLayout() self._iconlabel.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) layout.addWidget(self._iconlabel) layout.setAlignment(self._iconlabel, Qt.AlignTop) message_layout = QVBoxLayout() self._titlelabel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) if sys.platform == "darwin": self._titlelabel.setContentsMargins(0, 1, 0, 0) else: self._titlelabel.setContentsMargins(0, 0, 0, 0) message_layout.addWidget(self._titlelabel) self._textlabel.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) message_layout.addWidget(self._textlabel) self.buttonLayout = QHBoxLayout() self.buttonLayout.setAlignment(Qt.AlignLeft) message_layout.addLayout(self.buttonLayout) layout.addLayout(message_layout) layout.setSpacing(7) self.setLayout(layout) self.setIcon(icon) self.setStandardButtons(standardButtons) def setText(self, text): """ Set the current message text. :type message: str """ if self._text != text: self._text = text self._textlabel.setText(text) def text(self): """ Return the current message text. :rtype: str """ return self._text def setTitle(self, title): """ Set the current title text. :type title: str """ if self._title != title: self._title = title self._titleLabel.setText(title) def title(self): """ Return the current title text. :rtype: str """ return self._title def setIcon(self, icon): """ Set the message icon. :type icon: QIcon | QPixmap | QString | QStyle.StandardPixmap """ if isinstance(icon, QStyle.StandardPixmap): icon = self.style().standardIcon(icon) else: icon = QIcon(icon) if self._icon != icon: self._icon = QIcon(icon) if not self._icon.isNull(): size = self.style().pixelMetric( QStyle.PM_SmallIconSize, None, self) pm = self._icon.pixmap(QSize(size, size)) else: pm = QPixmap() self._iconlabel.setPixmap(pm) self._iconlabel.setVisible(not pm.isNull()) def icon(self): """ Return the current icon. :rtype: QIcon """ return QIcon(self._icon) def setWordWrap(self, wordWrap): """ Set the message text wrap property :type wordWrap: bool """ if self._wordWrap != wordWrap: self._wordWrap = wordWrap self._textlabel.setWordWrap(wordWrap) def wordWrap(self): """ Return the message text wrap property. :rtype: bool """ return self._wordWrap def setTextFormat(self, textFormat): """ Set message text format :type textFormat: Qt.TextFormat """ self._textlabel.setTextFormat(textFormat) def textFormat(self): """ Return the message text format. :rtype: Qt.TextFormat """ return self._textlabel.textFormat() def setAcceptLabel(self, label): """ Set the accept button label. :type label: str """ self._acceptLabel = label def acceptLabel(self): """ Return the accept button label. :rtype str """ return self._acceptLabel def setRejectLabel(self, label): """ Set the reject button label. :type label: str """ self._rejectLabel = label def rejectLabel(self): """ Return the reject button label. :rtype str """ return self._rejectLabel def setStandardButtons(self, buttons): for button in StandardButton: existing = self.button(button) if button & buttons and existing is None: self.addButton(button) elif existing is not None: self.removeButton(existing) def standardButtons(self): return functools.reduce( operator.ior, (slot.stdbutton for slot in self._buttons if slot.stdbutton is not None), NotificationMessageWidget.NoButton) def addButton(self, button, *rolearg): """ addButton(QAbstractButton, ButtonRole) addButton(str, ButtonRole) addButton(StandardButton) Add and return a button """ stdbutton = None if isinstance(button, QAbstractButton): if len(rolearg) != 1: raise TypeError("Wrong number of arguments for " "addButton(QAbstractButton, role)") role = rolearg[0] elif isinstance(button, StandardButton): if rolearg: raise TypeError("Wrong number of arguments for " "addButton(StandardButton)") stdbutton = button if button == NotificationMessageWidget.Ok: role = NotificationMessageWidget.AcceptRole button = QPushButton(self._acceptLabel, default=False, autoDefault=False) elif button == NotificationMessageWidget.Close: role = NotificationMessageWidget.RejectRole button = QPushButton(self._rejectLabel, default=False, autoDefault=False) elif isinstance(button, str): if len(rolearg) != 1: raise TypeError("Wrong number of arguments for " "addButton(str, ButtonRole)") role = rolearg[0] button = QPushButton(button, default=False, autoDefault=False) if sys.platform == "darwin": button.setAttribute(Qt.WA_MacSmallSize) self._buttons.append(NotificationMessageWidget._Button(button, role, stdbutton)) button.clicked.connect(self._button_clicked) self._relayout() return button def _relayout(self): for slot in self._buttons: self.buttonLayout.removeWidget(slot.button) order = { NotificationWidget.AcceptRole: 0, NotificationWidget.RejectRole: 1, } ordered = sorted([b for b in self._buttons], key=lambda slot: order.get(slot.role, -1)) prev = self._textlabel for slot in ordered: self.buttonLayout.addWidget(slot.button) QWidget.setTabOrder(prev, slot.button) def removeButton(self, button): """ Remove a `button`. :type button: QAbstractButton """ slot = [s for s in self._buttons if s.button is button] if slot: slot = slot[0] self._buttons.remove(slot) self.layout().removeWidget(slot.button) slot.button.setParent(None) def buttonRole(self, button): """ Return the ButtonRole for button :type button: QAbstractButton """ for slot in self._buttons: if slot.button is button: return slot.role return NotificationMessageWidget.InvalidRole def button(self, standardButton): """ Return the button for the StandardButton. :type standardButton: StandardButton """ for slot in self._buttons: if slot.stdbutton == standardButton: return slot.button return None def _button_clicked(self): button = self.sender() role = self.buttonRole(button) self.clicked.emit(button) if role == NotificationMessageWidget.AcceptRole: self.accepted.emit() elif role == NotificationMessageWidget.RejectRole: self.rejected.emit() class DismissButton(QAbstractButton): """ A simple icon button widget. """ def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) self.__focusframe = None def focusInEvent(self, event): # reimplemented event.accept() if self.__focusframe is None: self.__focusframe = QFocusFrame(self) self.__focusframe.setWidget(self) palette = self.palette() palette.setColor(QPalette.WindowText, palette.color(QPalette.Highlight)) self.__focusframe.setPalette(palette) def focusOutEvent(self, event): # reimplemented event.accept() if self.__focusframe is not None: self.__focusframe.hide() self.__focusframe.deleteLater() self.__focusframe = None def event(self, event): if event.type() == QEvent.Enter or event.type() == QEvent.Leave: self.update() return super().event(event) def sizeHint(self): # reimplemented self.ensurePolished() iconsize = self.iconSize() icon = self.icon() if not icon.isNull(): iconsize = icon.actualSize(iconsize) return iconsize def minimumSizeHint(self): # reimplemented return self.sizeHint() def paintEvent(self, event): painter = QStylePainter(self) option = QStyleOptionButton() option.initFrom(self) option.text = "" option.icon = self.icon() option.iconSize = self.iconSize() option.features = QStyleOptionButton.Flat if self.isDown(): option.state |= QStyle.State_Sunken painter.drawPrimitive(QStyle.PE_PanelButtonBevel, option) if not option.icon.isNull(): if option.state & QStyle.State_Active: mode = (QIcon.Active if option.state & QStyle.State_MouseOver else QIcon.Normal) else: mode = QIcon.Disabled if self.isChecked(): state = QIcon.On else: state = QIcon.Off option.icon.paint(painter, option.rect, Qt.AlignCenter, mode, state) def proxydoc(func): return functools.wraps(func, assigned=["__doc__"], updated=[]) class NotificationWidget(QWidget): #: Emitted when a button with an Accept role is clicked accepted = Signal() #: Emitted when a button with a Reject role is clicked rejected = Signal() #: Emitted when a button with a Dismiss role is clicked dismissed = Signal() #: Emitted when a button is clicked clicked = Signal(QAbstractButton) NoButton, Ok, Close, Dismiss = list(StandardButton) InvalidRole, AcceptRole, RejectRole, DismissRole = list(ButtonRole) def __init__(self, parent, title="", text="", textFormat=Qt.AutoText, icon=QIcon(), wordWrap=True, standardButtons=NoButton, acceptLabel="Ok", rejectLabel="No", **kwargs): super().__init__(parent, **kwargs) self._dismissMargin = 10 layout = QHBoxLayout() if sys.platform == "darwin": layout.setContentsMargins(6, 6, 6, 6) else: layout.setContentsMargins(9, 9, 9, 9) self._msgWidget = NotificationMessageWidget( parent=self, title=title, text=text, textFormat=textFormat, icon=icon, wordWrap=wordWrap, standardButtons=standardButtons, acceptLabel=acceptLabel, rejectLabel=rejectLabel ) self.dismissButton = DismissButton(parent=self, icon=QIcon(self.style().standardIcon( QStyle.SP_TitleBarCloseButton))) self.dismissButton.setFixedSize(18, 18) self.dismissButton.clicked.connect(self.dismissed) def dismiss_handler(): self.clicked.emit(self.dismissButton) self.dismissButton.clicked.connect(dismiss_handler) self._msgWidget.accepted.connect(self.accepted) self._msgWidget.rejected.connect(self.rejected) self._msgWidget.clicked.connect(self.clicked) layout.addWidget(self._msgWidget) self.setLayout(layout) self.setFixedWidth(400) def dismissMargin(self): return self._dismissMargin def setDismissMargin(self, margin): self._dismissMargin = margin dismissMargin_ = Property(int, fget=dismissMargin, fset=setDismissMargin, designable=True) def resizeEvent(self, event): super().resizeEvent(event) if sys.platform == "darwin": corner_margin = 6 else: corner_margin = 7 x = self.width() - self.dismissButton.width() - self._dismissMargin - corner_margin y = self._dismissMargin + corner_margin self.dismissButton.move(x, y) def paintEvent(self, event): opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) @staticmethod def fromNotification(notif: Notification, parent=None): """ Creates NotificatonWidget from a Notification. :type: Notification :rtype: NotificationWidget """ kwargs = {} kwargs['title'] = notif.title kwargs['text'] = notif.text if notif.icon: kwargs['icon'] = notif.icon buttons = 0 if notif.accept_button_label: kwargs['acceptLabel'] = notif.accept_button_label buttons |= NotificationWidget.Ok if notif.reject_button_label: kwargs['rejectLabel'] = notif.reject_button_label buttons |= NotificationWidget.Close kwargs['standardButtons'] = buttons notifWidget = NotificationWidget(parent, **kwargs) return notifWidget @proxydoc(NotificationMessageWidget.setText) def setText(self, text): self._msgWidget.setText(text) @proxydoc(NotificationMessageWidget.text) def text(self): return self._msgWidget.text() @proxydoc(NotificationMessageWidget.setTitle) def setTitle(self, title): self._msgWidget.setTitle(title) @proxydoc(NotificationMessageWidget.title) def title(self): return self._msgWidget.title() @proxydoc(NotificationMessageWidget.setIcon) def setIcon(self, icon): self._msgWidget.setIcon(icon) @proxydoc(NotificationMessageWidget.icon) def icon(self): return self._msgWidget.icon() @proxydoc(NotificationMessageWidget.textFormat) def textFormat(self): return self._msgWidget.textFormat() @proxydoc(NotificationMessageWidget.setTextFormat) def setTextFormat(self, textFormat): self._msgWidget.setTextFormat(textFormat) @proxydoc(NotificationMessageWidget.setStandardButtons) def setStandardButtons(self, buttons): self._msgWidget.setStandardButtons(buttons) @proxydoc(NotificationMessageWidget.addButton) def addButton(self, *args): return self._msgWidget.addButton(*args) @proxydoc(NotificationMessageWidget.removeButton) def removeButton(self, button): self._msgWidget.removeButton(button) @proxydoc(NotificationMessageWidget.buttonRole) def buttonRole(self, button): if button is self.dismissButton: return NotificationWidget.DismissRole return self._msgWidget.buttonRole(button) @proxydoc(NotificationMessageWidget.button) def button(self, standardButton): return self._msgWidget.button(standardButton) class NotificationOverlay(OverlayWidget): def __init__(self, parent, alignment=Qt.AlignRight | Qt.AlignBottom, **kwargs): """ An overlay for queueing/stacking notifications. """ super().__init__(parent, alignment=alignment, **kwargs) self.setWidget(parent) layout = StackLayout() self.setLayout(layout) self._widgets = [] def currentWidget(self): """ Return the currently displayed widget. """ if not self._widgets: return None return self._widgets[0] @Slot(Notification) def addNotification(self, notif: Notification): notifWidget = NotificationWidget.fromNotification(notif, parent=self) notifWidget.clicked.connect(lambda b: notif.clicked.emit(notifWidget.buttonRole(b))) notifWidget.accepted.connect(notif.accepted) notifWidget.rejected.connect(notif.rejected) notifWidget.dismissed.connect(notif.dismissed) self._addWidget(notifWidget) @Slot() def nextWidget(self): """ Removes first widget from the stack. """ if not self._widgets: log.error("Received next notification signal while no notification is displayed") return widget = self._widgets.pop(0) self.layout().removeWidget(widget) widget.close() def _addWidget(self, widget): """ Append the widget to the stack. """ self._widgets.append(widget) self.layout().addWidget(widget) class NotificationServer(QObject): # emits when new notification is registered newNotification = Signal(Notification) # emits when a notification is responded to nextNotification = Signal() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # list of queued Notification objects, to populate newly generated canvases self._notificationQueue = [] self.nextNotification.connect(self._nextNotification) def registerNotification(self, notif: Notification): """ :type notif: Notification After instantiating a Notification, use this method to send it. Queues notification in all canvas instances (shows it if no other notifications present). """ notif.clicked.connect(self.nextNotification) self._notificationQueue.append(notif) self.newNotification.emit(notif) def getNotificationQueue(self): """ Getter used to populate new canvas instances with the current notification queue. :rtype: Iterable[Notification] """ return self._notificationQueue @Slot() def _nextNotification(self): if not self._notificationQueue: log.error("Received next notification signal while no notification is enqueued") return self._notificationQueue.pop(0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/pickle.py0000644000175100002000000000555414730024325022365 0ustar00runnerdockerimport glob import os import pickle from AnyQt.QtCore import QSettings from orangecanvas import config from ..scheme import Scheme, SchemeNode, SchemeLink, BaseSchemeAnnotation class Pickler(pickle.Pickler): def __init__(self, file, document): super().__init__(file) self.document = document def persistent_id(self, obj): if isinstance(obj, Scheme): return 'scheme' elif isinstance(obj, SchemeNode) and obj in self.document.cleanNodes(): return "SchemeNode_" + str(self.document.cleanNodes().index(obj)) elif isinstance(obj, SchemeLink) and obj in self.document.cleanLinks(): return "SchemeLink_" + str(self.document.cleanLinks().index(obj)) elif isinstance(obj, BaseSchemeAnnotation) and obj in self.document.cleanAnnotations(): return "BaseSchemeAnnotation_" + str(self.document.cleanAnnotations().index(obj)) else: return None class Unpickler(pickle.Unpickler): def __init__(self, file, scheme): super().__init__(file) self.scheme = scheme def persistent_load(self, pid: str): if pid == 'scheme': return self.scheme elif pid.startswith('SchemeNode_'): node_index = int(pid.split('_')[1]) return self.scheme.nodes[node_index] elif pid.startswith('SchemeLink_'): link_index = int(pid.split('_')[1]) return self.scheme.links[link_index] elif pid.startswith('BaseSchemeAnnotation_'): annotation_index = int(pid.split('_')[1]) return self.scheme.annotations[annotation_index] else: raise pickle.UnpicklingError("Unsupported persistent object") def scratch_swp_base_name(): filename = 'scratch.swp.p' dirname = os.path.join(config.data_dir(), 'scratch-crashes') os.makedirs(dirname, exist_ok=True) swpname = os.path.join(dirname, filename) return swpname canvas_scratch_name_memo = {} def swp_name(canvas): document = canvas.current_document() if document.path(): filename = os.path.basename(document.path()) dirname = os.path.dirname(document.path()) return os.path.join(dirname, '.' + filename + ".swp.p") # else it's a scratch workflow if not QSettings().value('startup/load-crashed-workflows', True, type=bool): return None global canvas_scratch_name_memo if canvas in canvas_scratch_name_memo: return canvas_scratch_name_memo[canvas] swpname = scratch_swp_base_name() i = 0 while os.path.exists(swpname + '.' + str(i)): i += 1 swpname += '.' + str(i) canvas_scratch_name_memo[canvas] = swpname return swpname def register_loaded_swp(canvas, swpname): canvas_scratch_name_memo[canvas] = swpname def glob_scratch_swps(): swpname = scratch_swp_base_name() return glob.glob(swpname + ".*") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/pkgmeta.py0000644000175100002000000001235014730024325022536 0ustar00runnerdockerfrom __future__ import annotations import os import sys import re import json import email from operator import itemgetter from urllib.parse import urlparse from urllib.request import url2pathname from typing import List, Dict, Optional, Union, cast import packaging.version if sys.version_info < (3, 10): from importlib_metadata import EntryPoint, Distribution, entry_points, PackageNotFoundError else: from importlib.metadata import EntryPoint, Distribution, entry_points, PackageNotFoundError __all__ = [ "Distribution", "EntryPoint", "entry_points", "normalize_name", "trim", "trim_leading_lines", "trim_trailing_lines", "parse_meta", "get_dist_meta", "get_distribution", "develop_root", "get_dist_url" ] def normalize_name(name: str) -> str: """ PEP 503 normalization plus dashes as underscores. """ return re.sub(r"[-_.]+", "-", name).lower().replace('-', '_') def _direct_url(dist: Distribution) -> dict | None: """ Return PEP-0610 direct_url dict. """ direct_url_content = dist.read_text("direct_url.json") if direct_url_content: try: return json.loads(direct_url_content) except (json.JSONDecodeError, UnicodeDecodeError): return None return None def develop_root(dist: Distribution) -> str | None: """ Return the distribution's editable root path if applicable (pip install -e). """ direct_url = _direct_url(dist) if direct_url is not None and direct_url.get("dir_info", {}).get("editable", False): url = direct_url.get("url", None) if url is not None: url = urlparse(url) if url.scheme == "file": return url2pathname(url.path) egg_info_dir = dist.locate_file(f"{normalize_name(dist.name)}.egg-info/") setup = dist.locate_file("setup.py") if os.path.isdir(egg_info_dir) and os.path.isfile(setup): return os.path.dirname(setup) return None def is_develop_egg(dist: Distribution) -> bool: """ Is the distribution installed in development mode (setup.py develop) """ return develop_root(dist) is not None def left_trim_lines(lines: List[str]) -> List[str]: """ Remove all unnecessary leading space from lines. """ lines_striped = zip(lines[1:], map(str.lstrip, lines[1:])) lines_striped = filter(itemgetter(1), lines_striped) indent = min([len(line) - len(striped) for line, striped in lines_striped] + [sys.maxsize]) if indent < sys.maxsize: return [line[indent:] for line in lines] else: return list(lines) def trim_trailing_lines(lines: List[str]) -> List[str]: """ Trim trailing blank lines. """ lines = list(lines) while lines and not lines[-1]: lines.pop(-1) return lines def trim_leading_lines(lines: List[str]) -> List[str]: """ Trim leading blank lines. """ lines = list(lines) while lines and not lines[0]: lines.pop(0) return lines def trim(string: str) -> str: """ Trim a string in PEP-256 compatible way """ lines = string.expandtabs().splitlines() lines = list(map(str.lstrip, lines[:1])) + left_trim_lines(lines[1:]) return "\n".join(trim_leading_lines(trim_trailing_lines(lines))) # Fields allowing multiple use (from PEP-0345) MULTIPLE_KEYS = ["Platform", "Supported-Platform", "Classifier", "Requires-Dist", "Provides-Dist", "Obsoletes-Dist", "Project-URL"] def parse_meta(contents: str) -> Dict[str, Union[str, List[str]]]: message = email.message_from_string(contents) meta = {} # type: Dict[str, Union[str, List[str]]] for key in set(message.keys()): if key in MULTIPLE_KEYS: meta[key] = list(str(m) for m in message.get_all(key, [])) else: value = str(message.get(key)) if key == "Description": value = trim(value) meta[key] = value version_str = cast(str, meta["Metadata-Version"]) version = packaging.version.parse(version_str) if version >= packaging.version.parse("1.3") and "Description" not in meta: desc = message.get_payload() if isinstance(desc, str): meta["Description"] = desc return meta def get_meta_entry(dist: Distribution, name: str) -> Union[List[str], str, None]: """ Get the contents of the named entry from the distributions PKG-INFO file """ meta = get_dist_meta(dist) return meta.get(name) def get_dist_url(dist: Distribution) -> Optional[str]: """ Return the 'url' of the distribution (as passed to setup function) """ url = get_meta_entry(dist, "Home-page") assert isinstance(url, str) or url is None return url def get_dist_meta(dist: Distribution) -> Dict[str, Union[str, List[str]]]: metadata = dist.metadata meta: Dict[str, Union[str, List[str]]] = {} for key in metadata: if key == "Description": meta[key] = trim(metadata[key]) elif key in MULTIPLE_KEYS: meta[key] = metadata.get_all(key) else: meta[key] = metadata[key] return meta def get_distribution(name: str) -> Optional[Distribution]: try: return Distribution.from_name(name) except PackageNotFoundError: return None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/propertybindings.py0000644000175100002000000002354214730024325024515 0ustar00runnerdocker""" Qt Property Bindings (`propertybindings`) ----------------------------------------- """ import sys import ast from collections import defaultdict from functools import reduce from operator import add from AnyQt.QtCore import QObject, QEvent, QMetaProperty from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot from typing import Optional, Any, List, Dict, Tuple def find_meta_property(obj, name): # type: (QObject, str) -> QMetaProperty """ Return a named (`name`) `QMetaProperty` of a `QObject` instance `obj`. If a property by taht name does not exist raise an AttributeError. """ meta = obj.metaObject() index = meta.indexOfProperty(name) if index == -1: raise AttributeError("%s does no have a property named %r." % (meta.className(), name)) return meta.property(index) def find_notifier(obj, name): # type: (QObject, str) -> str """ Return the notifier signal name (`str`) for the property of `object` (instance of `QObject`). """ prop_meta = find_meta_property(obj, name) if not prop_meta.hasNotifySignal(): raise TypeError("%s does not have a notifier signal." % name) notifier = prop_meta.notifySignal() name = bytes(notifier.methodSignature()).decode("utf-8").split("(")[0] return name class AbstractBoundProperty(QObject): """ An abstract base class for property bindings. """ changed = Signal([], [object]) """Emited when the property changes""" def __init__(self, obj, propertyName, parent=None): # type: (QObject, str, Optional[QObject]) -> None super().__init__(parent) self.obj = obj # type: Optional[QObject] self.propertyName = propertyName self.obj.destroyed.connect(self._on_destroyed) self._source = None # type: Optional[AbstractBoundProperty] def set(self, value): # type: (Any) -> bool """ Set `value` to the property. """ if self.obj is not None: return self.obj.setProperty(self.propertyName, value) else: return False def get(self): # type: () -> Any """ Return the property value. """ if self.obj is not None: return self.obj.property(self.propertyName) else: return None @Slot() def notifyChanged(self): """ Notify the binding of a change in the property value. The default implementation emits the `changed` signals. """ val = self.get() self.changed.emit() self.changed[object].emit(val) def _on_destroyed(self): # type: () -> None self.obj = None def bindTo(self, source): # type: (AbstractBoundProperty) -> None """ Bind this property to `source` (instance of `AbstractBoundProperty`). """ if self._source != source: if self._source: self.unbind() self._source = source source.changed.connect(self.update) source.destroyed.connect(self.unbind) self.set(source.get()) self.notifyChanged() def unbind(self): # type: () -> None """ Unbind the currently bound property (set with `bindTo`). """ if self._source is not None: self._source.destroyed.disconnect(self.unbind) self._source.changed.disconnect(self.update) self._source = None def update(self): # type: () -> None """ Update the property value from `source` property (`bindTo`). """ if self._source: source_val = self._source.get() curr_val = self.get() if source_val != curr_val: self.set(source_val) def reset(self): """ Reset the property if possible. """ raise NotImplementedError class PropertyBindingExpr(AbstractBoundProperty): def __init__(self, expression, globals={}, locals={}, parent=None): # skip the AbstractBoundProperty's __init__. Despite its name it is # not an abstract. QObject.__init__(self, parent) self.ast = ast.parse(expression, mode="eval") self.code = compile(self.ast, "", "eval") self.expression = expression self.globals = dict(globals) self.locals = dict(locals) self._sources = {} # type: Dict[str, AbstractBoundProperty] names = self.code.co_names for name in names: v = locals.get(name, globals.get(name)) if isinstance(v, AbstractBoundProperty): self._sources[name] = v v.changed.connect(self.notifyChanged) v.destroyed.connect(self._on_destroyed) def sources(self): # type: () -> List[AbstractBoundProperty] """Return all source property bindings appearing in the expression namespace. """ return list(self._sources.values()) def set(self, value): raise NotImplementedError("Cannot set a value of an expression") def get(self): # type: () -> Any locals = dict(self.locals) locals.update(dict((name, source.get()) for name, source in self._sources.items())) try: value = eval(self.code, self.globals, locals) except Exception: raise return value def bindTo(self, source): raise NotImplementedError("Cannot bind an expression") def _on_destroyed(self): # type: () -> None source = self.sender() for name, obj in list(self._sources.items()): if obj is source: del self._sources[name] class PropertyBinding(AbstractBoundProperty): """ A Property binding of a QObject's property registered with Qt's meta class object system. """ def __init__(self, obj, propertyName, notifier=None, parent=None): super().__init__(obj, propertyName, parent) if notifier is None: notifier = find_notifier(obj, propertyName) if notifier is not None: signal = getattr(obj, notifier) signal.connect(self.notifyChanged) else: signal = None self.notifierSignal = signal def _on_destroyed(self): self.notifierSignal = None super()._on_destroyed() def reset(self): meta_prop = find_meta_property(self.obj, self.propertyName) if meta_prop.isResettable(): meta_prop.reset(self.obj) else: return super().reset() class DynamicPropertyBinding(AbstractBoundProperty): """ A Property binding of a QObject's dynamic property. """ def __init__(self, obj, propertyName, parent=None): super().__init__(obj, propertyName, parent) obj.installEventFilter(self) def eventFilter(self, obj, event): if obj is self.obj and event.type() == QEvent.DynamicPropertyChange: if event.propertyName() == self.propertyName: self.notifyChanged() return super().eventFilter(obj, event) class BindingManager(QObject): AutoSubmit = 0 ManualSubmit = 1 # Note: This should also apply to Gnome Default = 0 if sys.platform == "darwin" else 1 def __init__(self, parent=None, submitPolicy=Default): super().__init__(parent) self._bindings = defaultdict(list) self._modified = set() self.__submitPolicy = submitPolicy def setSubmitPolicy(self, policy): if self.__submitPolicy != policy: self.__submitPolicy = policy if policy == BindingManager.AutoSubmit: self.commit() def submitPolicy(self): return self.__submitPolicy def bind(self, target, source): if isinstance(target, tuple): target = binding_for(*target + (self, )) if source is None: return UnboundBindingWrapper(target, self) else: if isinstance(source, tuple): source = binding_for(*source + (self,)) source.changed.connect(self.__on_changed) self._bindings[source].append((target, source)) self.__on_changed(source) return None def bindings(self): """Return (target, source) binding tuples. """ return reduce(add, self._bindings.items(), []) def commit(self): self.__update() def __on_changed(self, sender=None): if sender is None: sender = self.sender() self._modified.add(sender) if self.__submitPolicy == BindingManager.AutoSubmit: self.__update() def __update(self): for modified in list(self._modified): self._modified.remove(modified) for target, source in self._bindings.get(modified, []): target.set(source.get()) class UnboundBindingWrapper(object): def __init__(self, target, manager): self.target = target self.manager = manager self.__source = None def to(self, source): if self.__source is None: if isinstance(source, tuple): source = binding_for(*source + (self.manager,)) self.manager.bind(self.target, source) self.__source = source else: raise ValueError("Can only call 'to' once.") def binding_for(obj, name, parent=None): """ Return a suitable binding for property `name` of an `obj`. Currently only supports PropertyBinding and DynamicPropertyBinding. """ if isinstance(obj, QObject): meta = obj.metaObject() index = meta.indexOfProperty(name) if index == -1: boundprop = DynamicPropertyBinding(obj, name, parent) else: boundprop = PropertyBinding(obj, name, parent) else: raise TypeError return boundprop ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/qinvoke.py0000644000175100002000000001140614730024325022563 0ustar00runnerdockerfrom typing import TypeVar, Callable, overload from functools import wraps from AnyQt.QtCore import Qt, QObject, Signal, BoundSignal from orangecanvas.utils.qobjref import qobjref_weak class _InvokeEmitter(QObject): sig = Signal(object, object) class _InvokeCaller(QObject): sig = Signal(object, object) A = TypeVar("A") T1 = TypeVar("T1") T2 = TypeVar("T2") T3 = TypeVar("T3") T4 = TypeVar("T4") T5 = TypeVar("T5") T6 = TypeVar("T6") @overload def qinvoke( func: Callable[[], A], context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[], None]: ... @overload def qinvoke( func: Callable[[T1], A], context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[T1], None]: ... @overload def qinvoke( func: Callable[[T1, T2], A], context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[T1, T2], None]: ... @overload def qinvoke( func: Callable[[T1, T2, T3], A], context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[T1, T2, T3], None]: ... @overload def qinvoke( func: Callable[[T1, T2, T3, T4], A], context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[T1, T2, T3, T4], None]: ... @overload def qinvoke( func: Callable[[T1, T2, T3, T4, T5], A], context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[T1, T2, T3, T4, T5], None]: ... @overload def qinvoke( func: Callable[[T1, T2, T3, T4, T5, T6], A], context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[T1, T2, T3, T4, T5, T6], None]: ... @overload def qinvoke( *, context: QObject, type: Qt.ConnectionType = Qt.QueuedConnection ) -> Callable[[Callable[..., A]], Callable[..., None]]: ... def qinvoke(func: Callable = None, context: QObject = None, type=Qt.QueuedConnection): """ Wrap and return a callable, such that it will be executed in the `context`'s thread/event loop. Parameters ---------- func: Callable[..., Any] The function to be executed. context: QObject The invoking context. The `func` will be called in the specific event loop of `context`. If `context` is deleted then the call will be a noop. type: Qt.ConnectionType The connection type. Returns ------- wrapped: Callable[..., None] A wrapped function taking the same arguments as `func`, but retuning no value. Calling this function will schedule `func` to be called from `context`'s event loop. """ def decorator(func: Callable[..., A]) -> Callable[..., None]: emitter = _InvokeEmitter() # caller 'lives' in context's thread. If context is deleted so is the # caller (child objects are deleted before parents). This is used to # achieve (of fake) connection auto-disconnect. caller = _InvokeCaller(context) caller_ref = qobjref_weak(caller) context_ref = qobjref_weak(context) def call_in_context(args, kwargs): context = context_ref() if context is not None: func(*args, *kwargs) # connection from emitter -(queued)-> caller -(direct)-> func emitter.sig.connect(caller.sig, type) caller.sig.connect(call_in_context, Qt.DirectConnection) def disconnect(): caller = caller_ref() if caller is not None: caller.sig.disconnect(call_in_context) caller.setParent(None) # this should delete the caller @wraps(func) def wrapped(*args, **kwargs): # emitter is captured in this closure. This should be the only # reference to it. It should ne deleted along with `wrapped`. emitter.sig.emit(args, kwargs) wrapped.disconnect = disconnect # type: ignore return wrapped if func is not None: if context is not None: return decorator(func) else: raise TypeError elif context is None: raise TypeError return decorator def connect_with_context( signal: BoundSignal, context: QObject, functor: Callable, type=Qt.AutoConnection ): """ Connect a signal to a callable functor to be placed in a specific event loop of context. The connection will automatically disconnect if the sender or the context is destroyed. However, you should take care that any objects used within the functor are still alive when the signal is emitted. Note ---- Like the QObject.connect overload that takes a explicit context QObject, which is not exposed by PyQt """ f = qinvoke(functor, context=context, type=type) return signal.connect(f) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/qobjref.py0000644000175100002000000001123714730024325022541 0ustar00runnerdockerimport weakref from types import SimpleNamespace as namespace from typing import Optional, TypeVar, Generic from AnyQt.QtCore import QObject, Qt __all__ = [ "qobjref", "qobjref_weak" ] Q = TypeVar("Q", bound=QObject) class qobjref(Generic[Q]): """ A 'guarded reference' to a QObject. An instance of a `qobjref` holds a reference to a `QObject` for as long as it is alive (not destroyed by C++ destructor). Example ------- >>> import sip >>> obj = QObject() >>> ref = qobjref(obj) >>> assert ref() is obj >>> sip.delete(obj) # forcibly delete it >>> assert ref() is None Note ---- This is not thread safe in the sense that the object can be deleted from a different thread while the ref() is returning it. Note ---- `qobjref` keeps a reference to the object, meaning it will not be garbage collected until qobjref is also reaped. Use qobjref_weak for weak references. See also -------- QPointer """ __slots__ = ("__obj", "__state", "__weakref__") def __init__(self, obj): # type: (Q) -> None assert isinstance(obj, QObject) self.__obj = obj def finalize(_weakref): # finalize when self is collected if state.objref is None: return objref = state.objref() if objref is not None: objref.destroyed.disconnect(state.zero_ref) # destroy/zero the reference when QObject is destroyed def zero_ref(): selfref = state.selfref() if selfref is not None: selfref.__obj = None state.objref = None state = namespace( selfref=weakref.ref(self, finalize), objref=weakref.ref(obj), zero_ref=zero_ref, finalize=finalize) self.__state = state # Must not capture self in the zero_ref's closure obj.destroyed.connect(zero_ref, Qt.DirectConnection) def __call__(self): # type: () -> Optional[Q] """ Return the QObject instance or None if it was destroyed. Return ------ obj : Optional[QObject] """ return self.__obj def __repr__(self): objrep = "to " + repr(self.__obj) if self.__obj is None: objrep = "dead" return "" class qobjref_weak(Generic[Q]): """ A weak 'guarded reference' to a QObject. Similar to `qobjref`, except that the reference to the QObject is weak Example ------- >>> import sip >>> obj = QObject() >>> ref = qobjref_weak(obj) >>> assert ref() is obj >>> sip.delete(obj) # forcibly delete it >>> assert ref() is None >>> obj = QObject() >>> ref = qobjref_weak(obj) >>> assert ref() is obj >>> del obj # assuming ref count is 1 >>> assert ref() is None Note ---- This is not thread safe in the sense that the object can be deleted from a different thread while the ref() is returning it. See also -------- qobjref, QPointer """ __slots__ = ("__obj_ref", "__state", "__weakref__") def __init__(self, obj): # type: (Q) -> None assert isinstance(obj, QObject) self.__obj_ref = weakref.ref(obj) # finalize when self is collected def disconnect(_weakref): if state.objref is None: return objref = state.objref() if objref is not None: objref.destroyed.disconnect(state.zero_ref) # destroy/zero the reference when QObject is destroyed def zero_ref(): selfref = state.selfref() if selfref is not None: selfref.__obj_ref = None state.objref = None state = namespace( selfref=weakref.ref(self, disconnect), objref=weakref.ref(obj), zero_ref=zero_ref, disconnect=disconnect) self.__state = state # Must not capture self in the zero_ref's closure obj.destroyed.connect(zero_ref, Qt.DirectConnection) def __call__(self): # type: () -> Optional[Q] """ Return the QObject instance or None if it is no longer alive Return ------ obj : Optional[QObject] """ ref = self.__obj_ref if ref is not None: return ref() else: return None def __repr__(self): obj = self.__call__() if obj is not None: objrep = "to " + repr(obj) else: objrep = "dead" return "" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/qtcompat.py0000644000175100002000000000125314730024325022736 0ustar00runnerdockerimport warnings # Back-compatibility. Should not be imported from here from AnyQt.QtCore import QSettings # pylint: disable=unused-import def toPyObject(variant): warnings.warn( "toPyObject is deprecated and will be removed.", DeprecationWarning, stacklevel=2 ) return variant def qunwrap(variant): """Unwrap a `variant` and return it's contents. """ warnings.warn( "qunwrap is deprecated and will be removed.", DeprecationWarning, stacklevel=2 ) return variant def qwrap(obj): warnings.warn( "qwrap is deprecated and will be removed.", DeprecationWarning, stacklevel=2 ) return obj ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/redirect.py0000644000175100002000000000030114730024325022700 0ustar00runnerdockerimport warnings from contextlib import redirect_stderr, redirect_stdout warnings.warn( "'{}' is deprecated use contextlib.redirect_{stderr,stdin}.", DeprecationWarning, stacklevel=2 ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/settings.py0000644000175100002000000003015514730024325022751 0ustar00runnerdocker""" Settings (`settings`) ===================== A more `dict` like interface for QSettings """ import abc import logging import typing from typing import List, Dict, Tuple, Union, Any, Type, Optional from collections import namedtuple from collections.abc import MutableMapping from AnyQt.QtCore import QObject, QEvent, QCoreApplication, QSettings from AnyQt.QtCore import pyqtSignal as Signal log = logging.getLogger(__name__) config_slot = namedtuple( "config_slot", ["key", "value_type", "default_value", "doc"] ) class SettingChangedEvent(QEvent): """ A settings has changed. This event is sent by Settings instance to itself when a setting for a key has changed. """ SettingChanged = QEvent.Type(QEvent.registerEventType()) """Setting was changed""" SettingAdded = QEvent.Type(QEvent.registerEventType()) """A setting was added""" SettingRemoved = QEvent.Type(QEvent.registerEventType()) """A setting was removed""" def __init__(self, etype, key, value=None, oldValue=None): """ Initialize the event instance """ super().__init__(etype) self.__key = key self.__value = value self.__oldValue = oldValue def key(self): return self.__key def value(self): return self.__value def oldValue(self): return self.__oldValue _QObjectType = type(QObject) class QABCMeta(_QObjectType, abc.ABCMeta): # type: ignore # pylint: disable=all def __init__(self, name, bases, attr_dict): _QObjectType.__init__(self, name, bases, attr_dict) abc.ABCMeta.__init__(self, name, bases, attr_dict) # Backward compatibility. # Settings used to store values, for which the explicit type # was not registered in default' slots, by wrapping it in a _pickledvalue, so # it would be pickled and unpickled when read from .ini files by PyQt. # But this creates a mess when reading the settings back with plain QSettings. class _pickledvalue: value = ... def __init__(self, value): raise RuntimeError("'_pickledvalue' instances should not be created") class Settings(QObject, MutableMapping, metaclass=QABCMeta): """ A `dict` like interface to a QSettings store. """ valueChanged = Signal(str, object) valueAdded = Signal(str, object) keyRemoved = Signal(str) def __init__(self, parent=None, defaults=(), path=None, store=None): super().__init__(parent) if store is None: store = QSettings() path = (path or "").rstrip("/") self.__path = path self.__defaults = dict([(slot.key, slot) for slot in defaults]) self.__store = store def __key(self, key): """ Return the full key (including group path). """ if self.__path: return "/".join([self.__path, key]) else: return key def __delitem__(self, key): """ Delete the setting for key. If key is a group remove the whole group. .. note:: defaults cannot be deleted they are instead reverted to their original state. """ if key not in self: raise KeyError(key) if self.isgroup(key): group = self.group(key) for key in group: del group[key] else: fullkey = self.__key(key) oldValue = self.get(key) if self.__store.contains(fullkey): self.__store.remove(fullkey) newValue = None if fullkey in self.__defaults: newValue = self.__defaults[fullkey].default_value etype = SettingChangedEvent.SettingChanged else: etype = SettingChangedEvent.SettingRemoved QCoreApplication.sendEvent( self, SettingChangedEvent(etype, key, newValue, oldValue) ) def __value(self, fullkey, value_type): if value_type is None: value = self.__store.value(fullkey) else: try: value = self.__store.value(fullkey, type=value_type) except (TypeError, RuntimeError): # In case the value was pickled in a type unsafe mode value = self.__store.value(fullkey) if isinstance(value, _pickledvalue): # back-compat value = value.value return value def __getitem__(self, key): """ Get the setting for key. """ if key not in self: raise KeyError(key) if self.isgroup(key): raise KeyError("{0!r} is a group".format(key)) fullkey = self.__key(key) slot = self.__defaults.get(fullkey, None) if self.__store.contains(fullkey): value = self.__value(fullkey, slot.value_type if slot else None) elif slot is not None: value = slot.default_value else: raise KeyError() return value def __setitem__(self, key, value): """ Set the setting for key. """ if not isinstance(key, str): raise TypeError(key) fullkey = self.__key(key) if fullkey in self.__defaults: value_type = self.__defaults[fullkey].value_type if not isinstance(value, value_type): if not isinstance(value, value_type): raise TypeError("Expected {0!r} got {1!r}".format( value_type.__name__, type(value).__name__) ) if key in self: oldValue = self.get(key) etype = SettingChangedEvent.SettingChanged else: oldValue = None etype = SettingChangedEvent.SettingAdded self.__store.setValue(fullkey, value) QCoreApplication.sendEvent( self, SettingChangedEvent(etype, key, value, oldValue) ) def __contains__(self, key): """ Return `True` if settings contain the `key`, False otherwise. """ fullkey = self.__key(key) return self.__store.contains(fullkey) or (fullkey in self.__defaults) def __iter__(self): """Return an iterator over over all keys. """ keys = self.__store.allKeys() + list(self.__defaults.keys()) if self.__path: path = self.__path + "/" keys = filter(lambda key: key.startswith(path), keys) keys = [key[len(path):] for key in keys] return iter(sorted(set(keys))) def __len__(self): return len(list(iter(self))) def group(self, path): if self.__path: path = "/".join([self.__path, path]) return Settings(self, self.__defaults.values(), path, self.__store) def isgroup(self, key): """ Is the `key` a settings group i.e. does it have subkeys. """ if key not in self: raise KeyError("{0!r} is not a valid key".format(key)) return len(self.group(key)) > 0 def isdefault(self, key): """ Is the value for key the default. """ if key not in self: raise KeyError(key) return not self.__store.contains(self.__key(key)) def clear(self): """ Clear the settings and restore the defaults. """ self.__store.clear() def add_default_slot(self, default): """ Add a default slot to the settings This also replaces any previously set value for the key. """ value = default.default_value oldValue = None etype = SettingChangedEvent.SettingAdded key = default.key if key in self: oldValue = self.get(key) etype = SettingChangedEvent.SettingChanged if not self.isdefault(key): # Replacing a default value. self.__store.remove(self.__key(key)) self.__defaults[key] = default event = SettingChangedEvent(etype, key, value, oldValue) QCoreApplication.sendEvent(self, event) def get_default_slot(self, key): return self.__defaults[self.__key(key)] def customEvent(self, event): super().customEvent(event) if isinstance(event, SettingChangedEvent): if event.type() == SettingChangedEvent.SettingChanged: self.valueChanged.emit(event.key(), event.value()) elif event.type() == SettingChangedEvent.SettingAdded: self.valueAdded.emit(event.key(), event.value()) elif event.type() == SettingChangedEvent.SettingRemoved: self.keyRemoved.emit(event.key()) parent = self.parent() if isinstance(parent, Settings): # Assumption that the parent is a parent setting group. parent.customEvent( SettingChangedEvent(event.type(), "/".join([self.__path, event.key()]), event.value(), event.oldValue()) ) if typing.TYPE_CHECKING: # pragma: no cover _T = typing.TypeVar("_T") #: Specification for an value in the return value of readArray #: Can be single type or a tuple of (type, defaultValue) where default #: value is used where a stored entry is missing. ValueSpec = Union[Type[_T], Tuple[Type[_T], _T]] def QSettings_readArray(settings, key, scheme): # type: (QSettings, str, Dict[str, ValueSpec]) -> List[Dict[str, _T]] """ Read the whole array from a QSettings instance. Parameters ---------- settings : QSettings key : str scheme : Dict[str, ValueSpec] Example ------- >>> s = QSettings("./login.ini") >>> QSettings_readArray(s, "array", {"username": str, "password": str}) [{"username": "darkhelmet", "password": "1234"}} >>> QSettings_readArray( ... s, "array", {"username": str, "noexist": (str, "~||~")}) ... [{"username": "darkhelmet", "noexist": "~||~"}} """ items = [] count = settings.beginReadArray(key) def normalize_spec(spec): # type: (ValueSpec) -> Tuple[Type[_T], Optional[_T]] if isinstance(spec, tuple): if len(spec) != 2: raise ValueError("len(spec) != 2") type_, default = spec else: type_, default = spec, None return type_, default specs = { name: normalize_spec(spec) for name, spec in scheme.items() } for i in range(count): settings.setArrayIndex(i) item = {} for key, (type_, default) in specs.items(): value = settings.value(key, type=type_, defaultValue=default) item[key] = value items.append(item) settings.endArray() return items def QSettings_writeArray(settings, key, values): # type: (QSettings, str, List[Dict[str, Any]]) -> None """ Write an array of values to a QSettings instance. Parameters ---------- settings : QSettings key : str values : List[Dict[str, Any]] Examples -------- >>> s = QSettings("./login.ini") >>> QSettings_writeArray( ... s, "array", [{"username": "darkhelmet", "password": "1234"}] ... ) """ settings.beginWriteArray(key, len(values)) for i in range(len(values)): settings.setArrayIndex(i) for key_, val in values[i].items(): settings.setValue(key_, val) settings.endArray() def QSettings_writeArrayItem(settings, key, index, item, arraysize=-1): # type: (QSettings, str, int, Dict[str, Any], int) -> None """ Write/update an array item at index. Parameters ---------- settings : QSettings key : str index : int item : Dict[str, Any] arraysize : int The full array size. Note that the array will be truncated to this size. """ if arraysize < 0: arraysize = settings.beginReadArray(key) settings.endArray() settings.beginWriteArray(key, arraysize) settings.setArrayIndex(index) for key_, val in item.items(): settings.setValue(key_, val) settings.endArray() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/shtools.py0000644000175100002000000001006714730024325022604 0ustar00runnerdockerfrom typing import List, Optional, Generator import os import sys import tempfile import subprocess from contextlib import contextmanager def python_process( args: List[str], script_name: Optional[str]=None, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, **kwargs ) -> subprocess.Popen: """ Run a `sys.executable` in a subprocess with `args`. Parameters ---------- args: List[str] A list of arguments for the python interpreter (e.g. `['-m', 'pip' ]`) script_name : Optional[str] If supplied the `script_name` replaces 'python' as the first argument of `exec?` call. kwargs: Passed to subproces.Popen Return ------ process : subprocess.Popen Examples -------- >>> p = python_process(['--version']) >>> p.communicate()[0] 'Python ... >>> p = python_process(['-c', 'print("hello")']) >>> p.communicate()[0].rstrip() 'hello' """ executable = sys.executable if os.name == "nt" and os.path.basename(executable) == "pythonw.exe": # Don't run the script with a 'gui' (detached) process. dirname = os.path.dirname(executable) executable = os.path.join(dirname, "python.exe") if script_name is not None: progname = script_name else: progname = executable return create_process( [progname] + args, executable=executable, stderr=stderr, stdout=stdout, **kwargs ) def __nt_kwargs_defaults(kwargs): # do not open a new console window for command on windows. if hasattr(subprocess, "CREATE_NO_WINDOW"): # Python >= 3.7 CREATE_NO_WINDOW = subprocess.CREATE_NO_WINDOW # type: ignore else: CREATE_NO_WINDOW = 0x08000000 kwargs.setdefault("creationflags", CREATE_NO_WINDOW) def python_run(args, *args_, **kwargs): executable = sys.executable if os.name == "nt" and os.path.basename(executable) == "pythonw.exe": # Don't run the script with a 'gui' (detached) process. dirname = os.path.dirname(executable) executable = os.path.join(dirname, "python.exe") __nt_kwargs_defaults(kwargs) return subprocess.run([executable] + args, *args_, **kwargs) def create_process( args: List[str], executable: Optional[str] = None, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, universal_newlines=True, **kwargs ) -> subprocess.Popen: """ Create and return a `subprocess.Popen` instance. This is a thin wrapper around the `subprocess.Popen`. The only thing it does is it ensures that a console window does not open by default on Windows. """ if os.name == "nt": __nt_kwargs_defaults(kwargs) return subprocess.Popen( args, executable=executable, stderr=stderr, stdout=stdout, universal_newlines=universal_newlines, **kwargs ) @contextmanager def temp_named_file( content: str, encoding="utf-8", suffix: Optional[str] = None, prefix: Optional[str] = None, dir: Optional[str] = None, delete=True, ) -> Generator[str, None, None]: """ Create a named temporary file initialized with `contents` and yield its name. Parameters ---------- content: str The contents to write into the temp file encoding: str Encoding suffix: Optional[str] Filename suffix prefix: Optional[str] Filename prefix dir: Optional[str] Directory where the file will be created. If None then $TEMP is used. delete: bool If true the file will be deleted on context exit. Returns ------- context: ContextManager A context manager that deletes the file on exit (if delete is True). See Also -------- tempfile.mkstemp """ fd, name = tempfile.mkstemp(suffix, prefix, dir=dir, text=True) file = os.fdopen(fd, mode="wt", encoding=encoding,) file.write(content) file.close() try: yield name finally: if delete: os.remove(name) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.810081 orange_canvas_core-0.2.5/orangecanvas/utils/tests/0000755000175100002000000000000014730024333021674 5ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/__init__.py0000644000175100002000000000000014730024325023774 0ustar00runnerdocker././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_after_exit.py0000644000175100002000000000150614730024325025442 0ustar00runnerdockerimport os import sys import time import unittest import orangecanvas.utils.after_exit as ae import orangecanvas.utils.shtools as sh def remove_after_exit(fname): ae.run_after_exit([ sys.executable, '-c', f'import os, sys; os.remove(sys.argv[1])', fname ]) class TestAfterExit(unittest.TestCase): def test_after_exit(self): with sh.temp_named_file('', delete=False) as fname: r = sh.python_run([ "-c", f"import sys, {__name__} as m\n" f"m.remove_after_exit(sys.argv[1])", fname ]) start = time.perf_counter() while os.path.exists(fname) and time.perf_counter() - start < 5: pass self.assertEqual(r.returncode, 0) self.assertFalse(os.path.exists(fname)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_graph.py0000644000175100002000000000306014730024325024406 0ustar00runnerdockerimport unittest from orangecanvas.utils.graph import strongly_connected_components class TestSCC(unittest.TestCase): def test_scc(self): E1 = {} scc = strongly_connected_components(E1, E1.__getitem__) self.assertEqual(scc, []) E2 = {1: []} scc = strongly_connected_components(E2, E2.__getitem__) self.assertEqual(scc, [[1]]) T1 = {1: [2, 3], 2: [4, 5], 3: [6, 7], 4: [], 5: [], 6: [], 7: []} scc = strongly_connected_components(T1, T1.__getitem__) self.assertEqual(scc, [[4], [5], [2], [6], [7], [3], [1]]) C1 = {1: [2], 2: [3], 3: [1]} scc = strongly_connected_components(C1, C1.__getitem__) self.assertEqual(scc, [[1, 2, 3]]) G1 = {1: [2, 3], 2: [3, 5], 3: [], 5: [2]} scc = strongly_connected_components(G1, G1.__getitem__) self.assertEqual(scc, [[3], [2, 5], [1]]) DAG1 = {1: [2, 3], 2: [3], 3: [4], 4: []} scc = strongly_connected_components( DAG1, DAG1.__getitem__) self.assertEqual(scc, [[4], [3], [2], [1]]) G2 = {1: [2], 2: [1, 5], 3: [4], 4: [3, 5], 5: [6], 6: [7], 7: [8], 8: [6, 9], 9: []} scc = strongly_connected_components(G2, G2.__getitem__) self.assertEqual(scc, [[9], [6, 7, 8], [5], [1, 2], [3, 4]]) G3 = {1: [2], 2: [3], 3: [1], 4: [5, 3], 5: [4, 6], 6: [3, 7], 7: [6], 8: [8]} scc = strongly_connected_components(G3, G3.__getitem__) self.assertEqual(scc, [[1, 2, 3], [6, 7], [4, 5], [8]]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_markup.py0000644000175100002000000000137114730024325024607 0ustar00runnerdockerimport unittest from orangecanvas.utils import markup RST = """ Title ----- * aaa * bbb """ MD = """ Title ----- * aaa * bbb """ HTML = """

              Title

              • aaa
              • bbb

              """ # This does not really test much since most of it is in 3rd party # implementation. Just run through the calls class TestMarkup(unittest.TestCase): def test_markup(self): c = markup.render_as_rich_text(RST, "text/x-rst",) self.assertIn("<", c) c = markup.render_as_rich_text(MD, "text/markdown") self.assertTrue(c.startswith("<")) c = markup.render_as_rich_text(HTML, "text/html") self.assertTrue(c.startswith("<")) c = markup.render_as_rich_text(RST, "text/plain") self.assertIn("<", c) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_overlay.py0000644000175100002000000001043614730024325024773 0ustar00runnerdocker# pylint: disable=protected-access import unittest.mock from AnyQt.QtCore import Qt, QEvent from AnyQt.QtTest import QTest, QSignalSpy from AnyQt.QtWidgets import QWidget, QApplication from orangecanvas.gui.test import QAppTestCase from orangecanvas.utils.overlay import NotificationWidget, NotificationOverlay, Notification, \ NotificationServer class TestOverlay(QAppTestCase): def setUp(self) -> None: super().setUp() self.container = QWidget() self.overlay = NotificationOverlay(self.container) self.server = NotificationServer() self.server.newNotification.connect(self.overlay.addNotification) self.server.nextNotification.connect(self.overlay.nextWidget) self.notif = Notification(title="Hello world!", text="Welcome to the testing grounds – this is where your resolve" "and stability will be tried and tested.", accept_button_label="Ok") def tearDown(self) -> None: self.container = None self.overlay = None self.notif = None self.server = None super().tearDown() def test_notification_widget(self): stdb = NotificationWidget.Ok | NotificationWidget.Close notifw = NotificationWidget(self.overlay, title="Titl", text="Tixt", standardButtons=stdb) QApplication.sendPostedEvents(notifw, QEvent.LayoutRequest) self.assertTrue(notifw.geometry().isValid()) button_ok = notifw.button(NotificationWidget.Ok) button_close = notifw.button(NotificationWidget.Close) self.assertTrue(all([button_ok, button_close])) button = notifw.button(NotificationWidget.Ok) self.assertIsNot(button, None) self.assertEqual(notifw.buttonRole(button), NotificationWidget.AcceptRole) def test_notification_dismiss(self): mock = unittest.mock.MagicMock() self.notif.clicked.connect(mock) self.server.registerNotification(self.notif) notifw = self.overlay.currentWidget() QTest.mouseClick(notifw.dismissButton, Qt.LeftButton) mock.assert_called_once_with(self.notif.DismissRole) def test_notification_accept(self): mock = unittest.mock.MagicMock() self.notif.clicked.connect(mock) self.server.registerNotification(self.notif) notifw = self.overlay.currentWidget() b = notifw._msgWidget.button(NotificationWidget.Ok) QTest.mouseClick(b, Qt.LeftButton) mock.assert_called_once_with(self.notif.AcceptRole) def test_two_overlays(self): container2 = QWidget() overlay2 = NotificationOverlay(container2) self.server.newNotification.connect(overlay2.addNotification) self.server.nextNotification.connect(overlay2.nextWidget) spy = QSignalSpy(self.notif.accepted) self.server.registerNotification(self.notif) self.container.show() container2.show() w1 = self.overlay.currentWidget() w2 = overlay2.currentWidget() self.assertTrue(w1.isVisible()) self.assertTrue(w2.isVisible()) button = w2.button(NotificationWidget.Ok) QTest.mouseClick(button, Qt.LeftButton) self.assertSequenceEqual(list(spy), [[]]) self.assertFalse(w1.isVisible()) self.assertFalse(w2.isVisible()) def test_queued_notifications(self): notif2 = Notification(title="Hello universe!", text="I'm another notif! I'm about to queue behind my older brother.") def handle_click(role): self.assertEqual(role, Notification.DismissRole) self.notif.clicked.connect(handle_click) self.server.registerNotification(self.notif) notif1 = self.overlay.currentWidget() button = notif1.dismissButton self.server.registerNotification(notif2) notif2 = self.overlay._widgets[1] self.container.show() self.assertTrue(notif1.isVisible()) self.assertFalse(notif2.isVisible()) QTest.mouseClick(button, Qt.LeftButton) self.assertFalse(notif1.isVisible()) self.assertTrue(notif2.isVisible()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_pkgmeta.py0000644000175100002000000000321314730024325024735 0ustar00runnerdockerimport os from unittest import TestCase from orangecanvas.utils.pkgmeta import ( Distribution, normalize_name, is_develop_egg, get_dist_meta, parse_meta, trim, develop_root, ) class TestPkgMeta(TestCase): def test_normalize_name(self): self.assertEqual(normalize_name("a-c_4"), "a_c_4") def test_is_develop_egg(self): d = Distribution.from_name("AnyQt") is_develop_egg(d) try: d = Distribution.from_name("orange-canvas-core") except Exception: pass else: is_develop_egg(d) def test_develop_root(self): d = Distribution.from_name("AnyQt") path = develop_root(d) if path is not None: self.assertTrue(os.path.isfile(d.locate_file("setup.py"))) try: d = Distribution.from_name("orange-canvas-core") except Exception: pass else: path = develop_root(d) if path is not None: self.assertTrue(os.path.isfile(d.locate_file("setup.py"))) def test_get_dist_meta(self): d = Distribution.from_name("AnyQt") meta = get_dist_meta(d) self.assertEqual(meta["Name"], "AnyQt") def test_parse_meta(self): m = parse_meta(trim(""" Metadata-Version: 1.0 Name: AA Version: 0.1 Requires-Dist: foo Requires-Dist: bar """)) self.assertEqual(m["Name"], "AA") self.assertEqual(m["Version"], "0.1") self.assertEqual(m["Requires-Dist"], ["foo", "bar"]) def test_trim(self): self.assertEqual(trim("A\n a\n b"), "A\na\nb") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_propertybindings.py0000644000175100002000000001422714730024325026716 0ustar00runnerdocker"""Tests for `propertybindings` """ from AnyQt.QtWidgets import ( QWidget, QVBoxLayout, QCheckBox, QSpinBox, QLineEdit, QTextEdit ) from AnyQt.QtCore import QObject from ...gui import test from ..propertybindings import ( BindingManager, DynamicPropertyBinding, PropertyBindingExpr, PropertyBinding, binding_for ) class Test(test.QAppTestCase): def test_dyn(self): obj = QObject() changed = [] binding = DynamicPropertyBinding(obj, "test") binding.changed[object].connect(changed.append) self.assertIs(binding.get(), None) obj.setProperty("test", 1) self.assertEqual(binding.get(), 1) self.assertEqual(len(changed), 1) self.assertEqual(changed[-1], 1) binding.set(2) self.assertEqual(binding.get(), 2) self.assertEqual(len(changed), 2) self.assertEqual(changed[-1], 2) target = QObject() binding1 = DynamicPropertyBinding(target, "prop") binding1.bindTo(binding) self.assertEqual(binding1.get(), binding.get()) self.assertEqual(target.property("prop"), 2) binding.set("a string") self.assertEqual(binding1.get(), "a string") self.assertEqual(binding1.get(), binding.get()) self.assertEqual(target.property("prop"), "a string") binding1.unbind() binding.set(1) self.assertEqual(binding1.get(), "a string") self.assertEqual(target.property("prop"), "a string") self.assertEqual(binding.get(), 1) self.assertEqual(obj.property("test"), 1) def test_manager(self): source = QObject() target = QObject() manager = BindingManager(submitPolicy=BindingManager.ManualSubmit) manager.bind((target, "target"), None).to((source, "source")) tbind = DynamicPropertyBinding(target, "target_copy") sbind = DynamicPropertyBinding(source, "source") schanged = [] sbind.changed[object].connect(schanged.append) manager.bind(tbind, None).to(sbind) source.setProperty("source", 1) self.assertEqual(len(schanged), 1) self.assertEqual(target.property("target"), None) manager.commit() self.assertEqual(target.property("target"), 1) self.assertEqual(target.property("target_copy"), 1) source.setProperty("source", 2) manager.setSubmitPolicy(BindingManager.AutoSubmit) self.assertEqual(target.property("target"), 2) self.assertEqual(target.property("target_copy"), 2) def test_prop(self): w = QWidget() layout = QVBoxLayout() cb = QCheckBox("Check", w) sp = QSpinBox(w) le = QLineEdit(w) textw = QTextEdit(w, readOnly=True) textw.setProperty("checked_", False) textw.setProperty("spin_", 0) textw.setProperty("line_", "") textexpr = PropertyBindingExpr(r""" ("Check box is {0}\n" "Spin has value {1}\n" "Line contains {2}").format( "checked" if checked else "unchecked", spin, line) """, dict(checked=binding_for(cb, "checked"), spin=binding_for(sp, "value"), line=binding_for(le, "text")), ) layout.addWidget(cb) layout.addWidget(sp) layout.addWidget(le) layout.addWidget(textw) manager = BindingManager(submitPolicy=BindingManager.AutoSubmit) manager.bind(PropertyBinding(textw, "plainText", "textChanged"), textexpr) w.setLayout(layout) w.show() self.qWait() def test_expr(self): obj1 = QObject() obj1.setProperty("value", 1) obj1.setProperty("other", 2) result = DynamicPropertyBinding(obj1, "result") result.bindTo( PropertyBindingExpr( "value + other", locals={"value": binding_for(obj1, "value"), "other": binding_for(obj1, "other")} ) ) expr = PropertyBindingExpr( "True if value < 3 else False", dict(value=DynamicPropertyBinding(obj1, "result")) ) result_values = [] result.changed[object].connect(result_values.append) expr_values = [] expr.changed[object].connect(expr_values.append) self.assertEqual(result.get(), 3) self.assertEqual(expr.get(), False) obj1.setProperty("value", 0) self.assertEqual(result_values[-1], 2) self.assertEqual(expr_values[-1], True) self.assertEqual(result.get(), 2) self.assertEqual(expr.get(), True) # @test.unittest.skip("Not yet implemented") # def test_decl(self): # # class MyObj(QCheckBox): # __metaclass__ = declarative # # display_value = property_expr( # "'Checked' if checked else 'Unchecked'", # type=unicode # ) # # display_value_changed = display_value.changed # # child_value = property_expr( # "'Child enabled' if my_child.enabled else 'Child disabled'", # type=unicode # ) # # child_value_changed = child_value.changed # # parent_area = property_expr("parent.width * parent.height", # queued_update=True) # # area = property_expr("width * height") # # color = property_expr( # lambda: 'red' if area > parent_area else 'blue', # type=QColor # ) # # def _on_color_changed(self, color): # self.setStyleSheet("color: {0};".format(color.name())) # # color.changed[QColor]("_on_color_changed()") # # _width_binding = bind("width: parent.width") # # _css_bind = bind( # "styleSheet = 'color: {0!s};'.format(color.name())" # ) # # def __init__(self, *args, **kwargs): # QCheckBox.__init__(self, *args, **kwargs) # # child = QWidget(self, objectName="my-child") # # child.setEnabled(False) # # binding(self, "width").bindTo(from_exp("parent.width")) # bind(self, "width = parent.width") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_qinvoke.py0000644000175100002000000001347314730024325024772 0ustar00runnerdockerimport time import gc import weakref from types import SimpleNamespace from typing import List, Optional, Callable import unittest from AnyQt.QtCore import QCoreApplication, QThread, QObject, Signal, QEvent from concurrent.futures.thread import ThreadPoolExecutor from AnyQt.QtTest import QSignalSpy, QTest from ..qinvoke import qinvoke, connect_with_context class CoreAppTestCase(unittest.TestCase): def setUp(self) -> None: self.app = QCoreApplication.instance() if self.app is None: self.app = QCoreApplication([]) super().setUp() def tearDown(self) -> None: self.app = None super().tearDown() def delete(qobj: QObject): assert qobj.thread() is QThread.currentThread() spy = QSignalSpy(qobj.destroyed) qobj.deleteLater() QCoreApplication.sendPostedEvents(qobj, QEvent.DeferredDelete) assert len(spy) == 1 class TestMethodinvoke(CoreAppTestCase): def test_qinvoke(self): executor = ThreadPoolExecutor() state = [None, None] # type: List[Optional[QThread]] class StateSetter(QObject): didsetstate = Signal() def set_state(self, value: QThread) -> None: state[0] = value state[1] = QThread.currentThread() self.didsetstate.emit() def func(callback): # type: (Callable[[QThread], None]) -> None callback(QThread.currentThread()) obj = StateSetter() spy = QSignalSpy(obj.didsetstate) f = executor.submit( func, qinvoke(obj.set_state, context=obj) ) self.assertTrue(spy.wait()) self.assertIs(state[1], QThread.currentThread(), "set_state was called from the wrong thread") self.assertIsNot(state[0], QThread.currentThread(), "set_state was invoked in the main thread") # test that disconnect works 'atomically' w.r.t. event loop spy = QSignalSpy(obj.didsetstate) callback = qinvoke(obj.set_state, context=obj) f = executor.submit(func, callback) f.result() time.sleep(0.01) # wait for finish callback.disconnect() # type: ignore self.assertFalse(spy.wait(100), "") self.assertSequenceEqual(spy, []) executor.shutdown(wait=True) def test_qinvoke_context_delete(self): executor = ThreadPoolExecutor(max_workers=1) context = QObject() isdeleted = False lastindex = -1 def mark_deleted(): nonlocal isdeleted isdeleted = True context.destroyed.connect(mark_deleted) def func(i): nonlocal isdeleted nonlocal lastindex lastindex = i self.assertFalse(isdeleted) self.assertIs(context.thread(), self.app.thread()) callback = qinvoke(func, context=context) _ = executor.map(callback, range(1000)) while lastindex < 0: QTest.qWait(10) assert lastindex >= 0 delete(context) assert isdeleted lasti = lastindex QTest.qWait(50) assert lasti == lastindex executor.shutdown() def test_qinvoke_as_decorator(self): context = QObject() @qinvoke(context=context) def f(name): context.setObjectName(name) spy = QSignalSpy(context.objectNameChanged) f("name") self.assertTrue(spy.wait(500)) def test_errors(self): with self.assertRaises(TypeError): qinvoke(lambda: None) with self.assertRaises(TypeError): qinvoke() class TestConnectWithContext(CoreAppTestCase): def test_connect_with_context(self): state = SimpleNamespace(th=None, greeting=None) target = QObject() def in_target_context(greeting: str): state.th = QThread.currentThread() state.greeting = greeting target.setObjectName(greeting) executor = ThreadPoolExecutor() emiter = QObject() connect_with_context( emiter.objectNameChanged, target, in_target_context, ) def run(): emiter.objectNameChanged.emit("hello") spy = QSignalSpy(target.objectNameChanged) executor.submit(run) self.assertTrue(spy.wait()) self.assertIs(target.thread(), state.th) # delete target context (and connection) delete(target) state.th = None state.greeting = None executor.map(run, range(10)) executor.shutdown(wait=True) QTest.qWait(10) self.assertIsNone(state.th) self.assertIsNone(state.greeting) def test_gc_captured_context(self): emitter = QObject() context = QObject() ref = weakref.ref(context) connect_with_context( emitter.objectNameChanged, context, lambda name: context.setObjectName(name) # captures context ) emitter.setObjectName("AA") self.assertEqual(context.objectName(), emitter.objectName()) del context # This needs to clear captured context in connected func self.assertIsNone(ref()) emitter.setObjectName("BB") def test_gc_captured_context_cycle(self): emitter = QObject() context = QObject() context.cycle = context ref = weakref.ref(context) connect_with_context( emitter.objectNameChanged, context, lambda name: context.setObjectName(name) # captures context ) emitter.setObjectName("AA") self.assertEqual(context.objectName(), emitter.objectName()) delete(context) del context # This needs to clear captured context in connected func gc.collect() self.assertIsNone(ref()) emitter.setObjectName("BB") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_qobjref.py0000644000175100002000000000324014730024325024735 0ustar00runnerdockerimport weakref from AnyQt.QtCore import QObject, QCoreApplication, QEvent from ...gui.test import QCoreAppTestCase from ..qobjref import qobjref, qobjref_weak def delete_qobject(obj): obj.deleteLater() QCoreApplication.sendPostedEvents(obj, QEvent.DeferredDelete) class TestQObjectRef(QCoreAppTestCase): def test_delete(self): obj = QObject() ref = qobjref(obj) assert ref() is obj delete_qobject(obj) # forcibly delete it assert ref() is None def test_deref(self): obj = QObject() obj_wref = weakref.ref(obj) ref = qobjref(obj) del obj assert ref() is obj_wref() del ref assert obj_wref() is None def test_self_finalize(self): obj = QObject() ref = qobjref(obj) del ref def test_repr(self): obj = QObject() ref = qobjref(obj) assert " to " in repr(ref) delete_qobject(obj) assert "dead" in repr(ref) class TestQObjectWeakRef(QCoreAppTestCase): def test_delete(self): obj = QObject() ref = qobjref_weak(obj) assert ref() is obj delete_qobject(obj) # forcibly delete it assert ref() is None def test_deref(self): obj = QObject() ref = qobjref_weak(obj) obj_wref = weakref.ref(obj) del obj assert ref() is obj_wref() is None def test_self_finalize(self): obj = QObject() ref = qobjref_weak(obj) del ref def test_repr(self): obj = QObject() ref = qobjref_weak(obj) assert " to " in repr(ref) delete_qobject(obj) assert "dead" in repr(ref) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_resources.py0000644000175100002000000000136214730024325025322 0ustar00runnerdockerimport unittest import os from orangecanvas import resources from orangecanvas.resources import icon_loader class TestIconLoader(unittest.TestCase): def setUp(self): from AnyQt.QtWidgets import QApplication self.app = QApplication.instance() if self.app is None: self.app = QApplication([]) def test_loader(self): loader = icon_loader() self.assertEqual(loader.search_paths(), resources.DEFAULT_SEARCH_PATHS) icon = loader.get("icons/CanvasIcon.png") self.assertTrue(not icon.isNull()) path = loader.find(":icons/Arrow.svg") self.assertTrue(os.path.isfile(path)) icon = loader.get(":icons/CanvasIcon.png") self.assertTrue(not icon.isNull()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_settings.py0000644000175100002000000001103514730024325025146 0ustar00runnerdocker""" Tests for settings utility module. """ import tempfile import unittest import logging from AnyQt.QtCore import QSettings from ..settings import Settings, config_slot, QSettings_readArray, \ QSettings_writeArray, QSettings_writeArrayItem from ...gui import test class TestUserSettings(test.QAppTestCase): def setUp(self): logging.basicConfig() super().setUp() def test_settings(self): spec = [config_slot("foo", bool, True, "foo doc"), config_slot("bar", int, 0, "bar doc"), ] store = QSettings(QSettings.IniFormat, QSettings.UserScope, "biolab.si", "Orange Canvas Unit Tests") store.clear() settings = Settings(defaults=spec, store=store) self.assertEqual(settings["foo"], True) self.assertEqual(settings.get("foo"), True) self.assertEqual(settings.get("bar", 3), 0, "Defaults") self.assertEqual(settings.get("bar"), settings["bar"]) self.assertEqual(settings.get("does not exist", "^&"), "^&", "get with default") self.assertIs(settings.get("does not exist"), None) with self.assertRaises(KeyError): settings["does not exist"] self.assertTrue(settings.isdefault("foo")) changed = [] settings.valueChanged.connect( lambda key, value: changed.append((key, value)) ) settings["foo"] = False self.assertEqual(changed[-1], ("foo", False), "valueChanged signal") self.assertEqual(len(changed), 1) self.assertEqual(settings["foo"], False, "updated value") self.assertEqual(settings.get("foo"), False) self.assertFalse(settings.isdefault("foo")) settings["bar"] = 1 self.assertEqual(changed[-1], ("bar", 1), "valueChanged signal") self.assertEqual(len(changed), 2) self.assertEqual(settings["bar"], 1) self.assertFalse(settings.isdefault("bar")) del settings["bar"] self.assertEqual(settings["bar"], 0) self.assertEqual(changed[-1], ("bar", 0)) # Only str or unicode can be keys with self.assertRaises(TypeError): settings[1] = 3 # value type check with self.assertRaises(TypeError): settings["foo"] = [] self.assertEqual(len(changed), 3) settings.add_default_slot(config_slot("foobar/foo", object, None, "")) group = settings.group("foobar") self.assertIs(group["foo"], None) group["foo"] = 3 self.assertEqual(changed[-1], ("foobar/foo", 3)) group["foonew"] = 5 self.assertIn("foobar/foonew", settings) settings["newkey"] = "newkeyvalue" self.assertIn("newkey", settings) group1 = group.group("bar") group1["barval"] = "barval" self.assertIn("foobar/bar/barval", settings) settings["foobar/bar/barval"] = 5 self.assertEqual(changed[-1], ("foobar/bar/barval", 5)) settings.clear() self.assertSetEqual(set(settings.keys()), set(["foo", "bar", "foobar/foo"])) class TestQSettings_array(unittest.TestCase): filename = "" # type: str def setUp(self): self.file = tempfile.NamedTemporaryFile() self.filename = self.file.name self.settings = QSettings(self.filename, QSettings.IniFormat) def tearDown(self): self.settings.sync() del self.settings self.file.close() def test_readwrite_array(self): s = self.settings scheme = { "name": str, "price": int } items = QSettings_readArray(s, "items", scheme) self.assertSequenceEqual(items, []) items_ = [ {"name": "apple", "price": 10}, {"name": "pear", "price": 12}, ] QSettings_writeArray(s, "items", items_) items = QSettings_readArray(s, "items", scheme) self.assertSequenceEqual(items, items_) scheme = { "quality": (int, -1), **scheme } items = QSettings_readArray(s, "items", scheme) self.assertSequenceEqual(items, [{"quality": -1, **d} for d in items_]) QSettings_writeArrayItem( s, "items", 1, {"name": "banana", "price": 5, "quality": 5}, arraysize=2 ) items = QSettings_readArray(s, "items", scheme) self.assertSequenceEqual(items, [ {"name": "apple", "price": 10, "quality": -1}, {"name": "banana", "price": 5, "quality": 5} ]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_shtools.py0000644000175100002000000000141014730024325024775 0ustar00runnerdockerimport os from orangecanvas.utils.shtools import python_process, temp_named_file import unittest class Test(unittest.TestCase): def test_python_process(self): p = python_process(["-c", "print('Hello')"]) out, _ = p.communicate() self.assertEqual(out.strip(), "Hello") self.assertEqual(p.wait(), 0) def test_temp_named_file(self): cases = [ ("Hello", "utf-8"), ("Hello", "utf-16"), ] for content, encoding in cases: with temp_named_file(content, encoding=encoding) as fname: with open(fname, "r", encoding=encoding) as f: c = f.read() self.assertEqual(c, content) self.assertFalse(os.path.exists(fname)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/orangecanvas/utils/tests/test_utils.py0000644000175100002000000000101314730024325024441 0ustar00runnerdockerimport unittest from .. import assocf, assocv class TestUtils(unittest.TestCase): def test_assoc(self): cases = [ ([], "a", None), ([("a", 1)], "a", ("a", 1)), ([("a", 1)], "b", None), ([("a", 1), ("b", 2), ("b", 3)], "b", ("b", 2)), ] for seq, key, expected in cases: res = assocf(seq, lambda k: k == key) self.assertEqual(res, expected) res = assocv(seq, key) self.assertEqual(res, expected) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/pyproject.toml0000644000175100002000000000015314730024325017637 0ustar00runnerdocker[build-system] requires = ["setuptools", "wheel", "trubar>=0.3.3"] build-backend = "setuptools.build_meta" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1734355162.814081 orange_canvas_core-0.2.5/setup.cfg0000644000175100002000000000047414730024333016551 0ustar00runnerdocker[metadata] license_file = LICENSE.txt [coverage:run] source = orangecanvas omit = */tests/*.py [coverage:report] exclude_lines = noqa pragma: no coverage raise NotImplementedError if __name__ == .__main__.: if typing.TYPE_CHECKING: if TYPE_CHECKING: assert False [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1734355157.0 orange_canvas_core-0.2.5/setup.py0000755000175100002000000000547714730024325016456 0ustar00runnerdocker#! /usr/bin/env python import os from setuptools import setup, find_packages from setuptools.command.install import install NAME = "orange-canvas-core" VERSION = "0.2.5" DESCRIPTION = "Core component of Orange Canvas" with open("README.rst", "rt", encoding="utf-8") as f: LONG_DESCRIPTION = f.read() URL = "http://orange.biolab.si/" AUTHOR = "Bioinformatics Laboratory, FRI UL" AUTHOR_EMAIL = 'contact@orange.biolab.si' LICENSE = "GPLv3" DOWNLOAD_URL = 'https://github.com/biolab/orange-canvas-core' PACKAGES = find_packages() PACKAGE_DATA = { "orangecanvas": ["icons/*.svg", "icons/*png"], "orangecanvas.styles": ["*.qss", "orange/*.svg"], } INSTALL_REQUIRES = ( "AnyQt>=0.2.0", "docutils", "commonmark>=0.8.1", "requests", "requests-cache", "pip>=18.0", "dictdiffer", "qasync>=0.10.0", "importlib_metadata>=4.6; python_version<'3.10'", "importlib_resources; python_version<'3.9'", "typing_extensions", "packaging", "numpy", ) CLASSIFIERS = ( "Development Status :: 1 - Planning", "Environment :: X11 Applications :: Qt", "Programming Language :: Python :: 3", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Topic :: Scientific/Engineering :: Visualization", "Topic :: Software Development :: Libraries :: Python Modules", "Intended Audience :: Education", "Intended Audience :: Developers", ) EXTRAS_REQUIRE = { 'DOCBUILD': ['sphinx', 'sphinx-rtd-theme'], } PROJECT_URLS = { "Bug Reports": "https://github.com/biolab/orange-canvas-core/issues", "Source": "https://github.com/biolab/orange-canvas-core/", "Documentation": "https://orange-canvas-core.readthedocs.io/en/latest/", } PYTHON_REQUIRES = ">=3.9" class InstallMultilingualCommand(install): def run(self): super().run() self.compile_to_multilingual() def compile_to_multilingual(self): from trubar import translate package_dir = os.path.dirname(os.path.abspath(__file__)) translate( "msgs.jaml", source_dir=os.path.join(self.install_lib, "orangecanvas"), config_file=os.path.join(package_dir, "i18n", "trubar-config.yaml")) if __name__ == "__main__": setup( name=NAME, version=VERSION, description=DESCRIPTION, long_description=LONG_DESCRIPTION, long_description_content_type="text/x-rst", url=URL, author=AUTHOR, author_email=AUTHOR_EMAIL, license=LICENSE, packages=PACKAGES, package_data=PACKAGE_DATA, install_requires=INSTALL_REQUIRES, cmdclass={ 'install': InstallMultilingualCommand, }, extras_require=EXTRAS_REQUIRE, project_urls=PROJECT_URLS, python_requires=PYTHON_REQUIRES, )