pax_global_header 0000666 0000000 0000000 00000000064 14425135267 0014523 g ustar 00root root 0000000 0000000 52 comment=2e6ea1a6d38a283fa97c3968fad7385fb7c384b3
orange-canvas-core-0.1.31/ 0000775 0000000 0000000 00000000000 14425135267 0015257 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/.github/ 0000775 0000000 0000000 00000000000 14425135267 0016617 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/.github/workflows/ 0000775 0000000 0000000 00000000000 14425135267 0020654 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/.github/workflows/run-docs-build.yml 0000664 0000000 0000000 00000002166 14425135267 0024233 0 ustar 00root root 0000000 0000000 name : Run Docs Build
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 5
env:
PIP_NO_PIP_VERSION_CHECK: 1
PIP_CACHE_DIR: .pip-cache
PIP_PREFER_BINARY: 1
strategy:
fail-fast: False
matrix:
include:
- os: ubuntu-20.04
python: 3.7
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python }}
- name: Setup Pip Cache
uses: actions/cache@v3
with:
path: .pip-cache
key: ${{ runner.os }}-py-${{ matrix.python }}-pip-${{ hashFiles('setup.*', '.github/workflows/run-docs-build.yml') }}
restore-keys: |
${{ runner.os }}-py-${{ matrix.python }}-pip
- name: Install Doc Deps
run: python -m pip install -r docs/requirements-rtd.txt
- name: Install
run: python -m pip install -e .
- name: List Build Env
run: pip list --format=freeze
- name: Build Docs
run: (cd docs; make SPHINXOPTS="-W --keep-going" html)
orange-canvas-core-0.1.31/.github/workflows/run-tests-workflow.yml 0000664 0000000 0000000 00000010520 14425135267 0025211 0 ustar 00root root 0000000 0000000 name : Run tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
timeout-minutes: 5
env:
PYTHONFAULTHANDLER: 1
PIP_NO_PIP_VERSION_CHECK: 1
PIP_CACHE_DIR: .pip-cache
PIP_PREFER_BINARY: 1
strategy:
fail-fast: False
matrix:
include:
# Linux
- os: ubuntu-20.04
python-version: 3.7
test-env: "PyQt5~=5.9.2 qasync<0.19.0"
- os: ubuntu-20.04
python-version: 3.8
test-env: "PyQt5~=5.12.0"
- os: ubuntu-20.04
python-version: 3.7
test-env: "PyQt5~=5.15.0"
- os: ubuntu-20.04
python-version: 3.8
test-env: "PyQt5~=5.15.0"
- os: ubuntu-20.04
python-version: 3.9
test-env: "PyQt5~=5.14.0"
- os: ubuntu-20.04
python-version: "3.10"
test-env: "PyQt5~=5.15.0"
- os: ubuntu-20.04
python-version: "3.11"
test-env: "PyQt5~=5.15.0"
- os: ubuntu-20.04
python-version: "3.11"
test-env: "PyQt6~=6.2.3 PyQt6-Qt6~=6.2.3"
- os: ubuntu-20.04
python-version: "3.11"
test-env: "PyQt6~=6.5.0 PyQt6-Qt6~=6.5.0"
# macOS
- os: macos-11
python-version: 3.8
test-env: "PyQt5~=5.12.0"
- os: macos-11
python-version: 3.9
test-env: "PyQt5~=5.14.0"
- os: macos-11
python-version: "3.10"
test-env: "PyQt5~=5.15.0"
- os: macos-12
python-version: "3.11"
test-env: "PyQt5~=5.15.0"
- os: macos-12
python-version: "3.11"
test-env: "PyQt6~=6.2.3 PyQt6-Qt6~=6.2.3"
- os: macos-12
python-version: "3.11"
test-env: "PyQt6~=6.5.0 PyQt6-Qt6~=6.5.0"
# Windows
- os: windows-2019
python-version: 3.7
test-env: "PyQt5~=5.9.2"
- os: windows-2019
python-version: 3.8
test-env: "PyQt5~=5.12.0"
- os: windows-2019
python-version: 3.9
test-env: "PyQt5~=5.15.0"
- os: windows-2019
python-version: "3.10"
test-env: "PyQt5~=5.15.0"
- os: windows-2019
python-version: "3.11"
test-env: "PyQt5~=5.15.0"
- os: windows-2019
python-version: "3.11"
test-env: "PyQt6~=6.2.3 PyQt6-Qt6~=6.2.3"
- os: windows-2019
python-version: "3.11"
test-env: "PyQt6~=6.5.0 PyQt6-Qt6~=6.5.0"
steps:
- uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install System Deps
if: ${{ startsWith(runner.os, 'Linux') }}
# https://www.riverbankcomputing.com/pipermail/pyqt/2020-June/042949.html
run: |
sudo apt-get update
sudo apt-get install -y libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libegl1-mesa libxcb-shape0 libxcb-cursor0
- name: Setup Pip Cache
uses: actions/cache@v3
with:
path: .pip-cache
key: ${{ runner.os }}-py-${{ matrix.python-version }}-pip-${{ hashFiles('setup.*', '.github/workflows/run-tests-workflow.yml') }}
restore-keys: |
${{ runner.os }}-py-${{ matrix.python-version }}-pip
- name: Install Test Deps
env:
TEST_ENV: ${{ matrix.test-env }}
TEST_DEPS: pytest pytest-cov wheel
run: python -m pip install $TEST_DEPS $TEST_ENV
shell: bash
- name: Install
run: python -m pip install -e .
- name: List Test Env
run: pip list --format=freeze
- name: Run Tests
if: ${{ !startsWith(runner.os, 'Linux') }}
run: pytest -v --cov=orangecanvas
- name: Run Tests with Xvfb
if: ${{ startsWith(runner.os, 'Linux') }}
env:
XVFBARGS: "-screen 0 1280x1024x24"
run: catchsegv xvfb-run -a -s "$XVFBARGS" pytest -v --cov=orangecanvas --cov-report=xml
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true
orange-canvas-core-0.1.31/.gitignore 0000664 0000000 0000000 00000000416 14425135267 0017250 0 ustar 00root root 0000000 0000000 # Ignore dot files.
.*
# Ignore backup files
*.orig
# Ignore temporary editor files.
*.swp
*~
# Ignore binaries.
*.py[cod]
*.so
*.dll
# Ignore files created by setup.py.
build
dist
MANIFEST
*.egg-info
# Built documentation.
docs/build/*
# Coverage report
htmlcov
orange-canvas-core-0.1.31/.readthedocs.yml 0000664 0000000 0000000 00000000310 14425135267 0020337 0 ustar 00root root 0000000 0000000 version: 2
python:
version: "3.7"
install:
- requirements: docs/requirements-rtd.txt
# no - method: pip here, -e . is already in docs/requirements-rtd.txt
system_packages: true
orange-canvas-core-0.1.31/LICENSE.txt 0000664 0000000 0000000 00000104513 14425135267 0017106 0 ustar 00root root 0000000 0000000 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
.
orange-canvas-core-0.1.31/MANIFEST.in 0000664 0000000 0000000 00000000071 14425135267 0017013 0 ustar 00root root 0000000 0000000 include LICENSE.txt README.rst
recursive-include docs *.* orange-canvas-core-0.1.31/README.rst 0000664 0000000 0000000 00000002003 14425135267 0016741 0 ustar 00root root 0000000 0000000 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
orange-canvas-core-0.1.31/docs/ 0000775 0000000 0000000 00000000000 14425135267 0016207 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/docs/Makefile 0000664 0000000 0000000 00000016442 14425135267 0017656 0 ustar 00root root 0000000 0000000 # Makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
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 " applehelp to make an Apple Help Book"
@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 " latexpdf to make LaTeX files and run them through pdflatex"
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of 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)"
clean:
rm -rf $(BUILDDIR)/*
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@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.qhc"
applehelp:
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
@echo
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
@echo "N.B. You won't be able to view it unless you put it in" \
"~/Library/Documentation/Help or install it in your application" \
"bundle."
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/OrangeCanvasCore"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/OrangeCanvasCore"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
latexpdfja:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through platex and dvipdfmx..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
coverage:
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
@echo "Testing of coverage in the sources finished, look at the " \
"results in $(BUILDDIR)/coverage/python.txt."
xml:
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
@echo
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
pseudoxml:
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
@echo
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
orange-canvas-core-0.1.31/docs/make.bat 0000664 0000000 0000000 00000016151 14425135267 0017620 0 ustar 00root root 0000000 0000000 @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
orange-canvas-core-0.1.31/docs/requirements-rtd.txt 0000664 0000000 0000000 00000000533 14425135267 0022263 0 ustar 00root root 0000000 0000000 --only-binary PyQt5,numpy
setuptools
sphinx~=4.2.0
sphinx-rtd-theme
PyQt5~=5.9.2
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 . orange-canvas-core-0.1.31/docs/source/ 0000775 0000000 0000000 00000000000 14425135267 0017507 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/docs/source/conf.py 0000664 0000000 0000000 00000023001 14425135267 0021002 0 ustar 00root root 0000000 0000000 # -*- 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 pkg_resources
dist = pkg_resources.get_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'
# 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 = None
# 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 = {'https://docs.python.org/': None}
orange-canvas-core-0.1.31/docs/source/index.rst 0000664 0000000 0000000 00000000772 14425135267 0021356 0 ustar 00root root 0000000 0000000 .. 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`
orange-canvas-core-0.1.31/docs/source/orangecanvas/ 0000775 0000000 0000000 00000000000 14425135267 0022156 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/docs/source/orangecanvas/application.canvasmain.rst 0000664 0000000 0000000 00000001133 14425135267 0027330 0 ustar 00root root 0000000 0000000 ===================================
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 orange-canvas-core-0.1.31/docs/source/orangecanvas/application.rst 0000664 0000000 0000000 00000000353 14425135267 0025214 0 ustar 00root root 0000000 0000000 .. application:
#############################
Application (``application``)
#############################
.. automodule:: orangecanvas.application
.. toctree::
:maxdepth: 1
application.welcomedialog
application.canvasmain
orange-canvas-core-0.1.31/docs/source/orangecanvas/application.welcomedialog.rst 0000664 0000000 0000000 00000001170 14425135267 0030024 0 ustar 00root root 0000000 0000000 ==================================
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 orange-canvas-core-0.1.31/docs/source/orangecanvas/canvas.items.annotationitem.rst 0000664 0000000 0000000 00000000736 14425135267 0030341 0 ustar 00root root 0000000 0000000 .. 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:
orange-canvas-core-0.1.31/docs/source/orangecanvas/canvas.items.linkitem.rst 0000664 0000000 0000000 00000000355 14425135267 0027121 0 ustar 00root root 0000000 0000000 .. canvas-link-item:
========================
Link Item (``linkitem``)
========================
.. automodule:: orangecanvas.canvas.items.linkitem
.. autoclass:: LinkItem
:members:
:member-order: bysource
:show-inheritance:
orange-canvas-core-0.1.31/docs/source/orangecanvas/canvas.items.nodeitem.rst 0000664 0000000 0000000 00000001473 14425135267 0027113 0 ustar 00root root 0000000 0000000 .. 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)
orange-canvas-core-0.1.31/docs/source/orangecanvas/canvas.rst 0000664 0000000 0000000 00000000323 14425135267 0024161 0 ustar 00root root 0000000 0000000 ===================
Canvas (``canvas``)
===================
.. automodule:: orangecanvas.canvas
.. toctree::
canvas.scene
canvas.items.nodeitem
canvas.items.linkitem
canvas.items.annotationitem
orange-canvas-core-0.1.31/docs/source/orangecanvas/canvas.scene.rst 0000664 0000000 0000000 00000002201 14425135267 0025252 0 ustar 00root root 0000000 0000000 .. 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
orange-canvas-core-0.1.31/docs/source/orangecanvas/document.interactions.rst 0000664 0000000 0000000 00000001644 14425135267 0027234 0 ustar 00root root 0000000 0000000 ==================================
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:
orange-canvas-core-0.1.31/docs/source/orangecanvas/document.quickmenu.rst 0000664 0000000 0000000 00000000753 14425135267 0026533 0 ustar 00root root 0000000 0000000 =============================
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:
orange-canvas-core-0.1.31/docs/source/orangecanvas/document.rst 0000664 0000000 0000000 00000000305 14425135267 0024524 0 ustar 00root root 0000000 0000000 =======================
Document (``document``)
=======================
.. automodule:: orangecanvas.document
.. toctree::
document.schemeedit
document.quickmenu
document.interactions
orange-canvas-core-0.1.31/docs/source/orangecanvas/document.schemeedit.rst 0000664 0000000 0000000 00000001337 14425135267 0026643 0 ustar 00root root 0000000 0000000 =================================
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()
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.dock.rst 0000664 0000000 0000000 00000000527 14425135267 0024417 0 ustar 00root root 0000000 0000000 ==================================
Collapsible Dock Widget (``dock``)
==================================
.. automodule:: orangecanvas.gui.dock
.. autoclass:: orangecanvas.gui.dock.CollapsibleDockWidget
:members:
:member-order: bysource
:show-inheritance:
:exclude-members:
setWidget, animationEnabled, setAnimationEnabled
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.dropshadow.rst 0000664 0000000 0000000 00000000420 14425135267 0025641 0 ustar 00root root 0000000 0000000 ==================================
Drop Shadow Frame (``dropshadow``)
==================================
.. automodule:: orangecanvas.gui.dropshadow
.. autoclass:: orangecanvas.gui.dropshadow.DropShadowFrame
:members:
:member-order: bysource
:show-inheritance:
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.framelesswindow.rst 0000664 0000000 0000000 00000000473 14425135267 0026710 0 ustar 00root root 0000000 0000000 =============================================
Frameless Window Widget (``framelesswindow``)
=============================================
.. automodule:: orangecanvas.gui.framelesswindow
.. autoclass:: orangecanvas.gui.framelesswindow.FramelessWindow
:members:
:member-order: bysource
:show-inheritance:
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.lineedit.rst 0000664 0000000 0000000 00000000740 14425135267 0025271 0 ustar 00root root 0000000 0000000 ===============================
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)
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.quickhelp.rst 0000664 0000000 0000000 00000000360 14425135267 0025457 0 ustar 00root root 0000000 0000000 ==========================
Quick Help (``quickhelp``)
==========================
.. automodule:: orangecanvas.gui.quickhelp
.. autoclass:: orangecanvas.gui.quickhelp.QuickHelp
:members:
:member-order: bysource
:show-inheritance:
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.rst 0000664 0000000 0000000 00000000467 14425135267 0023503 0 ustar 00root root 0000000 0000000 .. 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
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.splashscreen.rst 0000664 0000000 0000000 00000000413 14425135267 0026163 0 ustar 00root root 0000000 0000000 ================================
Splash Screen (``splashscreen``)
================================
.. automodule:: orangecanvas.gui.splashscreen
.. autoclass:: orangecanvas.gui.splashscreen.SplashScreen
:members:
:member-order: bysource
:show-inheritance:
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.stackedwidget.rst 0000664 0000000 0000000 00000001226 14425135267 0026316 0 ustar 00root root 0000000 0000000 :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:
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.toolbar.rst 0000664 0000000 0000000 00000000353 14425135267 0025136 0 ustar 00root root 0000000 0000000 ======================
Tool Bar (``toolbar``)
======================
.. automodule:: orangecanvas.gui.toolbar
.. autoclass:: orangecanvas.gui.toolbar.DynamicResizeToolBar
:members:
:member-order: bysource
:show-inheritance:
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.toolbox.rst 0000664 0000000 0000000 00000000546 14425135267 0025166 0 ustar 00root root 0000000 0000000 =============================
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.
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.toolgrid.rst 0000664 0000000 0000000 00000000776 14425135267 0025330 0 ustar 00root root 0000000 0000000 ===============================
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.
orange-canvas-core-0.1.31/docs/source/orangecanvas/gui.tooltree.rst 0000664 0000000 0000000 00000000716 14425135267 0025334 0 ustar 00root root 0000000 0000000 ===============================
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.
orange-canvas-core-0.1.31/docs/source/orangecanvas/index.rst 0000664 0000000 0000000 00000000272 14425135267 0024020 0 ustar 00root root 0000000 0000000 #######################
Orange Canvas Reference
#######################
The Orange Canvas API reference
.. toctree::
gui
scheme
registry
canvas
document
application
orange-canvas-core-0.1.31/docs/source/orangecanvas/overview.rst 0000664 0000000 0000000 00000004056 14425135267 0024563 0 ustar 00root root 0000000 0000000 .. _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`.
orange-canvas-core-0.1.31/docs/source/orangecanvas/registry.rst 0000664 0000000 0000000 00000001373 14425135267 0024564 0 ustar 00root root 0000000 0000000 #######################
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
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.annotation.rst 0000664 0000000 0000000 00000001260 14425135267 0026324 0 ustar 00root root 0000000 0000000 .. 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.
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.events.rst 0000664 0000000 0000000 00000004355 14425135267 0025466 0 ustar 00root root 0000000 0000000 .. 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
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.link.rst 0000664 0000000 0000000 00000000613 14425135267 0025110 0 ustar 00root root 0000000 0000000 .. 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)
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.node.rst 0000664 0000000 0000000 00000001030 14425135267 0025072 0 ustar 00root root 0000000 0000000 .. 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)
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.readwrite.rst 0000664 0000000 0000000 00000000301 14425135267 0026133 0 ustar 00root root 0000000 0000000 .. schemereadwrite:
====================================
Scheme Serialization (``readwrite``)
====================================
.. automodule:: orangecanvas.scheme.readwrite
:members:
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.rst 0000664 0000000 0000000 00000000427 14425135267 0024157 0 ustar 00root root 0000000 0000000 .. scheme:
###################
Scheme (``scheme``)
###################
.. automodule:: orangecanvas.scheme
.. toctree::
scheme.scheme
scheme.node
scheme.link
scheme.annotation
scheme.readwrite
scheme.widgetmanager
scheme.signalmanager
scheme.events
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.scheme.rst 0000664 0000000 0000000 00000002660 14425135267 0025423 0 ustar 00root root 0000000 0000000 .. 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:
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.signalmanager.rst 0000664 0000000 0000000 00000001110 14425135267 0026754 0 ustar 00root root 0000000 0000000 .. 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:
orange-canvas-core-0.1.31/docs/source/orangecanvas/scheme.widgetmanager.rst 0000664 0000000 0000000 00000001152 14425135267 0026770 0 ustar 00root root 0000000 0000000 .. 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.
orange-canvas-core-0.1.31/mypy.ini 0000664 0000000 0000000 00000000246 14425135267 0016760 0 ustar 00root root 0000000 0000000 [mypy]
ignore_missing_imports = True
allow_redefinition = True
[mypy-orangecanvas.*.tests.*]
ignore_errors = True
[mypy-orangecanvas.gui.test]
ignore_errors = True
orange-canvas-core-0.1.31/orangecanvas/ 0000775 0000000 0000000 00000000000 14425135267 0017726 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/__init__.py 0000664 0000000 0000000 00000000000 14425135267 0022025 0 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/__main__.py 0000664 0000000 0000000 00000000150 14425135267 0022014 0 ustar 00root root 0000000 0000000 import sys
from orangecanvas.main import main
if __name__ == "__main__":
sys.exit(main(sys.argv))
orange-canvas-core-0.1.31/orangecanvas/application/ 0000775 0000000 0000000 00000000000 14425135267 0022231 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/application/__init__.py 0000664 0000000 0000000 00000000100 14425135267 0024331 0 ustar 00root root 0000000 0000000 """
Main Orange Canvas Application and supporting classes.
"""
orange-canvas-core-0.1.31/orangecanvas/application/aboutdialog.py 0000664 0000000 0000000 00000002614 14425135267 0025100 0 ustar 00root root 0000000 0000000 """
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)
orange-canvas-core-0.1.31/orangecanvas/application/addons.py 0000664 0000000 0000000 00000105401 14425135267 0024054 0 ustar 00root root 0000000 0000000 import 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
import pkg_resources
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
Requirement = pkg_resources.Requirement
Distribution = pkg_resources.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.project_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.project_name.casefold(): r
for r in (Requirement.parse(r) for r in config.core_packages())
}
def constrain(item): # type: (Item) -> Item
"""Include constraint in Installed when in core_constraint"""
if isinstance(item, Installed):
name = item.local.project_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.project_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.project_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 pkg_resources.parse_requirements(inst.requirements):
if req.name in core_required and core_required[req.name] not in req:
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))
orange-canvas-core-0.1.31/orangecanvas/application/application.py 0000664 0000000 0000000 00000022351 14425135267 0025111 0 ustar 00root root 0000000 0000000 """
"""
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.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
if 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)
__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)
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()
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)
__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)
orange-canvas-core-0.1.31/orangecanvas/application/canvasmain.py 0000664 0000000 0000000 00000275576 14425135267 0024751 0 ustar 00root root 0000000 0000000 """
Orange Canvas Main Window
"""
import os
import sys
import logging
import operator
import io
import traceback
from concurrent import futures
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
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.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)
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.parse(r).project_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 = QSettings()
settings.beginGroup("mainwindow")
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")
filename, _ = QFileDialog.getSaveFileName(
self, self.tr("Save Orange Workflow File"),
start_dir, self.tr("Orange Workflow (*.ows)")
)
if filename:
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 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=True,
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)
orange-canvas-core-0.1.31/orangecanvas/application/canvastooldock.py 0000664 0000000 0000000 00000055504 14425135267 0025626 0 ustar 00root root 0000000 0000000 """
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
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 ..gui.toolgrid import ToolGrid
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 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 = super().createButtonForAction(action)
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)
orange-canvas-core-0.1.31/orangecanvas/application/examples.py 0000664 0000000 0000000 00000007022 14425135267 0024422 0 ustar 00root root 0000000 0000000 """
Example workflows discovery.
"""
import os
import logging
import types
from typing import List, Optional, IO
import pkg_resources
from orangecanvas import config as _config
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 = pkg_resources.resource_listdir(package.__name__, ".")
return sorted(filter(is_ows, 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.resolve()
except pkg_resources.DistributionNotFound as ex:
log.warning("Could not load examples from %r (%r)",
ep.dist, ex)
continue
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[pkg_resources.Distribution]) -> None
self.resource = resource
self.package = package
self.distribution = distribution
def abspath(self):
# type: () -> str
"""
Return absolute filename for the workflow if possible else
raise an ValueError.
"""
if self.package is not None:
return pkg_resources.resource_filename(self.package.__name__,
self.resource)
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):
# type: () -> IO[bytes]
"""
Return the example file as an open stream.
"""
if self.package is not None:
return pkg_resources.resource_stream(self.package.__name__,
self.resource)
elif isinstance(self.resource, str):
if os.path.isabs(self.resource) and os.path.exists(self.resource):
return open(self.resource, "rb")
raise ValueError
orange-canvas-core-0.1.31/orangecanvas/application/outputview.py 0000664 0000000 0000000 00000034404 14425135267 0025043 0 ustar 00root root 0000000 0000000 """
"""
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))
orange-canvas-core-0.1.31/orangecanvas/application/schemeinfo.py 0000664 0000000 0000000 00000013477 14425135267 0024737 0 ustar 00root root 0000000 0000000 """
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)
orange-canvas-core-0.1.31/orangecanvas/application/settings.py 0000664 0000000 0000000 00000057713 14425135267 0024460 0 ustar 00root root 0000000 0000000 """
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 ..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)
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
)
orange-canvas-core-0.1.31/orangecanvas/application/tests/ 0000775 0000000 0000000 00000000000 14425135267 0023373 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/application/tests/__init__.py 0000664 0000000 0000000 00000000000 14425135267 0025472 0 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/application/tests/test_addons.py 0000664 0000000 0000000 00000014535 14425135267 0026264 0 ustar 00root root 0000000 0000000 import 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 pkg_resources import Distribution, EntryPoint
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
@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 TestAddonManagerDialog(QAppTestCase):
def test_widget(self):
items = [
Installed(
Installable("foo", "1.1", "", "", "", []),
Distribution(project_name="foo", version="1.0"),
),
Available(
Installable("q", "1.2", "", "", "", [])
),
Installed(
None,
Distribution(project_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=[EntryPoint(
"a", "b", dist=Distribution(project_name="foo", version="1.0"))])
def test_drop(self, p1):
items = [
Installed(
Installable("foo", "1.1", "", "", "", []),
Distribution(project_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()
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_addons_utils.py 0000664 0000000 0000000 00000006614 14425135267 0027503 0 ustar 00root root 0000000 0000000 import unittest
from pkg_resources import Requirement
from orangecanvas.application.utils.addons import (
Available,
Installable,
Installed,
installable_from_json_response,
installable_items,
is_updatable,
prettify_name,
)
from orangecanvas.config import Distribution
class TestUtils(unittest.TestCase):
def test_items_1(self):
inst = Installable("foo", "1.0", "a foo", "", "", [])
dist = Distribution(project_name="foo", version="1.0")
item = Available(inst)
self.assertFalse(is_updatable(item))
item = Installed(None, dist)
self.assertFalse(is_updatable(item))
item = Installed(inst, dist)
self.assertFalse(is_updatable(item))
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.parse("foo<1.99"))
self.assertFalse(is_updatable(item))
item = Installed(inst._replace(version="2.0"), dist,
constraint=Requirement.parse("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 = Distribution(project_name="bar", version="0.9")
dist3 = Distribution(project_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'))
if __name__ == "__main__":
unittest.main()
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_application.py 0000664 0000000 0000000 00000003406 14425135267 0027312 0 ustar 00root root 0000000 0000000 import os
import sys
import time
import unittest
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([])
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)
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_canvastooldock.py 0000664 0000000 0000000 00000006472 14425135267 0030027 0 ustar 00root root 0000000 0000000 """
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)
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_main.py 0000664 0000000 0000000 00000010556 14425135267 0025737 0 ustar 00root root 0000000 0000000 import logging
import unittest
from contextlib import contextmanager
from functools import wraps
from typing import Iterable
from unittest.mock import patch, Mock
from orangecanvas import config
from orangecanvas.application.canvasmain import CanvasMainWindow
from orangecanvas.config import Config, EntryPoint
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
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.parse(f"add = {pkg}.operators.add"),
EntryPoint.parse(f"sub = {pkg}.operators.sub")
)
def workflow_constructor(self, *args, **kwargs):
return Scheme(*args, **kwargs)
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(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)
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_mainwindow.py 0000664 0000000 0000000 00000030476 14425135267 0027172 0 ustar 00root root 0000000 0000000 import os
import tempfile
from unittest.mock import patch
from AnyQt.QtGui import QWhatsThisClickedEvent
from AnyQt.QtWidgets import QToolButton, QDialog, QMessageBox, QApplication
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("")
with patch("AnyQt.QtWidgets.QFileDialog.getSaveFileName",
return_value=(self.filename, "")) as f:
w.save_scheme()
self.assertEqual(w.current_document().path(), self.filename)
def test_save_swp(self):
w = self.w
swpname = swp_name(w)
with patch.object(w, "save_swp_to") as f:
w.save_swp()
f.assert_not_called()
desc = self.registry.widgets()[0]
w.current_document().createNewNode(desc)
w = self.w
with patch.object(w, "save_swp_to") as f:
w.save_swp()
f.assert_called_with(swpname)
w.clear_swp()
def test_load_swp(self):
w = self.w
swpname = swp_name(w)
w2 = MainWindow()
w2.set_widget_registry(self.registry)
with patch.object(w2, "load_swp_from") as f:
w2.load_swp()
f.assert_not_called()
desc = self.registry.widgets()[0]
w.current_document().createNewNode(desc)
from orangecanvas.utils.pickle import canvas_scratch_name_memo as memo
memo.clear()
with patch.object(w2, "load_swp_from") as f:
w2.load_swp()
f.assert_called_with(swpname)
w2.clear_swp()
del w2
def test_dont_load_swp_on_new_window(self):
w = self.w
desc = self.registry.widgets()[0]
w.current_document().createNewNode(desc)
with patch.object(CanvasMainWindow, 'ask_load_swp', self.fail):
w.new_workflow_window()
def test_swp_functionality(self):
w = self.w
w2 = MainWindow()
w2.set_widget_registry(self.registry)
def test(predicate):
_, tf = tempfile.mkstemp()
w.save_swp_to(tf)
w2.load_swp_from(tf)
predicate()
w.scheme_widget.setModified(False)
# test widget add
desc = self.registry.widget('zero')
node = w.current_document().createNewNode(desc)
node.properties['dummy'] = 0
test(lambda:
self.assertEqual(w2.scheme_widget.scheme().nodes[0].properties['dummy'], 0))
w2_node = w2.scheme_widget.scheme().nodes[0]
# test widget change properties
node.properties['dummy'] = 1
test(lambda:
self.assertEqual(w2_node.properties['dummy'], 1))
desc = self.registry.widget('add')
node2 = w.current_document().createNewNode(desc)
link = SchemeLink(node, node.output_channels()[0], node2, node2.input_channels()[0])
# test link add
w.current_document().addLink(link)
test(lambda:
self.assertTrue(w2.scheme_widget.scheme().links))
# test link remove
w.current_document().removeLink(link)
test(lambda:
self.assertFalse(w2.scheme_widget.scheme().links))
# test widget remove
w.scheme_widget.removeNode(node)
w.scheme_widget.removeNode(node2)
test(lambda:
self.assertFalse(w2.scheme_widget.scheme().nodes))
# test annotation add
a = SchemeTextAnnotation((200, 300, 50, 20), "text")
w.current_document().addAnnotation(a)
test(lambda:
self.assertTrue(w2.scheme_widget.scheme().annotations))
# test annotation remove
w.current_document().removeAnnotation(a)
test(lambda:
self.assertFalse(w2.scheme_widget.scheme().annotations))
def test_open_ows_req(self):
w = self.w
with temp_named_file(TEST_OWS_REQ.decode()) as f:
with patch("AnyQt.QtWidgets.QMessageBox.exec",
return_value=QMessageBox.Ignore):
w.load_scheme(f)
self.assertEqual(w.current_document().path(), f)
with patch("AnyQt.QtWidgets.QMessageBox.exec",
return_value=QMessageBox.Abort):
w.load_scheme(f)
self.assertEqual(w.current_document().path(), f)
def test_install_requirements_dialog(self):
def query(names):
return [
_QueryResult(name, Installable(name, "0.0", "", "", "", []))
for name in names
]
w = self.w
with patch.object(addons, "query_pypi", query), \
patch.object(addons.AddonManagerDialog, "exec",
return_value=QDialog.Rejected):
w.install_requirements(["uber-package-shiny", "spasm"])
def test_load_unsupported_format(self):
w = self.w
workflow = w.current_document().scheme()
with temp_named_file('') 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"""\
$$
"""
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_outputview.py 0000664 0000000 0000000 00000011335 14425135267 0027242 0 ustar 00root root 0000000 0000000 import 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")
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_schemeinfo.py 0000664 0000000 0000000 00000001177 14425135267 0027132 0 ustar 00root root 0000000 0000000 from ...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())
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_settings.py 0000664 0000000 0000000 00000003435 14425135267 0026651 0 ustar 00root root 0000000 0000000 import 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())
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_welcomedialog.py 0000664 0000000 0000000 00000002161 14425135267 0027617 0 ustar 00root root 0000000 0000000 """
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)
orange-canvas-core-0.1.31/orangecanvas/application/tests/test_widgettoolbox.py 0000664 0000000 0000000 00000004407 14425135267 0027703 0 ustar 00root root 0000000 0000000 """
Tests for WidgetsToolBox.
"""
from AnyQt.QtWidgets import QWidget, QHBoxLayout
from AnyQt.QtCore import QSize
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 test_widgettoolgrid(self):
w = QWidget()
layout = QHBoxLayout()
reg = registry_tests.small_testing_registry()
qt_reg = QtWidgetRegistry(reg)
triggered_actions1 = []
triggered_actions2 = []
model = qt_reg.model()
data_descriptions = qt_reg.widgets("Constants")
one_action = qt_reg.action_for_widget("one")
actions = list(map(qt_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()
reg = registry_tests.small_testing_registry()
qt_reg = QtWidgetRegistry(reg)
triggered_actions = []
model = qt_reg.model()
one_action = qt_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))
self.qWait()
orange-canvas-core-0.1.31/orangecanvas/application/utils/ 0000775 0000000 0000000 00000000000 14425135267 0023371 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/application/utils/__init__.py 0000664 0000000 0000000 00000000000 14425135267 0025470 0 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/application/utils/addons.py 0000664 0000000 0000000 00000050132 14425135267 0025214 0 ustar 00root root 0000000 0000000 import itertools
import json
import logging
import os
import re
import shlex
import sys
import sysconfig
from collections import deque
from enum import Enum
from types import SimpleNamespace
from typing import AnyStr, Callable, List, NamedTuple, Optional, Tuple, TypeVar, Union
import requests
from AnyQt.QtCore import QObject, QSettings, QStandardPaths, QTimer, Signal, Slot
from pkg_resources import (
Requirement,
ResolutionError,
VersionConflict,
WorkingSet,
get_distribution,
parse_version,
)
from orangecanvas.utils import unique
from orangecanvas.utils.pkgmeta import parse_meta
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 normalize_name(name):
return re.sub(r"[-_.]+", "-", name).lower()
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 project_name(self):
if self.installable is not None:
return self.installable.name
else:
return self.local.project_name
@property
def normalized_name(self):
return normalize_name(self.project_name)
#: An installable item/slot
Item = Union[Available, Installed]
def is_updatable(item):
# type: (Item) -> bool
if isinstance(item, Available):
return False
elif item.installable is None:
return False
else:
inst, dist = item.installable, item.local
try:
v1 = parse_version(dist.version)
v2 = parse_version(inst.version)
except ValueError:
return False
if inst.force:
return True
if item.constraint is not None and str(v2) not in item.constraint:
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
"""
import cachecontrol.caches
if cachedir is None:
cachedir = QStandardPaths.writableLocation(QStandardPaths.CacheLocation)
cachedir = os.path.join(cachedir, "networkcache", "requests")
session = requests.Session()
session = cachecontrol.CacheControl(
session, cache=cachecontrol.caches.FileCache(directory=cachedir)
)
return 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, session=None):
# type: (config.Config, Optional[requests.Session]) -> (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.project_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 of Installable
installed : list of pkg_resources.Distribution
"""
dists = {dist.project_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.
ws = WorkingSet()
for pkg_name in set(packages.keys()).difference(set(dists.keys())):
try:
d = ws.find(Requirement.parse(pkg_name))
except ResolutionError:
pass
except ValueError:
# Requirements.parse error ?
pass
else:
if d is not None:
dists[d.project_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], working_set: Optional[WorkingSet] = None
) -> bool:
if not isinstance(req, Requirement):
req = Requirement.parse(req)
try:
if working_set is None:
d = get_distribution(req)
else:
d = working_set.find(req)
except VersionConflict:
return False
except ResolutionError:
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.project_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.project_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",
"--satisfied-skip-solve",
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",
"--satisfied-skip-solve",
self._normalize(pkg.name) + version]
return run_command(cmd)
def uninstall(self, dist):
cmd = [self.conda, "uninstall", "--yes",
self._normalize(dist.project_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
orange-canvas-core-0.1.31/orangecanvas/application/welcomedialog.py 0000664 0000000 0000000 00000022577 14425135267 0025433 0 ustar 00root root 0000000 0000000 """
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
orange-canvas-core-0.1.31/orangecanvas/application/widgettoolbox.py 0000664 0000000 0000000 00000037430 14425135267 0025504 0 ustar 00root root 0000000 0000000 """
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
)
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
)
from AnyQt.QtCore import pyqtSignal as Signal, pyqtProperty as Property
from ..gui.toolbox import ToolBox
from ..gui.toolgrid import ToolGrid
from ..gui.quickhelp import StatusTipPromoter
from ..gui.utils import create_gradient
from ..registry.qt import QtWidgetRegistry
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 a 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 = QModelIndex() # type: QModelIndex
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 = None
self.__model = model
self.__rootIndex = rootIndex
if self.__model is not None:
self.__model.rowsInserted.connect(self.__on_rowsInserted)
self.__model.rowsRemoved.connect(self.__on_rowsRemoved)
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 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, 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 == self.__rootIndex:
for i in range(start, end + 1):
item = self.__model.index(i, 0, self.__rootIndex)
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 == self.__rootIndex:
for i in reversed(range(start - 1, end)):
action = self.actions()[i]
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.__iconSize = QSize(25, 25)
self.__buttonSize = QSize(50, 50)
self.setSizePolicy(QSizePolicy.Fixed,
QSizePolicy.Expanding)
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.
"""
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.__model = model
if self.__model is not None:
self.__model.dataChanged.connect(self.__on_dataChanged)
self.__model.rowsInserted.connect(self.__on_rowsInserted)
self.__model.rowsRemoved.connect(self.__on_rowsRemoved)
self.__initFromModel(self.__model)
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()
gradient = create_gradient(highlight.color())
highlight = QBrush(gradient)
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.__model.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 range(end, start - 1, -1):
self.removeItem(i)
orange-canvas-core-0.1.31/orangecanvas/canvas/ 0000775 0000000 0000000 00000000000 14425135267 0021201 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/canvas/__init__.py 0000664 0000000 0000000 00000000471 14425135267 0023314 0 ustar 00root root 0000000 0000000 """
======
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"]
orange-canvas-core-0.1.31/orangecanvas/canvas/items/ 0000775 0000000 0000000 00000000000 14425135267 0022322 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/canvas/items/__init__.py 0000664 0000000 0000000 00000000440 14425135267 0024431 0 ustar 00root root 0000000 0000000 """
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
orange-canvas-core-0.1.31/orangecanvas/canvas/items/annotationitem.py 0000664 0000000 0000000 00000063175 14425135267 0025741 0 ustar 00root root 0000000 0000000 from 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)
orange-canvas-core-0.1.31/orangecanvas/canvas/items/controlpoints.py 0000664 0000000 0000000 00000040006 14425135267 0025611 0 ustar 00root root 0000000 0000000 import 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()
orange-canvas-core-0.1.31/orangecanvas/canvas/items/graphicspathobject.py 0000664 0000000 0000000 00000007741 14425135267 0026551 0 ustar 00root root 0000000 0000000 from 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
orange-canvas-core-0.1.31/orangecanvas/canvas/items/graphicstextitem.py 0000664 0000000 0000000 00000045675 14425135267 0026301 0 ustar 00root root 0000000 0000000 import 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()
orange-canvas-core-0.1.31/orangecanvas/canvas/items/linkitem.py 0000664 0000000 0000000 00000064645 14425135267 0024527 0 ustar 00root root 0000000 0000000 """
=========
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)
orange-canvas-core-0.1.31/orangecanvas/canvas/items/nodeitem.py 0000664 0000000 0000000 00000167314 14425135267 0024514 0 ustar 00root root 0000000 0000000 """
=========
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 ...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)
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
)
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)
)
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
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/ 0000775 0000000 0000000 00000000000 14425135267 0023464 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/__init__.py 0000664 0000000 0000000 00000001342 14425135267 0025575 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/test_annotationitem.py 0000664 0000000 0000000 00000004035 14425135267 0030130 0 ustar 00root root 0000000 0000000 import 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()
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/test_controlpoints.py 0000664 0000000 0000000 00000003463 14425135267 0030020 0 ustar 00root root 0000000 0000000 from 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())
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/test_graphicspathobject.py 0000664 0000000 0000000 00000003755 14425135267 0030753 0 ustar 00root root 0000000 0000000 from 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)
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/test_graphicstextitem.py 0000664 0000000 0000000 00000006216 14425135267 0030466 0 ustar 00root root 0000000 0000000 from 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
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/test_linkitem.py 0000664 0000000 0000000 00000007701 14425135267 0026716 0 ustar 00root root 0000000 0000000 import 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())
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/test_nodeitem.py 0000664 0000000 0000000 00000015604 14425135267 0026707 0 ustar 00root root 0000000 0000000 from 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())
orange-canvas-core-0.1.31/orangecanvas/canvas/items/tests/test_utils.py 0000664 0000000 0000000 00000007330 14425135267 0026240 0 ustar 00root root 0000000 0000000 import 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))
orange-canvas-core-0.1.31/orangecanvas/canvas/items/utils.py 0000664 0000000 0000000 00000024646 14425135267 0024050 0 ustar 00root root 0000000 0000000 import 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
orange-canvas-core-0.1.31/orangecanvas/canvas/layout.py 0000664 0000000 0000000 00000013240 14425135267 0023070 0 ustar 00root root 0000000 0000000 """
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
orange-canvas-core-0.1.31/orangecanvas/canvas/scene.py 0000664 0000000 0000000 00000100430 14425135267 0022646 0 ustar 00root root 0000000 0000000 """
=====================
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):
# type: (QGraphicsScene) -> str
"""
Return a SVG rendering of the scene contents.
Parameters
----------
scene : :class:`CanvasScene`
"""
svg_buffer = QBuffer()
gen = _QSvgGenerator()
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")
orange-canvas-core-0.1.31/orangecanvas/canvas/tests/ 0000775 0000000 0000000 00000000000 14425135267 0022343 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/canvas/tests/__init__.py 0000664 0000000 0000000 00000000000 14425135267 0024442 0 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/canvas/tests/test_layout.py 0000664 0000000 0000000 00000005135 14425135267 0025275 0 ustar 00root root 0000000 0000000 import 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
orange-canvas-core-0.1.31/orangecanvas/canvas/tests/test_scene.py 0000664 0000000 0000000 00000021502 14425135267 0025051 0 ustar 00root root 0000000 0000000 from 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
orange-canvas-core-0.1.31/orangecanvas/canvas/view.py 0000664 0000000 0000000 00000024025 14425135267 0022530 0 ustar 00root root 0000000 0000000 """
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(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)
orange-canvas-core-0.1.31/orangecanvas/config.py 0000664 0000000 0000000 00000041077 14425135267 0021556 0 ustar 00root root 0000000 0000000 """
Orange Canvas Configuration
"""
import os
import sys
import logging
import warnings
from distutils.version import LooseVersion
import typing
from typing import Dict, Optional, Tuple, List, Union, Iterable, Any
import pkg_resources
from AnyQt.QtGui import (
QPainter, QFont, QFontMetrics, QColor, QPixmap, 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 .utils.settings import Settings, config_slot
if typing.TYPE_CHECKING:
import requests
from .scheme import Scheme
T = typing.TypeVar("T")
EntryPoint = pkg_resources.EntryPoint
Distribution = pkg_resources.Distribution
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 pkg_resources.
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.
"""
path = pkg_resources.resource_filename(
__name__, "icons/orange-canvas.svg"
)
return QIcon(path)
@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.
"""
path = pkg_resources.resource_filename(
__name__, "icons/orange-canvas-core-splash.svg")
pm = QPixmap(path)
version = QCoreApplication.applicationVersion()
if version:
version_parsed = LooseVersion(version)
version_comp = version_parsed.version
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():
# type: () -> Iterable[EntryPoint]
"""
Return an iterator over entry points defining the set of
'nodes/widgets' available to the workflow model.
"""
return pkg_resources.iter_entry_points(WIDGETS_ENTRY)
@staticmethod
def addon_entry_points():
# type: () -> Iterable[EntryPoint]
return pkg_resources.iter_entry_points(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 pkg_resources.iter_entry_points(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"),
("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
orange-canvas-core-0.1.31/orangecanvas/document/ 0000775 0000000 0000000 00000000000 14425135267 0021544 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/document/__init__.py 0000664 0000000 0000000 00000000620 14425135267 0023653 0 ustar 00root root 0000000 0000000 """
========
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
orange-canvas-core-0.1.31/orangecanvas/document/commands.py 0000664 0000000 0000000 00000027467 14425135267 0023737 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/document/editlinksdialog.py 0000664 0000000 0000000 00000102070 14425135267 0025264 0 ustar 00root root 0000000 0000000 """
===========
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)
orange-canvas-core-0.1.31/orangecanvas/document/interactions.py 0000664 0000000 0000000 00000230073 14425135267 0024625 0 ustar 00root root 0000000 0000000 """
=========================
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
)
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
if typing.TYPE_CHECKING:
from .schemeedit import SchemeEditWidget
A = typing.TypeVar("A")
#: Output/Input pair of a link
OIPair = Tuple[OutputSignal, InputSignal]
try:
from importlib.metadata import EntryPoint, entry_points
except ImportError:
from importlib_metadata import EntryPoint, entry_points
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 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()
helpevent = QuickHelpTipEvent(
self.tr("Create a new link"),
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 ...'
)
)
QCoreApplication.postEvent(self.document, helpevent)
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)
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
helpevent = QuickHelpTipEvent("", "")
QCoreApplication.postEvent(self.document, helpevent)
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)
helpevent = QuickHelpTipEvent(
self.tr("Click and drag to create a new arrow"),
self.tr('
'
# ''
# 'More ...'
)
)
QCoreApplication.postEvent(self.document, helpevent)
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)
# Clear the help tip
helpevent = QuickHelpTipEvent("", "")
QCoreApplication.postEvent(self.document, helpevent)
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 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
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().get(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:
menu = QMenu(event.widget())
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
orange-canvas-core-0.1.31/orangecanvas/document/quickmenu.py 0000664 0000000 0000000 00000171444 14425135267 0024132 0 ustar 00root root 0000000 0000000 """
==========
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 ..gui.framelesswindow import FramelessWindow
from ..gui.lineedit import LineEdit
from ..gui.tooltree import ToolTree, FlattenedTreeItemModel
from ..gui.utils import StyledWidget_paintEvent, innerGlowBackgroundPixmap, innerShadowPixmap
from ..registry.qt import QtWidgetRegistry
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
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
name = description.name.lower()
keywords = [k.lower() for k in description.keywords]
for k in keywords[:]:
if '-' in k:
keywords.append(k.replace('-', ''))
keywords.append(k.replace('-', ' '))
query = self.__query
# match name and keywords
accepted = (not query or
query in name or
query in name.replace(' ', '') or
any(k.startswith(query) for k in keywords))
# 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
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)
orange-canvas-core-0.1.31/orangecanvas/document/schemeedit.py 0000664 0000000 0000000 00000266571 14425135267 0024251 0 ustar 00root root 0000000 0000000 """
====================
Scheme Editor Widget
====================
"""
import enum
import io
import logging
import itertools
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 + nodedups}
for nodedup in nodedups:
nodedup.title = uniquify(
nodedup.title, allnames, pattern="{item} ({_})", start=1)
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 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, 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())
orange-canvas-core-0.1.31/orangecanvas/document/suggestions.py 0000664 0000000 0000000 00000010143 14425135267 0024467 0 ustar 00root root 0000000 0000000 import 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)
orange-canvas-core-0.1.31/orangecanvas/document/tests/ 0000775 0000000 0000000 00000000000 14425135267 0022706 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/document/tests/__init__.py 0000664 0000000 0000000 00000000000 14425135267 0025005 0 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/document/tests/test_editlinksdialog.py 0000664 0000000 0000000 00000007434 14425135267 0027475 0 ustar 00root root 0000000 0000000 from 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)
orange-canvas-core-0.1.31/orangecanvas/document/tests/test_quickmenu.py 0000664 0000000 0000000 00000005427 14425135267 0026330 0 ustar 00root root 0000000 0000000 from 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)
orange-canvas-core-0.1.31/orangecanvas/document/tests/test_schemeedit.py 0000664 0000000 0000000 00000057435 14425135267 0026447 0 ustar 00root root 0000000 0000000 """
Tests for scheme document.
"""
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)
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",
# "pkg_resources.WorkingSet.iter_entry_points",
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
orange-canvas-core-0.1.31/orangecanvas/document/tests/test_usagestatistics.py 0000664 0000000 0000000 00000003301 14425135267 0027533 0 ustar 00root root 0000000 0000000 from 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)
orange-canvas-core-0.1.31/orangecanvas/document/usagestatistics.py 0000664 0000000 0000000 00000032467 14425135267 0025351 0 ustar 00root root 0000000 0000000 import 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
orange-canvas-core-0.1.31/orangecanvas/gui/ 0000775 0000000 0000000 00000000000 14425135267 0020512 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/gui/__init__.py 0000664 0000000 0000000 00000000243 14425135267 0022622 0 ustar 00root root 0000000 0000000 """
===========
GUI toolkit
===========
A GUI toolkit with widgets used by other parts of Orange Canvas.
Extends basic Qt classes with extra functionality.
"""
orange-canvas-core-0.1.31/orangecanvas/gui/dock.py 0000664 0000000 0000000 00000021644 14425135267 0022013 0 ustar 00root root 0000000 0000000 """
=======================
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)
orange-canvas-core-0.1.31/orangecanvas/gui/dropshadow.py 0000664 0000000 0000000 00000025602 14425135267 0023243 0 ustar 00root root 0000000 0000000 """
=================
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]
orange-canvas-core-0.1.31/orangecanvas/gui/examples/ 0000775 0000000 0000000 00000000000 14425135267 0022330 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/gui/examples/dock.py 0000664 0000000 0000000 00000001535 14425135267 0023626 0 ustar 00root root 0000000 0000000 import sys
from AnyQt.QtCore import Qt
from AnyQt.QtGui import QKeySequence
from AnyQt.QtWidgets import (
QApplication, QAction, QMainWindow, QTextEdit, QToolButton,
QTreeView
)
from orangecanvas.gui.dock import CollapsibleDockWidget
def main(argv):
app = QApplication(argv)
mw = QMainWindow()
dock = CollapsibleDockWidget()
w1 = QTreeView()
w1.header().hide()
w2 = QToolButton()
w2.setFixedSize(38, 200)
dock.setExpandedWidget(w1)
dock.setCollapsedWidget(w2)
mw.addDockWidget(Qt.LeftDockWidgetArea, dock)
mw.setCentralWidget(QTextEdit())
mw.show()
a = QAction(
"Expand", mw, checkable=True,
shortcut=QKeySequence("Ctrl+D")
)
a.triggered[bool].connect(dock.setExpanded)
mw.addAction(a)
return app.exec()
if __name__ == "__main__":
sys.exit(main(sys.argv))
orange-canvas-core-0.1.31/orangecanvas/gui/examples/toolbox.py 0000664 0000000 0000000 00000001546 14425135267 0024376 0 ustar 00root root 0000000 0000000 import sys
from AnyQt.QtGui import QIcon
from AnyQt.QtWidgets import (
QApplication, QLabel, QListView, QSpinBox, QTextBrowser, QStyle
)
from orangecanvas.gui.toolbox import ToolBox
def main(argv=[]):
app = QApplication(argv)
w = ToolBox()
style = app.style()
icon = QIcon(style.standardIcon(QStyle.SP_FileIcon))
p1 = QLabel("A Label")
p2 = QListView()
p3 = QLabel("Another\nlabel")
p4 = QSpinBox()
i1 = w.addItem(p1, "Tab 1", icon)
i2 = w.addItem(p2, "Tab 2", icon, "The second tab")
i3 = w.addItem(p3, "Tab 3")
i4 = w.addItem(p4, "Tab 4")
p6 = QTextBrowser()
p6.setHtml(
"
Hello Visitor
"
"
Are you interested in some of our wares?
"
)
w.insertItem(2, p6, "Dear friend")
w.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main(sys.argv))
orange-canvas-core-0.1.31/orangecanvas/gui/examples/toolgrid.py 0000664 0000000 0000000 00000001217 14425135267 0024526 0 ustar 00root root 0000000 0000000 import sys
from AnyQt.QtWidgets import QAction, QStyle, QApplication
from orangecanvas.gui.toolgrid import ToolGrid
def main(argv=[]):
app = QApplication(argv)
toolbox = ToolGrid(columns=3)
icon = app.style().standardIcon(QStyle.SP_FileIcon)
actions = [
QAction("A", None, icon=icon),
QAction("B", None, icon=icon),
QAction("This one is longer.", icon=icon),
QAction("Not done yet!", icon=icon),
QAction("The quick brown fox ... does something I guess", icon=icon),
]
toolbox.addActions(actions)
toolbox.show()
return app.exec()
if __name__ == "__main__":
main(sys.argv)
orange-canvas-core-0.1.31/orangecanvas/gui/framelesswindow.py 0000664 0000000 0000000 00000005071 14425135267 0024300 0 ustar 00root root 0000000 0000000 """
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)
orange-canvas-core-0.1.31/orangecanvas/gui/iconview.py 0000664 0000000 0000000 00000006542 14425135267 0022716 0 ustar 00root root 0000000 0000000 from 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()
orange-canvas-core-0.1.31/orangecanvas/gui/itemmodels.py 0000664 0000000 0000000 00000003046 14425135267 0023231 0 ustar 00root root 0000000 0000000 from 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)
orange-canvas-core-0.1.31/orangecanvas/gui/lineedit.py 0000664 0000000 0000000 00000016605 14425135267 0022671 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/gui/quickhelp.py 0000664 0000000 0000000 00000011113 14425135267 0023046 0 ustar 00root root 0000000 0000000 from 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)
orange-canvas-core-0.1.31/orangecanvas/gui/splashscreen.py 0000664 0000000 0000000 00000011563 14425135267 0023564 0 ustar 00root root 0000000 0000000 """
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)
orange-canvas-core-0.1.31/orangecanvas/gui/stackedwidget.py 0000664 0000000 0000000 00000027146 14425135267 0023720 0 ustar 00root root 0000000 0000000 """
=====================
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)
orange-canvas-core-0.1.31/orangecanvas/gui/svgiconengine.py 0000664 0000000 0000000 00000030333 14425135267 0023724 0 ustar 00root root 0000000 0000000 import io
from contextlib import contextmanager
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,
QPaintDevice
)
from AnyQt.QtSvg import QSvgRenderer
from AnyQt.QtWidgets import QStyleOption, QApplication
from .utils import luminance, merged_color
_cache_id_gen = count()
class SvgIconEngine(QIconEngine):
"""
An 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)
class StyledSvgIconEngine(QIconEngine):
"""
A basic styled icon engine based on a QPalette colors.
This engine can draw css styled svg icons of specific format so as 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", "__palette", "__renderer",
"__cache_key", "__style_object",
)
def __init__(
self,
contents: bytes,
*,
palette: Optional[QPalette] = None,
styleObject: Optional[QObject] = None,
) -> None:
super().__init__()
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
@staticmethod
def __paletteFromPaintDevice(dev: QPaintDevice) -> Optional[QPalette]:
if isinstance(dev, QObject):
palette_ = dev.property("palette")
if isinstance(palette_, QPalette):
return palette_
return None
@staticmethod
def __paletteFromStyleObject(obj: QObject) -> Optional[QPalette]:
palette = obj.property("palette")
if isinstance(palette, QPalette):
return palette
else:
return None
def paint(self, painter, rect, mode, state):
# type: (QPainter, QRect, QIcon.Mode, QIcon.State) -> None
if self.__renderer.isValid():
if self.__paletteOverride is not None:
palette = self.__paletteOverride
elif self.__palette is None:
palette = self.__paletteFromPaintDevice(painter.device())
if palette is None:
palette = self._palette()
else:
palette = self._palette()
size = rect.size()
dpr = painter.device().devicePixelRatioF()
size = size * dpr
pm = self.__renderStyledPixmap(size, mode, state, palette)
painter.drawPixmap(rect, pm)
def _palette(self) -> QPalette:
if self.__paletteOverride is not None:
return self.__paletteOverride
if self.__palette is not None:
return self.__palette
elif self.__style_object is not None:
palette = self.__paletteFromStyleObject(self.__style_object)
if palette is not None:
return palette
if self.__paletteOverride is not None:
return QPalette(self.__paletteOverride)
return QApplication.palette()
def pixmap(self, size, mode, state):
# type: (QSize, QIcon.Mode, QIcon.State) -> QPixmap
return self.__renderStyledPixmap(size, mode, state, self._palette())
def __renderStyledPixmap(
self, size: QSize, mode: QIcon.Mode, state: QIcon.State,
palette: QPalette
) -> QPixmap:
active = mode in (QIcon.Active, QIcon.Selected)
disabled = mode == QIcon.Disabled
cg = QPalette.Disabled if disabled else QPalette.Active
role = QPalette.HighlightedText if active else QPalette.WindowText
namespace = "{}:{}/{}/".format(
__name__, __class__.__name__, self.__cache_key)
style_key = "{}-{}-{}".format(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 = namespace + style_key + \
"/{}x{}".format(size.width(), 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)
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
)
__paletteOverride = None
@classmethod
@contextmanager
def setOverridePalette(cls, 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 = StyledSvgIconEngine.__paletteOverride
try:
StyledSvgIconEngine.__paletteOverride = palette
yield
finally:
StyledSvgIconEngine.__paletteOverride = old
#: 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) -> 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:
contrast = QColor(Qt.black) if luminance(color) 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)),
background=_hexrgb_solid(palette.color(background)),
highlight=_hexrgb_solid(palette.color(hligh)),
disabled_text=_hexrgb_solid(palette.color(QPalette.Disabled, text)),
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()
orange-canvas-core-0.1.31/orangecanvas/gui/test.py 0000664 0000000 0000000 00000012623 14425135267 0022047 0 ustar 00root root 0000000 0000000 """
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
orange-canvas-core-0.1.31/orangecanvas/gui/tests/ 0000775 0000000 0000000 00000000000 14425135267 0021654 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/gui/tests/__init__.py 0000664 0000000 0000000 00000000037 14425135267 0023765 0 ustar 00root root 0000000 0000000 """
Tests for gui toolkit
"""
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_dock.py 0000664 0000000 0000000 00000003113 14425135267 0024203 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_dropshadow.py 0000664 0000000 0000000 00000005626 14425135267 0025450 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_framelesswindow.py 0000664 0000000 0000000 00000000764 14425135267 0026505 0 ustar 00root root 0000000 0000000 from 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()
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_lineedit.py 0000664 0000000 0000000 00000002676 14425135267 0025075 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_splashscreen.py 0000664 0000000 0000000 00000002332 14425135267 0025757 0 ustar 00root root 0000000 0000000 """
Test for splashscreen
"""
from datetime import datetime
import pkg_resources
from AnyQt.QtGui import QPixmap
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):
splash = pkg_resources.resource_filename(
config.__package__, "icons/orange-canvas-core-splash.svg"
)
w = SplashScreen()
w.setPixmap(QPixmap(splash))
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() orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_stackedwidget.py 0000664 0000000 0000000 00000004225 14425135267 0026112 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_toolbar.py 0000664 0000000 0000000 00000002242 14425135267 0024727 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_toolbox.py 0000664 0000000 0000000 00000004254 14425135267 0024760 0 ustar 00root root 0000000 0000000 """
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
)
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_toolgrid.py 0000664 0000000 0000000 00000005565 14425135267 0025123 0 ustar 00root root 0000000 0000000 from 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()
orange-canvas-core-0.1.31/orangecanvas/gui/tests/test_tooltree.py 0000664 0000000 0000000 00000005050 14425135267 0025122 0 ustar 00root root 0000000 0000000 """
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()
orange-canvas-core-0.1.31/orangecanvas/gui/textlabel.py 0000664 0000000 0000000 00000005634 14425135267 0023060 0 ustar 00root root 0000000 0000000 from 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()
orange-canvas-core-0.1.31/orangecanvas/gui/toolbar.py 0000664 0000000 0000000 00000006676 14425135267 0022545 0 ustar 00root root 0000000 0000000 """
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)
orange-canvas-core-0.1.31/orangecanvas/gui/toolbox.py 0000664 0000000 0000000 00000052553 14425135267 0022564 0 ustar 00root root 0000000 0000000 """
===============
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 ..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())
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)
orange-canvas-core-0.1.31/orangecanvas/gui/toolgrid.py 0000664 0000000 0000000 00000037616 14425135267 0022724 0 ustar 00root root 0000000 0000000 """
A widget containing a grid of clickable actions/buttons.
"""
import sys
from collections import deque
from typing import NamedTuple, List, Iterable, Optional, Any, Union
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)
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:
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 = self.layout()
assert isinstance(layout, QGridLayout)
layout.addWidget(button, row, column)
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 = self.layout()
assert isinstance(layout, QGridLayout)
button_count = layout.count()
columns = self.__columns
direction = 1 if count >= 0 else -1
if direction == 1:
start, end = button_count - 1, start - 1
else:
start, end = start, button_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,
)
def __relayout(self):
# type: () -> None
"""Relayout the buttons.
"""
layout = self.layout()
assert isinstance(layout, QGridLayout)
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 __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
orange-canvas-core-0.1.31/orangecanvas/gui/tooltree.py 0000664 0000000 0000000 00000031776 14425135267 0022737 0 ustar 00root root 0000000 0000000 """
=========
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()
orange-canvas-core-0.1.31/orangecanvas/gui/utils.py 0000664 0000000 0000000 00000055303 14425135267 0022232 0 ustar 00root root 0000000 0000000 """
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_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()
orange-canvas-core-0.1.31/orangecanvas/gui/windowlistmanager.py 0000664 0000000 0000000 00000007764 14425135267 0024640 0 ustar 00root root 0000000 0000000 from 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
WindowListManager.__instance = self
super().__init__(*args, **kwargs)
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)
orange-canvas-core-0.1.31/orangecanvas/help/ 0000775 0000000 0000000 00000000000 14425135267 0020656 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/help/__init__.py 0000664 0000000 0000000 00000000104 14425135267 0022762 0 ustar 00root root 0000000 0000000 from .provider import HelpProvider
from .manager import HelpManager
orange-canvas-core-0.1.31/orangecanvas/help/intersphinx.py 0000664 0000000 0000000 00000004326 14425135267 0023610 0 ustar 00root root 0000000 0000000 """
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
orange-canvas-core-0.1.31/orangecanvas/help/manager.py 0000664 0000000 0000000 00000025026 14425135267 0022647 0 ustar 00root root 0000000 0000000 """
"""
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
import pkg_resources
from AnyQt.QtCore import QObject, QUrl, QDir
from ..utils.pkgmeta import get_dist_url, is_develop_egg
from . import provider
if typing.TYPE_CHECKING:
from ..registry import WidgetRegistry, WidgetDescription
Distribution = pkg_resources.Distribution
EntryPoint = pkg_resources.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 = pkg_resources.get_distribution(project)
except pkg_resources.ResolutionError:
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.project_name,
"PROJECT_NAME_LOWER": dist.project_name.lower(),
"PROJECT_VERSION": dist.version,
"DATA_DIR": get_path("data")}
try:
replacements["URL"] = get_dist_url(dist)
except KeyError:
pass
if is_develop_egg(dist):
replacements["DEVELOP_ROOT"] = dist.location
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.resolve()
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.resolve()
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.resolve()
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):
# type: (pkg_resources.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.project_name in _providers_cache:
return _providers_cache[dist.project_name]
eps = dist.get_entry_map()
entry_points = eps.get("orange.canvas.help", {})
if not entry_points:
# alternative name
entry_points = eps.get("orangecanvas.help", {})
provider = None
for name, entry_point in entry_points.items():
create = _providers.get(name, None)
if create:
try:
provider = create(entry_point)
except pkg_resources.DistributionNotFound as err:
log.warning("Unsatisfied dependencies (%r)", err)
continue
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.project_name] = provider
return provider
orange-canvas-core-0.1.31/orangecanvas/help/provider.py 0000664 0000000 0000000 00000031547 14425135267 0023074 0 ustar 00root root 0000000 0000000 """
"""
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
orange-canvas-core-0.1.31/orangecanvas/help/tests/ 0000775 0000000 0000000 00000000000 14425135267 0022020 5 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/help/tests/__init__.py 0000664 0000000 0000000 00000000000 14425135267 0024117 0 ustar 00root root 0000000 0000000 orange-canvas-core-0.1.31/orangecanvas/help/tests/test_provider.py 0000664 0000000 0000000 00000005550 14425135267 0025270 0 ustar 00root root 0000000 0000000 import 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'