././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8639245 orange-widget-base-4.22.0/0000755000076500000240000000000014501051361014502 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/CONTRIBUTING.md0000644000076500000240000001154614306600442016745 0ustar00primozstaffContributing ============ Thanks for taking the time to contribute to Orange Widget Base! Please submit contributions in accordance with the flow explained in the [GitHub Guides]. [GitHub Guides]: https://guides.github.com/ Installing for development -------------------------- While in the local source checkout run pip install -e . Reporting bugs -------------- Please report bugs according to established [bug reporting guidelines]. At least, include a method to reproduce the bug (if consistently reproducible) and a screenshot (if applicable). [bug reporting guidelines]: https://www.google.com/search?q=reporting+bugs Coding style ------------ Roughly conform to [PEP-8] style guide for Python code. Whenever PEP-8 is undefined, adhere to [Google Python Style Guide]. In addition, we add the following guidelines: * Only ever `import *` to make objects available in another namespace, preferably in *\_\_init\_\_.py*. Everywhere else use explicit object imports. * Use [Napoleon]-compatible (e.g. NumPy style) docstrings, preferably with [tests]. * When instantiating Qt widgets, pass static property values as [keyword args to the constructor] instead of calling separate property setters later. For example, do: view = QListView(alternatingRowColors=True, selectionMode=QAbstractItemView.ExtendedSelection) instead of: view = QListView() view.setAlternatingRowColors(True) view.setSelectionMode(QAbstractItemView.ExtendedSelection) Please ensure your commits pass code quality assurance by executing: pip install -r requirements-dev.txt python setup.py lint [PEP-8]: https://www.python.org/dev/peps/pep-0008/ [Google Python Style Guide]: https://google.github.io/styleguide/pyguide.html [Napoleon]: http://www.sphinx-doc.org/en/stable/ext/napoleon.html [keyword args to the constructor]: http://pyqt.sourceforge.net/Docs/PyQt5/qt_properties.html Human Interface Guidelines -------------------------- For UI design, conform to the [OS X Human Interface Guidelines]. In a nutshell, use title case for titles, push buttons, menu titles and menu options. Elsewhere, use sentence case. Use title case for combo box options where the item is imperative (e.g. Initialize with Method) and sentence case otherwise. [OS X Human Interface Guidelines]: https://developer.apple.com/library/mac/documentation/UserExperience/Conceptual/OSXHIGuidelines/TerminologyWording.html Testing ------- [tests]: #tests If you contribute new code, write [unit tests] for it in _orangewidget/tests_ or _orangewidget/utils/tests_, as appropriate. Ensure the tests pass by running: python -m unittest If testing on GNU/Linux, perhaps install _xvfb_ package and prefix the above command with `xvfb-run `. [unit tests]: https://en.wikipedia.org/wiki/Unit_testing Commit messages --------------- Make a separate commit for each logical change you introduce. We prefer short commit messages with descriptive titles. For a general format see [Commit Guidelines]. E.g.: > io: Fix reader for XYZ file format > > The reader didn't work correctly in such-and-such case. The commit title (first line) should concisely explain _WHAT_ is the change. If the reasons for the change aren't reasonably obvious, also explain the _WHY_ and _HOW_ in the commit body. The commit title should start with a tag which concisely conveys what Python package, module, or class the introduced change pertains to. **ProTip**: Examine project's commit history to see examples of commit messages most probably acceptable to that project. [Commit Guidelines]: http://git-scm.com/book/ch5-2.html#Commit-Guidelines Pull requests ------------- Implement new features in separate topic branches: git checkout master git checkout -b my-new-feature # spin a branch off of current branch When you are asked to make changes to your pull request, and you add the commits that implement those changes, squash commits that fit together. E.g., if your pull request looks like this: d43ef09 Some feature I made b803d26 reverts part of previous commit 77d5ad3 Some other bugfix 9e30343 Another new feature 1d5b3bc fix typo (in previous commit) interactively rebase the commits onto the master branch: git rebase --interactive master and mark `fixup` or `squash` the commits that are just minor patches on previous commits (interactive rebase also allows you to reword and reorder commits). The resulting example pull request should look clean: b432f18 some_module: Some feature I made 85d5a0a other.module: Some other bugfix 439e303 OWSomeWidget: Another new feature Read more [about squashing commits]. [about squashing commits]: https://www.google.com/search?q=git+squash+commits Documentation ------------- Documentation in located in doc folder. You can build it with: cd doc/ make html # Now open build/html/index.html to see it ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/LICENSE0000644000076500000240000000057214306600442015516 0ustar00primozstaffCopyright (c) 2016 Bioinformatics Laboratory, University of Ljubljana, Faculty of Computer and Information Science All rights reserved. THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT ANY WARRANTY WHATSOEVER. If you use or redistribute this software, you are permitted to do so under the terms of GNU [GPL-3.0]+ license. [GPL-3.0]: https://www.gnu.org/licenses/gpl-3.0.en.html ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/MANIFEST.in0000644000076500000240000000026714306600442016250 0ustar00primozstaffrecursive-include orangewidget *.png *.svg *.js *.css *.html recursive-include distribute *.svg *.desktop include README.md include CONTRIBUTING.md include LICENSE include setup.py ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1694782192.863664 orange-widget-base-4.22.0/PKG-INFO0000644000076500000240000000251414501051361015601 0ustar00primozstaffMetadata-Version: 2.1 Name: orange-widget-base Version: 4.22.0 Summary: Base Widget for Orange Canvas Home-page: http://orange.biolab.si/ Author: Bioinformatics Laboratory, FRI UL Author-email: info@biolab.si License: GPLv3+ Keywords: workflow,widget Classifier: Development Status :: 4 - Beta Classifier: Environment :: X11 Applications :: Qt Classifier: Environment :: Console Classifier: Environment :: Plugins Classifier: Programming Language :: Python Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Classifier: Operating System :: POSIX Classifier: Operating System :: Microsoft :: Windows Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence Classifier: Topic :: Scientific/Engineering :: Visualization Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Intended Audience :: Education Classifier: Intended Audience :: Science/Research Classifier: Intended Audience :: Developers Requires-Python: >=3.6 Description-Content-Type: text/x-rst License-File: LICENSE This project implements the base OWBaseWidget class and utilities for use in Orange Canvas workflows. Provides: * `OWBaseWidget` class * `gui` module for building GUI * `WidgetsScheme` the workflow execution model/bridge * basic configuration for a workflow based application ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/README.md0000644000076500000240000000166314306600442015772 0ustar00primozstaffOrange Widget Base ================== Orange Widget Base provides a base widget component for a interactive GUI based workflow. It is primarily used in the [Orange] data mining application. [Orange]: http://orange.biolab.si/ Orange Widget Base requires Python 3.6 or newer. Installing with pip ------------------- # Create a separate Python environment for Orange and its dependencies ... python3 -m venv orangevenv # ... and make it the active one source orangevenv/bin/activate # Clone the repository and move into it git clone https://github.com/biolab/orange-widget-base.git cd orange-widget-base # Install Qt dependencies for the GUI pip install PyQt5 PyQtWebEngineCore # Finally install this in editable/development mode. pip install -e . Starting the GUI ---------------- Start a default workflow editor GUI with python -m orangecanvas --config orangewidget.workflow.config.Config ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8407004 orange-widget-base-4.22.0/orange_widget_base.egg-info/0000755000076500000240000000000014501051361022004 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694782192.0 orange-widget-base-4.22.0/orange_widget_base.egg-info/PKG-INFO0000644000076500000240000000251414501051360023102 0ustar00primozstaffMetadata-Version: 2.1 Name: orange-widget-base Version: 4.22.0 Summary: Base Widget for Orange Canvas Home-page: http://orange.biolab.si/ Author: Bioinformatics Laboratory, FRI UL Author-email: info@biolab.si License: GPLv3+ Keywords: workflow,widget Classifier: Development Status :: 4 - Beta Classifier: Environment :: X11 Applications :: Qt Classifier: Environment :: Console Classifier: Environment :: Plugins Classifier: Programming Language :: Python Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Classifier: Operating System :: POSIX Classifier: Operating System :: Microsoft :: Windows Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence Classifier: Topic :: Scientific/Engineering :: Visualization Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Intended Audience :: Education Classifier: Intended Audience :: Science/Research Classifier: Intended Audience :: Developers Requires-Python: >=3.6 Description-Content-Type: text/x-rst License-File: LICENSE This project implements the base OWBaseWidget class and utilities for use in Orange Canvas workflows. Provides: * `OWBaseWidget` class * `gui` module for building GUI * `WidgetsScheme` the workflow execution model/bridge * basic configuration for a workflow based application ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694782192.0 orange-widget-base-4.22.0/orange_widget_base.egg-info/SOURCES.txt0000644000076500000240000000773614501051360023704 0ustar00primozstaffCONTRIBUTING.md LICENSE MANIFEST.in README.md setup.py orange_widget_base.egg-info/PKG-INFO orange_widget_base.egg-info/SOURCES.txt orange_widget_base.egg-info/dependency_links.txt orange_widget_base.egg-info/not-zip-safe orange_widget_base.egg-info/requires.txt orange_widget_base.egg-info/top_level.txt orangewidget/__init__.py orangewidget/gui.py orangewidget/io.py orangewidget/settings.py orangewidget/version.py orangewidget/widget.py orangewidget/icons/Dlg_arrow.png orangewidget/icons/Dlg_clear.png orangewidget/icons/Dlg_down3.png orangewidget/icons/Dlg_enter.png orangewidget/icons/Dlg_pan_hand.png orangewidget/icons/Dlg_redo.png orangewidget/icons/Dlg_send.png orangewidget/icons/Dlg_sort.png orangewidget/icons/Dlg_undo.png orangewidget/icons/Dlg_up3.png orangewidget/icons/Dlg_zoom.png orangewidget/icons/Dlg_zoom_reset.png orangewidget/icons/Dlg_zoom_selection.png orangewidget/icons/Unknown.png orangewidget/icons/chart.svg orangewidget/icons/downgreenarrow.png orangewidget/icons/error.png orangewidget/icons/hamburger.svg orangewidget/icons/help.svg orangewidget/icons/information.png orangewidget/icons/input-empty.svg orangewidget/icons/input-partial.svg orangewidget/icons/input.svg orangewidget/icons/output-empty.svg orangewidget/icons/output-partial.svg orangewidget/icons/output.svg orangewidget/icons/report.svg orangewidget/icons/reset.svg orangewidget/icons/upgreenarrow.png orangewidget/icons/visual-settings.svg orangewidget/icons/warning.png orangewidget/report/__init__.py orangewidget/report/index.html orangewidget/report/owreport.py orangewidget/report/report.py orangewidget/report/icons/delete.svg orangewidget/report/icons/scheme.svg orangewidget/report/tests/__init__.py orangewidget/report/tests/test_report.py orangewidget/tests/__init__.py orangewidget/tests/base.py orangewidget/tests/test_context_handler.py orangewidget/tests/test_control_getter.py orangewidget/tests/test_gui.py orangewidget/tests/test_io.py orangewidget/tests/test_matplotlib_export.py orangewidget/tests/test_setting_provider.py orangewidget/tests/test_settings_handler.py orangewidget/tests/test_test_base.py orangewidget/tests/test_widget.py orangewidget/tests/utils.py orangewidget/utils/PDFExporter.py orangewidget/utils/__init__.py orangewidget/utils/buttons.py orangewidget/utils/cache.py orangewidget/utils/combobox.py orangewidget/utils/concurrent.py orangewidget/utils/filedialogs.py orangewidget/utils/itemdelegates.py orangewidget/utils/itemmodels.py orangewidget/utils/listview.py orangewidget/utils/matplotlib_export.py orangewidget/utils/messages.py orangewidget/utils/messagewidget.py orangewidget/utils/overlay.py orangewidget/utils/progressbar.py orangewidget/utils/saveplot.py orangewidget/utils/signals.py orangewidget/utils/visual_settings_dlg.py orangewidget/utils/webview.py orangewidget/utils/widgetpreview.py orangewidget/utils/_webview/helpers.js orangewidget/utils/_webview/init-webengine-webchannel.js orangewidget/utils/tests/__init__.py orangewidget/utils/tests/test_buttons.py orangewidget/utils/tests/test_combobox.py orangewidget/utils/tests/test_concurrent.py orangewidget/utils/tests/test_filedialogs.py orangewidget/utils/tests/test_itemdelegates.py orangewidget/utils/tests/test_itemmodels.py orangewidget/utils/tests/test_listview.py orangewidget/utils/tests/test_messages.py orangewidget/utils/tests/test_messagewidget.py orangewidget/utils/tests/test_overlay.py orangewidget/utils/tests/test_save_plot.py orangewidget/utils/tests/test_signals.py orangewidget/utils/tests/test_visual_settings_dlg.py orangewidget/utils/tests/test_webview.py orangewidget/utils/tests/test_widgetpreview.py orangewidget/workflow/__init__.py orangewidget/workflow/config.py orangewidget/workflow/discovery.py orangewidget/workflow/drophandler.py orangewidget/workflow/errorreporting.py orangewidget/workflow/mainwindow.py orangewidget/workflow/utils.py orangewidget/workflow/widgetsscheme.py orangewidget/workflow/tests/__init__.py orangewidget/workflow/tests/test_drophandler.py orangewidget/workflow/tests/test_widgetsscheme.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694782192.0 orange-widget-base-4.22.0/orange_widget_base.egg-info/dependency_links.txt0000644000076500000240000000000114501051360026051 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714241.0 orange-widget-base-4.22.0/orange_widget_base.egg-info/not-zip-safe0000644000076500000240000000000114306600601024232 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694782192.0 orange-widget-base-4.22.0/orange_widget_base.egg-info/requires.txt0000644000076500000240000000020314501051360024376 0ustar00primozstaffmatplotlib pyqtgraph AnyQt>=0.1.0 typing_extensions>=3.7.4.3 orange-canvas-core<0.2a,>=0.1.30 [:sys_platform == "darwin"] appnope ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694782192.0 orange-widget-base-4.22.0/orange_widget_base.egg-info/top_level.txt0000644000076500000240000000001514501051360024531 0ustar00primozstafforangewidget ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1694782192.843538 orange-widget-base-4.22.0/orangewidget/0000755000076500000240000000000014501051361017161 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/__init__.py0000644000076500000240000000000014306600442021263 0ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/gui.py0000644000076500000240000035215514440334174020342 0ustar00primozstaff""" Wrappers for controls used in widgets """ import contextlib import math import re import itertools import sys import warnings import logging from types import LambdaType from collections import defaultdict import pkg_resources from AnyQt import QtWidgets, QtCore, QtGui from AnyQt.QtCore import Qt, QEvent, QObject, QTimer, pyqtSignal as Signal from AnyQt.QtGui import QCursor, QColor from AnyQt.QtWidgets import ( QApplication, QStyle, QSizePolicy, QWidget, QLabel, QGroupBox, QSlider, QTableWidgetItem, QStyledItemDelegate, QTableView, QHeaderView, QScrollArea, QFrame, QLineEdit, QCalendarWidget, QDateTimeEdit, ) from orangewidget.utils import getdeepattr from orangewidget.utils.buttons import VariableTextPushButton from orangewidget.utils.combobox import ( ComboBox as OrangeComboBox, ComboBoxSearch as OrangeComboBoxSearch ) from orangewidget.utils.itemdelegates import text_color_for_state from orangewidget.utils.itemmodels import PyListModel, signal_blocking __re_label = re.compile(r"(^|[^%])%\((?P[a-zA-Z]\w*)\)") log = logging.getLogger(__name__) OrangeUserRole = itertools.count(Qt.UserRole) LAMBDA_NAME = (f"_lambda_{i}" for i in itertools.count(1)) def is_macstyle(): style = QApplication.style() style_name = style.metaObject().className() return style_name == 'QMacStyle' class TableView(QTableView): """An auxilliary table view for use with PyTableModel in control areas""" def __init__(self, parent=None, **kwargs): kwargs = dict( dict(showGrid=False, sortingEnabled=True, cornerButtonEnabled=False, alternatingRowColors=True, selectionBehavior=self.SelectRows, selectionMode=self.ExtendedSelection, horizontalScrollMode=self.ScrollPerPixel, verticalScrollMode=self.ScrollPerPixel, editTriggers=self.DoubleClicked | self.EditKeyPressed), **kwargs) super().__init__(parent, **kwargs) h = self.horizontalHeader() h.setCascadingSectionResizes(True) h.setMinimumSectionSize(-1) h.setStretchLastSection(True) h.setSectionResizeMode(QHeaderView.ResizeToContents) v = self.verticalHeader() v.setVisible(False) v.setSectionResizeMode(QHeaderView.ResizeToContents) class BoldFontDelegate(QStyledItemDelegate): """Paints the text of associated cells in bold font. Can be used e.g. with QTableView.setItemDelegateForColumn() to make certain table columns bold, or if callback is provided, the item's model index is passed to it, and the item is made bold only if the callback returns true. Parameters ---------- parent: QObject The parent QObject. callback: callable Accepts model index and returns True if the item is to be rendered in bold font. """ def __init__(self, parent=None, callback=None): super().__init__(parent) self._callback = callback def paint(self, painter, option, index): """Paint item text in bold font""" if not callable(self._callback) or self._callback(index): option.font.setWeight(option.font.Bold) super().paint(painter, option, index) def sizeHint(self, option, index): """Ensure item size accounts for bold font width""" if not callable(self._callback) or self._callback(index): option.font.setWeight(option.font.Bold) return super().sizeHint(option, index) def resource_filename(path): """ Return a resource filename (package data) for path. """ return pkg_resources.resource_filename(__name__, path) class OWComponent: """ Mixin for classes that contain settings and/or attributes that trigger callbacks when changed. The class initializes the settings handler, provides `__setattr__` that triggers callbacks, and provides `control` attribute for access to Qt widgets controling particular attributes. Callbacks are exploited by controls (e.g. check boxes, line edits, combo boxes...) that are synchronized with attribute values. Changing the value of the attribute triggers a call to a function that updates the Qt widget accordingly. The class is mixed into `widget.OWBaseWidget`, and must also be mixed into all widgets not derived from `widget.OWBaseWidget` that contain settings or Qt widgets inserted by function in `orangewidget.gui` module. See `OWScatterPlotGraph` for an example. """ def __init__(self, widget=None): self.controlled_attributes = defaultdict(list) self.controls = ControlGetter(self) if widget is not None and widget.settingsHandler: widget.settingsHandler.initialize(self) def _reset_settings(self): """ Copy default settings to instance's settings. This method can be called from OWWidget's reset_settings, but will mostly have to be followed by calling a method that updates the widget. """ self.settingsHandler.reset_to_original(self) def connect_control(self, name, func): """ Add `func` to the list of functions called when the value of the attribute `name` is set. If the name includes a dot, it is assumed that the part the before the first dot is a name of an attribute containing an instance of a component, and the call is transferred to its `conntect_control`. For instance, `calling `obj.connect_control("graph.attr_x", f)` is equivalent to `obj.graph.connect_control("attr_x", f)`. Args: name (str): attribute name func (callable): callback function """ if "." in name: name, rest = name.split(".", 1) sub = getattr(self, name) sub.connect_control(rest, func) else: self.controlled_attributes[name].append(func) def __setattr__(self, name, value): """Set the attribute value and trigger any attached callbacks. For backward compatibility, the name can include dots, e.g. `graph.attr_x`. `obj.__setattr__('x.y', v)` is equivalent to `obj.x.__setattr__('x', v)`. Args: name (str): attribute name value (object): value to set to the member. """ if "." in name: name, rest = name.split(".", 1) sub = getattr(self, name) setattr(sub, rest, value) else: super().__setattr__(name, value) # First check that the widget is not just being constructed if hasattr(self, "controlled_attributes"): for callback in self.controlled_attributes.get(name, ()): callback(value) def miscellanea(control, box, parent, *, addToLayout=True, stretch=0, sizePolicy=None, disabled=False, tooltip=None, disabledBy=None, addSpaceBefore=False, **kwargs): """ Helper function that sets various properties of the widget using a common set of arguments. The function - sets the `control`'s attribute `box`, if `box` is given and `control.box` is not yet set, - attaches a tool tip to the `control` if specified, - disables the `control`, if `disabled` is set to `True`, - adds the `box` to the `parent`'s layout unless `addToLayout` is set to `False`; the stretch factor can be specified, - adds the control into the box's layout if the box is given (regardless of `addToLayout`!) - sets the size policy for the box or the control, if the policy is given, - adds space in the `parent`'s layout after the `box` if `addSpace` is set and `addToLayout` is not `False`. If `box` is the same as `parent` it is set to `None`; this is convenient because of the way complex controls are inserted. Unused keyword arguments are assumed to be properties; with this `gui` function mimic the behaviour of PyQt's constructors. For instance, if `gui.lineEdit` is called with keyword argument `sizePolicy=some_policy`, `miscallenea` will call `control.setSizePolicy(some_policy)`. :param control: the control, e.g. a `QCheckBox` :type control: QWidget :param box: the box into which the widget was inserted :type box: QWidget or None :param parent: the parent into whose layout the box or the control will be inserted :type parent: QWidget :param addSpaceBefore: the amount of space to add before the widget :type addSpaceBefore: bool or int :param disabled: If set to `True`, the widget is initially disabled :type disabled: bool :param addToLayout: If set to `False` the widget is not added to the layout :type addToLayout: bool :param stretch: the stretch factor for this widget, used when adding to the layout (default: 0) :type stretch: int :param tooltip: tooltip that is attached to the widget :type tooltip: str or None :param disabledBy: checkbox created with checkBox() function :type disabledBy: QCheckBox or None :param sizePolicy: the size policy for the box or the control :type sizePolicy: QSizePolicy """ if 'addSpace' in kwargs: warnings.warn("'addSpace' has been deprecated. Use gui.separator instead.", DeprecationWarning, stacklevel=3) kwargs.pop('addSpace') for prop, val in kwargs.items(): method = getattr(control, "set" + prop[0].upper() + prop[1:]) if isinstance(val, tuple): method(*val) else: method(val) if disabled: # if disabled==False, do nothing; it can be already disabled control.setDisabled(disabled) if tooltip is not None: control.setToolTip(tooltip) if box is parent: box = None elif box and box is not control and not hasattr(control, "box"): control.box = box if box and box.layout() is not None and \ isinstance(control, QtWidgets.QWidget) and \ box.layout().indexOf(control) == -1: box.layout().addWidget(control) if disabledBy is not None: disabledBy.disables.append(control) disabledBy.makeConsistent() if sizePolicy is not None: if isinstance(sizePolicy, tuple): sizePolicy = QSizePolicy(*sizePolicy) if box: box.setSizePolicy(sizePolicy) control.setSizePolicy(sizePolicy) if addToLayout and parent and parent.layout() is not None: _addSpace(parent, addSpaceBefore) parent.layout().addWidget(box or control, stretch) def _is_horizontal(orientation): if isinstance(orientation, str): warnings.warn("string literals for orientation are deprecated", DeprecationWarning) elif isinstance(orientation, bool): warnings.warn("boolean values for orientation are deprecated", DeprecationWarning) return (orientation == Qt.Horizontal or orientation == 'horizontal' or not orientation) def setLayout(widget, layout): """ Set the layout of the widget. If `layout` is given as `Qt.Vertical` or `Qt.Horizontal`, the function sets the layout to :obj:`~QVBoxLayout` or :obj:`~QVBoxLayout`. :param widget: the widget for which the layout is being set :type widget: QWidget :param layout: layout :type layout: `Qt.Horizontal`, `Qt.Vertical` or instance of `QLayout` """ if not isinstance(layout, QtWidgets.QLayout): if _is_horizontal(layout): layout = QtWidgets.QHBoxLayout() else: layout = QtWidgets.QVBoxLayout() widget.setLayout(layout) def _addSpace(widget, space): """ A helper function that adds space into the widget, if requested. The function is called by functions that have the `addSpace` argument. :param widget: Widget into which to insert the space :type widget: QWidget :param space: Amount of space to insert. If False, the function does nothing. If the argument is an `int`, the specified space is inserted. Otherwise, the default space is inserted by calling a :obj:`separator`. :type space: bool or int """ if space: if type(space) == int: # distinguish between int and bool! separator(widget, space, space) else: separator(widget) def separator(widget, width=None, height=None): """ Add a separator of the given size into the widget. :param widget: the widget into whose layout the separator is added :type widget: QWidget :param width: width of the separator :type width: int :param height: height of the separator :type height: int :return: separator :rtype: QWidget """ sep = QtWidgets.QWidget(widget) if widget is not None and widget.layout() is not None: widget.layout().addWidget(sep) size = separator_size(width, height) sep.setFixedSize(*size) return sep def separator_size(width=None, height=None): if is_macstyle(): width = 2 if width is None else width height = 2 if height is None else height else: width = 4 if width is None else width height = 4 if height is None else height return width, height def rubber(widget): """ Insert a stretch 100 into the widget's layout """ widget.layout().addStretch(100) def widgetBox(widget, box=None, orientation=Qt.Vertical, margin=None, spacing=None, **misc): """ Construct a box with vertical or horizontal layout, and optionally, a border with an optional label. If the widget has a frame, the space after the widget is added unless explicitly disabled. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param box: tells whether the widget has a border, and its label :type box: int or str or None :param orientation: orientation of the box :type orientation: `Qt.Horizontal`, `Qt.Vertical` or instance of `QLayout` :param sizePolicy: The size policy for the widget (default: None) :type sizePolicy: :obj:`~QSizePolicy` :param margin: The margin for the layout. Default is 7 if the widget has a border, and 0 if not. :type margin: int :param spacing: Spacing within the layout (default: 4) :type spacing: int :return: Constructed box :rtype: QGroupBox or QWidget """ if box: b = QtWidgets.QGroupBox(widget) if isinstance(box, str): b.setTitle(" " + box.strip() + " ") if is_macstyle() and widget and widget.layout() and \ isinstance(widget.layout(), QtWidgets.QVBoxLayout) and \ not widget.layout().isEmpty(): misc.setdefault('addSpaceBefore', True) if margin is None: margin = 4 else: b = QtWidgets.QWidget(widget) b.setContentsMargins(0, 0, 0, 0) if margin is None: margin = 0 setLayout(b, orientation) if spacing is not None: b.layout().setSpacing(spacing) b.layout().setContentsMargins(margin, margin, margin, margin) miscellanea(b, None, widget, **misc) return b def hBox(*args, **kwargs): return widgetBox(orientation=Qt.Horizontal, *args, **kwargs) def vBox(*args, **kwargs): return widgetBox(orientation=Qt.Vertical, *args, **kwargs) def indentedBox(widget, sep=20, orientation=Qt.Vertical, **misc): """ Creates an indented box. The function can also be used "on the fly":: gui.checkBox(gui.indentedBox(box), self, "spam", "Enable spam") To align the control with a check box, use :obj:`checkButtonOffsetHint`:: gui.hSlider(gui.indentedBox(self.interBox), self, "intervals") :param widget: the widget into which the box is inserted :type widget: QWidget :param sep: Indent size (default: 20) :type sep: int :param orientation: orientation of the inserted box :type orientation: `Qt.Vertical` (default), `Qt.Horizontal` or instance of `QLayout` :return: Constructed box :rtype: QGroupBox or QWidget """ outer = hBox(widget, spacing=0) separator(outer, sep, 0) indented = widgetBox(outer, orientation=orientation) miscellanea(indented, outer, widget, **misc) indented.box = outer return indented def widgetLabel(widget, label="", labelWidth=None, **misc): """ Construct a simple, constant label. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param label: The text of the label (default: None) :type label: str :param labelWidth: The width of the label (default: None) :type labelWidth: int :return: Constructed label :rtype: QLabel """ lbl = QtWidgets.QLabel(label, widget) if labelWidth: lbl.setFixedSize(labelWidth, lbl.sizeHint().height()) miscellanea(lbl, None, widget, **misc) return lbl def label(widget, master, label, labelWidth=None, box=None, orientation=Qt.Vertical, **misc): """ Construct a label that contains references to the master widget's attributes; when their values change, the label is updated. Argument :obj:`label` is a format string following Python's syntax (see the corresponding Python documentation): the label's content is rendered as `label % master.__dict__`. For instance, if the :obj:`label` is given as "There are %(mm)i monkeys", the value of `master.mm` (which must be an integer) will be inserted in place of `%(mm)i`. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param label: The text of the label, including attribute names :type label: str :param labelWidth: The width of the label (default: None) :type labelWidth: int :param orientation: layout of the inserted box :type orientation: `Qt.Vertical` (default), `Qt.Horizontal` or instance of `QLayout` :return: label :rtype: QLabel """ if box: b = hBox(widget, box, addToLayout=False) else: b = widget lbl = QtWidgets.QLabel("", b) reprint = CallFrontLabel(lbl, label, master) for mo in __re_label.finditer(label): master.connect_control(mo.group("value"), reprint) reprint() if labelWidth: lbl.setFixedSize(labelWidth, lbl.sizeHint().height()) miscellanea(lbl, b, widget, **misc) return lbl class SpinBoxMixin: """ The class overloads :obj:`onChange` event handler to show the commit button, and :obj:`onEnter` to commit the change when enter is pressed. Also, click and drag to increase/decrease the spinbox's value, instead of scrolling. """ valueCommitted = Signal(object) def __init__(self, minv, maxv, step, parent=None, verticalDrag=True): """ Construct the object and set the range (`minv`, `maxv`) and the step. :param minv: Minimal value :type minv: int :param maxv: Maximal value :type maxv: int :param step: Step :type step: int :param parent: Parent widget :type parent: QWidget :param verticalDrag: Drag direction :type verticalDrag: bool """ super().__init__(parent) self.setRange(minv, maxv) self.setSingleStep(step) self.equalityChecker = int.__eq__ self.mouseHeld = False self.verticalDirection = verticalDrag self.mouseStartPos = QtCore.QPoint() self.preDragValue = 0 self.textEditing = False self.preEditvalue = 0 self.lineEdit().installEventFilter(self) self.installEventFilter(self) self.editingFinished.connect(self.__onEditingFinished) self.valueChanged.connect(self.__onValueChanged) # don't focus on scroll self.setFocusPolicy(Qt.StrongFocus) self.cback = None self.cfunc = None def __onEditingFinished(self): """ After user input is finished, commit the new value. """ if not self.mouseHeld and not self.textEditing: # value hasn't been altered return if self.mouseHeld: self.mouseHeld = False initialValue = self.preDragValue if self.textEditing: # mouse held can be triggered after editing, but not vice versa self.textEditing = False initialValue = self.preEditvalue value = self.value() if not self.equalityChecker(initialValue, value): # if value has changed, commit it self.__commitValue(value) def __onValueChanged(self, value): """ When the value is changed outwith user input, commit it. """ if not self.mouseHeld and not self.textEditing: self.__commitValue(value) def __commitValue(self, value): self.valueCommitted.emit(value) if self.cback: self.cback(value) if self.cfunc: self.cfunc() def eventFilter(self, obj, event): if not self.isEnabled() or \ not (isinstance(obj, SpinBoxMixin) or isinstance(obj, QLineEdit)): return super().eventFilter(obj, event) cursor = Qt.SizeVerCursor if self.verticalDirection else Qt.SizeHorCursor if event.type() == QEvent.MouseButtonPress: # prepare click+drag self.mouseStartPos = event.globalPos() self.preDragValue = self.value() self.mouseHeld = True elif event.type() == QEvent.MouseMove and self.mouseHeld and isinstance(obj, QLineEdit): # do click+drag # override default cursor on drag if QApplication.overrideCursor() != cursor: QApplication.setOverrideCursor(cursor) stepSize = self.singleStep() pos = event.globalPos() posVal = pos.y() if self.verticalDirection else -pos.x() posValStart = self.mouseStartPos.y() if self.verticalDirection else -self.mouseStartPos.x() diff = posValStart - posVal # these magic params are pretty arbitrary, ensure that it's still # possible to easily highlight the text if moving mouse slightly # up/down, with the default stepsize normalizedDiff = abs(diff) / 30 exponent = 1 + min(normalizedDiff / 10, 3) valueOffset = int(normalizedDiff ** exponent) * stepSize valueOffset = math.copysign(valueOffset, diff) # Need to preserve the value type valueOffset = type(self.preDragValue)(valueOffset) self.setValue(self.preDragValue + valueOffset) elif event.type() == QEvent.MouseButtonRelease: # end click+drag # restore default cursor on release while QApplication.overrideCursor() is not None: QApplication.restoreOverrideCursor() self.__onEditingFinished() elif event.type() == QEvent.Wheel: # disable wheelEvents (scrolling to change value) event.ignore() return True elif event.type() in (QEvent.KeyPress, QEvent.KeyRelease): # handle committing keyboard entry only on editingFinished if self.mouseHeld: # if performing click+drag, ignore key events event.ignore() return True elif not self.textEditing: self.preEditvalue = self.value() self.textEditing = True return super().eventFilter(obj, event) def onEnter(self): warnings.warn( "Testing by calling a spinbox's 'onEnter' method is deprecated, " "a call to 'setValue' should be sufficient.", DeprecationWarning ) class SpinBox(SpinBoxMixin, QtWidgets.QSpinBox): """ A class derived from QSpinBox, which postpones the synchronization of the control's value with the master's attribute until the control loses focus, and adds click-and-drag to change value functionality. """ class DoubleSpinBox(SpinBoxMixin, QtWidgets.QDoubleSpinBox): """ Same as :obj:`SpinBoxWFocusOut`, except that it is derived from :obj:`~QDoubleSpinBox`""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setDecimals(math.ceil(-math.log10(self.singleStep()))) self.equalityChecker = math.isclose # deprecated SpinBoxWFocusOut = SpinBox DoubleSpinBoxWFocusOut = DoubleSpinBox def spin(widget, master, value, minv, maxv, step=1, box=None, label=None, labelWidth=None, orientation=Qt.Horizontal, callback=None, controlWidth=None, callbackOnReturn=False, checked=None, checkCallback=None, posttext=None, disabled=False, alignment=Qt.AlignLeft, keyboardTracking=True, decimals=None, spinType=int, **misc): """ A spinbox with lots of bells and whistles, such as a checkbox and various callbacks. It constructs a control of type :obj:`SpinBoxWFocusOut` or :obj:`DoubleSpinBoxWFocusOut`. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param minv: minimal value :type minv: int or float :param maxv: maximal value :type maxv: int or float :param step: step (default: 1) :type step: int or float :param box: tells whether the widget has a border, and its label :type box: int or str or None :param label: label that is put in above or to the left of the spin box :type label: str :param labelWidth: optional label width (default: None) :type labelWidth: int :param orientation: tells whether to put the label above or to the left :type orientation: `Qt.Horizontal` (default), `Qt.Vertical` or instance of `QLayout` :param callback: a function that is called when the value is entered; the function is called when the user finishes editing the value :type callback: function :param controlWidth: the width of the spin box :type controlWidth: int :param callbackOnReturn: (deprecated) :type callbackOnReturn: bool :param checked: if not None, a check box is put in front of the spin box; when unchecked, the spin box is disabled. Argument `checked` gives the name of the master's attribute given whose value is synchronized with the check box's state (default: None). :type checked: str :param checkCallback: a callback function that is called when the check box's state is changed :type checkCallback: function :param posttext: a text that is put to the right of the spin box :type posttext: str :param alignment: alignment of the spin box (e.g. `Qt.AlignLeft`) :type alignment: Qt.Alignment :param keyboardTracking: If `True`, the valueChanged signal is emitted when the user is typing (default: True) :type keyboardTracking: bool :param spinType: determines whether to use QSpinBox (int) or QDoubleSpinBox (float) :type spinType: type :param decimals: number of decimals (if `spinType` is `float`) :type decimals: int :return: Tuple `(spin box, check box) if `checked` is `True`, otherwise the spin box :rtype: tuple or gui.SpinBoxWFocusOut """ if callbackOnReturn: warnings.warn( "'callbackOnReturn' is deprecated, all spinboxes callback " "only when the user is finished editing the value.", DeprecationWarning, stacklevel=2 ) # b is the outermost box or the widget if there are no boxes; # b is the widget that is inserted into the layout # bi is the box that contains the control or the checkbox and the control; # bi can be the widget itself, if there are no boxes # cbox is the checkbox (or None) # sbox is the spinbox itself if box or label and not checked: b = widgetBox(widget, box, orientation, addToLayout=False) hasHBox = _is_horizontal(orientation) else: b = widget hasHBox = False if not hasHBox and (checked or callback or posttext): bi = hBox(b, addToLayout=False) else: bi = b cbox = None if checked is not None: cbox = checkBox(bi, master, checked, label, labelWidth=labelWidth, callback=checkCallback) elif label: b.label = widgetLabel(b, label, labelWidth) if posttext: widgetLabel(bi, posttext) isDouble = spinType == float sbox = (SpinBox, DoubleSpinBox)[isDouble](minv, maxv, step, bi) if bi is not None: bi.control = sbox if b is not None: b.control = sbox if bi is not widget: bi.setDisabled(disabled) else: sbox.setDisabled(disabled) if decimals is not None: sbox.setDecimals(decimals) sbox.setAlignment(alignment) sbox.setKeyboardTracking(keyboardTracking) if controlWidth: sbox.setFixedWidth(controlWidth) if value: sbox.setValue(getdeepattr(master, value)) cfront, sbox.cback, sbox.cfunc = connectControl( master, value, callback, not (callback) and sbox.valueCommitted, (CallFrontSpin, CallFrontDoubleSpin)[isDouble](sbox)) if checked: sbox.cbox = cbox cbox.disables = [sbox] cbox.makeConsistent() if callback: if hasattr(sbox, "upButton"): sbox.upButton().clicked.connect( lambda c=sbox.editor(): c.setFocus()) sbox.downButton().clicked.connect( lambda c=sbox.editor(): c.setFocus()) miscellanea(sbox, b if b is not widget else bi, widget, **misc) if checked: if isDouble and b == widget: # TODO Backward compatilibity; try to find and eliminate sbox.control = b.control return sbox return cbox, sbox else: return sbox # noinspection PyTypeChecker def doubleSpin(widget, master, value, minv, maxv, step=1, box=None, label=None, labelWidth=None, orientation=Qt.Horizontal, callback=None, controlWidth=None, callbackOnReturn=False, checked=None, checkCallback=None, posttext=None, alignment=Qt.AlignLeft, keyboardTracking=True, decimals=None, **misc): """ Backward compatilibity function: calls :obj:`spin` with `spinType=float`. """ return spin(widget, master, value, minv, maxv, step, box=box, label=label, labelWidth=labelWidth, orientation=orientation, callback=callback, controlWidth=controlWidth, callbackOnReturn=callbackOnReturn, checked=checked, checkCallback=checkCallback, posttext=posttext, alignment=alignment, keyboardTracking=keyboardTracking, decimals=decimals, spinType=float, **misc) class CheckBoxWithDisabledState(QtWidgets.QCheckBox): def __init__(self, label, parent, disabledState): super().__init__(label, parent) self.disabledState = disabledState # self.trueState is always stored as Qt.Checked, Qt.PartiallyChecked # or Qt.Unchecked, even if the button is two-state, because in # setCheckState, which is used for setting it, "true" would result # in partially checked. self.trueState = self.checkState() def changeEvent(self, event): super().changeEvent(event) if event.type() == event.EnabledChange: with signal_blocking(self): self._updateChecked() def setCheckState(self, state): self.trueState = state self._updateChecked() def setChecked(self, state): self._storeTrueState(state) self._updateChecked() def _updateChecked(self): if self.isEnabled(): super().setCheckState(self.trueState) else: super().setCheckState(self.disabledState) def _storeTrueState(self, state): self.trueState = Qt.Checked if state else Qt.Unchecked def checkBox(widget, master, value, label, box=None, callback=None, getwidget=False, id_=None, labelWidth=None, disables=None, stateWhenDisabled=None, **misc): """ A simple checkbox. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param label: label :type label: str :param box: tells whether the widget has a border, and its label :type box: int or str or None :param callback: a function that is called when the check box state is changed :type callback: function :param getwidget: If set `True`, the callback function will get a keyword argument `widget` referencing the check box :type getwidget: bool :param id_: If present, the callback function will get a keyword argument `id` with this value :type id_: any :param labelWidth: the width of the label :type labelWidth: int :param disables: a list of widgets that are disabled if the check box is unchecked :type disables: list or QWidget or None :param stateWhenDisabled: the shown state of the checkbox when it is disabled (default: None, unaffected) :type stateWhenDisabled: bool or Qt.CheckState or None :return: constructed check box; if is is placed within a box, the box is return in the attribute `box` :rtype: QCheckBox """ if box: b = hBox(widget, box, addToLayout=False) else: b = widget if stateWhenDisabled is not None: if isinstance(stateWhenDisabled, bool): stateWhenDisabled = Qt.Checked if stateWhenDisabled else Qt.Unchecked cbox = CheckBoxWithDisabledState(label, b, stateWhenDisabled) cbox.clicked.connect(cbox._storeTrueState) else: cbox = QtWidgets.QCheckBox(label, b) if labelWidth: cbox.setFixedSize(labelWidth, cbox.sizeHint().height()) cbox.setChecked(getdeepattr(master, value)) connectControl(master, value, None, cbox.toggled[bool], CallFrontCheckBox(cbox), cfunc=callback and FunctionCallback( master, callback, widget=cbox, getwidget=getwidget, id=id_)) if isinstance(disables, QtWidgets.QWidget): disables = [disables] cbox.disables = disables or [] cbox.makeConsistent = Disabler(cbox, master, value) cbox.toggled[bool].connect(cbox.makeConsistent) cbox.makeConsistent(value) miscellanea(cbox, b, widget, **misc) return cbox class LineEditWFocusOut(QtWidgets.QLineEdit): """ A class derived from QLineEdit, which postpones the synchronization of the control's value with the master's attribute until the user leaves the line edit or presses Tab when the value is changed. The class also allows specifying a callback function for focus-in event. .. attribute:: callback Callback that is called when the change is confirmed .. attribute:: focusInCallback Callback that is called on the focus-in event """ def __init__(self, parent, callback, focusInCallback=None): super().__init__(parent) if parent is not None and parent.layout() is not None: parent.layout().addWidget(self) self.callback = callback self.focusInCallback = focusInCallback self.returnPressed.connect(self.returnPressedHandler) # did the text change between focus enter and leave self.__changed = False self.textEdited.connect(self.__textEdited) def __textEdited(self): self.__changed = True def returnPressedHandler(self): self.selectAll() self.__callback_if_changed() def __callback_if_changed(self): if self.__changed: self.__changed = False if hasattr(self, "cback") and self.cback: self.cback(self.text()) if self.callback: self.callback() def setText(self, text): self.__changed = False super().setText(text) def focusOutEvent(self, *e): super().focusOutEvent(*e) self.__callback_if_changed() def focusInEvent(self, *e): self.__changed = False if self.focusInCallback: self.focusInCallback() return super().focusInEvent(*e) def lineEdit(widget, master, value, label=None, labelWidth=None, orientation=Qt.Vertical, box=None, callback=None, valueType=None, validator=None, controlWidth=None, callbackOnType=False, focusInCallback=None, **misc): """ Insert a line edit. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param label: label :type label: str :param labelWidth: the width of the label :type labelWidth: int :param orientation: tells whether to put the label above or to the left :type orientation: `Qt.Vertical` (default) or `Qt.Horizontal` :param box: tells whether the widget has a border, and its label :type box: int or str or None :param callback: a function that is called when the check box state is changed :type callback: function :param valueType: the type into which the entered string is converted when synchronizing to `value`. If omitted, the type of the current `value` is used. If `value` is `None`, the text is left as a string. :type valueType: type or None :param validator: the validator for the input :type validator: QValidator :param controlWidth: the width of the line edit :type controlWidth: int :param callbackOnType: if set to `True`, the callback is called at each key press (default: `False`) :type callbackOnType: bool :param focusInCallback: a function that is called when the line edit receives focus :type focusInCallback: function :rtype: QLineEdit or a box """ if box or label: b = widgetBox(widget, box, orientation, addToLayout=False) if label is not None: widgetLabel(b, label, labelWidth) else: b = widget baseClass = misc.pop("baseClass", None) if baseClass: ledit = baseClass(b) if b is not widget: b.layout().addWidget(ledit) elif focusInCallback or callback and not callbackOnType: ledit = LineEditWFocusOut(b, callback, focusInCallback) else: ledit = QtWidgets.QLineEdit(b) if b is not widget: b.layout().addWidget(ledit) current_value = getdeepattr(master, value) if value else "" ledit.setText(str(current_value)) if controlWidth: ledit.setFixedWidth(controlWidth) if validator: ledit.setValidator(validator) if value: ledit.cback = connectControl( master, value, callbackOnType and callback, ledit.textChanged[str], CallFrontLineEdit(ledit), fvcb=valueType or type(current_value))[1] miscellanea(ledit, b, widget, **misc) return ledit def button(widget, master, label, callback=None, width=None, height=None, toggleButton=False, value="", default=False, autoDefault=True, buttonType=QtWidgets.QPushButton, **misc): """ Insert a button (QPushButton, by default) :param widget: the widget into which the button is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param label: label :type label: str :param callback: a function that is called when the button is pressed :type callback: function :param width: the width of the button :type width: int :param height: the height of the button :type height: int :param toggleButton: if set to `True`, the button is checkable, but it is not synchronized with any attribute unless the `value` is given :type toggleButton: bool :param value: the master's attribute with which the value is synchronized (the argument is optional; if present, it makes the button "checkable", even if `toggleButton` is not set) :type value: str :param default: if `True` it makes the button the default button; this is the button that is activated when the user presses Enter unless some auto default button has current focus :type default: bool :param autoDefault: all buttons are auto default: they are activated if they have focus (or are the next in the focus chain) when the user presses enter. By setting `autoDefault` to `False`, the button is not activated on pressing Return. :type autoDefault: bool :param buttonType: the button type (default: `QPushButton`) :type buttonType: QPushButton :rtype: QPushButton """ button = buttonType(widget) if is_macstyle(): btnpaddingbox = vBox(widget, margin=0, spacing=0) separator(btnpaddingbox, 0, 4) # lines up with a WA_LayoutUsesWidgetRect checkbox button.outer_box = btnpaddingbox else: button.outer_box = None if label: button.setText(label) if width: button.setFixedWidth(width) if height: button.setFixedHeight(height) if toggleButton or value: button.setCheckable(True) if buttonType == QtWidgets.QPushButton: button.setDefault(default) button.setAutoDefault(autoDefault) if value: button.setChecked(getdeepattr(master, value)) connectControl( master, value, None, button.toggled[bool], CallFrontButton(button), cfunc=callback and FunctionCallback(master, callback, widget=button)) elif callback: button.clicked.connect(callback) miscellanea(button, button.outer_box, widget, **misc) return button def toolButton(widget, master, label="", callback=None, width=None, height=None, tooltip=None): """ Insert a tool button. Calls :obj:`button` :param widget: the widget into which the button is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param label: label :type label: str :param callback: a function that is called when the button is pressed :type callback: function :param width: the width of the button :type width: int :param height: the height of the button :type height: int :rtype: QToolButton """ return button(widget, master, label, callback, width, height, buttonType=QtWidgets.QToolButton, tooltip=tooltip) # btnLabels is a list of either char strings or pixmaps def radioButtons(widget, master, value, btnLabels=(), tooltips=None, box=None, label=None, orientation=Qt.Vertical, callback=None, **misc): """ Construct a button group and add radio buttons, if they are given. The value with which the buttons synchronize is the index of selected button. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param btnLabels: a list of labels or icons for radio buttons :type btnLabels: list of str or pixmaps :param tooltips: a list of tool tips of the same length as btnLabels :type tooltips: list of str :param box: tells whether the widget has a border, and its label :type box: int or str or None :param label: a label that is inserted into the box :type label: str :param callback: a function that is called when the selection is changed :type callback: function :param orientation: orientation of the box :type orientation: `Qt.Vertical` (default), `Qt.Horizontal` or an instance of `QLayout` :rtype: QButtonGroup """ bg = widgetBox(widget, box, orientation, addToLayout=misc.get('addToLayout', True)) misc['addToLayout'] = False if label is not None: widgetLabel(bg, label) rb = QtWidgets.QButtonGroup(bg) if bg is not widget: bg.group = rb bg.buttons = [] bg.ogValue = value bg.ogMaster = master for i, lab in enumerate(btnLabels): appendRadioButton(bg, lab, tooltip=tooltips and tooltips[i], id=i + 1) connectControl(master, value, callback, bg.group.idClicked, CallFrontRadioButtons(bg), CallBackRadioButton(bg, master)) miscellanea(bg.group, bg, widget, **misc) return bg radioButtonsInBox = radioButtons def appendRadioButton(group, label, insertInto=None, disabled=False, tooltip=None, sizePolicy=None, addToLayout=True, stretch=0, addSpace=None, id=None): """ Construct a radio button and add it to the group. The group must be constructed with :obj:`radioButtons` since it adds additional attributes need for the call backs. The radio button is inserted into `insertInto` or, if omitted, into the button group. This is useful for more complex groups, like those that have radio buttons in several groups, divided by labels and inside indented boxes. :param group: the button group :type group: QButtonGroup :param label: string label or a pixmap for the button :type label: str or QPixmap :param insertInto: the widget into which the radio button is inserted :type insertInto: QWidget :rtype: QRadioButton """ if addSpace is not None: warnings.warn("'addSpace' has been deprecated. Use gui.separator instead.", DeprecationWarning, stacklevel=2) i = len(group.buttons) if isinstance(label, str): w = QtWidgets.QRadioButton(label) else: w = QtWidgets.QRadioButton(str(i)) w.setIcon(QtGui.QIcon(label)) if not hasattr(group, "buttons"): group.buttons = [] group.buttons.append(w) if id is None: group.group.addButton(w) else: group.group.addButton(w, id) w.setChecked(getdeepattr(group.ogMaster, group.ogValue) == i) # miscellanea for this case is weird, so we do it here if disabled: w.setDisabled(disabled) if tooltip is not None: w.setToolTip(tooltip) if sizePolicy: if isinstance(sizePolicy, tuple): sizePolicy = QSizePolicy(*sizePolicy) w.setSizePolicy(sizePolicy) if addToLayout: dest = insertInto or group dest.layout().addWidget(w, stretch) return w class DelayedNotification(QObject): """ A proxy for successive calls/signals that emits a signal only when there are no calls for a given time. Also allows for mechanism that prevents successive equivalent calls: ff values are passed to the "changed" method, a signal is only emitted if the last passed values differ from the last passed values at the previous emission. """ notification = Signal() def __init__(self, parent=None, timeout=500): super().__init__(parent=parent) self.timeout = timeout self._timer = QTimer(self) self._timer.timeout.connect(self.notify_immediately) self._did_notify = False # if anything was sent at all self._last_value = None # last value passed to changed self._last_notified = None # value at the last notification def changed(self, *args): self._last_value = args self._timer.start(self.timeout) def notify_immediately(self): self._timer.stop() if self._did_notify and self._last_notified == self._last_value: return self._last_notified = self._last_value self._did_notify = True self.notification.emit() def hSlider(widget, master, value, box=None, minValue=0, maxValue=10, step=1, callback=None, callback_finished=None, label=None, labelFormat=" %d", ticks=False, divideFactor=1.0, vertical=False, createLabel=True, width=None, intOnly=True, **misc): """ Construct a slider. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param box: tells whether the widget has a border, and its label :type box: int or str or None :param label: a label that is inserted into the box :type label: str :param callback: a function that is called when the value is changed :type callback: function :param callback_finished: a function that is called when the slider value stopped changing for at least 500 ms or when the slider is released :type callback_finished: function :param minValue: minimal value :type minValue: int or float :param maxValue: maximal value :type maxValue: int or float :param step: step size :type step: int or float :param labelFormat: the label format; default is `" %d"` :type labelFormat: str :param ticks: if set to `True`, ticks are added below the slider :type ticks: bool :param divideFactor: a factor with which the displayed value is divided :type divideFactor: float :param vertical: if set to `True`, the slider is vertical :type vertical: bool :param createLabel: unless set to `False`, labels for minimal, maximal and the current value are added to the widget :type createLabel: bool :param width: the width of the slider :type width: int :param intOnly: if `True`, the slider value is integer (the slider is of type :obj:`QSlider`) otherwise it is float (:obj:`FloatSlider`, derived in turn from :obj:`QSlider`). :type intOnly: bool :rtype: :obj:`QSlider` or :obj:`FloatSlider` """ # The last condition, misc.get("addToLayout") is here for backward # compatibility. This function used to always create sliderBox; if # addToLayout was False and widget was not None, this created a QWidget # that was (usually) not added to any layout and could invisibly hang over # some other widget (and capture its clicks). Conditions `label or # createLabel or box` are here to ensure the box is created when needed. # addToLayout is checked so that the returned `slider` still has the `box` # attribute in case any caller used it (for reasons I can't see). if label or createLabel or box or (misc.get("addToLayout") and widget): sliderBox = hBox(widget, box, addToLayout=False) else: sliderBox = None if label: widgetLabel(sliderBox, label) sliderOrient = Qt.Vertical if vertical else Qt.Horizontal if intOnly: slider = Slider(sliderOrient, sliderBox) slider.setRange(minValue, maxValue) if step: slider.setSingleStep(step) slider.setPageStep(step) slider.setTickInterval(step) signal = slider.valueChanged[int] else: slider = FloatSlider(sliderOrient, minValue, maxValue, step) signal = slider.valueChangedFloat[float] if sliderBox is not None: sliderBox.layout().addWidget(slider) slider.setValue(getdeepattr(master, value)) if width: slider.setFixedWidth(width) if ticks: slider.setTickPosition(QSlider.TicksBelow) slider.setTickInterval(ticks) if createLabel: slider.label = label = QLabel(sliderBox) sliderBox.layout().addWidget(label) label.setText(labelFormat % minValue) width1 = label.sizeHint().width() label.setText(labelFormat % maxValue) width2 = label.sizeHint().width() label.setFixedSize(max(width1, width2), label.sizeHint().height()) label.setAlignment(Qt.AlignRight) txt = labelFormat % (getdeepattr(master, value) / divideFactor) label.setText(txt) label.setLbl = lambda x: \ label.setText(labelFormat % (x / divideFactor)) signal.connect(label.setLbl) connectControl(master, value, callback, signal, CallFrontHSlider(slider)) if callback_finished: dn = DelayedNotification(slider, timeout=500) dn.notification.connect(callback_finished) signal.connect(dn.changed) slider.sliderReleased.connect(dn.notify_immediately) miscellanea(slider, sliderBox, widget, **misc) slider.box = sliderBox return slider def labeledSlider(widget, master, value, box=None, label=None, labels=(), labelFormat=" %d", ticks=False, callback=None, vertical=False, width=None, **misc): """ Construct a slider with labels instead of numbers. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param box: tells whether the widget has a border, and its label :type box: int or str or None :param label: a label that is inserted into the box :type label: str :param labels: labels shown at different slider positions :type labels: tuple of str :param callback: a function that is called when the value is changed :type callback: function :param ticks: if set to `True`, ticks are added below the slider :type ticks: bool :param vertical: if set to `True`, the slider is vertical :type vertical: bool :param width: the width of the slider :type width: int :rtype: :obj:`QSlider` """ sliderBox = hBox(widget, box, addToLayout=False) if label: widgetLabel(sliderBox, label) sliderOrient = Qt.Vertical if vertical else Qt.Horizontal slider = Slider(sliderOrient, sliderBox) slider.ogValue = value slider.setRange(0, len(labels) - 1) slider.setSingleStep(1) slider.setPageStep(1) slider.setTickInterval(1) sliderBox.layout().addWidget(slider) slider.setValue(labels.index(getdeepattr(master, value))) if width: slider.setFixedWidth(width) if ticks: slider.setTickPosition(QSlider.TicksBelow) slider.setTickInterval(ticks) max_label_size = 0 slider.value_label = value_label = QLabel(sliderBox) value_label.setAlignment(Qt.AlignRight) sliderBox.layout().addWidget(value_label) for lb in labels: value_label.setText(labelFormat % lb) max_label_size = max(max_label_size, value_label.sizeHint().width()) value_label.setFixedSize(max_label_size, value_label.sizeHint().height()) value_label.setText(getdeepattr(master, value)) if isinstance(labelFormat, str): value_label.set_label = lambda x: \ value_label.setText(labelFormat % x) else: value_label.set_label = lambda x: value_label.setText(labelFormat(x)) slider.valueChanged[int].connect(value_label.set_label) connectControl(master, value, callback, slider.valueChanged[int], CallFrontLabeledSlider(slider, labels), CallBackLabeledSlider(slider, master, labels)) miscellanea(slider, sliderBox, widget, **misc) return slider def valueSlider(widget, master, value, box=None, label=None, values=(), labelFormat=" %d", ticks=False, callback=None, vertical=False, width=None, **misc): """ Construct a slider with different values. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param box: tells whether the widget has a border, and its label :type box: int or str or None :param label: a label that is inserted into the box :type label: str :param values: values at different slider positions :type values: list of int :param labelFormat: label format; default is `" %d"`; can also be a function :type labelFormat: str or func :param callback: a function that is called when the value is changed :type callback: function :param ticks: if set to `True`, ticks are added below the slider :type ticks: bool :param vertical: if set to `True`, the slider is vertical :type vertical: bool :param width: the width of the slider :type width: int :rtype: :obj:`QSlider` """ if isinstance(labelFormat, str): labelFormat = lambda x, f=labelFormat: f % x sliderBox = hBox(widget, box, addToLayout=False) if label: widgetLabel(sliderBox, label) slider_orient = Qt.Vertical if vertical else Qt.Horizontal slider = Slider(slider_orient, sliderBox) slider.ogValue = value slider.setRange(0, len(values) - 1) slider.setSingleStep(1) slider.setPageStep(1) slider.setTickInterval(1) sliderBox.layout().addWidget(slider) slider.setValue(values.index(getdeepattr(master, value))) if width: slider.setFixedWidth(width) if ticks: slider.setTickPosition(QSlider.TicksBelow) slider.setTickInterval(ticks) max_label_size = 0 slider.value_label = value_label = QLabel(sliderBox) value_label.setAlignment(Qt.AlignRight) sliderBox.layout().addWidget(value_label) for lb in values: value_label.setText(labelFormat(lb)) max_label_size = max(max_label_size, value_label.sizeHint().width()) value_label.setFixedSize(max_label_size, value_label.sizeHint().height()) value_label.setText(labelFormat(getdeepattr(master, value))) value_label.set_label = lambda x: value_label.setText(labelFormat(values[x])) slider.valueChanged[int].connect(value_label.set_label) connectControl(master, value, callback, slider.valueChanged[int], CallFrontLabeledSlider(slider, values), CallBackLabeledSlider(slider, master, values)) miscellanea(slider, sliderBox, widget, **misc) return slider def comboBox(widget, master, value, box=None, label=None, labelWidth=None, orientation=Qt.Vertical, items=(), callback=None, sendSelectedValue=None, emptyString=None, editable=False, contentsLength=None, searchable=False, *, model=None, tooltips=None, **misc): """ Construct a combo box. The `value` attribute of the `master` contains the text or the index of the selected item. :param widget: the widget into which the box is inserted :type widget: QWidget or None :param master: master widget :type master: OWWidget or OWComponent :param value: the master's attribute with which the value is synchronized :type value: str :param box: tells whether the widget has a border, and its label :type box: int or str or None :param orientation: tells whether to put the label above or to the left :type orientation: `Qt.Horizontal` (default), `Qt.Vertical` or instance of `QLayout` :param label: a label that is inserted into the box :type label: str :param labelWidth: the width of the label :type labelWidth: int :param callback: a function that is called when the value is changed :type callback: function :param items: items (optionally with data) that are put into the box :type items: tuple of str or tuples :param sendSelectedValue: decides whether the `value` contains the text of the selected item (`True`) or its index (`False`). If omitted (or `None`), the type will match the current value type, or index, if the current value is `None`. :type sendSelectedValue: bool or `None` :param emptyString: the string value in the combo box that gets stored as an empty string in `value` :type emptyString: str :param editable: a flag telling whether the combo is editable. Editable is ignored when searchable=True. :type editable: bool :param int contentsLength: Contents character length to use as a fixed size hint. When not None, equivalent to:: combo.setSizeAdjustPolicy( QComboBox.AdjustToMinimumContentsLengthWithIcon) combo.setMinimumContentsLength(contentsLength) :param searchable: decides whether combo box has search-filter option :type searchable: bool :param tooltips: tooltips for individual items; applied only if model is None :type tooltips: list of str :rtype: QComboBox """ widget_label = None if box or label: hb = widgetBox(widget, box, orientation, addToLayout=misc.get('addToLayout', True)) misc['addToLayout'] = False if label is not None: widget_label = widgetLabel(hb, label, labelWidth) else: hb = widget if searchable: combo = OrangeComboBoxSearch(hb) if editable: warnings.warn( "'editable' is ignored for searchable combo box." ) else: combo = OrangeComboBox(hb, editable=editable) if contentsLength is not None: combo.setSizeAdjustPolicy( QtWidgets.QComboBox.AdjustToMinimumContentsLengthWithIcon) combo.setMinimumContentsLength(contentsLength) combo.box = hb combo.label = widget_label for item in items: if isinstance(item, (tuple, list)): combo.addItem(*item) else: combo.addItem(str(item)) if value: combo.setObjectName(value) cindex = getdeepattr(master, value) if model is not None: combo.setModel(model) if isinstance(model, PyListModel): callfront = CallFrontComboBoxModel(combo, model) callfront.action(cindex) connectControl( master, value, callback, combo.activated[int], callfront, ValueCallbackComboModel(master, value, model)) else: if isinstance(cindex, str): if items and cindex in items: cindex = items.index(cindex) else: cindex = 0 if cindex > combo.count() - 1: cindex = 0 combo.setCurrentIndex(cindex) if sendSelectedValue: connectControl( master, value, callback, combo.textActivated, CallFrontComboBox(combo, emptyString), ValueCallbackCombo(master, value, emptyString)) else: connectControl( master, value, callback, combo.activated[int], CallFrontComboBox(combo, emptyString)) if tooltips is not None and model is None: for i, tip in enumerate(tooltips): combo.setItemData(i, tip, Qt.ToolTipRole) if misc.pop("valueType", False): log.warning("comboBox no longer accepts argument 'valueType'") miscellanea(combo, hb, widget, **misc) combo.emptyString = emptyString return combo # Decorator deferred allows for doing this: # # class MyWidget(OWBaseWidget): # def __init__(self): # ... # # construct some control, for instance a checkbox, which calls # # a deferred commit # cb = gui.checkbox(..., callback=commit.deferred) # ... # gui.auto_commit(..., commit=commit) # # @deferred # def commit(self): # ... # # def some_method(self): # ... # self.commit.now() # # def another_method(self): # ... # self.commit.deferred() # # Calling self.commit() will raise an exception that one must choose to call # either `now` or `deferred`. # # 1. `now` and `deferred` are created by `gui.auto_commit` and must be # stored to the instance. # # Hence, auto_commit stores `now` and `then` into `master.___data.now` # and`master._data.deferred`, where is the name of the # decorated method (usually "commit"). # # 2. Calling a decorated self.commit() should raise an exception that one # must choose between `now` and `deferred` ... unless when calling # super().commit() from overriden commit. # # Decorator would thus replace the method with a function (or a callable) # that raises an exception ... except for when calling super(). # # With everything else in place, the only simple way to allow calling super # that I see is to have a flag (..._data.commit_depth) that we're already # within commit.If we're in a commit, we allow calling the decorated commit, # otherwise raise an exception. `commit` is usually not called from multiple # threads, and even if is is, the only consequence would be that we will # (in rare, unfortunate cases with very exact timing) allow calling # self.commit instead of (essentially) self.commit.now instead of raising # a rather diagnostic exception. This is a non-issue. # # 3. `now` and `deferred` must have a reference to the widget instance # (that is, to what will be bound to `self` in the actual `commit`. # # Therefore, we cannot simply have a `commit` to which we attach `now` # and `deferred` because `commit.now` would then be a function - one and the # same function for all widgets of that class, not a method bound to a # particular instance's commit. # # To solve this problem, the decorated `commit` is not a function but a # property (of class `DeferrerProperty`) that returns a callable object # (of class `Deferred`). When the property is retrieved, we get a reference # to the instance which is kept in the closure. `Deferred` than provides # `now` and `deferred`. # 4. Although `now` and `deferred` are constructed only in gui.auto_commit, # they (esp. `deferred`) must by available before that - not for # being called but for being stored as a callback for the checkbox. # # To solve this problem, `Deferred` returns either a `..._data.now` and # `..._data.deferred` set by auto_commit; if they are not yet set, it returns # a lambda that calls them, essentially thunking a function that does not # yet exist. # # 5. `now` and `deferred` are set by `auto_commit` as well as by mocking in # unit tests. Because on the outside we only see commit.now and # commit.deffered (although they are stored elsewhere, not as attributes # of commit), they need to pretend to be attributes. Hence we patch # __setattr__, __getattribute__ and __delattr__. # The type hint is somewhat wrong: the decorator returns a property, which is # a function, but in practice it's the same. PyCharm correctly recognizes it. if sys.version_info >= (3, 8): from typing import Protocol class DeferredFunc(Protocol): def deferred(self) -> None: ... def now(self) -> None: ... else: from typing import Any DeferredFunc = Any if sys.version_info >= (3, 7): from typing import Optional, Callable from dataclasses import dataclass @dataclass class DeferredData: # now and deferred are set by auto_commit now: Optional[Callable] = None deferred: Optional[Callable] = None # if True, data was changed while auto was disabled, # so enabling auto commit must call `func` dirty: bool = False # A flag (counter) telling that we're within commit and # super().commit() should not raise an exception commit_depth: int = 0 else: class DeferredData: def __init__(self): self.now = self.deferred = None self.dirty = False self.commit_depth = 0 def deferred(func) -> DeferredFunc: name = func.__name__ # Deferred method is turned into a property that returns a class, with # __call__ that raises an exception about being deferred class DeferrerProperty: def __get__(self, instance, owner=None): if instance is None: # We come here is somebody retrieves, e.g. OWTable.commit data = None else: # `DeferredData` is created once per instance data = instance.__dict__.setdefault(f"__{name}_data", DeferredData()) class Deferred: # A property that represents commit. Its closure include # - func: the original commit method # - instance: a widget instance # and, for practicality # - data: a data class containing `now`, `deferred`, `dirty` # and `commit_depth` for this `instance` # - name: name of the method being decorate (usually "commit") # Name of the function being decorated, copied to a standard # attribute; used, for instance, in auto_commit to check for # decorated overriden methods and in exception messages __name__ = name # A flag that tells an observer that the method is decorated # auto_commit uses it to check that a widget that overrides # a decorated method also decorates its method decorated = True @classmethod def __call__(cls): # Semantically, decorated method is replaced by this one, # which raises an exception except in super calls. # If commit_depth > 0, we're calling super (assuming # no threading on commit!) if data.commit_depth: cls.call() else: raise RuntimeError( "This function is deferred; explicitly call " f"{name}.deferred or {name}.now") @staticmethod def call(): data.commit_depth += 1 try: acting_func = instance.__dict__.get(name, func) acting_func(instance) finally: data.commit_depth -= 1 def __setattr__(self, key, value): if key in ("now", "deferred"): setattr(data, key, value) else: super().__setattr__(key, value) def __getattribute__(self, key): if key in ("now", "deferred"): # If auto_commit already set a function, return it. # If not, return that function that calls a function, # which will later be set by auto_commit value = getattr(data, key) if value is not None: return value else: return lambda: getattr(data, key)() return super().__getattribute__(key) def __delattr__(self, key): if key in ("now", "deferred"): setattr(data, key, None) else: super().__delattr__(self, key) @property def dirty(_): return data.dirty @dirty.setter def dirty(_, value): data.dirty = value return Deferred() def __set__(self, instance, value): raise ValueError( f"decorated {name} can't be mocked; " f"mock '{name}.now' and/or '{name}.deferred'.") return DeferrerProperty() def auto_commit(widget, master, value, label, auto_label=None, box=False, checkbox_label=None, orientation=None, commit=None, callback=None, **misc): """ Add a commit button with auto-commit check box. When possible, use auto_apply or auto_send instead of auto_commit. The widget must have a commit method and a setting that stores whether auto-commit is on. The function replaces the commit method with a new commit method that checks whether auto-commit is on. If it is, it passes the call to the original commit, otherwise it sets the dirty flag. The checkbox controls the auto-commit. When auto-commit is switched on, the checkbox callback checks whether the dirty flag is on and calls the original commit. Important! Do not connect any signals to the commit before calling auto_commit. :param widget: the widget into which the box with the button is inserted :type widget: QWidget or None :param value: the master's attribute which stores whether the auto-commit is on :type value: str :param master: master widget :type master: OWBaseWidget or OWComponent :param label: The button label :type label: str :param auto_label: The label used when auto-commit is on; default is `label + " Automatically"` :type auto_label: str :param commit: master's method to override ('commit' by default) :type commit: function :param callback: function to call whenever the checkbox's statechanged :type callback: function :param box: tells whether the widget has a border, and its label :type box: int or str or None :return: the box """ commit = commit or getattr(master, 'commit') if isinstance(commit, LambdaType): commit_name = next(LAMBDA_NAME) else: commit_name = commit.__name__ decorated = hasattr(commit, "deferred") def checkbox_toggled(): if getattr(master, value): btn.setText(auto_label) btn.setEnabled(False) if is_dirty(): do_commit() else: btn.setText(label) btn.setEnabled(is_dirty()) if callback: callback() if decorated: def is_dirty(): return commit.dirty def set_dirty(state): commit.dirty = state btn.setEnabled(state) else: dirty = False def is_dirty(): return dirty def set_dirty(state): nonlocal dirty dirty = state btn.setEnabled(state) def conditional_commit(): if getattr(master, value): do_commit() else: set_dirty(True) def do_commit(): QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) try: if decorated: commit.call() else: commit() set_dirty(False) finally: QApplication.restoreOverrideCursor() if not auto_label: if checkbox_label: auto_label = label else: auto_label = f"{label.title()} Automatically" if isinstance(box, QWidget): b = box addToLayout = False else: if orientation is None: orientation = Qt.Vertical if checkbox_label else Qt.Horizontal b = widgetBox(widget, box=box, orientation=orientation, addToLayout=False, margin=0, spacing=0) addToLayout = misc.get('addToLayout', True) if addToLayout and widget and \ not widget.layout().isEmpty() \ and _is_horizontal(orientation) \ and isinstance(widget.layout(), QtWidgets.QHBoxLayout): # put a separator before the checkbox separator(b, 16, 0) b.checkbox = cb = checkBox(b, master, value, checkbox_label, callback=checkbox_toggled, tooltip=auto_label) if _is_horizontal(orientation): w = b.style().pixelMetric(QStyle.PM_CheckBoxLabelSpacing) separator(b, w, 0) cb.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) b.button = btn = VariableTextPushButton( b, text=label, textChoiceList=[label, auto_label], clicked=do_commit) btn.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) if b.layout() is not None: if is_macstyle(): btnpaddingbox = vBox(b, margin=0, spacing=0) separator(btnpaddingbox, 0, 4) btnpaddingbox.layout().addWidget(btn) else: b.layout().addWidget(btn) if not checkbox_label: btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) if decorated: commit.now = do_commit commit.deferred = conditional_commit else: if not isinstance(commit, LambdaType): for supertype in type(master).mro()[1:]: inherited_commit = getattr(supertype, commit_name, None) if getattr(inherited_commit, "decorated", False): raise RuntimeError( f"{type(master).__name__}.{commit_name} must be" "decorated with gui.deferred because it overrides" f"a decorated {supertype.__name__}.") warnings.warn( f"decorate {type(master).__name__}.{commit_name} " "with @gui.deferred and then explicitly call " f"{commit_name}.now or {commit_name}.deferred.") # TODO: I suppose we don't need to to this for lambdas, do we? # Maybe we can change `else` to `elif not isinstance(commit, LambdaType) # and remove `if` that follows? setattr(master, 'unconditional_' + commit_name, commit) setattr(master, commit_name, conditional_commit) checkbox_toggled() misc['addToLayout'] = addToLayout miscellanea(b, widget, widget, **misc) cb.setAttribute(Qt.WA_LayoutUsesWidgetRect) btn.setAttribute(Qt.WA_LayoutUsesWidgetRect) return b def auto_send(widget, master, value="auto_send", **kwargs): """ Convenience function that creates an auto_commit box, for widgets that send selected data (as opposed to applying changes). :param widget: the widget into which the box with the button is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute which stores whether the auto-commit (default 'auto_send') :type value: str :return: the box """ return auto_commit(widget, master, value, "Send Selection", "Send Automatically", **kwargs) def auto_apply(widget, master, value="auto_apply", **kwargs): """ Convenience function that creates an auto_commit box, for widgets that apply changes (as opposed to sending a selection). :param widget: the widget into which the box with the button is inserted :type widget: QWidget or None :param master: master widget :type master: OWBaseWidget or OWComponent :param value: the master's attribute which stores whether the auto-commit (default 'auto_apply') :type value: str :return: the box """ return auto_commit(widget, master, value, "Apply", "Apply Automatically", **kwargs) def connectControl(master, value, f, signal, cfront, cback=None, cfunc=None, fvcb=None): cback = cback or value and ValueCallback(master, value, fvcb) if cback: if signal: signal.connect(cback) cback.opposite = cfront if value and cfront: master.connect_control(value, cfront) cfunc = cfunc or f and FunctionCallback(master, f) if cfunc: if signal: signal.connect(cfunc) cfront.opposite = tuple(x for x in (cback, cfunc) if x) return cfront, cback, cfunc @contextlib.contextmanager def disable_opposite(obj): opposite = getattr(obj, "opposite", None) if opposite: opposite.disabled += 1 try: yield finally: if opposite: opposite.disabled -= 1 class ControlledCallback: def __init__(self, widget, attribute, f=None): self.widget = widget self.attribute = attribute self.func = f self.disabled = 0 if isinstance(widget, dict): return # we can't assign attributes to dict if not hasattr(widget, "callbackDeposit"): widget.callbackDeposit = [] widget.callbackDeposit.append(self) def acyclic_setattr(self, value): if self.disabled: return if self.func: if self.func in (int, float) and ( not value or isinstance(value, str) and value in "+-"): value = self.func(0) else: value = self.func(value) with disable_opposite(self): if isinstance(self.widget, dict): self.widget[self.attribute] = value else: setattr(self.widget, self.attribute, value) class ValueCallback(ControlledCallback): # noinspection PyBroadException def __call__(self, value): if value is None: return self.acyclic_setattr(value) class ValueCallbackCombo(ValueCallback): def __init__(self, widget, attribute, emptyString=""): super().__init__(widget, attribute) self.emptyString = emptyString def __call__(self, value): if value == self.emptyString: value = "" return super().__call__(value) class ValueCallbackComboModel(ValueCallback): def __init__(self, widget, attribute, model): super().__init__(widget, attribute) self.model = model def __call__(self, index): # Can't use super here since, it doesn't set `None`'s?! return self.acyclic_setattr(self.model[index]) class ValueCallbackLineEdit(ControlledCallback): def __init__(self, control, widget, attribute, f=None): ControlledCallback.__init__(self, widget, attribute, f) self.control = control # noinspection PyBroadException def __call__(self, value): if value is None: return pos = self.control.cursorPosition() self.acyclic_setattr(value) self.control.setCursorPosition(pos) class SetLabelCallback: def __init__(self, widget, label, format="%5.2f", f=None): self.widget = widget self.label = label self.format = format self.f = f if hasattr(widget, "callbackDeposit"): widget.callbackDeposit.append(self) self.disabled = 0 def __call__(self, value): if not self.disabled and value is not None: if self.f: value = self.f(value) self.label.setText(self.format % value) class FunctionCallback: def __init__(self, master, f, widget=None, id=None, getwidget=False): self.master = master self.widget = widget self.func = f self.id = id self.getwidget = getwidget if hasattr(master, "callbackDeposit"): master.callbackDeposit.append(self) self.disabled = 0 def __call__(self, *value): if not self.disabled and value is not None: kwds = {} if self.id is not None: kwds['id'] = self.id if self.getwidget: kwds['widget'] = self.widget if isinstance(self.func, list): for func in self.func: func(**kwds) else: self.func(**kwds) class CallBackRadioButton: def __init__(self, control, widget): self.control = control self.widget = widget self.disabled = False def __call__(self, *_): # triggered by toggled() if not self.disabled and self.control.ogValue is not None: arr = [butt.isChecked() for butt in self.control.buttons] self.widget.__setattr__(self.control.ogValue, arr.index(1)) class CallBackLabeledSlider: def __init__(self, control, widget, lookup): self.control = control self.widget = widget self.lookup = lookup self.disabled = False def __call__(self, *_): if not self.disabled and self.control.ogValue is not None: self.widget.__setattr__(self.control.ogValue, self.lookup[self.control.value()]) ############################################################################## # call fronts (change of the attribute value changes the related control) class ControlledCallFront: def __init__(self, control): self.control = control self.disabled = 0 def action(self, *_): pass def __call__(self, *args): if not self.disabled: opposite = getattr(self, "opposite", None) if opposite: try: for op in opposite: op.disabled += 1 self.action(*args) finally: for op in opposite: op.disabled -= 1 else: self.action(*args) class CallFrontSpin(ControlledCallFront): def action(self, value): if value is not None: self.control.setValue(value) class CallFrontDoubleSpin(ControlledCallFront): def action(self, value): if value is not None: self.control.setValue(value) class CallFrontCheckBox(ControlledCallFront): def action(self, value): if value is not None: values = [Qt.Unchecked, Qt.Checked, Qt.PartiallyChecked] self.control.setCheckState(values[value]) class CallFrontButton(ControlledCallFront): def action(self, value): if value is not None: self.control.setChecked(bool(value)) class CallFrontComboBox(ControlledCallFront): def __init__(self, control, emptyString=""): super().__init__(control) self.emptyString = emptyString def action(self, value): def action_str(): items = [combo.itemText(i) for i in range(combo.count())] try: index = items.index(value or self.emptyString) except ValueError: if items: msg = f"Combo '{combo.objectName()}' has no item '{value}'; " \ f"current items are {', '.join(map(repr, items))}." else: msg = f"combo '{combo.objectName()}' is empty." warnings.warn(msg, stacklevel=5) else: self.control.setCurrentIndex(index) def action_int(): if value < combo.count(): combo.setCurrentIndex(value) else: if combo.count(): msg = f"index {value} is out of range " \ f"for combo box '{combo.objectName()}' " \ f"with {combo.count()} item(s)." else: msg = f"combo box '{combo.objectName()}' is empty." warnings.warn(msg, stacklevel=5) combo = self.control if isinstance(value, int): action_int() else: action_str() class CallFrontComboBoxModel(ControlledCallFront): def __init__(self, control, model): super().__init__(control) self.model = model def action(self, value): if value == "": # the latter accomodates PyListModel value = None if value is None and None not in self.model: return # e.g. values in half-initialized widgets if value in self.model: self.control.setCurrentIndex(self.model.indexOf(value)) return if isinstance(value, str): for i, val in enumerate(self.model): if value == str(val): self.control.setCurrentIndex(i) return raise ValueError("Combo box does not contain item " + repr(value)) class CallFrontHSlider(ControlledCallFront): def action(self, value): if value is not None: self.control.setValue(value) class CallFrontLabeledSlider(ControlledCallFront): def __init__(self, control, lookup): super().__init__(control) self.lookup = lookup def action(self, value): if value is not None: self.control.setValue(self.lookup.index(value)) class CallFrontLogSlider(ControlledCallFront): def action(self, value): if value is not None: if value < 1e-30: print("unable to set %s to %s (value too small)" % (self.control, value)) else: self.control.setValue(math.log10(value)) class CallFrontLineEdit(ControlledCallFront): def action(self, value): self.control.setText(str(value)) class CallFrontRadioButtons(ControlledCallFront): def action(self, value): if value < 0 or value >= len(self.control.buttons): value = 0 self.control.buttons[value].setChecked(1) class CallFrontLabel: def __init__(self, control, label, master): self.control = control self.label = label self.master = master def __call__(self, *_): self.control.setText(self.label % self.master.__dict__) ############################################################################## ## Disabler is a call-back class for check box that can disable/enable other ## widgets according to state (checked/unchecked, enabled/disable) of the ## given check box ## ## Tricky: if self.propagateState is True (default), then if check box is ## disabled the related widgets will be disabled (even if the checkbox is ## checked). If self.propagateState is False, the related widgets will be ## disabled/enabled if check box is checked/clear, disregarding whether the ## check box itself is enabled or not. (If you don't understand, see the ## code :-) DISABLER = 1 HIDER = 2 # noinspection PyShadowingBuiltins class Disabler: def __init__(self, widget, master, valueName, propagateState=True, type=DISABLER): self.widget = widget self.master = master self.valueName = valueName self.propagateState = propagateState self.type = type def __call__(self, *value): currState = self.widget.isEnabled() if currState or not self.propagateState: if len(value): disabled = not value[0] else: disabled = not getdeepattr(self.master, self.valueName) else: disabled = True for w in self.widget.disables: if isinstance(w, tuple): if isinstance(w[0], int): i = 1 if w[0] == -1: disabled = not disabled else: i = 0 if self.type == DISABLER: w[i].setDisabled(disabled) elif self.type == HIDER: if disabled: w[i].hide() else: w[i].show() if hasattr(w[i], "makeConsistent"): w[i].makeConsistent() else: if self.type == DISABLER: w.setDisabled(disabled) elif self.type == HIDER: if disabled: w.hide() else: w.show() ############################################################################## # some table related widgets # noinspection PyShadowingBuiltins class tableItem(QTableWidgetItem): def __init__(self, table, x, y, text, editType=None, backColor=None, icon=None, type=QTableWidgetItem.Type): super().__init__(type) if icon: self.setIcon(QtGui.QIcon(icon)) if editType is not None: self.setFlags(editType) else: self.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable) if backColor is not None: self.setBackground(QtGui.QBrush(backColor)) # we add it this way so that text can also be int and sorting will be # done properly (as integers and not as text) self.setData(Qt.DisplayRole, text) table.setItem(x, y, self) BarRatioRole = next(OrangeUserRole) # Ratio for drawing distribution bars BarBrushRole = next(OrangeUserRole) # Brush for distribution bar SortOrderRole = next(OrangeUserRole) # Used for sorting class BarItemDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, parent, brush=QtGui.QBrush(QtGui.QColor(255, 170, 127)), scale=(0.0, 1.0)): super().__init__(parent) self.brush = brush self.scale = scale def paint(self, painter, option, index): if option.widget is not None: style = option.widget.style() else: style = QApplication.style() style.drawPrimitive( QStyle.PE_PanelItemViewRow, option, painter, option.widget) style.drawPrimitive( QStyle.PE_PanelItemViewItem, option, painter, option.widget) rect = option.rect val = index.data(Qt.DisplayRole) if isinstance(val, float): minv, maxv = self.scale val = (val - minv) / (maxv - minv) painter.save() if option.state & QStyle.State_Selected: painter.setOpacity(0.75) painter.setBrush(self.brush) painter.drawRect( rect.adjusted(1, 1, int(- rect.width() * (1.0 - val) - 2), -2)) painter.restore() class IndicatorItemDelegate(QtWidgets.QStyledItemDelegate): IndicatorRole = next(OrangeUserRole) def __init__(self, parent, role=IndicatorRole, indicatorSize=2): super().__init__(parent) self.role = role self.indicatorSize = indicatorSize def paint(self, painter, option, index): super().paint(painter, option, index) rect = option.rect indicator = index.data(self.role) # We exit on `indicator is None`, essentially. But for backward compat, # we also exit on other falsy values, except False, which now indicates # an empty indicator. if not indicator and indicator is not False: return color_for_state = text_color_for_state(option.palette, option.state) pen = QtGui.QPen(color_for_state, 1) if indicator is False: brush = Qt.NoBrush else: brush = index.data(Qt.ForegroundRole) if brush is None: brush = QtGui.QBrush(color_for_state) painter.save() painter.setRenderHints(QtGui.QPainter.Antialiasing) painter.setBrush(brush) painter.setPen(pen) painter.drawEllipse(rect.center(), self.indicatorSize, self.indicatorSize) painter.restore() class LinkStyledItemDelegate(QStyledItemDelegate): LinkRole = next(OrangeUserRole) def __init__(self, parent): super().__init__(parent) self.mousePressState = QtCore.QModelIndex(), QtCore.QPoint() parent.entered.connect(self.onEntered) def sizeHint(self, option, index): size = super().sizeHint(option, index) return QtCore.QSize(size.width(), max(size.height(), 20)) def linkRect(self, option, index): if option.widget is not None: style = option.widget.style() else: style = QApplication.style() text = self.displayText(index.data(Qt.DisplayRole), QtCore.QLocale.system()) self.initStyleOption(option, index) textRect = style.subElementRect( QStyle.SE_ItemViewItemText, option, option.widget) if not textRect.isValid(): textRect = option.rect margin = style.pixelMetric( QStyle.PM_FocusFrameHMargin, option, option.widget) + 1 textRect = textRect.adjusted(margin, 0, -margin, 0) font = index.data(Qt.FontRole) if not isinstance(font, QtGui.QFont): font = option.font metrics = QtGui.QFontMetrics(font) elideText = metrics.elidedText(text, option.textElideMode, textRect.width()) return metrics.boundingRect(textRect, option.displayAlignment, elideText) def editorEvent(self, event, model, option, index): if event.type() == QtCore.QEvent.MouseButtonPress and \ self.linkRect(option, index).contains(event.pos()): self.mousePressState = (QtCore.QPersistentModelIndex(index), QtCore.QPoint(event.pos())) elif event.type() == QtCore.QEvent.MouseButtonRelease: link = index.data(LinkRole) if not isinstance(link, str): link = None pressedIndex, pressPos = self.mousePressState if pressedIndex == index and \ (pressPos - event.pos()).manhattanLength() < 5 and \ link is not None: import webbrowser webbrowser.open(link) self.mousePressState = QtCore.QModelIndex(), event.pos() elif event.type() == QtCore.QEvent.MouseMove: link = index.data(LinkRole) if not isinstance(link, str): link = None if link is not None and \ self.linkRect(option, index).contains(event.pos()): self.parent().viewport().setCursor(Qt.PointingHandCursor) else: self.parent().viewport().setCursor(Qt.ArrowCursor) return super().editorEvent(event, model, option, index) def onEntered(self, index): link = index.data(LinkRole) if not isinstance(link, str): link = None if link is None: self.parent().viewport().setCursor(Qt.ArrowCursor) def paint(self, painter, option, index): link = index.data(LinkRole) if not isinstance(link, str): link = None if link is not None: if option.widget is not None: style = option.widget.style() else: style = QApplication.style() style.drawPrimitive( QStyle.PE_PanelItemViewRow, option, painter, option.widget) style.drawPrimitive( QStyle.PE_PanelItemViewItem, option, painter, option.widget) text = self.displayText(index.data(Qt.DisplayRole), QtCore.QLocale.system()) textRect = style.subElementRect( QStyle.SE_ItemViewItemText, option, option.widget) if not textRect.isValid(): textRect = option.rect margin = style.pixelMetric( QStyle.PM_FocusFrameHMargin, option, option.widget) + 1 textRect = textRect.adjusted(margin, 0, -margin, 0) elideText = QtGui.QFontMetrics(option.font).elidedText( text, option.textElideMode, textRect.width()) painter.save() font = index.data(Qt.FontRole) if not isinstance(font, QtGui.QFont): font = option.font painter.setFont(font) if option.state & QStyle.State_Selected: color = option.palette.highlightedText().color() else: color = option.palette.link().color() painter.setPen(QtGui.QPen(color)) painter.drawText(textRect, option.displayAlignment, elideText) painter.restore() else: super().paint(painter, option, index) LinkRole = LinkStyledItemDelegate.LinkRole class ColoredBarItemDelegate(QtWidgets.QStyledItemDelegate): """ Item delegate that can also draws a distribution bar """ def __init__(self, parent=None, decimals=3, color=Qt.red): super().__init__(parent) self.decimals = decimals self.float_fmt = "%%.%if" % decimals self.color = QtGui.QColor(color) def displayText(self, value, locale=QtCore.QLocale()): if value is None or isinstance(value, float) and math.isnan(value): return "NA" if isinstance(value, float): return self.float_fmt % value return str(value) def sizeHint(self, option, index): font = self.get_font(option, index) metrics = QtGui.QFontMetrics(font) height = metrics.lineSpacing() + 8 # 4 pixel margin width = metrics.horizontalAdvance( self.displayText(index.data(Qt.DisplayRole), QtCore.QLocale())) + 8 return QtCore.QSize(width, height) def paint(self, painter, option, index): self.initStyleOption(option, index) text = self.displayText(index.data(Qt.DisplayRole)) ratio, have_ratio = self.get_bar_ratio(option, index) rect = option.rect if have_ratio: # The text is raised 3 pixels above the bar. # TODO: Style dependent margins? text_rect = rect.adjusted(4, 1, -4, -4) else: text_rect = rect.adjusted(4, 4, -4, -4) painter.save() font = self.get_font(option, index) painter.setFont(font) if option.widget is not None: style = option.widget.style() else: style = QApplication.style() style.drawPrimitive( QStyle.PE_PanelItemViewRow, option, painter, option.widget) style.drawPrimitive( QStyle.PE_PanelItemViewItem, option, painter, option.widget) # TODO: Check ForegroundRole. painter.setPen( QtGui.QPen(text_color_for_state(option.palette, option.state))) align = self.get_text_align(option, index) metrics = QtGui.QFontMetrics(font) elide_text = metrics.elidedText( text, option.textElideMode, text_rect.width()) painter.drawText(text_rect, align, elide_text) painter.setRenderHint(QtGui.QPainter.Antialiasing, True) if have_ratio: brush = self.get_bar_brush(option, index) painter.setBrush(brush) painter.setPen(QtGui.QPen(brush, 1)) bar_rect = QtCore.QRect(text_rect) bar_rect.setTop(bar_rect.bottom() - 1) bar_rect.setBottom(bar_rect.bottom() + 1) w = text_rect.width() bar_rect.setWidth(max(0, min(int(w * ratio), w))) painter.drawRoundedRect(bar_rect, 2, 2) painter.restore() def get_font(self, option, index): font = index.data(Qt.FontRole) if not isinstance(font, QtGui.QFont): font = option.font return font def get_text_align(self, _, index): align = index.data(Qt.TextAlignmentRole) if not isinstance(align, int): align = Qt.AlignLeft | Qt.AlignVCenter return align def get_bar_ratio(self, _, index): ratio = index.data(BarRatioRole) return ratio, isinstance(ratio, float) and 0.0 <= ratio <= 1.0 def get_bar_brush(self, _, index): bar_brush = index.data(BarBrushRole) if not isinstance(bar_brush, (QtGui.QColor, QtGui.QBrush)): bar_brush = self.color return QtGui.QBrush(bar_brush) class HorizontalGridDelegate(QStyledItemDelegate): def paint(self, painter, option, index): painter.save() painter.setPen(QColor(212, 212, 212)) painter.drawLine(option.rect.bottomLeft(), option.rect.bottomRight()) painter.restore() QStyledItemDelegate.paint(self, painter, option, index) class VerticalLabel(QLabel): def __init__(self, text, parent=None): super().__init__(text, parent) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding) self.setMaximumWidth(self.sizeHint().width() + 2) self.setMargin(4) def sizeHint(self): metrics = QtGui.QFontMetrics(self.font()) rect = metrics.boundingRect(self.text()) size = QtCore.QSize(rect.height() + self.margin(), rect.width() + self.margin()) return size def setGeometry(self, rect): super().setGeometry(rect) def paintEvent(self, event): painter = QtGui.QPainter(self) rect = self.geometry() text_rect = QtCore.QRect(0, 0, rect.width(), rect.height()) painter.translate(text_rect.bottomLeft()) painter.rotate(-90) painter.drawText( QtCore.QRect(QtCore.QPoint(0, 0), QtCore.QSize(rect.height(), rect.width())), Qt.AlignCenter, self.text()) painter.end() class VerticalItemDelegate(QStyledItemDelegate): # Extra text top/bottom margin. Margin = 6 def __init__(self, extend=False): super().__init__() self._extend = extend # extend text over cell borders def sizeHint(self, option, index): sh = super().sizeHint(option, index) return QtCore.QSize(sh.height() + self.Margin * 2, sh.width()) def paint(self, painter, option, index): option = QtWidgets.QStyleOptionViewItem(option) self.initStyleOption(option, index) if not option.text: return if option.widget is not None: style = option.widget.style() else: style = QApplication.style() style.drawPrimitive( QStyle.PE_PanelItemViewRow, option, painter, option.widget) cell_rect = option.rect itemrect = QtCore.QRect(0, 0, cell_rect.height(), cell_rect.width()) opt = QtWidgets.QStyleOptionViewItem(option) opt.rect = itemrect textrect = style.subElementRect( QStyle.SE_ItemViewItemText, opt, opt.widget) painter.save() painter.setFont(option.font) if option.displayAlignment & (Qt.AlignTop | Qt.AlignBottom): brect = painter.boundingRect( textrect, option.displayAlignment, option.text) diff = textrect.height() - brect.height() offset = max(min(diff / 2, self.Margin), 0) if option.displayAlignment & Qt.AlignBottom: offset = -offset textrect.translate(0, offset) if self._extend and brect.width() > itemrect.width(): textrect.setWidth(brect.width()) painter.translate(option.rect.x(), option.rect.bottom()) painter.rotate(-90) painter.drawText(textrect, option.displayAlignment, option.text) painter.restore() ############################################################################## # progress bar management class ProgressBar: def __init__(self, widget, iterations): self.iter = iterations self.widget = widget self.count = 0 self.widget.progressBarInit() self.finished = False def __del__(self): if not self.finished: self.widget.progressBarFinished(processEvents=False) def advance(self, count=1): self.count += count self.widget.progressBarSet(int(self.count * 100 / max(1, self.iter))) def finish(self): self.finished = True self.widget.progressBarFinished() ############################################################################## def tabWidget(widget): w = QtWidgets.QTabWidget(widget) if widget.layout() is not None: widget.layout().addWidget(w) return w def createTabPage(tab_widget, name, widgetToAdd=None, canScroll=False, orientation=Qt.Vertical): if widgetToAdd is None: widgetToAdd = widgetBox(tab_widget, orientation=orientation, addToLayout=0, margin=4) if canScroll: scrollArea = QtWidgets.QScrollArea() tab_widget.addTab(scrollArea, name) scrollArea.setWidget(widgetToAdd) scrollArea.setWidgetResizable(1) scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn) else: tab_widget.addTab(widgetToAdd, name) return widgetToAdd def table(widget, rows=0, columns=0, selectionMode=-1, addToLayout=True): w = QtWidgets.QTableWidget(rows, columns, widget) if widget and addToLayout and widget.layout() is not None: widget.layout().addWidget(w) if selectionMode != -1: w.setSelectionMode(selectionMode) w.setHorizontalScrollMode(QtWidgets.QTableWidget.ScrollPerPixel) w.horizontalHeader().setSectionsMovable(True) return w class VisibleHeaderSectionContextEventFilter(QtCore.QObject): def __init__(self, parent, itemView=None): super().__init__(parent) self.itemView = itemView def eventFilter(self, view, event): if not isinstance(event, QtGui.QContextMenuEvent): return False model = view.model() headers = [(view.isSectionHidden(i), model.headerData(i, view.orientation(), Qt.DisplayRole)) for i in range(view.count())] menu = QtWidgets.QMenu("Visible headers", view) for i, (checked, name) in enumerate(headers): action = QtWidgets.QAction(name, menu) action.setCheckable(True) action.setChecked(not checked) menu.addAction(action) def toogleHidden(visible, section=i): view.setSectionHidden(section, not visible) if not visible: return if self.itemView: self.itemView.resizeColumnToContents(section) else: view.resizeSection(section, max(view.sectionSizeHint(section), 10)) action.toggled.connect(toogleHidden) menu.exec(event.globalPos()) return True def checkButtonOffsetHint(button, style=None): option = QtWidgets.QStyleOptionButton() option.initFrom(button) if style is None: style = button.style() if isinstance(button, QtWidgets.QCheckBox): pm_spacing = QStyle.PM_CheckBoxLabelSpacing pm_indicator_width = QStyle.PM_IndicatorWidth else: pm_spacing = QStyle.PM_RadioButtonLabelSpacing pm_indicator_width = QStyle.PM_ExclusiveIndicatorWidth space = style.pixelMetric(pm_spacing, option, button) width = style.pixelMetric(pm_indicator_width, option, button) # TODO: add other styles (Maybe load corrections from .cfg file?) style_correction = {"macintosh (aqua)": -2, "macintosh(aqua)": -2, "plastique": 1, "cde": 1, "motif": 1} return space + width + \ style_correction.get(QApplication.style().objectName().lower(), 0) def toolButtonSizeHint(button=None, style=None): if button is None and style is None: style = QApplication.style() elif style is None: style = button.style() button_size = \ style.pixelMetric(QStyle.PM_SmallIconSize) + \ style.pixelMetric(QStyle.PM_ButtonMargin) return button_size class Slider(QSlider): """ Slider that disables wheel events. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setFocusPolicy(Qt.StrongFocus) def wheelEvent(self, event): event.ignore() class FloatSlider(Slider): """ Slider for continuous values. The slider is derived from `QtGui.QSlider`, but maps from its discrete numbers to the desired continuous interval. """ valueChangedFloat = Signal(float) def __init__(self, orientation, min_value, max_value, step, parent=None): super().__init__(orientation, parent) self.setScale(min_value, max_value, step) self.valueChanged[int].connect(self._send_value) def _update(self): self.setSingleStep(1) if self.min_value != self.max_value: self.setEnabled(True) self.setMinimum(int(round(self.min_value / self.step))) self.setMaximum(int(round(self.max_value / self.step))) else: self.setEnabled(False) def _send_value(self, slider_value): value = min(max(slider_value * self.step, self.min_value), self.max_value) self.valueChangedFloat.emit(value) def setValue(self, value): """ Set current value. The value is divided by `step` Args: value: new value """ super().setValue(int(round(value / self.step))) def setScale(self, minValue, maxValue, step=0): """ Set slider's ranges (compatibility with qwtSlider). Args: minValue (float): minimal value maxValue (float): maximal value step (float): step """ if minValue >= maxValue: ## It would be more logical to disable the slider in this case ## (self.setEnabled(False)) ## However, we do nothing to keep consistency with Qwt # TODO If it's related to Qwt, remove it return if step <= 0 or step > (maxValue - minValue): if isinstance(maxValue, int) and isinstance(minValue, int): step = 1 else: step = float(minValue - maxValue) / 100.0 self.min_value = float(minValue) self.max_value = float(maxValue) self.step = step self._update() def setRange(self, minValue, maxValue, step=1.0): """ Set slider's ranges (compatibility with qwtSlider). Args: minValue (float): minimal value maxValue (float): maximal value step (float): step """ # For compatibility with qwtSlider # TODO If it's related to Qwt, remove it self.setScale(minValue, maxValue, step) class ControlGetter: """ Provide access to GUI elements based on their corresponding attributes in widget. Every widget has an attribute `controls` that is an instance of this class, which uses the `controlled_attributes` dictionary to retrieve the control (e.g. `QCheckBox`, `QComboBox`...) corresponding to the attribute. For `OWComponents`, it returns its controls so that subsequent `__getattr__` will retrieve the control. """ def __init__(self, widget): self.widget = widget def __getattr__(self, name): widget = self.widget callfronts = widget.controlled_attributes.get(name, None) if callfronts is None: # This must be an OWComponent try: return getattr(widget, name).controls except AttributeError: raise AttributeError( "'{}' is not an attribute related to a gui element or " "component".format(name)) else: return callfronts[0].control class VerticalScrollArea(QScrollArea): """ A QScrollArea that can only scroll vertically because it never needs to scroll horizontally: it adapts its width to the contents. """ def __init__(self, parent): super().__init__(parent) self.setWidgetResizable(True) self.setFrameShape(QFrame.NoFrame) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.horizontalScrollBar().setEnabled(False) self.verticalScrollBar().installEventFilter(self) def eventFilter(self, obj, event): if obj == self.verticalScrollBar() and event.type() == QEvent.StyleChange: self.updateGeometry() return super().eventFilter(obj, event) def resizeEvent(self, event): sb = self.verticalScrollBar() isTransient = sb.style().styleHint(QStyle.SH_ScrollBar_Transient, widget=sb) if isTransient or sb.minimum() == sb.maximum(): self.setViewportMargins(0, 0, 0, 0) else: self.setViewportMargins(0, 0, 5, 0) super().resizeEvent(event) self.updateGeometry() self.parent().updateGeometry() def sizeHint(self): if not self.widget(): return super().sizeHint() width = self.widget().sizeHint().width() sb = self.verticalScrollBar() isTransient = sb.style().styleHint(QStyle.SH_ScrollBar_Transient, widget=sb) if not isTransient and sb.maximum() != sb.minimum(): width += sb.style().pixelMetric(QStyle.PM_ScrollBarExtent, widget=sb) width += 5 sh = self.widget().sizeHint() sh.setWidth(width) return sh class CalendarWidgetWithTime(QCalendarWidget): def __init__(self, parent=None, time=None, format="hh:mm:ss"): super().__init__(parent) if time is None: time = QtCore.QTime.currentTime() self.timeedit = QDateTimeEdit(displayFormat=format) self.timeedit.setTime(time) self._time_layout = sublay = QtWidgets.QHBoxLayout() sublay.setContentsMargins(6, 6, 6, 6) sublay.addStretch(1) sublay.addWidget(QLabel("Time: ")) sublay.addWidget(self.timeedit) sublay.addStretch(1) self.layout().addLayout(sublay) def minimumSize(self): return self.sizeHint() def sizeHint(self): size = super().sizeHint() size.setHeight( size.height() + self._time_layout.sizeHint().height() + self.layout().spacing()) return size class DateTimeEditWCalendarTime(QDateTimeEdit): def __init__(self, parent, format="yyyy-MM-dd hh:mm:ss"): QDateTimeEdit.__init__(self, parent) self.setDisplayFormat(format) self.setCalendarPopup(True) self.calendarWidget = CalendarWidgetWithTime(self) self.calendarWidget.timeedit.timeChanged.connect(self.set_datetime) self.setCalendarWidget(self.calendarWidget) def set_datetime(self, date_time=None): if date_time is None: date_time = QtCore.QDateTime.currentDateTime() if isinstance(date_time, QtCore.QTime): self.setDateTime( QtCore.QDateTime(self.date(), date_time)) else: self.setDateTime(date_time) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8495162 orange-widget-base-4.22.0/orangewidget/icons/0000755000076500000240000000000014501051361020274 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_arrow.png0000644000076500000240000000054014306600442022724 0ustar00primozstaff‰PNG  IHDRbògAMA± üa cHRMz&€„ú€èu0ê`:˜pœºQ< PLTEñïâÿÿÿÌŸ˜øtRNS@æØfbKGDf |d pHYs  ÒÝ~ütIMEá  <ÒF2IDAT×c`ƒá"A„ƒˆ, ‚D!ƒ+P¥#HÌD8µˆ@ ¾Á´îƒ Õ%tEXtdate:create2017-04-21T11:17:10+02:00‰ôYd%tEXtdate:modify2017-04-21T11:17:10+02:00ø©áØIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_clear.png0000644000076500000240000000040514306600442022660 0ustar00primozstaff‰PNG  IHDRóÿasRGB®ÎébKGDÿÿÿ ½§“ pHYs  ÒÝ~ütIMEÛ  Ž;°;…IDAT8Ë¥“Ñ À D—p‡s‡s“î`¿l,åÐT>õÞAŽ W«8©€Ãz JL½ÄÔw Y¼O@nU^ãA =Ä q†‡¸ÄÔujÀ&° è´Ø‚]= 6¬àܪÌ9, ¬u˜Ô€Á¿BôÂ}ÖÈö½}L»ð˜BNÏùÜZ—Ÿ5HIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_down3.png0000644000076500000240000000111014306600442022616 0ustar00primozstaff‰PNG  IHDRóÿaIDAT8‘MkSA…Ÿù¸·½%)Ö‹Mq#bUÄêVM©‚ ¡þñ/þ ÝT‚Tª.ìFAÓu]BwŠPS) IŠ©Ü;sçu‘FbгÞçÌ9ï(útnáíR”ØYD@Âþ)‚ÛM·>½¹9ß;oû l¬OÏÞ8uRíæØÈb­E[ƒ*F¬-¯90߀ú™£·2L,ØL¢l'MŸô^c Ql±±ÅDm J©N•&‚±±û°Ñ @Bø k0Œ5(­»ÍV˜@ƒ¶t`ȃ 6˜¹²xa´Š@dT6J!@>"!š¹ðh!¸v÷{wlœL^O'÷§Î—P¹'1æ`/‚ËsÜn`º|¢0qvêe@h|¬ÒÞØyh¶??«ŒOÝJŠ“ÅKÇM3–%è¬Ó9Áç9™÷¤{Ò†h4!ÝkÓøðm±Q¯Ü5Û_–Þ ™kCyA]ž(M@*„ý—3çÉœ'Í¡ h5l®¬/6ê•;[ߟŠé.£öõùûX®F¾@ylzßÎqÞ“9׋šÖÎß0€éÝhmc¹ûyëF¤y „£zåÞ à7Ô˜åÎIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_enter.png0000644000076500000240000000022014306600442022702 0ustar00primozstaff‰PNG  IHDR‘h6tRNSì騭¹œ†EIDATxÚc|óò)€‰$Õ4Ó "¾b„l;a U£h F5 ¦jü:á1 W÷æeQN¯»§‰ÑÃ8øä¶s)\ÇxIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_pan_hand.png0000644000076500000240000000111214306600442023336 0ustar00primozstaff‰PNG  IHDR‘ß]ÁgAMA± üa cHRMz&€„ú€èu0ê`:˜pœºQ<bKGDÿ‡Ì¿ pHYs  šœtIMEá  <ÒF=IDATÓ-Ê;kQÐs¯ƒ (¢>1A!hDL¡ ÅŠ X*ØVþK›tÖ‚…$¥6‚h#¤ V0[F 6³»³¯™Ï&œö¤ðÕZL™IÙZ®¤íw¿.¤@XŽˆa¬Æ‹ˆ¨ãYDüŒ§BÈœÂ~ÓJðÇm[ŽWAfÎ,ÚDc„‰‰«nz™‹i ÉÔBc,,8jGþÏãŸo¸-4îk”®ï¥üÉK|Ä Â’ÚA‹jëEအGj0m(;múyùŒJሉÆD¥RêØÑöÁeù°×ÆÆ*=»::zÚ6 Ì¥|!òF_W©ÔÕWéúí½'öɳÎkÙÔÓ×7P¨üÕv-ÍËKKÞÚÒU©¬¸§@欓鮃=»ZZn¥K(`V“ºQ¡Ñhûîs:þÖ–Þr‚îš%tEXtdate:create2017-04-21T11:17:10+02:00‰ôYd%tEXtdate:modify2017-04-21T11:17:10+02:00ø©áØIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_redo.png0000644000076500000240000000053114306600442022523 0ustar00primozstaff‰PNG  IHDRbògAMA± üa cHRMz&€„ú€èu0ê`:˜pœºQ< PLTEÿÿÿ€€€€ÿÿÿ¨ýtRNS@æØfbKGDˆH pHYs  ÒÝ~ütIMEá  <ÒF(IDAT×c`ÀW 6†   €ˆàZÓZÀÀä€]­L9_b,%tEXtdate:create2017-04-21T11:17:10+02:00‰ôYd%tEXtdate:modify2017-04-21T11:17:10+02:00ø©áØIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_send.png0000644000076500000240000000044314306600442022525 0ustar00primozstaff‰PNG  IHDRóÿasRGB®ÎébKGDÿÿÿ ½§“ pHYs  šœtIMEÛ  !¨¢&£IDAT8Ëcüøþ%€‰B@”GŽÞøO¶GŽÞøäÈuoßmX aD˜b8ŒÄ>rDœÁÆF’aëf/F¬À4·w¼Ãë"tC˜°ÙŒ×KGž£x‡¬X8rä9<`É2`ëkk F² @ÖÌÀÀÀÀ‚® ²B«ÆöŽwšQ °±ÑÄáßëXmF1›r ¬(d$;%âÓŒ‘$7êLRs“¶ÁzIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_sort.png0000644000076500000240000000050214306600442022557 0ustar00primozstaff‰PNG  IHDRÐZüù pHYsMMÀ9`côIDAT8Ëc`ÀŒ^M6fxýÊ„áu—1Ã;9rЇ@ü_­îAƒàø6ÐÐ|K†BDjxõ‡A0ü ˆç3¼Ð…ô…€Bøƒ6Ã6J\ôÁ˜áÍ3†Šä†Ñy†WÉ– 9É ìŸF ¯2¼µ$'Özø$× )Ò{xK(Ãf’ ¦¨aoQ½öŽŸ(€)Ùˆ·ãd<$1P'€ áðð8LYZzõl!ÃsQ#†7- LJ‘‹`”BŽß…D6 ˜2¼2€ä¥×Ÿ)25"^åƒr=0EÃ•Ž£ b4ˆ‘=ÕIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_undo.png0000644000076500000240000000035414306600442022542 0ustar00primozstaff‰PNG  IHDRóÿasRGB®ÎébKGDÿÿÿ ½§“ pHYs  ÒÝ~ütIMEÛ  & ûlIDAT8Ëc|óò%€‰B0 `!¤@D|Åd~CCNf#†"â+þ¿y‰€iDCˆ¯ø3„ÈÐ Ãæ*˜<>I²ÝßdÅ.CDÄWüohÀ Ä8{Êtˆ¡È±À8šþ†5v%ZF™IEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_up3.png0000644000076500000240000000105314306600442022301 0ustar00primozstaff‰PNG  IHDRóÿaòIDAT8’ÏKTQÅ?÷ÝûšQ™'3*ÛÈåvHZ)%AD‹„Ú¶®MÛ6¹bˆþ‹ÚGR È,‚þŠÚ¸¨H)‰¡ÒÉ}ïþø¶˜ÍÍ]ŸÏ9ç¾pÌ«Ï6çê×^Ü?NcŽçk“µ†Ž5øÅ⻥ës‡éôQp:YkLœ®1š #es¡ ¯”ÚŸž´þk° §˜@ì"FOŽà“hº —KíÏO[Gôà´qv¼F¼бACd•Ú>QÓw)i¯.´ì§RÌzÀÄ1:Ö¨Hƒv„$-ã“hªÍ$í/ÏZ»õÙæ|z.mœO÷'ë”".ì–e¨š Ãfª¸=“´×ž·t}¶97Px061ªºYN^R”‡ÐARàCÀ„Ÿj“Îúz &ïlO•‹WMÞýö6ÿ-·;+QêC•±‡Õ›«dœÖ{\Añañ}góëÚÝ`» A~˜å—·–öyþFóžRª*€ÁyOîÎE ¡»üæÎã½úƒ‡„¿É¹uäÖá]„9 ?Ä@!¬säÖ’åqú2Á‡€s½äÌZ2kQ®×®çÃ>8·í#Dúh AðEð•±åÀ¸ˆ(‰ûû‚ýµ³úúÑ«a‚ôé„ìûÖÊ¿ú?(· ì«DÛIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_zoom.png0000644000076500000240000000060614306600442022561 0ustar00primozstaff‰PNG  IHDRíÝâRgAMA± üa cHRMz%€ƒùÿ€éu0ê`:˜o’_ÅFPLTEÿÿÿñï⬨™ÿÿÿÚštRNSv“Í8bKGDˆH pHYs  ÒÝ~ütIMEá  <ÒFQIDAT×mÍÑ À EQ´ ôÊêöß­Øè__ø8 / rR˜á€5r.0õ) ¹î,` þЪ5Æ*gôyF®¾ +aø ¯È mã˜ua%tEXtdate:create2017-04-21T11:17:10+02:00‰ôYd%tEXtdate:modify2017-04-21T11:17:10+02:00ø©áØIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/Dlg_zoom_reset.png0000644000076500000240000000053414306600442023763 0ustar00primozstaff‰PNG  IHDRµú7êgAMA± üa cHRMz%€ƒùÿ€éu0ê`:˜o’_ÅFbKGDÿ‡Ì¿ pHYs  ÒÝ~ütIMEá  <ÒFOIDAT(Ï¥PÁ 1 Ò Òî?V'ñ=Hó‰…ê# ¨ =Ú’ÿYÚ ¢(ž®TlEÕ[ræ]BÚ?p€fo, Z©Ð&IEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/chart.svg0000644000076500000240000000157414334703654022142 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/downgreenarrow.png0000644000076500000240000000163414306600442024054 0ustar00primozstaff‰PNG  IHDR+1ìÍgAMA± üa pHYstt+AEtEXtSoftwarePaint.NET v3.22·‘}IDAT8O¥”[HT[Ç¿µöÚ—±ÑnB$z*H Ï ¦a*žlìbS õ0],M}ˆl`º§Î,äœÐL5ËÊz¨¨èöõTô½DщÃ!*¢÷¿OÓ"ˆN—¿ý±öþÖo­µ¿ÅªT=ˆŸŸ"•$Û¶iX “­Ûd›ŒaFÒ½‘oH(AÂ`F"á' p´%ÉaAº­Ó[ûõ#šê›z>Ô>xë "×#¨½P‹à™ |'}ðözQØ[Oî~7´¨m@ƒº¢ ôû:Œ{Ì&Ìs&œ]NüÒšdÏþ;á*iK´ìäõÉ·+º*¾FõùjN°òøJE‹Ó™ƒ´Î4Ìí ê P?s¹ÉÜa™ËyJÂÙ ÇØ»2bäýFºµÌZ‘^•þpcßFÔ]¬Cp ˆò¾rw#·3HíNµ²¤—`.2W˜KqNÀ³iâcµÇòQX4ÚŠÈ2–!OçYe%BgCðð¡$Z‚üŽ|d¶gŽ®šZXt”é[ùéÑè6whò2bVQ½æø o+h•Юù;æ?œÀßçGi´…í…p·ºájsAü!@‡Yvdl‚v®íQ›§¼aµ›¶‘óséxo1%ð)iË‹ä½ó÷ø±¼s9µ-BÖ_Ypµ¸ ïÕA,mbš Ú!·7¤¬.ªÑ’¾,ý$Ÿ·*îFÁþ”+CÑ.àḚ\0wš –Fø¿îˆÙï„Ñà¤Z™öuéØW¹Dfů‹TÐX€â.`°1f5‹kX\Gp4ÄðD1OÅvµà›¤£I^Òd™ôN«˜ö$wßBäýž‡ÌÝ™P Ì­&ŒzëQ£-£J¡}»x$³”œ²\nIÜ”ø,;œ ÏN¸µV‡YåøWVé5Ôb¿O:ž]FÓõÕÆŸ36Ï|é®uCyÌ€õJÛd4S…Lø1éG¹H1ýÖ©Ô­iC³6$¬ ˆÙ?'=ïš5zzâæ¤;Åß›Y?ëבwÿ'~mEéø› -IEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/error.png0000644000076500000240000000122314306600442022134 0ustar00primozstaff‰PNG  IHDRóÿaZIDAT8}Ó¿‹UGÆñï;óÎ{öênvQØ«,(v–[ ZHH¬â±„@Š@ª”ô"”´i’!…@°Lo³Bš ½kn!Ü=3sÎySœ{MÀ†iæ3óÌ/á­ú|o/Ü>þ[Ké¶‹€;¸îÎ*çÇ¿/—ŸÝ{ùr˜Œþwaw÷ mlC@UQUbŒ¤9^.ïöîÍŸÞ_OßÁóùªÔ®!¬ûí­-fM³?;9¹<7{ôÇ›7'|q>?U°”03,%RJ¤u’;[[l˜í7«Õå¹Ù£øõ•+÷/ÎçwƒƒûÍ0Õwp ‚Ûgϲ‘Ò¾­V»AU?>³¹‰»Ó˜Ñ¬±M8ÆS @ßÓåÌÞ¹s4f·Ôkúk×H—.BÀEèEDé¤ÝYqæÙ34Üuw4%<f7oráÆ ÞWGÏŸSž>Å×祤Dä½x áä¶%¥Äà>N ª0 ¬<àχÇÈîø00t}×Ñ—BW+]ÎÌJAÖLˆ1‚;Sw¼ëèûž¾ëèJ¡–BÍ™.gJ)¨*ƒ;¡-å—¿^½·0 µŽ«•Bm[JÛRNNÆÖ¶¨*—B[ëÏñ‡Åâ·[M³B¸¾³¹IW+}­ôΙڶԜ©¥Ìø'F^ß»sxøeøq±xò‘Ùv¹þÁl6‚ M}ÎXJ,U'üÅé_øi±xò¡Ù˜d6 ˆc$Ĉ™q ¼xýúüïÞ¾¿zõ›ã'¾¾2wg¨}ÿëÃïÞÿ/*7+#Š!:IEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/icons/hamburger.svg0000644000076500000240000000070514440334174023003 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/help.svg0000644000076500000240000000217614334703654021770 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/information.png0000644000076500000240000000126314306600442023334 0ustar00primozstaff‰PNG  IHDRóÿazIDAT8“MHTQ†Ÿ{îu4ЛÁ´2 R’Ò¨6¡Ñ!EË,)" [´¨……í"(ˆ‚@¢(QÓ] ¢ä(Y6”…#þd£â8sïùZÜÉÔˆúàã;‹÷9ïÇË9 ëð­u¤f—©”¥›­ôYöØÀ žyKx°Ç'{çÊÙÓ‘;+•/ïÆÖ¢­¥U1 ¼u`:‘(†àÞ¤ÝßÞ¢‡zÎð¨âóïkŽ>,Zt]¾‰‰ˆ#"ZdpZäýwHäYP¤±[¤ö­HÒ5=±ºB“}—Ó‹Ê_tUx2 2A1`F â@Ô†Çí¨€8Pšc$·M®Úo/ΨUdœ¾¹+Å—“ Q 暴˜À„ ã1Û±Á1À¸²-ÙKFîYËZ–[²=¦P0¯awj wÖ€`˜à[žœ‚=–JLIŽi˜ŠºŽýaÏ€¡\¡i‚hÐj^ähTJzšûÖÓšZ¾ÅQü¢@Y.¬ˆ»:€ X.,’Áõ}R2¼}±'8þ6\gÃr§™ÊJ¹›)Þ ƒ=l0 ´„¾¥mž1½kwæ{3.4]᯵ ‰g¡a2ÕM=~ÝÕXiÐñèUwbþ@ëôú½$ù–€6\·¹/Κ>ÂÕúÎçNßÓƒ´]š˜ °»:ìM Yy‡*®.,Y債8Íþpot £S¡:­-øïËß`¶Ž×—UùEšGEN½áDË…¿‹Vñ9Ë:ÿ®½mXd_›hN>©ùxÃ~ƒÍRïŠxj¾tSþxï¿ù¬)Vä(%î§õbà<X¾©/tIEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/input-empty.svg0000644000076500000240000000152114334703654023324 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/input-partial.svg0000644000076500000240000000122414334703654023622 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/input.svg0000644000076500000240000000102414334703654022166 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/output-empty.svg0000644000076500000240000000146514334703654023534 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/output-partial.svg0000644000076500000240000000123314334703654024023 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/output.svg0000644000076500000240000000103414334703654022370 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/report.svg0000644000076500000240000000165514334703654022354 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/reset.svg0000644000076500000240000000540114334703654022154 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/upgreenarrow.png0000644000076500000240000000163614306600442023533 0ustar00primozstaff‰PNG  IHDR+1ìÍgAMA± üa pHYstt+AEtEXtSoftwarePaint.NET v3.22·‘}IDAT8O“íK”Y‡ïç<ç9Ï3¯"ŒSô"mØ0 jIÕdX–¢#™1í´Ù.á4ÍL¯¦E55Ma“¥¥‹i¾…C±Z»lT„ì²KE,ìm°Eî²,µû%©_Çü‘Õ‹óáæ¾¸¹ç¨4Ùq‘BÅJŽcYæ÷«6®Ž>šýÇ/cÿÒ_“5NV¯R²ÄFý»ìhöË/‚sÇÔ 6L›•9“µ}¸^M™Z@$gFf½Èß•¶‚AÛ$þcQí…˜óóä•de~¶cZdúè 1ï|° ÕÏ!"Æßl‡¶‡‚ªíÓä>RY5ó9‚Ž'KâK°4±y‡òÀ7pÐWÐêõgTǪ(¤ð–«åjaÆ×‹N¡ôl)ŸZŒÜx.ô°ÚJ me·œ~ŸxªÔñE'.£¹öõöŸÇ¥Õ«Qr¾ž¤î“nèõR\/Åû¤ø€Ó3´ƒÆ=ªg®ËËÈÉ}¼Ë{Ôû²f U—ªPÜQŒÂÖB¸[ÜR¢bRœœ °&s“uŒÒh·:ãýòµd¡r:ìÙïù7˜ ¢f°•½•XÞ¹íp·»¡$P³”¶HÚ&à­öä”çJŒ'h;½æJ2D¥Øº aÁhäJákaR”÷•ÃÛíE^g\Ý.PRÊ.Hº$½’þ‰[ë°µLù‡ÅõÔ¨š&&/#ÍXc¬ÍÙ™ó0šŠ¢ñf#¶ o?åGi¿ ®G׋y}ó@íRÔ#II†$×%Ãrß)F— æ¤ýO5¡(Æ©ª'+˜u?ÔBl$†ºu¨ªÅºÁu(é—Áõzàêq!{ {bÒqé¸ð¶dDrGòƒÜ÷ Ü÷·VͶßÙQá¥ô/Ó _ ¿:}÷4â?ÅÑp£áíÄ+T V èr . ÿªüy}ò‰ ©Po©à¿rðß8´Ĉ€¸&`é³ÀÑá|-šÍwÈßäê;æƒs³ŽoȨÍ@z8SwMEZC¬ûm°¶ÀrÌõ¸~J€ŸÑ!Z èíf˜.Xan³Ãr6 ™m™ÐšMß`TRž:åà³IEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/icons/visual-settings.svg0000644000076500000240000000557214334703654024204 0ustar00primozstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/icons/warning.png0000644000076500000240000000123014306600442022446 0ustar00primozstaff‰PNG  IHDRóÿa_IDAT8ÑÍKTaÇñïyžçÞ™{g Í|I3’f&gÌL2²Õ"¨6½ ôbD E¢h½@«BiQàB¢°?!Z´ *pÓ"0¨lSN¥3Þ™{ïÓ""':ËÃï|8œ5êÉeÒÑ Ï‡<}~¿V®fE3LF/ûlø¢×V¦¹]+§VkÎßc€5ëÆ¤¥ÕÞ醋Ÿ&Øö_Àø ¤©Ž»tõ(Â"ØŸ¨í}¦ÑãÎö3Lûæâ‡ˆë"ɪՃŽÎ½ ù'0=‚ë8ú†ä²,‡UrƒoÙ:øšØÄ˜Ý}8JݺSÞͨtvm?D§}ÞÏ-óác€¸.jAçó[ŽusvU`fŒ¤6æšä³HRcRž§I¥4¢4TœyDôõGÇqÿ†ú%ÓÝ&é ÄuI¥ 锆rû£ŒT¾£ ]fù˜:ƒ«”¾¤z¶ ŽeÀ‚ïkü¤Â—`a¾-àfÚÑWý½…8µ‹a²…IÿnÙjKž+xP\‚eX®"•ï8…lÇ¡ §ôÃÈ`Nf̾=Mb*T±‹ü,ÑT§È')l0Hl8Bµ´RË5{LÊç 4vgž8{{¡¼„-P øú¥Dë¾7X hmtV.o]ŸÒ«yгsLƒÇy•Ë`‹E(P ¨²6a¹yn=adinøãõH¥„[ØHêÝܘÝ5Ä‹@¤Ayˆ/\:ß²2[ÀZˆ-6Š¥zdaœ!G8m#êm„g#\al„"FlÖb‰‰¨eàÛrÈÔ/ÍLʼCÚ¾IEND®B`‚././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694782033.0 orange-widget-base-4.22.0/orangewidget/io.py0000644000076500000240000004607414501051121020147 0ustar00primozstaffimport os import sys import tempfile from collections import OrderedDict from typing import Union import numpy as np from AnyQt import QtGui, QtCore, QtSvg, QtWidgets from AnyQt.QtCore import QMarginsF, Qt, QRectF, QPoint, QRect, QSize from AnyQt.QtGui import QPalette from AnyQt.QtWidgets import ( QGraphicsScene, QGraphicsView, QApplication, QWidget ) from orangewidget.utils.matplotlib_export import scene_code try: from orangewidget.utils.webview import WebviewWidget except ImportError: WebviewWidget = None # This is needed just for type annotation try: from pyqtgraph import GraphicsItem except: class GraphicsItem: pass __all__ = [ "ImgFormat", "Compression", "PngFormat", "ClipboardFormat", "SvgFormat", "MatplotlibPDFFormat", "MatplotlibFormat", "PdfFormat", ] class Compression: """Supported compression extensions""" GZIP = '.gz' BZIP2 = '.bz2' XZ = '.xz' all = (GZIP, BZIP2, XZ) class _Registry(type): """Metaclass that registers subtypes.""" def __new__(mcs, name, bases, attrs): cls = type.__new__(mcs, name, bases, attrs) if not hasattr(cls, 'registry'): cls.registry = OrderedDict() else: cls.registry[name] = cls return cls def __iter__(cls): return iter(cls.registry) def __str__(cls): if cls in cls.registry.values(): return cls.__name__ return '{}({{{}}})'.format(cls.__name__, ', '.join(cls.registry)) def effective_background(scene: QGraphicsScene, view: QGraphicsView): background = scene.backgroundBrush() if background.style() != Qt.NoBrush: return background background = view.backgroundBrush() if background.style() != Qt.NoBrush: return background viewport = view.viewport() role = viewport.backgroundRole() if role != QPalette.NoRole: return viewport.palette().brush(role) return viewport.palette().brush(QPalette.Window) class classproperty(property): def __get__(self, instance, class_): return self.fget(class_) class ImgFormat(metaclass=_Registry): PRIORITY = sys.maxsize @staticmethod def _get_buffer(size, filename): raise NotImplementedError @staticmethod def _get_target(source): return QtCore.QRectF(0, 0, source.width(), source.height()) @classmethod def _setup_painter(cls, painter, object, source_rect, buffer): pass @staticmethod def _save_buffer(buffer, filename): raise NotImplementedError @staticmethod def _meta_data(buffer): meta_data = {} try: size = buffer.size() except AttributeError: pass else: meta_data["width"] = size.width() meta_data["height"] = size.height() try: meta_data["pixel_ratio"] = buffer.devicePixelRatio() except AttributeError: pass return meta_data @staticmethod def _get_exporter(): raise NotImplementedError @staticmethod def _export(self, exporter, filename): raise NotImplementedError @classmethod def write_image( cls, filename, object: Union[GraphicsItem, # via save_pyqtgraph QGraphicsScene, # via save_scene QGraphicsView, # via save_widget QWidget # via save_widget, but with different render ]): def get_scene_pixel_ratio(scene: QGraphicsScene): views = scene.views() if views: return views[0].devicePixelRatio() try: # It is unusual for scene not to be viewed, except in tests. # As a fallback, we get ratio for (any) screen return QApplication.primaryScreen().devicePixelRatio() except: # pylint: disable=broad-except # If there is no screen (in tests on headless server?) assume 1; # the worst that can happen is low resolution of images return 1 def save_pyqtgraph(): assert isinstance(object, GraphicsItem) exporter = cls._get_exporter() scene = object.scene() if scene is None: return cls._export(exporter(scene), filename) views = scene.views() if views: # preserve scene rect and background brush scenerect = scene.sceneRect() backgroundbrush = scene.backgroundBrush() try: view = scene.views()[0] scene.setSceneRect(view.sceneRect()) scene.setBackgroundBrush(effective_background(scene, view)) return cls._export(exporter(scene), filename) finally: # reset scene rect and background brush scene.setBackgroundBrush(backgroundbrush) scene.setSceneRect(scenerect) else: return cls._export(exporter(scene), filename) def save_scene(): assert isinstance(object, QGraphicsScene) ratio = get_scene_pixel_ratio(object) views = object.views() if not views: rect = object.itemsBoundingRect() return _render(rect, ratio, rect.size(), object) # Pick the first view. If there's a widget with multiple views that # cares which one is used, it must set graph_name to view, not scene view = views[0] rect = view.sceneRect() target_rect = view.mapFromScene(rect).boundingRect() source_rect = QRect( int(target_rect.x()), int(target_rect.y()), int(target_rect.width()), int(target_rect.height())) return _render(source_rect, ratio, target_rect.size(), view) def save_widget(): assert isinstance(object, QWidget) return _render(object.rect(), object.devicePixelRatio(), object.size(), object) def _render( source_rect: QRectF, pixel_ratio: float, size: QSize, renderer: Union[QGraphicsScene, QGraphicsView, QWidget]): buffer_size = size + type(size)(30, 30) try: buffer = cls._get_buffer(buffer_size, filename, pixel_ratio) except TypeError: # backward compatibility (with what?) buffer = cls._get_buffer(buffer_size, filename) painter = QtGui.QPainter() painter.begin(buffer) try: painter.setRenderHint(QtGui.QPainter.Antialiasing) if QtCore.QT_VERSION >= 0x050D00: painter.setRenderHint(QtGui.QPainter.LosslessImageRendering) cls._setup_painter( painter, renderer, QRectF(0, 0, buffer_size.width(), buffer_size.height()), buffer) if isinstance(renderer, (QGraphicsView, QGraphicsScene)): renderer.render(painter, QRectF(15, 15, size.width(), size.height()), source_rect) else: assert isinstance(object, QWidget) renderer.render(painter, QPoint(15, 15)) finally: # In case of exception, end painting so that we get an exception # not a core dump painter.end() cls._save_buffer(buffer, filename) return cls._meta_data(buffer) if isinstance(object, GraphicsItem): return save_pyqtgraph() elif isinstance(object, QGraphicsScene): return save_scene() elif isinstance(object, QWidget): # this includes QGraphicsView return save_widget() else: raise TypeError(f"{cls.__name__} " f"cannot imagine {type(object).__name__}") @classmethod def write(cls, filename, scene): if type(scene) == dict: scene = scene['scene'] return cls.write_image(filename, scene) @classproperty def img_writers(cls): # type: () -> Mapping[str, Type[ImgFormat]] formats = OrderedDict() for format in sorted(cls.registry.values(), key=lambda x: x.PRIORITY): for ext in getattr(format, 'EXTENSIONS', []): # Only adds if not yet registered formats.setdefault(ext, format) return formats graph_writers = img_writers @classproperty def formats(cls): return cls.registry.values() class PngFormat(ImgFormat): EXTENSIONS = ('.png',) DESCRIPTION = 'Portable Network Graphics' PRIORITY = 50 @staticmethod def _get_buffer(size, filename, ratio=1): img = QtGui.QPixmap(int(size.width() * ratio), int(size.height() * ratio)) img.setDevicePixelRatio(ratio) return img @classmethod def _setup_painter(cls, painter, object, source_rect, buffer): if isinstance(object, (QGraphicsScene, QGraphicsView)): brush = object.backgroundBrush() if brush.style() == QtCore.Qt.NoBrush: brush = QtGui.QBrush(object.palette().color(QtGui.QPalette.Base)) else: brush = QtGui.QBrush(QtCore.Qt.white) painter.fillRect(source_rect, brush) @staticmethod def _save_buffer(buffer, filename): image = buffer.toImage() dpm = int(2835 * image.devicePixelRatio()) image.setDotsPerMeterX(dpm) image.setDotsPerMeterY(dpm) image.save(filename, "png") @staticmethod def _get_exporter(): from pyqtgraph.exporters.ImageExporter import ImageExporter from pyqtgraph import functions as fn # Use devicePixelRatio class PngExporter(ImageExporter): def __init__(self, item): super().__init__(item) if isinstance(item, QGraphicsScene): self.ratio = item.views()[0].devicePixelRatio() else: # Let's hope it's a view or another QWidget self.ratio = item.devicePixelRatio() # Copied verbatim from super; # changes are in three lines that define self.png # and lines related to setDotsPerMeterX after painter.end() def export(self, fileName=None, toBytes=False, copy=False): if fileName is None and not toBytes and not copy: filter = self.getSupportedImageFormats() self.fileSaveDialog(filter=filter) return w = int(self.params['width']) h = int(self.params['height']) if w == 0 or h == 0: raise Exception( "Cannot export image with size=0 (requested " "export size is %dx%d)" % (w, h)) targetRect = QtCore.QRect(0, 0, w, h) sourceRect = self.getSourceRect() self.png = QtGui.QImage( int(w * self.ratio), int(h * self.ratio), QtGui.QImage.Format.Format_ARGB32) self.png.fill(self.params['background']) self.png.setDevicePixelRatio(self.ratio) ## set resolution of image: origTargetRect = self.getTargetRect() resolutionScale = targetRect.width() / origTargetRect.width() # self.png.setDotsPerMeterX(self.png.dotsPerMeterX() * resolutionScale) # self.png.setDotsPerMeterY(self.png.dotsPerMeterY() * resolutionScale) painter = QtGui.QPainter(self.png) # dtr = painter.deviceTransform() try: self.setExportMode(True, { 'antialias': self.params['antialias'], 'background': self.params['background'], 'painter': painter, 'resolutionScale': resolutionScale}) painter.setRenderHint( QtGui.QPainter.RenderHint.Antialiasing, self.params['antialias']) self.getScene().render(painter, QtCore.QRectF(targetRect), QtCore.QRectF(sourceRect)) finally: self.setExportMode(False) painter.end() # setDotsPerMeter* after painting avoids missized axes problem dpm = int(2835 * self.ratio) self.png.setDotsPerMeterX(dpm) self.png.setDotsPerMeterY(dpm) if self.params['invertValue']: bg = fn.ndarray_from_qimage(self.png) if sys.byteorder == 'little': cv = slice(0, 3) else: cv = slice(1, 4) mn = bg[..., cv].min(axis=2) mx = bg[..., cv].max(axis=2) d = (255 - mx) - mn bg[..., cv] += d[..., np.newaxis] if copy: QtWidgets.QApplication.clipboard().setImage(self.png) elif toBytes: return self.png else: return self.png.save(fileName) return PngExporter @classmethod def _export(cls, exporter, filename): buffer = exporter.export(toBytes=True) buffer.save(filename, "png") return cls._meta_data(buffer) class ClipboardFormat(PngFormat): EXTENSIONS = () DESCRIPTION = 'System Clipboard' PRIORITY = 50 @classmethod def _save_buffer(cls, buffer, _): meta_data = cls._meta_data(buffer) image = buffer.toImage() if meta_data is not None: ratio = meta_data.get("pixel_ratio", 1) dpm = int(2835 * ratio) image.setDotsPerMeterX(dpm) image.setDotsPerMeterY(dpm) QApplication.clipboard().setImage(image) @staticmethod def _export(exporter, _): exporter.export(copy=True) class SvgFormat(ImgFormat): EXTENSIONS = ('.svg',) DESCRIPTION = 'Scalable Vector Graphics' PRIORITY = 100 @staticmethod def _get_buffer(size, filename): buffer = QtSvg.QSvgGenerator() buffer.setResolution(int(QApplication.primaryScreen().logicalDotsPerInch())) buffer.setFileName(filename) buffer.setViewBox(QtCore.QRectF(0, 0, size.width(), size.height())) return buffer @staticmethod def _save_buffer(buffer, filename): dev = buffer.outputDevice() if dev is not None: dev.flush() pass @staticmethod def _get_exporter(): from pyqtgraph.exporters.SVGExporter import SVGExporter return SVGExporter @staticmethod def _export(exporter, filename): if isinstance(exporter.item, QGraphicsScene): scene = exporter.item params = exporter.parameters() brush = effective_background(scene, scene.views()[0]) params.param("background").setValue(brush.color()) exporter.export(filename) @classmethod def write_image(cls, filename, scene): # WebviewWidget exposes its SVG contents more directly; # no need to go via QPainter if we can avoid it svg = None if WebviewWidget is not None and isinstance(scene, WebviewWidget): try: svg = scene.svg() except (ValueError, IOError): pass if svg is None: super().write_image(filename, scene) svg = open(filename).read() svg = svg.replace( "= 0x050C00: # Qt 5.12+ class PdfFormat(ImgFormat): EXTENSIONS = ('.pdf', ) DESCRIPTION = 'Portable Document Format' PRIORITY = 110 @staticmethod def _get_buffer(size, filename): buffer = QtGui.QPdfWriter(filename) dpi = int(QApplication.primaryScreen().logicalDotsPerInch()) buffer.setResolution(dpi) buffer.setPageMargins(QMarginsF(0, 0, 0, 0)) pagesize = QtCore.QSizeF(size.width(), size.height()) / dpi * 25.4 buffer.setPageSize(QtGui.QPageSize(pagesize, QtGui.QPageSize.Millimeter)) return buffer @staticmethod def _save_buffer(buffer, filename): pass @staticmethod def _get_exporter(): from orangewidget.utils.PDFExporter import PDFExporter return PDFExporter @staticmethod def _export(exporter, filename): exporter.export(filename) else: # older Qt version have PdfWriter bugs and are handled through SVG class PdfFormat(ImgFormat): EXTENSIONS = ('.pdf', ) DESCRIPTION = 'Portable Document Format' PRIORITY = 110 @classmethod def write_image(cls, filename, scene): # export via svg to temp file then print that # NOTE: can't use NamedTemporaryFile with delete = True # (see https://bugs.python.org/issue14243) fd, tmpname = tempfile.mkstemp(suffix=".svg") os.close(fd) try: SvgFormat.write_image(tmpname, scene) with open(tmpname, "rb") as f: svgcontents = f.read() finally: os.unlink(tmpname) svgrend = QtSvg.QSvgRenderer(QtCore.QByteArray(svgcontents)) vbox = svgrend.viewBox() if not vbox.isValid(): size = svgrend.defaultSize() else: size = vbox.size() writer = QtGui.QPdfWriter(filename) pagesize = QtGui.QPageSize(QtCore.QSizeF(size) * 0.282, QtGui.QPageSize.Millimeter) writer.setPageSize(pagesize) painter = QtGui.QPainter(writer) svgrend.render(painter) painter.end() del svgrend del painter ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000034�00000000000�010212� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������28 mtime=1694782192.8504317 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/report/������������������������������������������������������0000755�0000765�0000024�00000000000�14501051361�020474� 5����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1662714146.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/report/__init__.py�������������������������������������������0000644�0000765�0000024�00000000172�14306600442�022610� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# The package is pulling names from modules with defined __all__ # pylint: disable=wildcard-import from .report import * ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000034�00000000000�010212� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������28 mtime=1694782192.8510106 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/report/icons/������������������������������������������������0000755�0000765�0000024�00000000000�14501051361�021607� 5����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1662714146.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/report/icons/delete.svg��������������������������������������0000644�0000765�0000024�00000010264�14306600442�023600� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������ image/svg+xml ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1662714146.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/report/icons/scheme.svg��������������������������������������0000644�0000765�0000024�00000021171�14306600442�023601� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������ �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1668515756.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/report/index.html��������������������������������������������0000644�0000765�0000024�00000005314�14334703654�022511� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������ Orange Report ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/report/owreport.py0000644000076500000240000004225314334703654022752 0ustar00primozstaffimport os import io import logging import traceback import warnings import pickle from collections import OrderedDict from enum import IntEnum from typing import Optional import pkg_resources from AnyQt.QtCore import Qt, QObject, pyqtSlot, QSize from AnyQt.QtGui import QIcon, QCursor, QStandardItemModel, QStandardItem from AnyQt.QtWidgets import ( QApplication, QDialog, QFileDialog, QTableView, QHeaderView, QMessageBox) from AnyQt.QtPrintSupport import QPrinter, QPrintDialog from orangewidget import gui from orangewidget.widget import OWBaseWidget from orangewidget.settings import Setting # Importing WebviewWidget can fail if neither QWebKit or QWebEngine # are available try: from orangewidget.utils.webview import WebviewWidget except ImportError: # pragma: no cover WebviewWidget = None HAVE_REPORT = False else: HAVE_REPORT = True log = logging.getLogger(__name__) class Column(IntEnum): item = 0 remove = 1 scheme = 2 class ReportItem(QStandardItem): def __init__(self, name, html, scheme, module, icon_name, comment=""): self.name = name self.html = html self.scheme = scheme self.module = module self.icon_name = icon_name self.comment = comment try: path = pkg_resources.resource_filename(module, icon_name) except ImportError: path = "" except ValueError: path = "" icon = QIcon(path) self.id = id(icon) super().__init__(icon, name) def __getnewargs__(self): return (self.name, self.html, self.scheme, self.module, self.icon_name, self.comment) class ReportItemModel(QStandardItemModel): def __init__(self, rows, columns, parent=None): super().__init__(rows, columns, parent) def add_item(self, item): row = self.rowCount() self.setItem(row, Column.item, item) self.setItem(row, Column.remove, self._icon_item("Remove")) self.setItem(row, Column.scheme, self._icon_item("Open Scheme")) def get_item_by_id(self, item_id): for i in range(self.rowCount()): item = self.item(i) if str(item.id) == item_id: return item return None @staticmethod def _icon_item(tooltip): item = QStandardItem() item.setEditable(False) item.setToolTip(tooltip) return item class ReportTable(QTableView): def __init__(self, parent): super().__init__(parent) self._icon_remove = QIcon(pkg_resources.resource_filename( __name__, "icons/delete.svg")) self._icon_scheme = QIcon(pkg_resources.resource_filename( __name__, "icons/scheme.svg")) def mouseMoveEvent(self, event): self._clear_icons() self._repaint(self.indexAt(event.pos())) def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton: super().mouseReleaseEvent(event) self._clear_icons() self._repaint(self.indexAt(event.pos())) def leaveEvent(self, _): self._clear_icons() def _repaint(self, index): row, column = index.row(), index.column() if column in (Column.remove, Column.scheme): self.setCursor(QCursor(Qt.PointingHandCursor)) else: self.setCursor(QCursor(Qt.ArrowCursor)) if row >= 0: self.model().item(row, Column.remove).setIcon(self._icon_remove) self.model().item(row, Column.scheme).setIcon(self._icon_scheme) def _clear_icons(self): model = self.model() for i in range(model.rowCount()): model.item(i, Column.remove).setIcon(QIcon()) model.item(i, Column.scheme).setIcon(QIcon()) class OWReport(OWBaseWidget): name = "Report" save_dir = Setting("") open_dir = Setting("") def __init__(self): super().__init__() self._setup_ui_() self.report_changed = False self.have_report_warning_shown = False index_file = pkg_resources.resource_filename(__name__, "index.html") with open(index_file, "r") as f: self.report_html_template = f.read() def _setup_ui_(self): self.table_model = ReportItemModel(0, len(Column.__members__)) self.table = ReportTable(self.controlArea) self.table.setModel(self.table_model) self.table.setShowGrid(False) self.table.setSelectionBehavior(QTableView.SelectRows) self.table.setSelectionMode(QTableView.SingleSelection) self.table.setWordWrap(False) self.table.setMouseTracking(True) self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed) self.table.verticalHeader().setDefaultSectionSize(20) self.table.verticalHeader().setVisible(False) self.table.horizontalHeader().setVisible(False) self.table.setFixedWidth(250) self.table.setColumnWidth(Column.item, 200) self.table.setColumnWidth(Column.remove, 23) self.table.setColumnWidth(Column.scheme, 25) self.table.clicked.connect(self._table_clicked) self.table.selectionModel().selectionChanged.connect( self._table_selection_changed) self.controlArea.layout().addWidget(self.table) self.last_scheme = None self.scheme_button = gui.button( self.controlArea, self, "Back to Last Scheme", callback=self._show_last_scheme ) box = gui.hBox(self.controlArea) box.setContentsMargins(-6, 0, -6, 0) self.save_button = gui.button( box, self, "Save", callback=self.save_report, disabled=True ) self.print_button = gui.button( box, self, "Print", callback=self._print_report, disabled=True ) class PyBridge(QObject): @pyqtSlot(str) def _select_item(myself, item_id): item = self.table_model.get_item_by_id(item_id) self.table.selectRow(self.table_model.indexFromItem(item).row()) self._change_selected_item(item) @pyqtSlot(str, str) def _add_comment(myself, item_id, value): item = self.table_model.get_item_by_id(item_id) item.comment = value self.report_changed = True if WebviewWidget is not None: self.report_view = WebviewWidget(self.mainArea, bridge=PyBridge(self)) self.mainArea.layout().addWidget(self.report_view) else: self.report_view = None def sizeHint(self): return QSize(850, 500) def _table_clicked(self, index): if index.column() == Column.remove: self._remove_item(index.row()) indexes = self.table.selectionModel().selectedIndexes() if indexes: item = self.table_model.item(indexes[0].row()) self._scroll_to_item(item) self._change_selected_item(item) if index.column() == Column.scheme: self._show_scheme(index.row()) def _table_selection_changed(self, new_selection, _): if new_selection.indexes(): item = self.table_model.item(new_selection.indexes()[0].row()) self._scroll_to_item(item) self._change_selected_item(item) def _remove_item(self, row): self.table_model.removeRow(row) self._empty_report() self.report_changed = True self._build_html() def clear(self): self.table_model.clear() self._empty_report() self.report_changed = True self._build_html() def _add_item(self, widget): name = widget.get_widget_name_extension() name = "{} - {}".format(widget.name, name) if name else widget.name item = ReportItem(name, widget.report_html, self._get_scheme(), widget.__module__, widget.icon) self.table_model.add_item(item) self._empty_report() self.report_changed = True return item def _empty_report(self): # disable save and print if no reports self.save_button.setEnabled(self.table_model.rowCount()) self.print_button.setEnabled(self.table_model.rowCount()) def _build_html(self, selected_id=None): if not self.report_view: return html = self.report_html_template if selected_id is not None: onload = f"(function (id) {{" \ f" setSelectedId(id); scrollToId(id); " \ f"}}" \ f"(\"{selected_id}\"));" \ f"" html += f"" else: html += "" for i in range(self.table_model.rowCount()): item = self.table_model.item(i) html += "
{}
" \ "
".format(item.id, item.html, item.comment) html += "" self.report_view.setHtml(html) def _scroll_to_item(self, item): if not self.report_view: return self.report_view.runJavaScript( f"scrollToId('{item.id}')", lambda res: log.debug("scrollToId returned %s", res) ) def _change_selected_item(self, item): if not self.report_view: return self.report_view.runJavaScript( f"setSelectedId('{item.id}');", lambda res: log.debug("setSelectedId returned %s", res) ) self.report_changed = True def make_report(self, widget): item = self._add_item(widget) self._build_html(item.id) self.table.selectionModel().selectionChanged.disconnect( self._table_selection_changed ) self.table.selectRow(self.table_model.rowCount() - 1) self.table.selectionModel().selectionChanged.connect( self._table_selection_changed ) def _get_scheme(self): canvas = self.get_canvas_instance() if canvas is None: return None scheme = canvas.current_document().scheme() return self._get_scheme_str(scheme) def _get_scheme_str(self, scheme): buffer = io.BytesIO() scheme.save_to(buffer, pickle_fallback=True) return buffer.getvalue().decode("utf-8") def _show_scheme(self, row): scheme = self.table_model.item(row).scheme canvas = self.get_canvas_instance() if canvas is None: return document = canvas.current_document() if document.isModifiedStrict(): self.last_scheme = self._get_scheme_str(document.scheme()) self._load_scheme(scheme) def _show_last_scheme(self): if self.last_scheme: self._load_scheme(self.last_scheme) def _load_scheme(self, contents): # forcibly load the contents into the associated CanvasMainWindow # instance if one exists. Preserve `self` as the designated report. canvas = self.get_canvas_instance() if canvas is not None: document = canvas.current_document() scheme = document.scheme() # Clear the undo stack as it will no longer apply to the new # workflow. document.undoStack().clear() scheme.clear() scheme.load_from(io.StringIO(contents)) def save_report(self): """Save report""" formats = (('HTML (*.html)', '.html'), ('PDF (*.pdf)', '.pdf')) if self.report_view else tuple() formats = formats + (('Report (*.report)', '.report'),) formats = OrderedDict(formats) filename, selected_format = QFileDialog.getSaveFileName( self, "Save Report", self.save_dir, ';;'.join(formats.keys())) if not filename: return QDialog.Rejected # Set appropriate extension if not set by the user expect_ext = formats[selected_format] if not filename.endswith(expect_ext): filename += expect_ext self.save_dir = os.path.dirname(filename) self.saveSettings() _, extension = os.path.splitext(filename) if extension == ".pdf": printer = QPrinter() printer.setPageSize(QPrinter.A4) printer.setOutputFormat(QPrinter.PdfFormat) printer.setOutputFileName(filename) self._print_to_printer(printer) elif extension == ".report": self.save(filename) else: def save_html(contents): try: with open(filename, "w", encoding="utf-8") as f: f.write(contents) except PermissionError: self.permission_error(filename) if self.report_view: save_html(self.report_view.html()) self.report_changed = False return QDialog.Accepted def _print_to_printer(self, printer): if not self.report_view: return filename = printer.outputFileName() if filename: try: # QtWebEngine return self.report_view.page().printToPdf(filename) except AttributeError: try: # QtWebKit return self.report_view.print_(printer) except AttributeError: # QtWebEngine 5.6 pass # Fallback to printing widget as an image self.report_view.render(printer) def _print_report(self): printer = QPrinter() print_dialog = QPrintDialog(printer, self) print_dialog.setWindowTitle("Print report") if print_dialog.exec() != QDialog.Accepted: return self._print_to_printer(printer) def save(self, filename): attributes = {} for key in ('last_scheme', 'open_dir'): attributes[key] = getattr(self, key, None) items = [self.table_model.item(i) for i in range(self.table_model.rowCount())] report = dict(__version__=1, attributes=attributes, items=items) try: with open(filename, 'wb') as f: pickle.dump(report, f) except PermissionError: self.permission_error(filename) @classmethod def load(cls, filename): with open(filename, 'rb') as f: report = pickle.load(f) if not isinstance(report, dict): return report self = cls() self.__dict__.update(report['attributes']) for item in report['items']: self.table_model.add_item( ReportItem(item.name, item.html, item.scheme, item.module, item.icon_name, item.comment) ) return self def permission_error(self, filename): log.error("PermissionError when trying to write report.", exc_info=True) mb = QMessageBox( self, icon=QMessageBox.Critical, windowTitle=self.tr("Error"), text=self.tr("Permission error when trying to write report."), informativeText=self.tr("Permission error occurred " "while saving '{}'.").format(filename), detailedText=traceback.format_exc(limit=20) ) mb.setWindowModality(Qt.WindowModal) mb.setAttribute(Qt.WA_DeleteOnClose) mb.exec() def is_empty(self): return not self.table_model.rowCount() def is_changed(self): return self.report_changed @staticmethod def set_instance(report): warnings.warn( "OWReport.set_instance is deprecated", DeprecationWarning, stacklevel=2 ) app_inst = QApplication.instance() app_inst._report_window = report @staticmethod def get_instance(): warnings.warn( "OWReport.get_instance is deprecated", DeprecationWarning, stacklevel=2 ) app_inst = QApplication.instance() if not hasattr(app_inst, "_report_window"): report = OWReport() app_inst._report_window = report return app_inst._report_window def get_canvas_instance(self): # type: () -> Optional[CanvasMainWindow] """ Return a CanvasMainWindow instance to which this report is attached. Return None if not associated with any window. Returns ------- window : Optional[CanvasMainWindow] """ try: from orangewidget.workflow.mainwindow import OWCanvasMainWindow except ImportError: return None # Run up the parent/window chain parent = self.parent() if parent is not None: window = parent.window() if isinstance(window, OWCanvasMainWindow): return window def copy_to_clipboard(self): if self.report_view: self.report_view.triggerPageAction(self.report_view.page().Copy) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/report/report.py0000644000076500000240000006154014440334174022377 0ustar00primozstaffimport itertools import math import time import warnings from collections.abc import Iterable from typing import Optional from AnyQt.QtCore import ( Qt, QAbstractItemModel, QByteArray, QBuffer, QIODevice, QLocale, QSize) from AnyQt.QtGui import QColor, QBrush, QIcon, QPalette from AnyQt.QtWidgets import \ QGraphicsScene, QTableView, QMessageBox, QGraphicsWidget, QGraphicsView from orangewidget.io import PngFormat from orangewidget.utils import getdeepattr __all__ = ["Report", "bool_str", "colored_square", "plural", "plural_w", "clip_string", "clipped_list", "get_html_img", "get_html_section", "get_html_subsection", "list_legend", "render_items", "render_items_vert"] def try_(func, default=None): """Try return the result of func, else return default.""" try: return func() except Exception: return default class Report: """ A class that adds report-related methods to the widget. """ report_html = "" name = "" # Report view. The canvas framework will override this when it needs to # route reports to a specific window. # `friend class WidgetsScheme` __report_view = None # type: Optional[Callable[[], OWReport]] def _get_designated_report_view(self): # OWReport is a Report from orangewidget.report.owreport import OWReport if self.__report_view is not None: return self.__report_view() else: return OWReport.get_instance() def show_report(self): """ Raise the report window. """ self.create_report_html() from orangewidget.report.owreport import HAVE_REPORT report = self._get_designated_report_view() if not HAVE_REPORT and not report.have_report_warning_shown: QMessageBox.critical( None, "Missing Component", "Orange can not display reports, because your installation " "contains neither WebEngine nor WebKit.\n\n" "If you installed Orange with conda or pip, try using another " "PyQt distribution. " "If you installed Orange with a standard installer, please " "report this bug." ) report.have_report_warning_shown = True # Should really have a signal `report_ready` or similar to decouple # the implementations. report.make_report(self) report.show() report.raise_() def get_widget_name_extension(self): """ Return the text that is added to the section name in the report. For instance, the Distribution widget adds the name of the attribute whose distribution is shown. :return: str or None """ return None def create_report_html(self): """ Start a new section in report and call :obj:`send_report` method to add content.""" self.report_html = '
' self.report_html += get_html_section(self.name) self.report_html += '
\n' self.send_report() self.report_html += '
\n\n' @staticmethod def _fix_args(name, items): if items is None: return "", name else: return name, items def report_items(self, name, items=None): """ Add a sequence of pairs or a dictionary as a HTML list to report. The first argument, `name` can be omitted. :param name: report section name (can be omitted) :type name: str or tuple or dict :param items: a sequence of items :type items: list or tuple or dict """ name, items = self._fix_args(name, items) self.report_name(name) self.report_html += render_items(items) def report_name(self, name): """ Add a section name to the report""" if name != "": self.report_html += get_html_subsection(name) def report_plot(self, name=None, plot=None): """ Add a plot to the report. Both arguments can be omitted. - `report_plot("graph name", self.plotView)` reports plot `self.plotView` with name `"graph name"` - `report_plot(self.plotView) reports plot without name - `report_plot()` reports plot stored in attribute whose name is taken from `self.graph_name` - `report_plot("graph name")` reports plot stored in attribute whose name is taken from `self.graph_name` :param name: report section name (can be omitted) :type name: str or tuple or dict :param plot: plot widget :type plot: QGraphicsScene or pyqtgraph.PlotItem or pyqtgraph.PlotWidget or pyqtgraph.GraphicsWidget. If omitted, the name of the attribute storing the graph is taken from `self.graph_name` """ if not (isinstance(name, str) and plot is None): name, plot = self._fix_args(name, plot) from pyqtgraph import PlotWidget, PlotItem, GraphicsWidget, GraphicsView try: from orangewidget.utils.webview import WebviewWidget except ImportError: WebviewWidget = None self.report_name(name) if plot is None: plot = getdeepattr(self, self.graph_name) if isinstance(plot, (QGraphicsScene, PlotItem)): self.report_html += get_html_img(plot) elif isinstance(plot, PlotWidget): self.report_html += get_html_img(plot.plotItem) elif isinstance(plot, QGraphicsWidget): self.report_html += get_html_img(plot.scene()) elif isinstance(plot, QGraphicsView): self.report_html += get_html_img(plot) elif WebviewWidget is not None and isinstance(plot, WebviewWidget): try: svg = plot.svg() except (IndexError, ValueError): svg = plot.html() self.report_html += svg # noinspection PyBroadException def report_table(self, name, table=None, header_rows=0, header_columns=0, num_format=None, indicate_selection=True): """ Add content of a table to the report. The method accepts different kinds of two-dimensional data, including Qt's views and models. The first argument, `name` can be omitted if other arguments (except `table`) are passed as keyword arguments. :param name: name of the section :type name: str :param table: table to be reported :type table: QAbstractItemModel or QStandardItemModel or two-dimensional list or any object with method `model()` that returns one of the above :param header_rows: the number of rows that are marked as header rows :type header_rows: int :param header_columns: the number of columns that are marked as header columns :type header_columns: int :param num_format: numeric format, e.g. `{:.3}` """ row_limit = 100 name, table = self._fix_args(name, table) join = "".join def report_abstract_model(model, view=None): columns = [i for i in range(model.columnCount()) if not view or not view.isColumnHidden(i)] rows = [i for i in range(model.rowCount()) if not view or not view.isRowHidden(i)] has_horizontal_header = (try_(lambda: not view.horizontalHeader().isHidden()) or try_(lambda: not view.header().isHidden())) has_vertical_header = try_(lambda: not view.verticalHeader().isHidden()) if view is not None: opts = view.viewOptions() decoration_size = QSize(opts.decorationSize) else: decoration_size = QSize(16, 16) def item_html(row, col): def data(role=Qt.DisplayRole, orientation=Qt.Horizontal if row is None else Qt.Vertical): if row is None or col is None: return model.headerData(col if row is None else row, orientation, role) data_ = model.data(model.index(row, col), role) if isinstance(data_, QGraphicsScene): data_ = get_html_img( data_, max_height=view.verticalHeader().defaultSectionSize() ) elif isinstance(data_, QIcon): data_ = get_icon_html(data_, size=decoration_size) + " " return data_ value = data() if view is not None and col is not None: delegate = view.itemDelegateForColumn(col) if delegate is None: delegate = view.itemDelegate() if hasattr(delegate, "displayText"): value = delegate.displayText(value, QLocale()) value = value or "" decoration = data(role=Qt.DecorationRole) or '' selected = (view.selectionModel().isSelected(model.index(row, col)) if view and row is not None and col is not None else False) if indicate_selection and selected: report = self._get_designated_report_view() fgcolor = report.palette().color( QPalette.ColorGroup.Active, QPalette.ColorRole.HighlightedText ).name() bgcolor = report.palette().color( QPalette.ColorGroup.Active, QPalette.ColorRole.Highlight ).name() else: fgcolor = data(Qt.ForegroundRole) if isinstance(fgcolor, (QBrush, QColor)): fgcolor = QBrush(fgcolor).color().name() else: fgcolor = 'black' bgcolor = data(Qt.BackgroundRole) if isinstance(bgcolor, (QBrush, QColor)): bgcolor = QBrush(bgcolor).color().name() if bgcolor.lower() == '#ffffff': bgcolor = 'transparent' else: bgcolor = 'transparent' font = data(Qt.FontRole) weight = 'font-weight: bold; ' if font and font.bold() else '' alignment = data(Qt.TextAlignmentRole) or Qt.AlignLeft halign = ('left' if alignment & Qt.AlignLeft else 'right' if alignment & Qt.AlignRight else 'center') valign = ('top' if alignment & Qt.AlignTop else 'bottom' if alignment & Qt.AlignBottom else 'middle') style = 'style="' \ f'color:{fgcolor}; background:{bgcolor}; {weight}' \ f'text-align:{halign}; vertical-align:{valign};"' tag = 'th' if row is None or col is None else 'td' return f'<{tag} {style}>{decoration}{value}\n' stream = [] if has_horizontal_header: stream.append('') if has_vertical_header: stream.append('') stream.extend(item_html(None, col) for col in columns) stream.append('') for row in rows[:row_limit]: stream.append('') if has_vertical_header: stream.append(item_html(row, None)) stream.extend(item_html(row, col) for col in columns) stream.append('') return ''.join(stream) if num_format: def fmtnum(s): try: return num_format.format(float(s)) except: return s else: def fmtnum(s): return s def report_list(data, header_rows=header_rows, header_columns=header_columns): cells = ["{}", "{}"] return join(" \n {}\n".format( join(cells[rowi < header_rows or coli < header_columns] .format(fmtnum(elm)) for coli, elm in enumerate(row)) ) for rowi, row in zip(range(row_limit + header_rows), data)) self.report_name(name) n_hidden_rows, n_cols = 0, 1 if isinstance(table, QTableView): body = report_abstract_model(table.model(), table) n_hidden_rows = table.model().rowCount() - row_limit n_cols = table.model().columnCount() elif isinstance(table, QAbstractItemModel): body = report_abstract_model(table) n_hidden_rows = table.rowCount() - row_limit n_cols = table.columnCount() elif isinstance(table, Iterable): body = report_list(table, header_rows, header_columns) table = list(table) n_hidden_rows = len(table) - row_limit if len(table) and isinstance(table[0], Iterable): n_cols = len(table[0]) else: body = None if n_hidden_rows > 0: body += """+ {} more """.format(n_cols, n_hidden_rows) if body: self.report_html += "\n" + body + "
" # noinspection PyBroadException def report_list(self, name, data=None, limit=1000): """ Add a list to the report. The method accepts different kinds of one-dimensional data, including Qt's views and models. The first argument, `name` can be omitted. :param name: name of the section :type name: str :param data: table to be reported :type data: QAbstractItemModel or any object with method `model()` that returns QAbstractItemModel :param limit: the maximal number of reported items (default: 1000) :type limit: int """ name, data = self._fix_args(name, data) def report_abstract_model(model): content = (model.data(model.index(row, 0)) for row in range(model.rowCount())) return clipped_list(content, limit, less_lookups=True) self.report_name(name) try: model = data.model() except: model = None if isinstance(model, QAbstractItemModel): txt = report_abstract_model(model) else: txt = "" self.report_html += txt def report_paragraph(self, name, text=None): """ Add a paragraph to the report. The first argument, `name` can be omitted. :param name: name of the section :type name: str :param text: text of the paragraph :type text: str """ name, text = self._fix_args(name, text) self.report_name(name) self.report_html += "

{}

".format(text) def report_caption(self, text): """ Add caption to the report. """ self.report_html += "

{}

".format(text) def report_raw(self, name, html=None): """ Add raw HTML to the report. """ name, html = self._fix_args(name, html) self.report_name(name) self.report_html += html def combo_value(self, combo): """ Add the value of a combo box to the report. The methods assumes that the combo box was created by :obj:`Orange.widget.gui.comboBox`. If the value of the combo equals `combo.emptyString`, this function returns None. """ text = combo.currentText() if text != combo.emptyString: return text def plural(s, number, suffix="s"): """ Insert the number into the string, and make plural where marked, if needed. The string should use `{number}` to mark the place(s) where the number is inserted and `{s}` where an "s" needs to be added if the number is not 1. For instance, a string could be "I saw {number} dog{s} in the forest". Argument `suffix` can be used for some forms or irregular plural, like: plural("I saw {number} fox{s} in the forest", x, "es") plural("I say {number} child{s} in the forest", x, "ren") :param s: string :type s: str :param number: number :type number: int :param suffix: the suffix to use; default is "s" :type suffix: str :rtype: str """ warnings.warn("Plural formed by this function is difficult to translate. " "Use orangecanvas.utils.localization.pl instead.") return s.format(number=number, s=suffix if number % 100 != 1 else "") def plural_w(s, number, suffix="s", capitalize=False): """ Insert the number into the string, and make plural where marked, if needed. If the number is smaller or equal to ten, a word is used instead of a numeric representation. The string should use `{number}` to mark the place(s) where the number is inserted and `{s}` where an "s" needs to be added if the number is not 1. For instance, a string could be "I saw {number} dog{s} in the forest". Argument `suffix` can be used for some forms or irregular plural, like: plural("I saw {number} fox{s} in the forest", x, "es") plural("I say {number} child{s} in the forest", x, "ren") :param s: string :type s: str :param number: number :type number: int :param suffix: the suffix to use; default is "s" :type suffix: str :rtype: str """ numbers = ("zero", "one", "two", "three", "four", "five", "six", "seven", "nine", "ten") number_str = numbers[number] if number < len(numbers) else str(number) if capitalize: number_str = number_str.capitalize() return s.format(number=number_str, s=suffix if number % 100 != 1 else "") def bool_str(v): """Convert a boolean to a string.""" return "Yes" if v else "No" def clip_string(s, limit=1000, sep=None): """ Clip a string at a given character and add "..." if the string was clipped. If a separator is specified, the string is not clipped at the given limit but after the last occurence of the separator below the limit. :param s: string to clip :type s: str :param limit: number of characters to retain (including "...") :type limit: int :param sep: separator :type sep: str :rtype: str """ if len(s) < limit: return s s = s[:limit - 3] if sep is None: return s sep_pos = s.rfind(sep) if sep_pos == -1: return s return s[:sep_pos + len(sep)] + "..." def clipped_list(items, limit=1000, less_lookups=False, total_min=10, total=""): """ Return a clipped comma-separated representation of the list. If `less_lookups` is `True`, clipping will use a generator across the first `(limit + 2) // 3` items only, which suffices even if each item is only a single character long. This is useful in case when retrieving items is expensive, while it is generally slower. If there are at least `total_lim` items, and argument `total` is present, the string `total.format(len(items))` is added to the end of string. Argument `total` can be, for instance `"(total: {} variables)"`. If `total` is given, `s` cannot be a generator. :param items: list :type items: list or another iterable object :param limit: number of characters to retain (including "...") :type limit: int :param total_min: the minimal number of items that triggers adding `total` :type total_min: int :param total: the string that is added if `len(items) >= total_min` :type total: str :param less_lookups: minimize the number of lookups :type less_lookups: bool :return: """ if less_lookups: s = ", ".join(itertools.islice(items, (limit + 2) // 3)) else: s = ", ".join(items) s = clip_string(s, limit, ", ") if total and len(items) >= total_min: s += " " + total.format(len(items)) return s def get_html_section(name): """ Return a new section as HTML, with the given name and a time stamp. :param name: section name :type name: str :rtype: str """ datetime = time.strftime("%a %b %d %y, %H:%M:%S") return "

{} {}

".format(name, datetime) def get_html_subsection(name): """ Return a subsection as HTML, with the given name :param name: subsection name :type name: str :rtype: str """ return "

{}

".format(name) def render_items(items): """ Render a sequence of pairs or a dictionary as a HTML list. The function skips the items whose values are `None` or `False`. :param items: a sequence of items :type items: list or tuple or dict :return: rendered content :rtype: str """ if isinstance(items, dict): items = items.items() return "
    " + "".join( "{}: {}
    ".format(key, value) for key, value in items if value is not None and value is not False) + "
" def render_items_vert(items): """ Render a sequence of pairs or a dictionary as a comma-separated list. The function skips the items whose values are `None` or `False`. :param items: a sequence of items :type items: list or tuple or dict :return: rendered content :rtype: str """ if isinstance(items, dict): items = items.items() return ", ".join("{}: {}".format(key, value) for key, value in items if value is not None and value is not False) def get_html_img( scene: QGraphicsScene, max_height: Optional[int] = None ) -> str: """ Create HTML img element with base64-encoded image from the scene. If max_height is not none set the max height of the image in html. """ byte_array = QByteArray() filename = QBuffer(byte_array) filename.open(QIODevice.WriteOnly) img_data = PngFormat.write(filename, scene) img_encoded = byte_array.toBase64().data().decode("utf-8") style_opts = [] if max_height is not None: style_opts.append(f"max-height: {max_height}px") if img_data is not None: ratio = img_data.get("pixel_ratio", 1) if ratio != 1: style_opts.append(f"zoom: {1 / ratio:.1f}") style = f' style="{"; ".join(style_opts)}"' if style_opts else '' return f'' def get_icon_html(icon: QIcon, size: QSize) -> str: """ Transform an icon to html tag. """ if not size.isValid(): return "" if size.width() < 0 or size.height() < 0: size = QSize(16, 16) # just in case byte_array = QByteArray() buffer = QBuffer(byte_array) buffer.open(QIODevice.WriteOnly) pixmap = icon.pixmap(size) if pixmap.isNull(): return "" pixmap.save(buffer, "PNG") buffer.close() dpr = pixmap.devicePixelRatioF() if dpr != 1.0: size_ = pixmap.size() / dpr size_part = ' width="{}" height="{}"'.format( int(math.floor(size_.width())), int(math.floor(size_.height())) ) else: size_part = '' img_encoded = byte_array.toBase64().data().decode("utf-8") return '' def colored_square(r, g, b): return ''.format(r, g, b) def list_legend(model, selected=None): """ Create HTML with a legend constructed from a Qt model or a view. This function can be used for reporting the legend for graph in widgets in which the colors representing different values are shown in a listbox with colored icons. The function returns a string with values from the listbox, preceded by squares of the corresponding colors. The model must return data for Qt.DecorationRole. If a view is passed as an argument, it has to have method `model()`. :param model: model or view, usually a list box :param selected: if given, only items with the specified indices are shown """ if hasattr(model, "model"): model = model.model() legend = "" for row in range(model.rowCount()): if selected is not None and row not in selected: continue index = model.index(row, 0) icon = model.data(index, Qt.DecorationRole) r, g, b, a = QColor( icon.pixmap(12, 12).toImage().pixel(0, 0)).getRgb() text = model.data(index, Qt.DisplayRole) legend += colored_square(r, g, b) + \ '{}'.format(text) return legend ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8512592 orange-widget-base-4.22.0/orangewidget/report/tests/0000755000076500000240000000000014501051361021636 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/report/tests/__init__.py0000644000076500000240000000000014306600442023740 0ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/report/tests/test_report.py0000644000076500000240000001444014440334174024575 0ustar00primozstaffimport os import re import tempfile import unittest from unittest.mock import patch from AnyQt.QtCore import Qt, QRectF from AnyQt.QtGui import QFont, QBrush, QPixmap, QColor, QIcon from AnyQt.QtWidgets import QGraphicsScene from orangewidget.report.owreport import OWReport, HAVE_REPORT from orangewidget import gui from orangewidget.utils.itemmodels import PyTableModel from orangewidget.widget import OWBaseWidget from orangewidget.tests.base import GuiTest class TstWidget(OWBaseWidget): def send_report(self): self.report_caption("AA") class TestReport(GuiTest): def test_report(self): count = 5 rep = OWReport() for _ in range(count): widget = TstWidget() widget.create_report_html() rep.make_report(widget) self.assertEqual(rep.table_model.rowCount(), count) def test_report_table(self): rep = OWReport() model = PyTableModel([['x', 1, 2], ['y', 2, 2]]) model.setHorizontalHeaderLabels(['a', 'b', 'c']) model.setData(model.index(0, 0), Qt.AlignHCenter | Qt.AlignTop, Qt.TextAlignmentRole) model.setData(model.index(1, 0), QFont('', -1, QFont.Bold), Qt.FontRole) model.setData(model.index(1, 2), QBrush(Qt.red), Qt.BackgroundRole) view = gui.TableView() view.show() view.setModel(model) rep.report_table('Name', view) self.maxDiff = None self.assertEqual( rep.report_html, """

Name

a b c
x 1 2
y 2 2
""".strip()) def test_save_report_permission(self): """ Permission Error may occur when trying to save report. GH-2147 """ rep = OWReport() filenames = ["f.report", "f.html"] for filename in filenames: with patch("orangewidget.report.owreport.open", create=True, side_effect=PermissionError),\ patch("AnyQt.QtWidgets.QFileDialog.getSaveFileName", return_value=(filename, 'Report (*.report)')),\ patch("AnyQt.QtWidgets.QMessageBox.exec", return_value=True), \ patch("orangewidget.report.owreport.log.error") as log: rep.save_report() log.assert_called() def test_save_report(self): rep = OWReport() widget = TstWidget() widget.create_report_html() rep.make_report(widget) temp_dir = tempfile.mkdtemp() temp_name = os.path.join(temp_dir, "f.report") try: with patch("AnyQt.QtWidgets.QFileDialog.getSaveFileName", return_value=(temp_name, 'Report (*.report)')), \ patch("AnyQt.QtWidgets.QMessageBox.exec", return_value=True): rep.save_report() finally: os.remove(temp_name) os.rmdir(temp_dir) @patch("AnyQt.QtWidgets.QFileDialog.getSaveFileName", return_value=(False, 'HTML (*.html)')) def test_save_report_formats(self, mock): rep = OWReport() widget = TstWidget() widget.create_report_html() rep.make_report(widget) rep.report_view = False rep.save_report() formats = mock.call_args_list[-1][0][-1].split(';;') self.assertEqual(["Report (*.report)"], formats) rep.report_view = True rep.save_report() formats = mock.call_args_list[-1][0][-1].split(';;') self.assertEqual(['HTML (*.html)', 'PDF (*.pdf)', 'Report (*.report)'], formats) def test_show_webengine_warning_only_once(self): rep = OWReport() widget = TstWidget() with patch("AnyQt.QtWidgets.QMessageBox.critical", return_value=True) as p: widget.show_report() self.assertEqual(0 if HAVE_REPORT else 1, len(p.call_args_list)) widget.show_report() self.assertEqual(0 if HAVE_REPORT else 1, len(p.call_args_list)) def test_disable_saving_empty(self): """Test if save and print buttons are disabled on empty report""" rep = OWReport() self.assertFalse(rep.save_button.isEnabled()) self.assertFalse(rep.print_button.isEnabled()) widget = TstWidget() widget.create_report_html() rep.make_report(widget) self.assertTrue(rep.save_button.isEnabled()) self.assertTrue(rep.print_button.isEnabled()) rep.clear() self.assertFalse(rep.save_button.isEnabled()) self.assertFalse(rep.print_button.isEnabled()) def test_report_table_with_images(self): def basic_icon(): pixmap = QPixmap(15, 15) pixmap.fill(QColor("red")) return QIcon(pixmap) def basic_scene(): scene = QGraphicsScene() scene.addRect(QRectF(0, 0, 100, 100)); return scene rep = OWReport() model = PyTableModel([['x', 1, 2]]) model.setHorizontalHeaderLabels(['a', 'b', 'c']) model.setData(model.index(0, 1), basic_icon(), Qt.DecorationRole) model.setData(model.index(0, 2), basic_scene(), Qt.DisplayRole) view = gui.TableView() view.show() view.setModel(model) rep.report_table('Name', view) self.assertIsNotNone( re.search(' None: """ Set the directory path components where widgets save their settings. This overrides the global current application name/version derived paths. Note ---- This should be set early in the application startup before any `OWBaseWidget` subclasses are imported (because it is needed at class definition time). Parameters ---------- basedir: str The application specific data directory. versionstr: str The application version string for the versioned path component. See Also -------- widget_settings_dir """ global __WIDGET_SETTINGS_DIR __WIDGET_SETTINGS_DIR = (basedir, versionstr) def widget_settings_dir(versioned=True) -> str: """ Return the effective directory where widgets save their settings. This is a composed path based on a application specific data directory and application version string (if `versioned` is True) with a final 'widgets' path component added (e.g. `'~/.local/share/MyApp/9.9.9/widgets'`) By default `QCoreApplication.applicationName` and `QCoreApplication.applicationVersion` are used to derive suitable paths (with a fallback if they are not set). Note ---- If the application sets the `applicationName`/`applicationVersion`, it should do this early in the application startup before any `OWBaseWidget` subclasses are imported (because it is needed at class definition time). Use `set_widget_settings_dir_components` to override the default paths. Parameters ---------- versioned: bool Should the returned path include the application version component. See Also -------- set_widget_settings_dir_components """ if __WIDGET_SETTINGS_DIR is None: from orangewidget.workflow.config import data_dir return os.path.join(data_dir(versioned), "widgets") else: base, version = __WIDGET_SETTINGS_DIR if versioned: return os.path.join(base, version, "widgets") else: return os.path.join(base, "widgets") class Setting: """Description of a setting. """ # Settings are automatically persisted to disk packable = True # Setting is only persisted to schema (default value does not change) schema_only = False def __new__(cls, default, *args, **kwargs): """A misleading docstring for providing type hints for Settings :type: default: T :rtype: T """ return super().__new__(cls) def __init__(self, default, **data): self.name = None # Name gets set in widget's meta class self.default = default self.__dict__.update(data) def __str__(self): return '{0} "{1}"'.format(self.__class__.__name__, self.name) __repr__ = __str__ def __getnewargs__(self): return (self.default, ) # Pylint ignores type annotations in assignments. For # # x: int = Setting(0) # # it ignores `int` and assumes x is of type `Setting`. Annotations in # comments ( # type: int) also don't work. The only way to annotate x is # # x: int # x = Setting(0) # # but we don't want to clutter the code with extra lines of annotations. Hence # we disable checking the type of `Setting` by confusing pylint with an extra # definition that is never executed. if 1 == 0: class Setting: # pylint: disable=function-redefined pass def _apply_setting(setting: Setting, instance: OWComponent, value: Any): """ Set `setting` of widget `instance` to the given `value`, in place if possible. If old and new values are of the same type, and the type is either a list or has methods `clear` and `update`, setting is updated in place. Otherwise the function calls `setattr`. Even if the target is update this way, call setattr (effectively calling instance.target = instance.target) to trigger any automatic updates related to this attribute). """ target = getattr(instance, setting.name, None) if type(target) is type(value): if isinstance(value, list): target[:] = value value = target elif hasattr(value, "clear") and hasattr(value, "update"): target.clear() target.update(value) value = target setattr(instance, setting.name, value) class SettingProvider: """A hierarchical structure keeping track of settings belonging to a class and child setting providers. At instantiation, it creates a dict of all Setting and SettingProvider members of the class. This dict is used to get/set values of settings from/to the instances of the class this provider belongs to. """ def __init__(self, provider_class): """ Construct a new instance of SettingProvider. Traverse provider_class members and store all instances of Setting and SettingProvider. Parameters ---------- provider_class : class class containing settings definitions """ self.name = "" self.provider_class = provider_class self.providers = {} """:type: dict[str, SettingProvider]""" self.settings = {} """:type: dict[str, Setting]""" self.initialization_data = None for name in dir(provider_class): value = getattr(provider_class, name, None) if isinstance(value, Setting): value = copy.deepcopy(value) value.name = name self.settings[name] = value if isinstance(value, SettingProvider): value = copy.deepcopy(value) value.name = name self.providers[name] = value def initialize(self, instance, data=None): """Initialize instance settings to their default values. Mutable values are (shallow) copied before they are assigned to the widget. Immutable are used as-is. Parameters ---------- instance : OWBaseWidget widget instance to initialize data : Optional[dict] optional data used to override the defaults (used when settings are loaded from schema) """ if data is None and self.initialization_data is not None: data = self.initialization_data self._initialize_settings(instance, data) self._initialize_providers(instance, data) def reset_to_original(self, instance): self._initialize_settings(instance, None) self._initialize_providers(instance, None) def _initialize_settings(self, instance, data): if data is None: data = {} for name, setting in self.settings.items(): value = data.get(name, setting.default) if isinstance(value, _IMMUTABLES): setattr(instance, name, value) else: setattr(instance, name, copy.copy(value)) def _initialize_providers(self, instance, data): if not data: return for name, provider in self.providers.items(): if name not in data: continue member = getattr(instance, name, None) if member is None or isinstance(member, SettingProvider): provider.store_initialization_data(data[name]) else: provider.initialize(member, data[name]) def store_initialization_data(self, initialization_data): """Store initialization data for later use. Used when settings handler is initialized, but member for this provider does not exists yet (because handler.initialize is called in __new__, but member will be created in __init__. Parameters ---------- initialization_data : dict data to be used for initialization when the component is created """ self.initialization_data = initialization_data @staticmethod def _default_packer(setting, instance): """A simple packet that yields setting name and value. Parameters ---------- setting : Setting instance : OWBaseWidget """ if setting.packable: if hasattr(instance, setting.name): yield setting.name, getattr(instance, setting.name) else: warnings.warn("{0} is declared as setting on {1} " "but not present on instance." .format(setting.name, instance)) def pack(self, instance, packer=None): """Pack instance settings in a name:value dict. Parameters ---------- instance : OWBaseWidget widget instance packer: callable (Setting, OWBaseWidget) -> Generator[(str, object)] optional packing function it will be called with setting and instance parameters and should yield (name, value) pairs that will be added to the packed_settings. """ if packer is None: packer = self._default_packer packed_settings = dict(itertools.chain( *(packer(setting, instance) for setting in self.settings.values()) )) packed_settings.update({ name: provider.pack(getattr(instance, name), packer) for name, provider in self.providers.items() if hasattr(instance, name) }) return packed_settings def unpack(self, instance, data): """Restore settings from data to the instance. Parameters ---------- instance : OWBaseWidget instance to restore settings to data : dict packed data """ for setting, _data, inst in self.traverse_settings(data, instance): if setting.name in _data and inst is not None: _apply_setting(setting, inst, _data[setting.name]) def get_provider(self, provider_class): """Return provider for provider_class. If this provider matches, return it, otherwise pass the call to child providers. Parameters ---------- provider_class : class """ if issubclass(provider_class, self.provider_class): return self for subprovider in self.providers.values(): provider = subprovider.get_provider(provider_class) if provider: return provider return None def traverse_settings(self, data=None, instance=None): """Generator of tuples (setting, data, instance) for each setting in this and child providers.. Parameters ---------- data : dict dictionary with setting values instance : OWBaseWidget instance matching setting_provider """ data = data if data is not None else {} for setting in self.settings.values(): yield setting, data, instance for provider in self.providers.values(): data_ = data.get(provider.name, {}) instance_ = getattr(instance, provider.name, None) for setting, component_data, component_instance in \ provider.traverse_settings(data_, instance_): yield setting, component_data, component_instance class SettingsHandler: """Reads widget setting files and passes them to appropriate providers.""" def __init__(self): """Create a setting handler template. Used in class definition. Bound instance will be created when SettingsHandler.create is called. """ self.widget_class = None self.provider = None """:type: SettingProvider""" self.defaults = {} self.known_settings = {} @staticmethod def create(widget_class, template=None): """Create a new settings handler based on the template and bind it to widget_class. Parameters ---------- widget_class : class template : SettingsHandler SettingsHandler to copy setup from Returns ------- SettingsHandler """ if template is None: template = SettingsHandler() setting_handler = copy.copy(template) setting_handler.defaults = {} setting_handler.bind(widget_class) return setting_handler def bind(self, widget_class): """Bind settings handler instance to widget_class. Parameters ---------- widget_class : class """ self.widget_class = widget_class self.provider = SettingProvider(widget_class) self.known_settings = {} self.analyze_settings(self.provider, "") self.read_defaults() def analyze_settings(self, provider, prefix): """Traverse through all settings known to the provider and analyze each of them. Parameters ---------- provider : SettingProvider prefix : str prefix the provider is registered to handle """ for setting in provider.settings.values(): self.analyze_setting(prefix, setting) for name, sub_provider in provider.providers.items(): new_prefix = '{0}{1}.'.format(prefix or '', name) self.analyze_settings(sub_provider, new_prefix) def analyze_setting(self, prefix, setting): """Perform any initialization task related to setting. Parameters ---------- prefix : str setting : Setting """ self.known_settings[prefix + setting.name] = setting def read_defaults(self): """Read (global) defaults for this widget class from a file. Opens a file and calls :obj:`read_defaults_file`. Derived classes should overload the latter.""" filename = self._get_settings_filename() if os.path.isfile(filename): settings_file = open(filename, "rb") try: self.read_defaults_file(settings_file) # Unpickling exceptions can be of any type # pylint: disable=broad-except except Exception as ex: warnings.warn("Could not read defaults for widget {0}\n" "The following error occurred:\n\n{1}" .format(self.widget_class, ex)) finally: settings_file.close() def read_defaults_file(self, settings_file): """Read (global) defaults for this widget class from a file. Parameters ---------- settings_file : file-like object """ defaults = pickle.load(settings_file) self.defaults = { key: value for key, value in defaults.items() if not isinstance(value, Setting) } self._migrate_settings(self.defaults) # remove schema_only settings introduced by the migration self._remove_schema_only(self.defaults) def write_defaults(self): """Write (global) defaults for this widget class to a file. Opens a file and calls :obj:`write_defaults_file`. Derived classes should overload the latter.""" filename = self._get_settings_filename() os.makedirs(os.path.dirname(filename), exist_ok=True) try: settings_file = open(filename, "wb") try: self.write_defaults_file(settings_file) except (EOFError, IOError, pickle.PicklingError) as ex: log.error("Could not write default settings for %s (%s).", self.widget_class, ex) settings_file.close() os.remove(filename) else: settings_file.close() except PermissionError as ex: log.error("Could not write default settings for %s (%s).", self.widget_class, type(ex).__name__) def write_defaults_file(self, settings_file): """Write defaults for this widget class to a file Parameters ---------- settings_file : file-like object """ defaults = dict(self.defaults) defaults[VERSION_KEY] = self.widget_class.settings_version pickle.dump(defaults, settings_file, protocol=PICKLE_PROTOCOL) def _get_settings_filename(self): """Return the name of the file with default settings for the widget""" return os.path.join(widget_settings_dir(), "{0.__module__}.{0.__qualname__}.pickle" .format(self.widget_class)) def initialize(self, instance, data=None): """ Initialize widget's settings. Replace all instance settings with their default values. Parameters ---------- instance : OWBaseWidget data : dict or bytes that unpickle into a dict values used to override the defaults """ provider = self._select_provider(instance) if isinstance(data, bytes): data = pickle.loads(data) self._migrate_settings(data) if provider is self.provider: data = self._add_defaults(data) provider.initialize(instance, data) def reset_to_original(self, instance): provider = self._select_provider(instance) provider.reset_to_original(instance) def _migrate_settings(self, settings): """Ask widget to migrate settings to the latest version.""" if settings: try: self.widget_class.migrate_settings( settings, settings.pop(VERSION_KEY, 0)) except Exception: # pylint: disable=broad-except sys.excepthook(*sys.exc_info()) settings.clear() def _select_provider(self, instance): provider = self.provider.get_provider(instance.__class__) if provider is None: message = "{0} has not been declared as setting provider in {1}. " \ "Settings will not be saved/loaded properly. Defaults will be used instead." \ .format(instance.__class__, self.widget_class) warnings.warn(message) provider = SettingProvider(instance.__class__) return provider def _add_defaults(self, data): if data is None: return self.defaults new_data = self.defaults.copy() new_data.update(data) return new_data def _remove_schema_only(self, settings_dict): for setting, data, _ in self.provider.traverse_settings(data=settings_dict): if setting.schema_only: data.pop(setting.name, None) def _prepare_defaults(self, widget): self.defaults = self.provider.pack(widget) self._remove_schema_only(self.defaults) def pack_data(self, widget): """ Pack the settings for the given widget. This method is used when saving schema, so that when the schema is reloaded the widget is initialized with its proper data and not the class-based defaults. See :obj:`SettingsHandler.initialize` for detailed explanation of its use. Inherited classes add other data, in particular widget-specific local contexts. Parameters ---------- widget : OWBaseWidget """ widget.settingsAboutToBePacked.emit() packed_settings = self.provider.pack(widget) packed_settings[VERSION_KEY] = self.widget_class.settings_version return packed_settings def update_defaults(self, widget): """ Writes widget instance's settings to class defaults. Called when the widget is deleted. Parameters ---------- widget : OWBaseWidget """ widget.settingsAboutToBePacked.emit() self._prepare_defaults(widget) self.write_defaults() def fast_save(self, widget, name, value): """Store the (changed) widget's setting immediately to the context. Parameters ---------- widget : OWBaseWidget name : str value : object """ if name in self.known_settings: setting = self.known_settings[name] if not setting.schema_only: setting.default = value def reset_settings(self, instance): """Reset widget settings to defaults Parameters ---------- instance : OWBaseWidget """ for setting, _, inst in self.provider.traverse_settings(instance=instance): if setting.packable: _apply_setting(setting, inst, setting.default) class ContextSetting(Setting): """Description of a context dependent setting""" OPTIONAL = 0 REQUIRED = 2 # Context settings are not persisted, but are stored in context instead. packable = False # These flags are not general - they assume that the setting has to do # something with the attributes. Large majority does, so this greatly # simplifies the declaration of settings in widget at no (visible) # cost to those settings that don't need it def __init__(self, default, *, required=2, exclude_attributes=False, exclude_metas=False, **data): super().__init__(default, **data) self.exclude_attributes = exclude_attributes self.exclude_metas = exclude_metas self.required = required class Context: """Class for data that defines context and values that should be applied to widget if given context is encountered.""" def __init__(self, **argkw): self.values = {} self.__dict__.update(argkw) def __eq__(self, other): return self.__dict__ == other.__dict__ class ContextHandler(SettingsHandler): """Base class for setting handlers that can handle contexts. Classes deriving from it need to implement method `match`. """ NO_MATCH = 0 MATCH = 1 PERFECT_MATCH = 2 MAX_SAVED_CONTEXTS = 50 def __init__(self): super().__init__() self.global_contexts = [] self.known_settings = {} def initialize(self, instance, data=None): """Initialize the widget: call the inherited initialization and add an attribute 'context_settings' to the widget. This method does not open a context.""" instance.current_context = None super().initialize(instance, data) if data and "context_settings" in data: instance.context_settings = data["context_settings"] self._migrate_contexts(instance.context_settings) else: instance.context_settings = [] def read_defaults_file(self, settings_file): """Call the inherited method, then read global context from the pickle.""" super().read_defaults_file(settings_file) self.global_contexts = pickle.load(settings_file) self._migrate_contexts(self.global_contexts) # remove schema_only settings introduced by the migration for context in self.global_contexts: self._remove_schema_only(context.values) def _migrate_contexts(self, contexts): i = 0 while i < len(contexts): context = contexts[i] try: self.widget_class.migrate_context( context, context.values.pop(VERSION_KEY, 0)) except IncompatibleContext: del contexts[i] except Exception: # pylint: disable=broad-except sys.excepthook(*sys.exc_info()) del contexts[i] else: i += 1 def write_defaults_file(self, settings_file): """Call the inherited method, then add global context to the pickle.""" super().write_defaults_file(settings_file) def add_version(context): context = copy.copy(context) context.values = dict(context.values) context.values[VERSION_KEY] = self.widget_class.settings_version return context pickle.dump([add_version(context) for context in self.global_contexts], settings_file, protocol=PICKLE_PROTOCOL) def pack_data(self, widget): """Call the inherited method, then add local contexts to the dict.""" data = super().pack_data(widget) self.settings_from_widget(widget) context_settings = [copy.copy(context) for context in widget.context_settings] for context in context_settings: context.values[VERSION_KEY] = self.widget_class.settings_version data["context_settings"] = context_settings return data def update_defaults(self, widget): """ Reimplemented from SettingsHandler Merge the widgets local contexts into the global contexts and persist the settings (including the contexts) to disk. """ widget.settingsAboutToBePacked.emit() self.settings_from_widget(widget) globs = self.global_contexts assert widget.context_settings is not globs new_contexts = [] for context in widget.context_settings: context = copy.deepcopy(context) self._remove_schema_only(context.values) if context not in globs: new_contexts.append(context) globs[:0] = reversed(new_contexts) del globs[self.MAX_SAVED_CONTEXTS:] # Save non-context settings. Do not call super().update_defaults, so that # settingsAboutToBePacked is emitted once. self._prepare_defaults(widget) self.write_defaults() def new_context(self, *args): """Create a new context.""" return Context() def open_context(self, widget, *args): """Open a context by finding one and setting the widget data or creating one and fill with the data from the widget.""" widget.current_context, is_new = \ self.find_or_create_context(widget, *args) if is_new: self.settings_from_widget(widget, *args) else: self.settings_to_widget(widget, *args) def match(self, context, *args): """Return the degree to which the stored `context` matches the data passed in additional arguments). When match returns 0 (ContextHandler.NO_MATCH), the context will not be used. When it returns ContextHandler.PERFECT_MATCH, the context is a perfect match so no further search is necessary. If imperfect matching is not desired, match should only return ContextHandler.NO_MATCH or ContextHandler.PERFECT_MATCH. Derived classes must overload this method. """ raise NotImplementedError def find_or_create_context(self, widget, *args): """Find the best matching context or create a new one if nothing useful is found. The returned context is moved to or added to the top of the context list.""" # First search the contexts that were already used in this widget instance best_context, best_score = self.find_context(widget.context_settings, args, move_up=True) # If the exact data was used, reuse the context if best_score == self.PERFECT_MATCH: return best_context, False # Otherwise check if a better match is available in global_contexts best_context, best_score = self.find_context(self.global_contexts, args, best_score, best_context) if best_context: context = self.clone_context(best_context, *args) else: context = self.new_context(*args) # Store context in widget instance. It will be pushed to global_contexts # when (if) update defaults is called. self.add_context(widget.context_settings, context) return context, best_context is None def find_context(self, known_contexts, args, best_score=0, best_context=None, move_up=False): """Search the given list of contexts and return the context which best matches the given args. best_score and best_context can be used to provide base_values. """ best_idx = None for i, context in enumerate(known_contexts): score = self.match(context, *args) if score > best_score: # NO_MATCH is not OK! best_context, best_score, best_idx = context, score, i if score == self.PERFECT_MATCH: break if best_idx is not None and move_up: self.move_context_up(known_contexts, best_idx) return best_context, best_score @staticmethod def move_context_up(contexts, index): """Move the context to the top of the list""" contexts.insert(0, contexts.pop(index)) def add_context(self, contexts, setting): """Add the context to the top of the list.""" contexts.insert(0, setting) del contexts[self.MAX_SAVED_CONTEXTS:] def clone_context(self, old_context, *args): """Construct a copy of the context settings suitable for the context described by additional arguments. The method is called by find_or_create_context with the same arguments. A class that overloads :obj:`match` to accept additional arguments must also overload :obj:`clone_context`.""" context = self.new_context(*args) context.values = copy.deepcopy(old_context.values) traverse = self.provider.traverse_settings for setting, data, _ in traverse(data=context.values): if not isinstance(setting, ContextSetting): continue self.filter_value(setting, data, *args) return context @staticmethod def filter_value(setting, data, *args): """Remove values related to setting that are invalid given args.""" def close_context(self, widget): """Close the context by calling :obj:`settings_from_widget` to write any relevant widget settings to the context.""" if widget.current_context is None: return self.settings_from_widget(widget) widget.current_context = None def settings_to_widget(self, widget, *args): """Apply context settings stored in currently opened context to the widget. """ context = widget.current_context if context is None: return widget.retrieveSpecificSettings() for setting, data, instance in \ self.provider.traverse_settings(data=context.values, instance=widget): if not isinstance(setting, ContextSetting) or setting.name not in data: continue value = self.decode_setting(setting, data[setting.name], *args) _apply_setting(setting, instance, value) def settings_from_widget(self, widget, *args): """Update the current context with the setting values from the widget. """ context = widget.current_context if context is None: return widget.storeSpecificSettings() def packer(setting, instance): if isinstance(setting, ContextSetting) and hasattr(instance, setting.name): value = getattr(instance, setting.name) yield setting.name, self.encode_setting(context, setting, value) context.values = self.provider.pack(widget, packer=packer) def fast_save(self, widget, name, value): """Update value of `name` setting in the current context to `value` """ setting = self.known_settings.get(name) if isinstance(setting, ContextSetting): context = widget.current_context if setting.schema_only or context is None: return value = self.encode_setting(context, setting, value) self.update_packed_data(context.values, name, value) else: super().fast_save(widget, name, value) @staticmethod def update_packed_data(data, name, value): """Updates setting value stored in data dict""" *prefixes, name = name.split('.') for prefix in prefixes: data = data.setdefault(prefix, {}) data[name] = value def encode_setting(self, context, setting, value): """Encode value to be stored in settings dict""" return copy.copy(value) def decode_setting(self, setting, value, *args): """Decode settings value from the setting dict format""" return value class IncompatibleContext(Exception): """Raised when a required variable in context is not available in data.""" class SettingsPrinter(pprint.PrettyPrinter): """Pretty Printer that knows how to properly format Contexts.""" def _format(self, obj, stream, indent, allowance, context, level): if not isinstance(obj, Context): super()._format(obj, stream, indent, allowance, context, level) return stream.write("Context(") for key, value in sorted(obj.__dict__.items(), key=itemgetter(0)): if key == "values": continue stream.write(key) stream.write("=") stream.write(self._repr(value, context, level + 1)) stream.write(",\n") stream.write(" " * (indent + 8)) stream.write("values=") stream.write(" ") self._format(obj.values, stream, indent+15, allowance+1, context, level + 1) stream.write(")") def rename_setting(settings, old_name, new_name): """ Rename setting from `old_name` to `new_name`. Used in migrations. The argument `settings` can be `dict` or `Context`. """ if isinstance(settings, Context): rename_setting(settings.values, old_name, new_name) else: settings[new_name] = settings.pop(old_name) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8536017 orange-widget-base-4.22.0/orangewidget/tests/0000755000076500000240000000000014501051361020323 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/tests/__init__.py0000644000076500000240000000000014306600442022425 0ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/tests/base.py0000644000076500000240000006746214440334174021636 0ustar00primozstaff""" Testing framework for OWWidgets """ import os import sys import pickle import tempfile import time import unittest from contextlib import contextmanager, ExitStack from unittest.mock import Mock, patch from typing import List, Optional, TypeVar, Type from AnyQt.QtCore import Qt, QObject, pyqtSignal, QElapsedTimer, pyqtSlot from AnyQt.QtTest import QTest, QSignalSpy from AnyQt.QtWidgets import ( QApplication, QComboBox, QSpinBox, QDoubleSpinBox, QSlider ) from AnyQt import sip from orangewidget.report.owreport import OWReport from orangewidget.settings import SettingsHandler from orangewidget.utils.signals import get_input_meta, notify_input_helper, \ Output, Input, LazyValue from orangewidget.widget import OWBaseWidget if hasattr(sip, "setdestroyonexit"): sip.setdestroyonexit(False) app = None DEFAULT_TIMEOUT = 5000 # pylint: disable=invalid-name T = TypeVar("T") @contextmanager def named_file(content, encoding=None, suffix=''): file = tempfile.NamedTemporaryFile("wt", delete=False, encoding=encoding, suffix=suffix) file.write(content) name = file.name file.close() try: yield name finally: os.remove(name) class _Invalidated(QObject): completed = pyqtSignal(object) class _FinishedMonitor(QObject): finished = pyqtSignal() def __init__(self, widget: 'OWBaseWidget') -> None: super().__init__(None) self.widget = widget self.sm = widget.signalManager # type: DummySignalManager widget.widgetStateChanged.connect(self._changed) self._finished = self.is_finished() self.invalidated_outputs = self.sm.invalidated_outputs(widget) for output in self.invalidated_outputs: output.completed.connect(self._completed, Qt.UniqueConnection) def is_finished(self) -> bool: finished = not (self.widget.isInvalidated() or self.sm.has_invalidated_outputs(self.widget)) return finished @pyqtSlot() def _changed(self): fin = self.is_finished() self.invalidated_outputs = self.sm.invalidated_outputs(self.widget) for output in self.invalidated_outputs: try: output.completed.connect(self._completed, Qt.UniqueConnection) except TypeError: # connection already exists pass if fin and fin != self._finished: self.finished.emit() @pyqtSlot() def _completed(self): self._changed() class DummySignalManager: def __init__(self): self.outputs = {} def clear(self): self.outputs.clear() def send(self, widget, signal_name, value, *args, **kwargs): if not isinstance(signal_name, str): signal_name = signal_name.name current = self.outputs.get((widget, signal_name), None) self.outputs[(widget, signal_name)] = value if isinstance(current, _Invalidated): current.completed.emit(value) def invalidate(self, widget, signal_name): if not isinstance(signal_name, str): signal_name = signal_name.name self.outputs[(widget, signal_name)] = _Invalidated() def wait_for_outputs(self, widget, timeout=DEFAULT_TIMEOUT): st = _Invalidated() invalidated = self.invalidated_outputs(widget) for val in invalidated: val.completed.connect(st.completed) if invalidated: return QSignalSpy(st.completed).wait(timeout) else: return True def has_invalidated_outputs(self, widget): invalidated = self.invalidated_outputs(widget) return bool(invalidated) def invalidated_outputs(self, widget): return [value for (w, name), value in self.outputs.items() if w is widget and isinstance(value, _Invalidated)] def get_output(self, widget, signal_name, timeout=DEFAULT_TIMEOUT): if not isinstance(signal_name, str): signal_name = signal_name.name elapsed = QElapsedTimer() if widget.isInvalidated(): elapsed.start() spy = QSignalSpy(widget.invalidatedStateChanged) assert spy.wait(timeout) timeout = timeout - elapsed.elapsed() value = self.outputs.get((widget, signal_name)) if isinstance(value, _Invalidated) and timeout >= 0: spy = QSignalSpy(value.completed) assert spy.wait(timeout), "Failed to get output in the specified timeout" assert len(spy) == 1 value = spy[0][0] return value def wait_for_finished( self, widget: 'OWBaseWidget', timeout=DEFAULT_TIMEOUT) -> bool: monitor = _FinishedMonitor(widget) if monitor.is_finished(): return True else: spy = QSignalSpy(monitor.finished) return spy.wait(timeout) class GuiTest(unittest.TestCase): """Base class for tests that require a QApplication instance GuiTest ensures that a QApplication exists before tests are run an """ tear_down_stack: ExitStack LANGUAGE = "English" @classmethod def setUpClass(cls): """Prepare for test execution. Ensure that a (single copy of) QApplication has been created """ global app if app is None: app = QApplication.instance() if app is None: app = QApplication(["-", "-widgetcount"]) # Disable App Nap on macOS (see # https://codereview.qt-project.org/c/qt/qtbase/+/202515 for more) if sys.platform == "darwin": try: import appnope except ImportError: pass else: appnope.nope() cls.tear_down_stack = ExitStack() if "pyqtgraph" in sys.modules: # undo pyqtgraph excepthook override, abort on exceptions in # slots, event handlers, ... sys.excepthook = sys.__excepthook__ super().setUpClass() @classmethod def tearDownClass(cls) -> None: if "pyqtgraph" in sys.modules: import pyqtgraph pyqtgraph.setConfigOption("exitCleanup", False) cls.tear_down_stack.close() super().tearDownClass() QTest.qWait(0) def tearDown(self) -> None: """ Process any pending events before the next test is executed. This includes deletes scheduled with `QObject.deleteLater`. """ super().tearDown() QTest.qWait(0) @classmethod def skipNonEnglish(cls, f): return cls.runOnLanguage("English")(f) @classmethod def runOnLanguage(cls, lang): def decorator(f): if cls.LANGUAGE != lang: f = unittest.skip(f"Test is valid only for {lang} release")(f) return f return decorator NO_VALUE = object() class WidgetTest(GuiTest): """Base class for widget tests Contains helper methods widget creation and working with signals. All widgets should be created by the create_widget method, as this will ensure they are created correctly. """ widgets = [] # type: List[OWBaseWidget] def __init_subclass__(cls, **kwargs): def test_minimum_size(self): widget = getattr(self, "widget", None) if widget is None: self.skipTest("minimum size not tested as .widget was not set") self.check_minimum_size(widget) def test_image_export(self): widget = getattr(self, "widget", None) if widget is None: self.skipTest("image exporting not tested as .widget was not set") self.check_export_image(widget) def test_msg_base_class(self): widget = getattr(self, "widget", None) if widget is None: self.skipTest("msg base class not tested as .widget was not set") self.check_msg_base_class(widget) if not hasattr(cls, "test_minimum_size"): cls.test_minimum_size = test_minimum_size if not hasattr(cls, "test_msg_base_class"): cls.test_msg_base_class = test_msg_base_class if not hasattr(cls, "test_image_export"): cls.test_image_export = test_image_export @classmethod def setUpClass(cls): """Prepare environment for test execution Construct a dummy signal manager and monkey patch OWReport.get_instance to return a manually created instance. """ super().setUpClass() cls.widgets = [] cls.signal_manager = DummySignalManager() report = None def get_instance(): nonlocal report if report is None: report = OWReport() report.have_report_warning_shown = True # if missing QtWebView/QtWebKit if not (os.environ.get("TRAVIS") or os.environ.get("APPVEYOR")): report.show = Mock() cls.widgets.append(report) return report cls.tear_down_stack.enter_context( patch.object(OWReport, "get_instance", get_instance) ) @classmethod def tearDownClass(cls) -> None: cls.signal_manager.clear() del cls.signal_manager widgets = cls.widgets[:] cls.widgets.clear() while widgets: w = widgets.pop(-1) if not w.__dict__.get("_Cls__didCallOnDeleteWidget", False): w.onDeleteWidget() if not sip.isdeleted(w): w.deleteLater() w.signalManager = None super().tearDownClass() def tearDown(self): """Process any pending events before the next test is executed.""" self.signal_manager.clear() super().tearDown() def create_widget(self, cls, stored_settings=None, reset_default_settings=True): # type: (Type[T], Optional[dict], bool) -> T """Create a widget instance using mock signal_manager. When used with default parameters, it also overrides settings stored on disk with default defined in class. After widget is created, QApplication.process_events is called to allow any singleShot timers defined in __init__ to execute. Parameters ---------- cls : WidgetMetaClass Widget class to instantiate stored_settings : dict Default values for settings reset_default_settings : bool If set, widget will start with default values for settings, if not, values accumulated through the session will be used Returns ------- Widget instance : cls """ # Use a substitute subclass to mark calls to onDeleteWidget; Some tests # call this on their own (this used to be done in tearDownClass, then # it was not, so tests did it themself, now it is done again). with open_widget_classes(): class Cls(cls): def onDeleteWidget(self): self.__didCallOnDeleteWidget = True super(Cls, self).onDeleteWidget() __didCallOnDeleteWidget = False Cls.__name__ = cls.__name__ Cls.__qualname__ = cls.__qualname__ Cls.__module__ = cls.__module__ if reset_default_settings: self.reset_default_settings(Cls) widget = Cls.__new__(Cls, signal_manager=self.signal_manager, stored_settings=stored_settings) widget.__init__() self.process_events() self.widgets.append(widget) return widget @staticmethod def reset_default_settings(widget): """Reset default setting values for widget Discards settings read from disk and changes stored by fast_save Parameters ---------- widget : OWBaseWidget widget to reset settings for """ settings_handler = getattr(widget, "settingsHandler", None) if settings_handler: # Rebind settings handler to get fresh copies of settings # in known_settings settings_handler.bind(widget) # Reset defaults read from disk settings_handler.defaults = {} # Reset context settings settings_handler.global_contexts = [] def process_events(self, until: callable = None, timeout=DEFAULT_TIMEOUT): """Process Qt events, optionally until `until` returns something True-ish. Needs to be called manually as QApplication.exec is never called. Parameters ---------- until: callable or None If callable, the events are processed until the function returns something True-ish. timeout: int If until condition is not satisfied within timeout milliseconds, a TimeoutError is raised. Returns ------- If until is not None, the True-ish result of its call. """ if until is None: until = lambda: True started = time.perf_counter() while True: app.processEvents() try: result = until() if result: return result except Exception: # until can fail with anything; pylint: disable=broad-except pass if (time.perf_counter() - started) * 1000 > timeout: raise TimeoutError() time.sleep(.05) def show(self, widget=None): """Show widget in interactive mode. Useful for debugging tests, as widget can be inspected manually. """ widget = widget or self.widget widget.show() app.exec() def send_signal(self, input, value=NO_VALUE, *args, widget=None, wait=-1): """ Send signal to widget by calling appropriate triggers. Parameters ---------- input : str value : Object id : int channel id, used for inputs with flag Multiple widget : Optional[OWBaseWidget] widget to send signal to. If not set, self.widget is used wait : int The amount of time to wait for the widget to complete. """ if value is NO_VALUE: value = input wid = widget or self.widget inputs = wid.inputs or wid.Inputs.__dict__.values() assert len(inputs) == 1 input = next(iter(inputs)) return self.send_signals([(input, value)], *args, widget=widget, wait=wait) def send_signals(self, signals, *args, widget=None, wait=-1): """ Send signals to widget by calling appropriate triggers. After all the signals are send, widget's handleNewSignals() in invoked. Parameters ---------- signals : list of (str, Object) widget : Optional[OWBaseWidget] widget to send signals to. If not set, self.widget is used wait : int The amount of time to wait for the widget to complete. """ if widget is None: widgets = {signal.widget for signal, _ in signals if hasattr(signal, "widget")} if not widgets: widget = self.widget elif len(widgets) == 1: widget = widgets.pop() else: raise ValueError("Signals are bound to different widgets") for input, value in signals: self._send_signal(widget, input, value, *args) widget.handleNewSignals() if wait >= 0: self.wait_until_finished(widget, timeout=wait) @staticmethod def _send_signal(widget, input, value, *args, **kwargs): if isinstance(input, str): input = get_input_meta(widget, input) if input is None: raise ValueError("'{}' is not an input name for widget {}" .format(input, type(widget).__name__)) if not widget.isReady(): raise RuntimeError("'send_signal' called but the widget is not " "in ready state and does not accept inputs.") # Assert sent input is of correct class assert isinstance(value, (input.type, type(None), type(input.closing_sentinel))), \ '{} should be {}'.format(value.__class__.__mro__, input.type) notify_input_helper(input, widget, value, *args, **kwargs) def wait_until_stop_blocking(self, widget=None, wait=DEFAULT_TIMEOUT): """Wait until the widget stops blocking i.e. finishes computation. Parameters ---------- widget : Optional[OWBaseWidget] widget to send signal to. If not set, self.widget is used wait : int The amount of time to wait for the widget to complete. """ if widget is None: widget = self.widget if widget.isBlocking(): spy = QSignalSpy(widget.blockingStateChanged) self.assertTrue(spy.wait(timeout=wait)) def wait_until_finished( self, widget: Optional[OWBaseWidget] = None, timeout=DEFAULT_TIMEOUT) -> None: """Wait until the widget finishes computation. The widget is considered finished once all its outputs are valid. Parameters ---------- widget : Optional[OWBaseWidget] widget to send signal to. If not set, self.widget is used timeout : int The amount of time to wait for the widget to complete. """ if widget is None: widget = self.widget self.assertTrue( self.signal_manager.wait_for_finished(widget, timeout), f"Did not finish in the specified {timeout}ms timeout" ) def commit_and_wait(self, widget=None, wait=DEFAULT_TIMEOUT): """Unconditional commit and wait until finished. Parameters ---------- widget : Optional[OWBaseWidget] widget to send signal to. If not set, self.widget is used wait : int The amount of time to wait for the widget to complete. """ if widget is None: widget = self.widget if hasattr(widget.commit, "now"): widget.commit.now() else: # Support for deprecated, non-decorated commit widget.unconditional_commit() self.wait_until_finished(widget=widget, timeout=wait) def get_output(self, output=None, widget=None, wait=DEFAULT_TIMEOUT): """Return the last output that has been sent from the widget. Parameters ---------- output_name : str widget : Optional[OWBaseWidget] widget whose output is returned. If not set, self.widget is used wait : int The amount of time (in milliseconds) to wait for widget to complete. Returns ------- The last sent value of given output or None if nothing has been sent. """ if widget is None: # `output` may be an unbound signal with `widget` set to `None` # In this case, we use `self.widget`. widget = getattr(output, "widget", self.widget) or self.widget outputs = widget.outputs or widget.Outputs.__dict__.values() if output is None: assert len(outputs) == 1 output = next(iter(outputs)).name elif not isinstance(output, str): output = output.name # widget.outputs are old-style signals; if empty, use new style assert output in (out.name for out in outputs), \ "widget {} has no output {}".format(widget.name, output) value = widget.signalManager.get_output(widget, output, wait) if LazyValue.is_lazy(value): value = value.get_value() return value @contextmanager def modifiers(self, modifiers): """ Context that simulates pressed modifiers Since QTest.keypress requries pressing some key, we simulate pressing "BassBoost" that looks exotic enough to not meddle with anything. """ old_modifiers = QApplication.keyboardModifiers() try: QTest.keyPress(self.widget, Qt.Key_BassBoost, modifiers) yield finally: QTest.keyRelease(self.widget, Qt.Key_BassBoost, old_modifiers) def check_minimum_size(self, widget): def invalidate_cached_size_hint(w): # as in OWBaseWidget.setVisible if w.controlArea is not None: w.controlArea.updateGeometry() if w.buttonsArea is not None: w.buttonsArea.updateGeometry() if w.mainArea is not None: w.mainArea.updateGeometry() invalidate_cached_size_hint(widget) min_size = widget.minimumSizeHint() self.assertLess(min_size.width(), 800) self.assertLess(min_size.height(), 700) def check_msg_base_class(self, widget): """ Test whether widget error, warning and info messages are derived from its (direct) parent message classes. """ def inspect(msg): msg_cls = getattr(widget, msg).__class__ msg_base_cls = getattr(widget.__class__.__bases__[0], msg) self.assertTrue(issubclass(msg_cls, msg_base_cls)) inspect("Error") inspect("Warning") inspect("Information") def check_export_image(self, widget): widget.copy_to_clipboard() class TestWidgetTest(WidgetTest): """Meta tests for widget test helpers""" def test_process_events_handles_timeouts(self): with self.assertRaises(TimeoutError): self.process_events(until=lambda: False, timeout=0) def test_minimum_size(self): return # skip this test def test_check_msg_base_class(self): class A(OWBaseWidget, openclass=True): pass class B(A): class Error(A.Error): pass class C(A, openclass=True): class Error(OWBaseWidget.Error): pass class D(C): class Error(A.Error): pass self.check_msg_base_class(B()) self.check_msg_base_class(C()) # It is unfortunate that this passes... self.assertRaises(AssertionError, self.check_msg_base_class, D()) def test_get_single_output(self): class A(OWBaseWidget): name = "A" class Inputs(OWBaseWidget.Inputs): question = Input("Question", str, auto_summary=False) class Outputs(OWBaseWidget.Outputs): answer = Output("Answer", int, auto_summary=False) @Inputs.question def question(self, s): self.Outputs.answer.send(eval(s)) self.widget = self.create_widget(A) self.assertIsNone(self.get_output()) self.send_signal("6 * 7") self.assertEquals(self.get_output(), 42) def test_compute_lazy_signals(self): class A(OWBaseWidget): name = "A" class Outputs(OWBaseWidget.Outputs): answer = Output("Answer", int, auto_summary=False) def __init__(self): self.Outputs.answer.send(LazyValue[int](lambda: 42)) self.widget = self.create_widget(A) self.assertEquals(self.get_output(), 42) class BaseParameterMapping: """Base class for mapping between gui components and learner's parameters when testing learner widgets. Parameters ---------- name : str Name of learner's parameter. gui_element : QWidget Gui component who's corresponding parameter is to be tested. values: list List of values to be tested. getter: function It gets component's value. setter: function It sets component's value. """ def __init__(self, name, gui_element, values, getter, setter, problem_type="both"): self.name = name self.gui_element = gui_element self.values = values self.get_value = getter self.set_value = setter self.problem_type = problem_type def __str__(self): if self.problem_type == "both": return self.name else: return "%s (%s)" % (self.name, self.problem_type) class DefaultParameterMapping(BaseParameterMapping): """Class for mapping between gui components and learner's parameters when testing unchecked properties and therefore default parameters should be used. Parameters ---------- name : str Name of learner's parameter. default_value: str, int, Value that should be used by default. """ def __init__(self, name, default_value): super().__init__(name, None, [default_value], lambda: default_value, lambda x: None) class ParameterMapping(BaseParameterMapping): """Class for mapping between gui components and learner parameters when testing learner widgets Parameters ---------- name : str Name of learner's parameter. gui_element : QWidget Gui component who's corresponding parameter is to be tested. values: list, mandatory for ComboBox, optional otherwise List of values to be tested. When None, it is set according to component's type. getter: function, optional It gets component's value. When None, it is set according to component's type. setter: function, optional It sets component's value. When None, it is set according to component's type. """ def __init__(self, name, gui_element, values=None, getter=None, setter=None, **kwargs): super().__init__( name, gui_element, values or self._default_values(gui_element), getter or self._default_get_value(gui_element, values), setter or self._default_set_value(gui_element, values), **kwargs) @staticmethod def get_gui_element(widget, attribute): return widget.controlled_attributes[attribute][0].control @classmethod def from_attribute(cls, widget, attribute, parameter=None): return cls(parameter or attribute, cls.get_gui_element(widget, attribute)) @staticmethod def _default_values(gui_element): if isinstance(gui_element, (QSpinBox, QDoubleSpinBox, QSlider)): return [gui_element.minimum(), gui_element.maximum()] else: raise TypeError("{} is not supported".format(gui_element)) @staticmethod def _default_get_value(gui_element, values): if isinstance(gui_element, (QSpinBox, QDoubleSpinBox, QSlider)): return lambda: gui_element.value() elif isinstance(gui_element, QComboBox): return lambda: values[gui_element.currentIndex()] else: raise TypeError("{} is not supported".format(gui_element)) @staticmethod def _default_set_value(gui_element, values): if isinstance(gui_element, (QSpinBox, QDoubleSpinBox, QSlider)): return lambda val: gui_element.setValue(val) elif isinstance(gui_element, QComboBox): def fun(val): value = values.index(val) gui_element.activated.emit(value) gui_element.setCurrentIndex(value) return fun else: raise TypeError("{} is not supported".format(gui_element)) @contextmanager def open_widget_classes(): with patch.object(OWBaseWidget, "__init_subclass__"): yield @contextmanager def override_default_settings(widget, defaults=None, context_defaults=[], handler=None): if defaults is None: defaults = {} h = (handler or SettingsHandler)() h.widget_class = widget h.defaults = defaults filename = h._get_settings_filename() os.makedirs(os.path.dirname(filename), exist_ok=True) with open(filename, "wb") as f: pickle.dump(defaults, f) pickle.dump(context_defaults, f) yield if os.path.isfile(filename): os.remove(filename) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/tests/test_context_handler.py0000644000076500000240000003107014334703654025133 0ustar00primozstaffimport pickle from copy import copy, deepcopy from io import BytesIO from unittest import TestCase from unittest.mock import Mock, patch, call from AnyQt.QtCore import pyqtSignal as Signal, QObject from orangewidget.settings import ( ContextHandler, ContextSetting, Context, Setting, SettingsPrinter, VERSION_KEY, IncompatibleContext, SettingProvider) from orangewidget.tests.base import override_default_settings __author__ = 'anze' class Component: int_setting = Setting(42) context_setting = ContextSetting("global") schema_only_context_setting = ContextSetting("only", schema_only=True) class SimpleWidget(QObject): settings_version = 1 setting = Setting(42) schema_only_setting = Setting(None, schema_only=True) context_setting = ContextSetting(42) schema_only_context_setting = ContextSetting(None, schema_only=True) settingsAboutToBePacked = Signal() component = SettingProvider(Component) migrate_settings = Mock() migrate_context = Mock() storeSpecificSettings = Mock() def __init__(self): super().__init__() self.component = Component() class DummyContext(Context): id = 0 def __init__(self, version=None): super().__init__() DummyContext.id += 1 self.id = DummyContext.id if version: self.values[VERSION_KEY] = version def __repr__(self): return "Context(id={})".format(self.id) __str__ = __repr__ def __eq__(self, other): if not isinstance(other, DummyContext): return False return self.id == other.id def create_defaults_file(contexts): b = BytesIO() pickle.dump({"x": 5}, b) pickle.dump(contexts, b) b.seek(0) return b class TestContextHandler(TestCase): def test_read_defaults(self): contexts = [DummyContext() for _ in range(3)] handler = ContextHandler() handler.widget_class = SimpleWidget handler.provider = SettingProvider(handler.widget_class) # Old settings without version migrate_context = Mock() with patch.object(SimpleWidget, "migrate_context", migrate_context): handler.read_defaults_file(create_defaults_file(contexts)) self.assertSequenceEqual(handler.global_contexts, contexts) migrate_context.assert_has_calls([call(c, 0) for c in contexts]) # Settings with version contexts = [DummyContext(version=i) for i in range(1, 4)] migrate_context.reset_mock() with patch.object(SimpleWidget, "migrate_context", migrate_context): handler.read_defaults_file(create_defaults_file(contexts)) self.assertSequenceEqual(handler.global_contexts, contexts) migrate_context.assert_has_calls([call(c, c.values[VERSION_KEY]) for c in contexts]) def test_read_defaults_ensures_no_schema_only(self): handler = ContextHandler() handler.widget_class = SimpleWidget handler.provider = SettingProvider(SimpleWidget) def migrate_settings(settings, _): settings["setting"] = 5 settings["schema_only_setting"] = True def migrate_context(context, _): context.values["context_setting"] = 5 context.values["schema_only_context_setting"] = True with patch.object(SimpleWidget, "migrate_settings", migrate_settings), \ patch.object(SimpleWidget, "migrate_context", migrate_context), \ override_default_settings(SimpleWidget, {"value": 42}, [DummyContext()], handler=ContextHandler): handler.read_defaults() self.assertEqual(handler.defaults, {'value': 42, 'setting': 5}) self.assertEqual(handler.global_contexts[0].values, {'context_setting': 5}) def test_initialize(self): handler = ContextHandler() handler.provider = Mock() handler.widget_class = SimpleWidget # Context settings from data widget = SimpleWidget() context_settings = [DummyContext()] handler.initialize(widget, {'context_settings': context_settings}) self.assertTrue(hasattr(widget, 'context_settings')) self.assertEqual(widget.context_settings, context_settings) # Default (global) context settings widget = SimpleWidget() handler.initialize(widget) self.assertTrue(hasattr(widget, 'context_settings')) self.assertEqual(widget.context_settings, handler.global_contexts) def test_initialize_migrates_contexts(self): handler = ContextHandler() handler.bind(SimpleWidget) widget = SimpleWidget() # Old settings without version contexts = [DummyContext() for _ in range(3)] migrate_context = Mock() with patch.object(SimpleWidget, "migrate_context", migrate_context): handler.initialize(widget, dict(context_settings=contexts)) migrate_context.assert_has_calls([call(c, 0) for c in contexts]) # Settings with version contexts = [DummyContext(version=i) for i in range(1, 4)] migrate_context = Mock() with patch.object(SimpleWidget, "migrate_context", migrate_context): handler.initialize(widget, dict(context_settings=deepcopy(contexts))) migrate_context.assert_has_calls([call(c, c.values[VERSION_KEY]) for c in contexts]) def test_migrates_settings_removes_incompatible(self): handler = ContextHandler() handler.bind(SimpleWidget) widget = SimpleWidget() contexts = [Context(foo=i) for i in (13, 13, 0, 1, 13, 2, 13)] def migrate_context(context, _): if context.foo == 13: raise IncompatibleContext() with patch.object(SimpleWidget, "migrate_context", migrate_context): handler.initialize(widget, dict(context_settings=contexts)) contexts = widget.context_settings self.assertEqual(len(contexts), 3) self.assertTrue( all(context.foo == i for i, context in enumerate(contexts))) def test_fast_save(self): handler = ContextHandler() handler.bind(SimpleWidget) widget = SimpleWidget() handler.initialize(widget) context = widget.current_context = handler.new_context() handler.fast_save(widget, 'context_setting', 55) self.assertEqual(context.values['context_setting'], 55) self.assertEqual(handler.known_settings['context_setting'].default, SimpleWidget.context_setting.default) def test_find_or_create_context(self): widget = SimpleWidget() handler = ContextHandler() handler.match = lambda context, i: (context.i == i) * 2 handler.clone_context = lambda context, i: copy(context) c1, c2, c3, c4, c5, c6, c7, c8, c9 = (Context(i=i) for i in range(1, 10)) # finding a perfect match in global_contexts should copy it to # the front of context_settings (and leave globals as-is) widget.context_settings = [c2, c5] handler.global_contexts = [c3, c7] context, new = handler.find_or_create_context(widget, 7) self.assertEqual(context.i, 7) self.assertEqual([c.i for c in widget.context_settings], [7, 2, 5]) self.assertEqual([c.i for c in handler.global_contexts], [3, 7]) # finding a perfect match in context_settings should move it to # the front of the list widget.context_settings = [c2, c5] handler.global_contexts = [c3, c7] context, new = handler.find_or_create_context(widget, 5) self.assertEqual(context.i, 5) self.assertEqual([c.i for c in widget.context_settings], [5, 2]) self.assertEqual([c.i for c in handler.global_contexts], [3, 7]) def test_pack_settings_stores_version(self): handler = ContextHandler() handler.bind(SimpleWidget) widget = SimpleWidget() handler.initialize(widget) widget.context_setting = [DummyContext() for _ in range(3)] settings = handler.pack_data(widget) self.assertIn("context_settings", settings) for c in settings["context_settings"]: self.assertIn(VERSION_KEY, c.values) def test_write_defaults_stores_version(self): handler = ContextHandler() handler.bind(SimpleWidget) widget = SimpleWidget() widget.current_context = None widget.context_settings = [DummyContext() for _ in range(3)] handler.update_defaults(widget) f = BytesIO() f.close = lambda: None with patch("builtins.open", Mock(return_value=f)): handler.write_defaults() f.seek(0) pickle.load(f) # settings contexts = pickle.load(f) for c in contexts: self.assertEqual(c.values.get("__version__", 0xBAD), 1) def test_close_context(self): handler = ContextHandler() handler.bind(SimpleWidget) widget = SimpleWidget() widget.storeSpecificSettings = Mock() handler.initialize(widget) widget.schema_only_setting = 0xD06F00D widget.current_context = handler.new_context() handler.close_context(widget) self.assertEqual(widget.schema_only_setting, 0xD06F00D) def test_about_pack_settings_signal(self): handler = ContextHandler() handler.bind(SimpleWidget) widget = SimpleWidget() handler.initialize(widget) fn = Mock() widget.settingsAboutToBePacked.connect(fn) handler.pack_data(widget) self.assertEqual(1, fn.call_count) handler.update_defaults(widget) self.assertEqual(2, fn.call_count) def test_schema_only_settings(self): handler = ContextHandler() with override_default_settings(SimpleWidget, handler=ContextHandler): handler.bind(SimpleWidget) # fast_save should not update defaults widget = SimpleWidget() handler.initialize(widget) context = widget.current_context = handler.new_context() widget.context_settings.append(context) handler.fast_save(widget, 'schema_only_context_setting', 5) self.assertEqual( handler.known_settings['schema_only_context_setting'].default, None) handler.fast_save(widget, 'component.schema_only_context_setting', 5) self.assertEqual( handler.known_settings['component.schema_only_context_setting'].default, "only") # update_defaults should not update defaults widget.schema_only_context_setting = 5 handler.update_defaults(widget) self.assertEqual( handler.known_settings['schema_only_context_setting'].default, None) widget.component.schema_only_setting = 5 self.assertEqual( handler.known_settings['component.schema_only_context_setting'].default, "only") # close_context should pack setting widget.schema_only_context_setting = 5 widget.component.context_setting = 5 widget.component.schema_only_context_setting = 5 handler.close_context(widget) global_values = handler.global_contexts[0].values self.assertTrue('context_setting' in global_values) self.assertFalse('schema_only_context_setting' in global_values) self.assertTrue('context_setting' in global_values["component"]) self.assertFalse('schema_only_context_setting' in global_values["component"]) class TestSettingsPrinter(TestCase): def test_formats_contexts(self): settings = dict(key1=1, key2=2, context_settings=[ Context(param1=1, param2=2, values=dict(value1=1, value2=2)), Context(param1=3, param2=4, values=dict(value1=5, value2=6)) ]) pp = SettingsPrinter() output = pp.pformat(settings) # parameter of all contexts should be visible in the output self.assertIn("param1=1", output) self.assertIn("param2=2", output) self.assertIn("param1=3", output) self.assertIn("param2=4", output) class TestContext(TestCase): def test_context_eq(self): context1 = Context(x=12, y=["a", "list"]) context2 = Context(x=12, y=["a", "list"]) context3 = Context(x=13, y=["a", "list"]) self.assertTrue(context1 == context2) self.assertFalse(context2 == context3) self.assertRaises(TypeError, hash, context1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714198.0 orange-widget-base-4.22.0/orangewidget/tests/test_control_getter.py0000644000076500000240000000177714306600526025010 0ustar00primozstaff# Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring from orangewidget import gui from orangewidget.gui import OWComponent from orangewidget.settings import SettingProvider from orangewidget.tests.base import WidgetTest from orangewidget.widget import OWBaseWidget class DummyComponent(OWComponent): foo = True class MyWidget(OWBaseWidget): foo = True component = SettingProvider(DummyComponent) def __init__(self): super().__init__() self.component = DummyComponent(self) self.foo_control = gui.checkBox(self.controlArea, self, "foo", "") self.component_foo_control = \ gui.checkBox(self.controlArea, self, "component.foo", "") class ControlGetterTests(WidgetTest): def test_getter(self): widget = self.create_widget(MyWidget) self.assertIs(widget.controls.foo, widget.foo_control) self.assertIs(widget.controls.component.foo, widget.component_foo_control) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/tests/test_gui.py0000644000076500000240000004117014440334174022533 0ustar00primozstaffimport time import unittest from unittest.mock import Mock from AnyQt.QtCore import Qt, QTimer, QDateTime, QDate, QTime from orangewidget import gui from orangewidget.tests.base import GuiTest, WidgetTest from orangewidget.utils.tests.test_itemdelegates import paint_with_data from orangewidget.widget import OWBaseWidget class TestDoubleSpin(GuiTest): # make sure that the gui element does not crash when # 'checked' parameter is forwarded, ie. is not None def test_checked_extension(self): widget = OWBaseWidget() widget.some_param = 0 widget.some_option = False gui.doubleSpin(widget=widget, master=widget, value="some_param", minv=1, maxv=10, checked="some_option") class TestFloatSlider(GuiTest): def test_set_value(self): w = gui.FloatSlider(Qt.Horizontal, 0., 1., 0.5) w.setValue(1) # Float slider returns value divided by step # 1/0.5 = 2 self.assertEqual(w.value(), 2) w = gui.FloatSlider(Qt.Horizontal, 0., 1., 0.05) w.setValue(1) # 1/0.05 = 20 self.assertEqual(w.value(), 20) class TestDelayedNotification(WidgetTest): def test_immediate(self): dn = gui.DelayedNotification(timeout=5000) call = Mock() dn.notification.connect(call) dn.notify_immediately() self.process_events(lambda: call.call_args is not None, timeout=1) def test_notify_eventually(self): dn = gui.DelayedNotification(timeout=500) call = Mock() dn.notification.connect(call) dn.changed() self.process_events(lambda: True, timeout=1) self.assertIsNone(call.call_args) # no immediate notification self.process_events(lambda: call.call_args is not None) def test_delay_by_change(self): dn = gui.DelayedNotification(timeout=500) call = Mock() dn.notification.connect(call) timer = QTimer() timer.timeout.connect(dn.changed) timer.start(100) dn.changed() # notification should never be emitted as the input changes too fast with self.assertRaises(TimeoutError): self.process_events(lambda: call.call_args is not None, timeout=1000) def test_no_notification_on_no_change(self): dn = gui.DelayedNotification(timeout=500) call = Mock() dn.notification.connect(call) dn.changed(42) dn.notify_immediately() # only for faster test self.process_events(lambda: call.call_args is not None) # wait for the first call dn.changed(43) dn.changed(42) # notification should not be called again with self.assertRaises(TimeoutError): self.process_events(lambda: len(call.call_args_list) > 1, timeout=1000) class TestCheckBoxWithDisabledState(GuiTest): def test_check_checkbox_disable_false(self): widget = OWBaseWidget() widget.some_option = False cb = gui.checkBox(widget, widget, "some_option", "foo", stateWhenDisabled=False) self.assertFalse(cb.isChecked()) cb.setEnabled(False) self.assertFalse(cb.isChecked()) widget.some_option = True self.assertFalse(cb.isChecked()) cb.setEnabled(True) self.assertTrue(cb.isChecked()) widget.some_option = False self.assertFalse(cb.isChecked()) cb.setDisabled(True) self.assertFalse(cb.isChecked()) widget.some_option = True self.assertFalse(cb.isChecked()) cb.setDisabled(False) self.assertTrue(cb.isChecked()) widget.some_option = False self.assertFalse(cb.isChecked()) def test_check_checkbox_disable_true(self): widget = OWBaseWidget() widget.some_option = False cb = gui.checkBox(widget, widget, "some_option", "foo", stateWhenDisabled=True) self.assertFalse(cb.isChecked()) cb.setEnabled(False) self.assertTrue(cb.isChecked()) widget.some_option = True self.assertTrue(cb.isChecked()) cb.setEnabled(True) self.assertTrue(cb.isChecked()) widget.some_option = False self.assertFalse(cb.isChecked()) cb.setDisabled(True) self.assertTrue(cb.isChecked()) widget.some_option = True self.assertTrue(cb.isChecked()) cb.setDisabled(False) self.assertTrue(cb.isChecked()) widget.some_option = False self.assertFalse(cb.isChecked()) def test_clicks(self): widget = OWBaseWidget() widget.some_option = False cb = gui.checkBox(widget, widget, "some_option", "foo", stateWhenDisabled=False) cb.clicked.emit(True) cb.setEnabled(False) cb.setEnabled(True) self.assertTrue(cb.isChecked()) def test_set_checked(self): widget = OWBaseWidget() widget.some_option = False cb = gui.checkBox(widget, widget, "some_option", "foo", stateWhenDisabled=False) self.assertFalse(cb.isChecked()) cb.setEnabled(False) cb.setChecked(True) self.assertFalse(cb.isChecked()) cb.setEnabled(True) self.assertTrue(cb.isChecked()) widget.some_option = True cb = gui.checkBox(widget, widget, "some_option", "foo", stateWhenDisabled=True) self.assertTrue(cb.isChecked()) cb.setEnabled(False) cb.setChecked(False) self.assertTrue(cb.isChecked()) cb.setEnabled(True) self.assertFalse(cb.isChecked()) def test_set_check_state(self): widget = OWBaseWidget() widget.some_option = 0 cb = gui.checkBox(widget, widget, "some_option", "foo", stateWhenDisabled=Qt.Unchecked) cb.setCheckState(Qt.Unchecked) cb.setEnabled(False) self.assertEqual(cb.checkState(), Qt.Unchecked) cb.setCheckState(Qt.PartiallyChecked) self.assertEqual(cb.checkState(), Qt.Unchecked) cb.setEnabled(True) self.assertEqual(cb.checkState(), Qt.PartiallyChecked) cb.setEnabled(False) self.assertEqual(cb.checkState(), Qt.Unchecked) cb.setCheckState(Qt.Checked) self.assertEqual(cb.checkState(), Qt.Unchecked) cb.setEnabled(True) self.assertEqual(cb.checkState(), Qt.Checked) cb.setEnabled(False) self.assertEqual(cb.checkState(), Qt.Unchecked) widget.some_option = 2 cb = gui.checkBox(widget, widget, "some_option", "foo", stateWhenDisabled=Qt.PartiallyChecked) cb.setCheckState(Qt.Unchecked) cb.setEnabled(False) self.assertEqual(cb.checkState(), Qt.PartiallyChecked) cb.setCheckState(Qt.Unchecked) self.assertEqual(cb.checkState(), Qt.PartiallyChecked) cb.setEnabled(True) self.assertEqual(cb.checkState(), Qt.Unchecked) cb.setEnabled(False) self.assertEqual(cb.checkState(), Qt.PartiallyChecked) cb.setCheckState(Qt.Checked) self.assertEqual(cb.checkState(), Qt.PartiallyChecked) cb.setEnabled(True) self.assertEqual(cb.checkState(), Qt.Checked) cb.setEnabled(False) self.assertEqual(cb.checkState(), Qt.PartiallyChecked) class TestDateTimeEditWCalendarTime(GuiTest): def test_set_datetime(self): c = gui.DateTimeEditWCalendarTime(None) # default time (now) c.set_datetime() self.assertLessEqual( abs(c.dateTime().toSecsSinceEpoch() - time.time()), 2) # some date poeh = QDateTime(QDate(1961, 4, 12), QTime(6, 7)) c.set_datetime(poeh) self.assertEqual(c.dateTime(), poeh) # set a different time ali = QTime(8, 5) c.set_datetime(ali) poeh.setTime(ali) self.assertEqual(c.dateTime(), poeh) class TestDeferred(WidgetTest): def test_deferred(self) -> None: class Widget(OWBaseWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.option = False self.autocommit = False self.checkbox = gui.checkBox(self, self, "option", "foo", callback=self.apply.deferred) self.commit_button = gui.auto_commit( self, self, 'autocommit', 'Commit', commit=self.apply) real_apply = Mock() # Unlike real functions, mocks don't have names real_apply.__name__ = "apply" apply = gui.deferred(real_apply) w = self.create_widget(Widget) self.assertFalse(w.commit_button.button.isEnabled()) # clicked, but no autocommit w.checkbox.click() w.real_apply.assert_not_called() self.assertTrue(w.commit_button.button.isEnabled()) # manual commit w.commit_button.button.click() self.assertFalse(w.commit_button.button.isEnabled()) w.real_apply.assert_called() w.real_apply.reset_mock() # enable auto commit - this should not trigger commit w.commit_button.checkbox.click() self.assertFalse(w.commit_button.button.isEnabled()) w.real_apply.assert_not_called() # clicking control should auto commit w.checkbox.click() self.assertFalse(w.commit_button.button.isEnabled()) w.real_apply.assert_called() w.real_apply.reset_mock() # disabling and reenable auto commit without changing the control # should not trigger commit w.commit_button.checkbox.click() # now disabled self.assertFalse(w.commit_button.button.isEnabled()) w.real_apply.assert_not_called() w.commit_button.checkbox.click() # now enabled again self.assertFalse(w.commit_button.button.isEnabled()) w.real_apply.assert_not_called() # enabling auto-commit at dirty state should auto-commit and disable the button w.commit_button.checkbox.click() # now disabled w.checkbox.click() # State is dirty w.real_apply.assert_not_called() # ... but commit is not called self.assertTrue(w.commit_button.button.isEnabled()) # ... so button is enabled. w.commit_button.checkbox.click() # Enabling auto commit w.real_apply.assert_called() # ... calls commit self.assertFalse(w.commit_button.button.isEnabled()) # ... and disables the button w.real_apply.reset_mock() w.commit_button.checkbox.click() # Disabling auto commit self.assertFalse(w.commit_button.button.isEnabled()) # ... keeps the button disabled w.commit_button.checkbox.click() # Enabling auto commit ... self.assertFalse(w.commit_button.button.isEnabled()) # ... keeps the button disabled # calling now should always call the apply w.apply.now() self.assertFalse(w.commit_button.button.isEnabled()) w.real_apply.assert_called_with(w) w.real_apply.reset_mock() # calling decorated method without `now` or `deferred` raises an expception self.assertRaises(RuntimeError, w.apply) w2 = self.create_widget(Widget) w.apply.now() w.real_apply.assert_called_with(w) w.real_apply.reset_mock() def test_warn_to_defer(self): class Widget(OWBaseWidget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.autocommit = False self.commit_button = gui.auto_commit( self, self, 'autocommit', 'Commit') def commit(self): pass with self.assertWarns(UserWarning): _ = self.create_widget(Widget) def test_override(self): class Widget(OWBaseWidget, openclass=True): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.autocommit = False self.commit_button = gui.auto_commit( self, self, 'autocommit', 'Commit') m = Mock() n = Mock() @gui.deferred def commit(self): self.m() class Widget2(Widget): @gui.deferred def commit(self): super().commit() self.n() w = self.create_widget(Widget2) w.commit.now() w.m.assert_called_once() w.n.assert_called_once() w.m.reset_mock() w.n.reset_mock() class Widget3(Widget): @gui.deferred def commit(self): self.n() w = self.create_widget(Widget3) w.commit.now() w.m.assert_not_called() w.n.assert_called_once() w.m.reset_mock() w.n.reset_mock() # This tests that exception is raised if derived method is undecorated class Widget4(Widget): def commit(self): self.n() self.assertRaises(RuntimeError, lambda: self.create_widget(Widget4)) def test_override_and_decorate(self): class Widget(OWBaseWidget, openclass=True): m = Mock() n = Mock() def commit(self): self.m() class Widget2(Widget): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.autocommit = False self.commit_button = gui.auto_commit( self, self, 'autocommit', 'Commit') @gui.deferred def commit(self): super().commit() self.n() w = self.create_widget(Widget2) w.commit.deferred() w.m.assert_not_called() w.n.assert_not_called() w.autocommit = True w.commit.deferred() w.m.assert_called_once() w.n.assert_called_once() def test_two_autocommits(self): class Widget(OWBaseWidget, openclass=True): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.autocommit = False self.automagog = False self.commit_button = gui.auto_commit( self, self, 'autocommit', 'Commit', commit=self.commit) self.magog_button = gui.auto_commit( self, self, 'automagog', 'Magog', commit=self.magog) real_commit = Mock() real_magog = Mock() @gui.deferred def commit(self): self.real_commit() @gui.deferred def magog(self): self.real_magog() w = self.create_widget(Widget) # Make a deffered call to commit; nothing should be called w.commit.deferred() w.real_commit.assert_not_called() w.real_magog.assert_not_called() # enable check boxes, but only commit is dirty w.commit_button.checkbox.click() w.magog_button.checkbox.click() w.real_commit.assert_called() w.real_magog.assert_not_called() w.real_commit.reset_mock() # disable, enable, disable; nothing is dirty => shouldn't call anything w.commit_button.checkbox.click() w.magog_button.checkbox.click() w.commit_button.checkbox.click() w.magog_button.checkbox.click() w.commit_button.checkbox.click() w.magog_button.checkbox.click() # Make a deffered call to magog; nothing should be called w.magog.deferred() w.real_commit.assert_not_called() w.real_magog.assert_not_called() # enable check boxes, but only magog is dirty w.commit_button.checkbox.click() w.magog_button.checkbox.click() w.real_commit.assert_not_called() w.real_magog.assert_called() w.real_magog.reset_mock() # disable, enable; nothing is dirty => shouldn't call anything w.commit_button.checkbox.click() w.magog_button.checkbox.click() w.commit_button.checkbox.click() w.magog_button.checkbox.click() class TestBarItemDelegate(GuiTest): def test(self): delegate = gui.BarItemDelegate(None) paint_with_data(delegate, {Qt.DisplayRole: 0.5}) class TestIndicatorItemDelegate(GuiTest): def test(self): delegate = gui.IndicatorItemDelegate(None) paint_with_data(delegate, {Qt.DisplayRole: True}) class TestColoredBarItemDelegate(GuiTest): def test(self): delegagte = gui.ColoredBarItemDelegate() paint_with_data(delegagte, {Qt.DisplayRole: 1.0, gui.BarRatioRole: 0.5}) nan = float("nan") paint_with_data(delegagte, {Qt.DisplayRole: nan, gui.BarRatioRole: nan}) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/tests/test_io.py0000644000076500000240000001152214440334174022354 0ustar00primozstaffimport os import tempfile import unittest from unittest.mock import patch, Mock import pyqtgraph import pyqtgraph.exporters from AnyQt.QtWidgets import QGraphicsScene, QGraphicsRectItem from AnyQt.QtGui import QImage from orangewidget.tests.base import GuiTest, named_file from orangewidget import io as imgio @unittest.skipUnless(hasattr(imgio, "PdfFormat"), "QPdfWriter not available") class TestIO(GuiTest): def test_pdf(self): sc = QGraphicsScene() sc.addItem(QGraphicsRectItem(0, 0, 20, 20)) fd, fname = tempfile.mkstemp() os.close(fd) try: imgio.PdfFormat.write_image(fname, sc) finally: os.unlink(fname) class TestImgFormat(GuiTest): def test_pyqtgraph_exporter(self): scene = QGraphicsScene() graph = pyqtgraph.ScatterPlotItem() scene.addItem(graph) with patch("orangewidget.io.ImgFormat._get_exporter") as mfn, \ patch("orangewidget.io.ImgFormat._export"): imgio.ImgFormat.write("", graph) self.assertEqual(1, mfn.call_count) # run pyqtgraph exporter def test_other_exporter(self): sc = QGraphicsScene() sc.addItem(QGraphicsRectItem(0, 0, 3, 3)) with patch("orangewidget.io.ImgFormat._get_exporter", Mock()) as mfn: with self.assertRaises(Exception): imgio.ImgFormat.write("", sc) self.assertEqual(0, mfn.call_count) class TestPng(GuiTest): def test_pyqtgraph(self): fd, fname = tempfile.mkstemp('.png') os.close(fd) graph = pyqtgraph.PlotWidget() try: imgio.PngFormat.write(fname, graph) im = QImage(fname) self.assertLess((200, 200), (im.width(), im.height())) finally: os.unlink(fname) def test_other(self): fd, fname = tempfile.mkstemp('.png') os.close(fd) sc = QGraphicsScene() sc.addItem(QGraphicsRectItem(0, 0, 3, 3)) try: imgio.PngFormat.write(fname, sc) im = QImage(fname) # writer adds 15*2 of empty space # actual size depends upon ratio! #self.assertEqual((30+4, 30+4), (im.width(), im.height())) finally: os.unlink(fname) class TestPdf(GuiTest): def test_pyqtgraph(self): fd, fname = tempfile.mkstemp('.pdf') os.close(fd) graph = pyqtgraph.PlotWidget() try: imgio.PdfFormat.write(fname, graph) with open(fname, "rb") as f: self.assertTrue(f.read().startswith(b'%PDF')) size_empty = os.path.getsize(fname) finally: os.unlink(fname) # does a ScatterPlotItem increases file size == is it drawn graph = pyqtgraph.PlotWidget() plot = pyqtgraph.ScatterPlotItem(x=list(range(100)), y=list(range(100))) graph.addItem(plot) try: imgio.PdfFormat.write(fname, plot) self.assertGreater(os.path.getsize(fname), size_empty + 5000) finally: os.unlink(fname) # does a PlotCurveItem increases file size == is it drawn graph = pyqtgraph.PlotWidget() graph.addItem(pyqtgraph.PlotCurveItem(x=list(range(100)), y=list(range(100)))) try: imgio.PdfFormat.write(fname, graph) self.assertGreater(os.path.getsize(fname), size_empty + 600) finally: os.unlink(fname) def test_other(self): fd, fname = tempfile.mkstemp('.pdf') os.close(fd) sc = QGraphicsScene() sc.addItem(QGraphicsRectItem(0, 0, 3, 3)) try: imgio.PdfFormat.write(fname, sc) with open(fname, "rb") as f: self.assertTrue(f.read().startswith(b'%PDF')) finally: os.unlink(fname) class TestMatplotlib(GuiTest): def setUp(self): super().setUp() plt = pyqtgraph.PlotWidget() plt.addItem(pyqtgraph.ScatterPlotItem( x=[0.0, 0.1, 0.2, 0.3], y=[0.1, 0.2, 0.1, 0.2], )) self.plt = plt def tearDown(self): del self.plt super().tearDown() def test_python(self): with named_file("", suffix=".py") as fname: imgio.MatplotlibFormat.write(fname, self.plt.plotItem) with open(fname, "rt") as f: code = f.read() self.assertIn("plt.show()", code) self.assertIn("plt.scatter", code) # test if the runs exec(code.replace("plt.show()", ""), {}) def test_pdf(self): with named_file("", suffix=".pdf") as fname: imgio.MatplotlibPDFFormat.write(fname, self.plt.plotItem) with open(fname, "rb") as f: code = f.read() self.assertTrue(code.startswith(b"%PDF")) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/tests/test_matplotlib_export.py0000644000076500000240000000122614306600442025510 0ustar00primozstaffimport pyqtgraph as pg from orangewidget.tests.base import GuiTest from orangewidget.utils.matplotlib_export import scatterplot_code def add_intro(a): r = "import matplotlib.pyplot as plt\n" + \ "from numpy import array\n" + \ "plt.clf()" return r + a class TestScatterPlot(GuiTest): def test_scatterplot_simple(self): plotWidget = pg.PlotWidget(background="w") scatterplot = pg.ScatterPlotItem() scatterplot.setData(x=[1, 2, 3], y=[3, 2, 1]) plotWidget.addItem(scatterplot) code = scatterplot_code(scatterplot) self.assertIn("plt.scatter", code) exec(add_intro(code), {}) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/tests/test_setting_provider.py0000644000076500000240000002513014306600442025327 0ustar00primozstaffimport unittest from orangewidget.settings import Setting, SettingProvider SHOW_ZOOM_TOOLBAR = "show_zoom_toolbar" SHOW_GRAPH = "show_graph" GRAPH = "graph" ZOOM_TOOLBAR = "zoom_toolbar" SHOW_LABELS = "show_labels" SHOW_X_AXIS = "show_x_axis" SHOW_Y_AXIS = "show_y_axis" ALLOW_ZOOMING = "allow_zooming" A_LIST = "a_list" A_SET = "a_set" A_DICT = "a_dict" class SettingProviderTestCase(unittest.TestCase): def setUp(self): global default_provider default_provider = SettingProvider(Widget) def tearDown(self): default_provider.settings[SHOW_GRAPH].default = True default_provider.settings[SHOW_ZOOM_TOOLBAR].default = True default_provider.providers[GRAPH].settings[SHOW_LABELS].default = True default_provider.providers[GRAPH].settings[SHOW_X_AXIS].default = True default_provider.providers[GRAPH].settings[SHOW_Y_AXIS].default = True default_provider.providers[ZOOM_TOOLBAR].settings[ALLOW_ZOOMING].default = True def test_registers_all_settings(self): self.assertDefaultSettingsEqual(default_provider, { GRAPH: { SHOW_LABELS: True, SHOW_X_AXIS: True, SHOW_Y_AXIS: True, }, ZOOM_TOOLBAR: { ALLOW_ZOOMING: True, }, SHOW_ZOOM_TOOLBAR: True, SHOW_GRAPH: True, }) def test_initialize_sets_defaults(self): widget = Widget() self.assertEqual(widget.show_graph, True) self.assertEqual(widget.show_zoom_toolbar, True) self.assertEqual(widget.graph.a_list, []) self.assertEqual(widget.graph.a_set, {1, 2, 3}) self.assertEqual(widget.graph.a_dict, {1: 2, 3: 4}) self.assertEqual(widget.graph.show_labels, True) self.assertEqual(widget.graph.show_x_axis, True) self.assertEqual(widget.graph.show_y_axis, True) self.assertEqual(widget.zoom_toolbar.allow_zooming, True) def test_initialize_with_data_sets_values_from_data(self): widget = Widget() default_provider.initialize(widget, { SHOW_GRAPH: False, GRAPH: { SHOW_Y_AXIS: False } }) self.assertEqual(widget.show_graph, False) self.assertEqual(widget.show_zoom_toolbar, True) self.assertEqual(widget.graph.show_labels, True) self.assertEqual(widget.graph.show_x_axis, True) self.assertEqual(widget.graph.show_y_axis, False) self.assertEqual(widget.zoom_toolbar.allow_zooming, True) def test_initialize_with_data_stores_initial_values_until_instance_is_connected(self): widget = Widget.__new__(Widget) default_provider.initialize(widget, { SHOW_GRAPH: False, GRAPH: { SHOW_Y_AXIS: False } }) self.assertFalse(hasattr(widget.graph, SHOW_Y_AXIS)) widget.graph = Graph() self.assertEqual(widget.graph.show_y_axis, False) def test_get_provider(self): self.assertEqual(default_provider.get_provider(BaseWidget), None) self.assertEqual(default_provider.get_provider(Widget), default_provider) self.assertEqual(default_provider.get_provider(BaseGraph), None) self.assertEqual(default_provider.get_provider(Graph), default_provider.providers[GRAPH]) self.assertEqual(default_provider.get_provider(ExtendedGraph), default_provider.providers[GRAPH]) self.assertEqual(default_provider.get_provider(ZoomToolbar), default_provider.providers[ZOOM_TOOLBAR]) def test_pack_settings(self): widget = Widget() widget.show_graph = False widget.graph.show_y_axis = False packed_settings = default_provider.pack(widget) self.assertEqual(packed_settings, { SHOW_GRAPH: False, SHOW_ZOOM_TOOLBAR: True, GRAPH: { SHOW_LABELS: True, SHOW_X_AXIS: True, SHOW_Y_AXIS: False, A_LIST: [], A_SET: {1, 2, 3}, A_DICT: {1: 2, 3: 4} }, ZOOM_TOOLBAR: { ALLOW_ZOOMING: True, }, }) def test_unpack_settings(self): widget = Widget() default_provider.unpack(widget, { SHOW_GRAPH: False, GRAPH: { SHOW_Y_AXIS: False, }, }) self.assertEqual(widget.show_graph, False) self.assertEqual(widget.show_zoom_toolbar, True) self.assertEqual(widget.graph.show_labels, True) self.assertEqual(widget.graph.show_x_axis, True) self.assertEqual(widget.graph.show_y_axis, False) self.assertEqual(widget.zoom_toolbar.allow_zooming, True) def test_mutables_are_unpacked_in_place(self): widget = Widget() a_list = widget.graph.a_list a_set = widget.graph.a_set a_dict = widget.graph.a_dict default_provider.unpack(widget, { GRAPH: { A_LIST: [1, 2, 3], A_SET: {4, 5}, A_DICT: {6: 7} }, }) self.assertIs(a_list, widget.graph.a_list) self.assertEqual(a_list, [1, 2, 3]) self.assertIs(a_set, widget.graph.a_set) self.assertEqual(a_set, {4, 5}) self.assertIs(a_dict, widget.graph.a_dict) self.assertEqual(a_dict, {6: 7}) def test_traverse_settings_works_without_instance_or_data(self): settings = set() for setting, data, _ in default_provider.traverse_settings(): settings.add(setting.name) self.assertEqual(settings, { SHOW_ZOOM_TOOLBAR, SHOW_GRAPH, SHOW_LABELS, SHOW_X_AXIS, SHOW_Y_AXIS, A_LIST, A_SET, A_DICT, ALLOW_ZOOMING}) def test_traverse_settings_selects_correct_data(self): settings = {} graph_data = {SHOW_LABELS: 3, SHOW_X_AXIS: 4, SHOW_Y_AXIS: 5, A_LIST: [], A_SET: {1, 2, 3}, A_DICT: {1: 2, 3: 4}} zoom_data = {ALLOW_ZOOMING: 6} all_data = {SHOW_GRAPH: 1, SHOW_ZOOM_TOOLBAR: 2, GRAPH: graph_data, ZOOM_TOOLBAR: zoom_data} for setting, data, _ in default_provider.traverse_settings(all_data): settings[setting.name] = data self.assertEqual( settings, { SHOW_GRAPH: all_data, SHOW_ZOOM_TOOLBAR: all_data, SHOW_LABELS: graph_data, SHOW_X_AXIS: graph_data, SHOW_Y_AXIS: graph_data, A_LIST: graph_data, A_SET: graph_data, A_DICT: graph_data, ALLOW_ZOOMING: zoom_data, } ) def test_traverse_settings_with_partial_data(self): settings = {} graph_data = {SHOW_LABELS: 3, SHOW_X_AXIS: 4} all_data = {SHOW_GRAPH: 1, SHOW_ZOOM_TOOLBAR: 2, GRAPH: graph_data} for setting, data, _ in default_provider.traverse_settings(all_data): settings[setting.name] = data self.assertEqual( settings, { SHOW_GRAPH: all_data, SHOW_ZOOM_TOOLBAR: all_data, SHOW_LABELS: graph_data, SHOW_X_AXIS: graph_data, SHOW_Y_AXIS: graph_data, A_LIST: graph_data, A_SET: graph_data, A_DICT: graph_data, ALLOW_ZOOMING: {}, } ) def test_traverse_settings_selects_correct_instance(self): settings = {} widget = Widget() for setting, _, instance in \ default_provider.traverse_settings(instance=widget): settings[setting.name] = instance self.assertEqual( { SHOW_GRAPH: widget, SHOW_ZOOM_TOOLBAR: widget, SHOW_LABELS: widget.graph, SHOW_X_AXIS: widget.graph, SHOW_Y_AXIS: widget.graph, A_LIST: widget.graph, A_SET: widget.graph, A_DICT: widget.graph, ALLOW_ZOOMING: widget.zoom_toolbar, }, settings ) def test_traverse_settings_with_partial_instance(self): settings = {} widget = Widget() widget.graph = None for setting, _, instance in \ default_provider.traverse_settings(instance=widget): settings[setting.name] = instance self.assertEqual( settings, { SHOW_GRAPH: widget, SHOW_ZOOM_TOOLBAR: widget, SHOW_LABELS: None, SHOW_X_AXIS: None, SHOW_Y_AXIS: None, A_LIST: None, A_SET: None, A_DICT: None, ALLOW_ZOOMING: widget.zoom_toolbar, } ) def assertDefaultSettingsEqual(self, provider, defaults): for name, value in defaults.items(): if isinstance(value, dict): self.assertIn(name, provider.providers) self.assertDefaultSettingsEqual(provider.providers[name], value) else: self.assertEqual(provider.settings[name].default, value) def initialize_settings(instance): """This is usually done in Widget's new, but we avoid all that complications for tests.""" provider = default_provider.get_provider(instance.__class__) if provider: provider.initialize(instance) default_provider = None """:type: SettingProvider""" class BaseGraph: show_labels = Setting(True) def __init__(self): initialize_settings(self) class Graph(BaseGraph): show_x_axis = Setting(True) show_y_axis = Setting(True) a_list = Setting([]) a_set = Setting({1, 2, 3}) a_dict = Setting({1: 2, 3: 4}) def __init__(self): super().__init__() initialize_settings(self) class ExtendedGraph(Graph): pass class ZoomToolbar: allow_zooming = Setting(True) def __init__(self): initialize_settings(self) class BaseWidget: settingsHandler = None show_graph = Setting(True) graph = SettingProvider(Graph) def __init__(self): initialize_settings(self) self.graph = Graph() class Widget(BaseWidget): show_zoom_toolbar = Setting(True) zoom_toolbar = SettingProvider(ZoomToolbar) def __init__(self): super().__init__() initialize_settings(self) self.zoom_toolbar = ZoomToolbar() if __name__ == '__main__': unittest.main(verbosity=2) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/tests/test_settings_handler.py0000644000076500000240000003416714334703654025321 0ustar00primozstaff# pylint: disable=protected-access import os import pickle from tempfile import mkstemp, NamedTemporaryFile import unittest from unittest.mock import patch, Mock import warnings from AnyQt.QtCore import pyqtSignal as Signal, QObject from orangewidget.tests.base import named_file, override_default_settings from orangewidget.settings import SettingsHandler, Setting, SettingProvider,\ VERSION_KEY, rename_setting, Context class SettingHandlerTestCase(unittest.TestCase): @patch('orangewidget.settings.SettingProvider', create=True) def test_create(self, SettingProvider): """:type SettingProvider: unittest.mock.Mock""" mock_read_defaults = Mock() with patch.object(SettingsHandler, 'read_defaults', mock_read_defaults): handler = SettingsHandler.create(SimpleWidget) self.assertEqual(handler.widget_class, SimpleWidget) # create needs to create a SettingProvider which traverses # the widget definition and collects all settings and read # all settings and for widget class SettingProvider.assert_called_once_with(SimpleWidget) mock_read_defaults.assert_called_once_with() def test_create_uses_template_if_provided(self): template = SettingsHandler() template.a = 'a' template.b = 'b' with override_default_settings(SimpleWidget): handler = SettingsHandler.create(SimpleWidget, template) self.assertEqual(handler.a, 'a') self.assertEqual(handler.b, 'b') # create should copy the template handler.b = 'B' self.assertEqual(template.b, 'b') def test_read_defaults(self): handler = SettingsHandler() handler.widget_class = SimpleWidget handler.provider = SettingProvider(SimpleWidget) defaults = {'a': 5, 'b': {1: 5}} with override_default_settings(SimpleWidget, defaults): handler.read_defaults() self.assertEqual(handler.defaults, defaults) def test_write_defaults(self): fd, settings_file = mkstemp(suffix='.ini') handler = SettingsHandler() handler.widget_class = SimpleWidget handler.defaults = {'a': 5, 'b': {1: 5}} handler._get_settings_filename = lambda: settings_file handler.write_defaults() with open(settings_file, 'rb') as f: default_settings = pickle.load(f) os.close(fd) self.assertEqual(default_settings.pop(VERSION_KEY, -0xBAD), handler.widget_class.settings_version,) self.assertEqual(default_settings, handler.defaults) os.remove(settings_file) def test_write_defaults_handles_permission_error(self): handler = SettingsHandler() with named_file("") as f: handler._get_settings_filename = lambda: f with patch("orangewidget.settings.log.error") as log, \ patch('orangewidget.settings.open', create=True, side_effect=PermissionError): handler.write_defaults() log.assert_called() def test_write_defaults_handles_writing_errors(self): handler = SettingsHandler() for error in (EOFError, IOError, pickle.PicklingError): f = NamedTemporaryFile("wt", delete=False) f.close() # so it can be opened on windows handler._get_settings_filename = lambda x=f: x.name with patch("orangewidget.settings.log.error") as log, \ patch.object(handler, "write_defaults_file", side_effect=error): handler.write_defaults() log.assert_called() # Corrupt setting files should be removed self.assertFalse(os.path.exists(f.name)) def test_initialize_widget(self): handler = SettingsHandler() handler.defaults = {'default': 42, 'setting': 1} handler.provider = provider = Mock() handler.widget_class = SimpleWidget provider.get_provider.return_value = provider widget = SimpleWidget() def reset_provider(): provider.get_provider.return_value = None provider.reset_mock() provider.get_provider.return_value = provider # No data handler.initialize(widget) provider.initialize.assert_called_once_with(widget, {'default': 42, 'setting': 1}) # Dictionary data reset_provider() handler.initialize(widget, {'setting': 5}) provider.initialize.assert_called_once_with(widget, {'default': 42, 'setting': 5}) # Pickled data reset_provider() handler.initialize(widget, pickle.dumps({'setting': 5})) provider.initialize.assert_called_once_with(widget, {'default': 42, 'setting': 5}) def test_initialize_component(self): handler = SettingsHandler() handler.defaults = {'default': 42} provider = Mock() handler.widget_class = SimpleWidget handler.provider = Mock(get_provider=Mock(return_value=provider)) widget = SimpleWidget() # No data handler.initialize(widget) provider.initialize.assert_called_once_with(widget, None) # Dictionary data provider.reset_mock() handler.initialize(widget, {'setting': 5}) provider.initialize.assert_called_once_with(widget, {'setting': 5}) # Pickled data provider.reset_mock() handler.initialize(widget, pickle.dumps({'setting': 5})) provider.initialize.assert_called_once_with(widget, {'setting': 5}) @patch('orangewidget.settings.SettingProvider', create=True) def test_initialize_with_no_provider(self, SettingProvider): """:type SettingProvider: unittest.mock.Mock""" handler = SettingsHandler() handler.provider = Mock(get_provider=Mock(return_value=None)) handler.widget_class = SimpleWidget provider = Mock() SettingProvider.return_value = provider widget = SimpleWidget() # initializing an undeclared provider should display a warning with warnings.catch_warnings(record=True) as w: handler.initialize(widget) self.assertEqual(1, len(w)) SettingProvider.assert_called_once_with(SimpleWidget) provider.initialize.assert_called_once_with(widget, None) def test_fast_save(self): handler = SettingsHandler() with override_default_settings(SimpleWidget): handler.bind(SimpleWidget) widget = SimpleWidget() handler.fast_save(widget, 'component.int_setting', 5) self.assertEqual( handler.known_settings['component.int_setting'].default, 5) self.assertEqual(Component.int_setting.default, 42) handler.fast_save(widget, 'non_setting', 4) def test_fast_save_siblings_spill(self): handler_mk1 = SettingsHandler() with override_default_settings(SimpleWidgetMk1): handler_mk1.bind(SimpleWidgetMk1) widget_mk1 = SimpleWidgetMk1() handler_mk1.fast_save(widget_mk1, "setting", -1) handler_mk1.fast_save(widget_mk1, "component.int_setting", 1) self.assertEqual( handler_mk1.known_settings['setting'].default, -1) self.assertEqual( handler_mk1.known_settings['component.int_setting'].default, 1) handler_mk1.initialize(widget_mk1, data=None) handler_mk1.provider.providers["component"].initialize( widget_mk1.component, data=None) self.assertEqual(widget_mk1.setting, -1) self.assertEqual(widget_mk1.component.int_setting, 1) handler_mk2 = SettingsHandler() with override_default_settings(SimpleWidgetMk2): handler_mk2.bind(SimpleWidgetMk2) widget_mk2 = SimpleWidgetMk2() handler_mk2.initialize(widget_mk2, data=None) handler_mk2.provider.providers["component"].initialize( widget_mk2.component, data=None) self.assertEqual(widget_mk2.setting, 42, "spils defaults into sibling classes") self.assertEqual(Component.int_setting.default, 42) self.assertEqual(widget_mk2.component.int_setting, 42, "spils defaults into sibling classes") def test_schema_only_settings(self): handler = SettingsHandler() with override_default_settings(SimpleWidget): handler.bind(SimpleWidget) # fast_save should not update defaults widget = SimpleWidget() handler.fast_save(widget, 'schema_only_setting', 5) self.assertEqual( handler.known_settings['schema_only_setting'].default, None) handler.fast_save(widget, 'component.schema_only_setting', 5) self.assertEqual( handler.known_settings['component.schema_only_setting'].default, "only") # update_defaults should not update defaults widget.schema_only_setting = 5 handler.update_defaults(widget) self.assertEqual( handler.known_settings['schema_only_setting'].default, None) widget.component.schema_only_setting = 5 self.assertEqual( handler.known_settings['component.schema_only_setting'].default, "only") # pack_data should pack setting widget.schema_only_setting = 5 widget.component.schema_only_setting = 5 data = handler.pack_data(widget) self.assertEqual(data['schema_only_setting'], 5) self.assertEqual(data['component']['schema_only_setting'], 5) def test_read_defaults_migrates_settings(self): handler = SettingsHandler() handler.widget_class = SimpleWidget handler.provider = SettingProvider(SimpleWidget) migrate_settings = Mock() with patch.object(SimpleWidget, "migrate_settings", migrate_settings): # Old settings without version settings = {"value": 5} with override_default_settings(SimpleWidget, settings): handler.read_defaults() migrate_settings.assert_called_with(settings, 0) migrate_settings.reset() # Settings with version settings_with_version = dict(settings) settings_with_version[VERSION_KEY] = 1 with override_default_settings(SimpleWidget, settings_with_version): handler.read_defaults() migrate_settings.assert_called_with(settings, 1) def test_read_defaults_ensures_no_schema_only(self): handler = SettingsHandler() handler.widget_class = SimpleWidget handler.provider = SettingProvider(SimpleWidget) def migrate_settings(settings, _): settings["setting"] = 5 settings["schema_only_setting"] = True with patch.object(SimpleWidget, "migrate_settings", migrate_settings), \ override_default_settings(SimpleWidget, {"value": 42}): handler.read_defaults() self.assertEqual(handler.defaults, {'value': 42, 'setting': 5}) def test_initialize_migrates_settings(self): handler = SettingsHandler() with override_default_settings(SimpleWidget): handler.bind(SimpleWidget) widget = SimpleWidget() migrate_settings = Mock() with patch.object(SimpleWidget, "migrate_settings", migrate_settings): # Old settings without version settings = {"value": 5} handler.initialize(widget, settings) migrate_settings.assert_called_with(settings, 0) migrate_settings.reset_mock() # Settings with version settings_with_version = dict(settings) settings_with_version[VERSION_KEY] = 1 handler.initialize(widget, settings_with_version) migrate_settings.assert_called_with(settings, 1) def test_pack_settings_stores_version(self): handler = SettingsHandler() handler.bind(SimpleWidget) widget = SimpleWidget() settings = handler.pack_data(widget) self.assertIn(VERSION_KEY, settings) def test_initialize_copies_mutables(self): handler = SettingsHandler() handler.bind(SimpleWidget) handler.defaults = dict(list_setting=[]) widget = SimpleWidget() handler.initialize(widget) widget2 = SimpleWidget() handler.initialize(widget2) self.assertNotEqual(id(widget.list_setting), id(widget2.list_setting)) def test_about_pack_settings_signal(self): handler = SettingsHandler() handler.bind(SimpleWidget) widget = SimpleWidget() handler.initialize(widget) fn = Mock() widget.settingsAboutToBePacked.connect(fn) handler.pack_data(widget) self.assertEqual(1, fn.call_count) handler.update_defaults(widget) self.assertEqual(2, fn.call_count) class Component: int_setting = Setting(42) schema_only_setting = Setting("only", schema_only=True) class SimpleWidget(QObject): settings_version = 1 setting = Setting(42) schema_only_setting = Setting(None, schema_only=True) list_setting = Setting([]) non_setting = 5 component = SettingProvider(Component) settingsAboutToBePacked = Signal() def __init__(self): super().__init__() self.component = Component() migrate_settings = Mock() migrate_context = Mock() class SimpleWidgetMk1(SimpleWidget): pass class SimpleWidgetMk2(SimpleWidget): pass class WidgetWithNoProviderDeclared: def __init__(self): self.undeclared_component = Component() class MigrationsTestCase(unittest.TestCase): def test_rename_settings(self): some_settings = dict(foo=42, bar=13) rename_setting(some_settings, "foo", "baz") self.assertDictEqual(some_settings, dict(baz=42, bar=13)) self.assertRaises(KeyError, rename_setting, some_settings, "qux", "quux") context = Context(values=dict(foo=42, bar=13)) rename_setting(context, "foo", "baz") self.assertDictEqual(context.values, dict(baz=42, bar=13)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/tests/test_test_base.py0000644000076500000240000000273314440334174023722 0ustar00primozstaffimport unittest from unittest.mock import Mock, patch from orangewidget.tests.base import GuiTest class SkipTest(Exception): pass class TestGuiTest(unittest.TestCase): @patch("unittest.case.SkipTest", SkipTest) def test_english(self): class TestA(GuiTest): pure_test = Mock() test = GuiTest.skipNonEnglish(pure_test) pure_test_si = Mock() test_si = GuiTest.runOnLanguage("Slovenian")(pure_test_si) test_obj = TestA() test_obj.test() test_obj.pure_test.assert_called() self.assertRaises(SkipTest, test_obj.test_si) test_obj.pure_test_si.assert_not_called() @patch("unittest.case.SkipTest", SkipTest) @patch("orangewidget.tests.base.GuiTest.LANGUAGE", "Slovenian") def test_non_english(self): class TestA(GuiTest): pure_test = Mock() test = GuiTest.skipNonEnglish(pure_test) pure_test_si = Mock() test_si = GuiTest.runOnLanguage("Slovenian")(pure_test_si) pure_test_fr = Mock() test_fr = GuiTest.runOnLanguage("French")(pure_test_fr) test_obj = TestA() self.assertRaises(SkipTest, test_obj.test) test_obj.pure_test.assert_not_called() test_obj.test_si() test_obj.pure_test_si.assert_called() self.assertRaises(SkipTest, test_obj.test_fr) test_obj.pure_test_fr.assert_not_called() if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/tests/test_widget.py0000644000076500000240000006307514440334174023242 0ustar00primozstaff# pylint: disable=protected-access import gc import weakref import unittest from unittest.mock import patch, MagicMock from AnyQt.QtCore import Qt, QPoint, QRect, QByteArray, QObject, pyqtSignal from AnyQt.QtGui import QShowEvent, QKeyEvent from AnyQt.QtWidgets import QAction, QMenu, QApplication from AnyQt.QtTest import QSignalSpy, QTest from orangewidget.gui import OWComponent from orangewidget.settings import Setting, SettingProvider from orangewidget.tests.base import WidgetTest from orangewidget.utils.buttons import SimpleButton from orangewidget.utils.signals import summarize, PartialSummary from orangewidget.widget import OWBaseWidget, Msg, StateInfo, Input, Output from orangewidget.utils.messagewidget import InOutStateWidget class DummyComponent(OWComponent): dummyattr = None class MyWidget(OWBaseWidget): name = "Dummy" field = Setting(42) component = SettingProvider(DummyComponent) def __init__(self): super().__init__() self.component = DummyComponent(self) self.widget = None class SignalTypeA: pass class SignalTypeB: pass class WidgetTestCase(WidgetTest): def test_setattr(self): widget = self.create_widget(MyWidget) widget.widget = self.create_widget(MyWidget) setattr(widget, 'field', 1) self.assertEqual(widget.field, 1) setattr(widget, 'component.dummyattr', 2) self.assertEqual(widget.component.dummyattr, 2) setattr(widget, 'widget.field', 3) self.assertEqual(widget.widget.field, 3) setattr(widget, 'unknown_field', 4) self.assertEqual(widget.unknown_field, 4) with self.assertRaises(AttributeError): setattr(widget, 'widget.widget.field', 5) with self.assertRaises(AttributeError): setattr(widget, 'unknown_field2.field', 6) def test_keywords(self): class Widget(OWBaseWidget): pass self.assertEqual(Widget.keywords, []) class Widget(OWBaseWidget): keywords = ["bar", "qux"] self.assertEqual(Widget.keywords, ["bar", "qux"]) class Widget(OWBaseWidget): keywords = "foo bar baz" self.assertEqual(Widget.keywords, ["foo", "bar", "baz"]) class Widget(OWBaseWidget): keywords = "foo bar, baz" self.assertEqual(Widget.keywords, ["foo bar", "baz"]) def test_notify_controller_on_attribute_change(self): widget = self.create_widget(MyWidget) callback = MagicMock() callback2 = MagicMock() widget.connect_control('field', callback) widget.connect_control('field', callback2) widget.field = 5 self.assertTrue(callback.called) self.assertTrue(callback2.called) def test_widget_tests_do_not_use_stored_settings(self): widget = self.create_widget(MyWidget) widget.field = 5 widget.saveSettings() widget2 = self.create_widget(MyWidget) self.assertEqual(widget2.field, 42) def test_widget_help_action(self): widget = self.create_widget(MyWidget) help_action = widget.findChild(QAction, "action-help") help_action.setEnabled(True) help_action.setVisible(True) def test_widget_without_basic_layout(self): class TestWidget2(OWBaseWidget): name = "Test" want_basic_layout = False w = TestWidget2() w.showEvent(QShowEvent()) QTest.mousePress(w, Qt.LeftButton, Qt.NoModifier, QPoint(1, 1)) _ = w.sizeHint() def test_store_restore_layout_geom(self): class Widget(OWBaseWidget): name = "Who" want_control_area = True w = Widget() w._OWBaseWidget__setControlAreaVisible(False) geom = QRect(151, 152, 53, 54) geom.setSize(geom.size().expandedTo(w.minimumSize())) w.setGeometry(geom) state = w.saveGeometryAndLayoutState() w1 = Widget() self.assertTrue(w1.restoreGeometryAndLayoutState(state)) self.assertEqual(w1.geometry(), geom) self.assertFalse(w1.controlAreaVisible) Widget.want_control_area = False w2 = Widget() self.assertTrue(w2.restoreGeometryAndLayoutState(state)) self.assertEqual(w1.geometry(), geom) self.assertFalse((w2.restoreGeometryAndLayoutState(QByteArray()))) self.assertFalse(w2.restoreGeometryAndLayoutState(QByteArray(b'ab'))) def test_resizing_disabled_width_hint(self): class TestWidget(OWBaseWidget): name = "Test" resizing_enabled = False want_main_area = True w = TestWidget() w._OWBaseWidget__setControlAreaVisible(False) sm1 = w.maximumSize() w._OWBaseWidget__setControlAreaVisible(True) sm2 = w.maximumSize() self.assertLess(sm1.width() + 30, sm2.width()) def test_garbage_collect(self): widget = MyWidget() ref = weakref.ref(widget) # insert an object in widget's __dict__ that will be deleted when its # __dict__ is cleared. widget._finalizer = QObject() spyw = DestroyedSignalSpy(widget) spyf = DestroyedSignalSpy(widget._finalizer) widget.deleteLater() del widget gc.collect() self.assertTrue(len(spyw) == 1 or spyw.wait(1000)) gc.collect() self.assertTrue(len(spyf) == 1 or spyf.wait(1000)) gc.collect() self.assertIsNone(ref()) def test_garbage_collect_from_scheme(self): from orangewidget.workflow.widgetsscheme import WidgetsScheme from orangewidget.workflow.discovery import widget_desc_from_module new_scheme = WidgetsScheme() w_desc = widget_desc_from_module("orangewidget.tests.test_widget") node = new_scheme.new_node(w_desc) widget = new_scheme.widget_for_node(node) widget._finalizer = QObject() spyw = DestroyedSignalSpy(widget) spyf = DestroyedSignalSpy(widget._finalizer) ref = weakref.ref(widget) del widget new_scheme.remove_node(node) gc.collect() self.assertTrue(len(spyw) == 1 or spyw.wait(1000)) gc.collect() self.assertTrue(len(spyf) == 1 or spyf.wait(1000)) self.assertIsNone(ref()) def _status_bar_visible_test(self, widget): # type: (OWBaseWidget) -> None # Test that statusBar().setVisible collapses/expands the bottom margins sb = widget.statusBar() m1 = widget.contentsMargins().bottom() sb.setVisible(False) m2 = widget.contentsMargins().bottom() self.assertLess(m2, m1) self.assertEqual(m2, 0) sb.setVisible(True) m3 = widget.contentsMargins().bottom() self.assertEqual(sb.height(), m3) self.assertNotEqual(m3, 0) def test_status_bar(self): # Test that statusBar().setVisible collapses/expands the bottom margins w = MyWidget() self._status_bar_visible_test(w) # run through drawing code (for coverage) w.statusBar().grab() def test_status_bar_no_basic_layout(self): # Test that statusBar() works when widget defines # want_basic_layout=False with patch.object(MyWidget, "want_basic_layout", False): w = MyWidget() self._status_bar_visible_test(w) def test_status_bar_action(self): w = MyWidget() action = w.findChild(QAction, "action-show-status-bar") # type: QAction self.assertIsNotNone(action) action.setEnabled(True) action.setChecked(True) self.assertTrue(w.statusBar().isVisibleTo(w)) action.setChecked(False) self.assertFalse(w.statusBar().isVisibleTo(w)) w.statusBar().hide() self.assertFalse(action.isChecked()) def test_widgets_cant_be_subclassed(self): # pylint: disable=unused-variable with self.assertWarns(RuntimeWarning): class MySubWidget(MyWidget): pass with patch("warnings.warn") as warn: class MyWidget2(OWBaseWidget, openclass=True): pass class MySubWidget2(MyWidget2): pass warn.assert_not_called() def test_reset_settings(self): w = MyWidget() w.field = 43 w._reset_settings() self.assertEqual(42, w.field) class WidgetMsgTestCase(WidgetTest): class TestWidget(OWBaseWidget): name = "Test" class Information(OWBaseWidget.Information): hello = Msg("A message") def __init__(self): super().__init__() self.Information.hello() @staticmethod def active_messages(widget): """Return all active messages in a widget""" return [m for g in widget.message_groups for m in g.active] def test_widget_emits_messages(self): """Widget emits messageActivates/messageDeactivated signals""" w = WidgetMsgTestCase.TestWidget() messages = set(self.active_messages(w)) self.assertEqual(len(messages), 1, ) w.messageActivated.connect(messages.add) w.messageDeactivated.connect(messages.remove) w.Information.hello() self.assertEqual(len(messages), 1) self.assertSetEqual(messages, set(self.active_messages(w))) w.Information.hello.clear() self.assertEqual(len(messages), 0) self.assertSetEqual(set(self.active_messages(w)), set()) # OWBaseWidget without a basic layout (completely empty; no default msg bar with patch.object(WidgetMsgTestCase.TestWidget, "want_basic_layout", False): w = WidgetMsgTestCase.TestWidget() messages = set(self.active_messages(w)) w.messageActivated.connect(messages.add) w.messageDeactivated.connect(messages.remove) self.assertEqual(len(messages), 1) w.Information.hello.clear() self.assertEqual(len(messages), 0) def test_message_exc_info(self): w = WidgetMsgTestCase.TestWidget() w.Error.add_message("error") messages = set([]) w.messageActivated.connect(messages.add) w.messageDeactivated.connect(messages.remove) try: _ = 1 / 0 except ZeroDivisionError: w.Error.error("AA", exc_info=True) self.assertEqual(len(messages), 1) m = list(messages).pop() self.assertIsNotNone(m.tb) self.assertIn("ZeroDivisionError", m.tb) w.Error.error("BB", exc_info=Exception("foobar")) self.assertIn("foobar", m.tb) w.Error.error("BB") self.assertIsNone(m.tb) def test_old_style_messages(self): w = WidgetMsgTestCase.TestWidget() w.Information.clear() messages = set(self.active_messages(w)) w.messageActivated.connect(messages.add) w.messageDeactivated.connect(messages.remove) with self.assertWarns(UserWarning): w.error(1, "A") self.assertEqual(len(w.Error.active), 1) self.assertEqual(len(messages), 1) with self.assertWarns(UserWarning): w.error(1) self.assertEqual(len(messages), 0) self.assertEqual(len(w.Error.active), 0) with self.assertWarns(UserWarning): w.error(2, "B") self.assertEqual(len(messages), 1) w.Error.clear() self.assertEqual(len(messages), 0) class TestWidgetStateTracking(WidgetTest): def test_blocking_state(self): w = MyWidget() spy = QSignalSpy(w.blockingStateChanged) w.setBlocking(True) self.assertSequenceEqual(spy, [[True]]) self.assertTrue(w.isBlocking()) w.setBlocking(True) self.assertSequenceEqual(spy, [[True]]) spy = QSignalSpy(w.blockingStateChanged) w.setBlocking(False) self.assertSequenceEqual(spy, [[False]]) w.setBlocking(False) self.assertSequenceEqual(spy, [[False]]) # Test that setReady, setInvalidate set blocking state as appropriate spy = QSignalSpy(w.blockingStateChanged) w.setInvalidated(True) self.assertSequenceEqual(spy, []) w.setReady(False) self.assertSequenceEqual(spy, [[True]]) w.setReady(True) self.assertSequenceEqual(spy, [[True], [False]]) w.setInvalidated(False) self.assertSequenceEqual(spy, [[True], [False]]) def test_invalidated_state(self): w = MyWidget() spy = QSignalSpy(w.invalidatedStateChanged) w.setInvalidated(True) self.assertSequenceEqual(spy, [[True]]) w.setInvalidated(True) self.assertSequenceEqual(spy, [[True]]) spy = QSignalSpy(w.invalidatedStateChanged) w.setInvalidated(False) self.assertSequenceEqual(spy, [[False]]) # Test also that setBlocking sets invalidated state spy = QSignalSpy(w.invalidatedStateChanged) w.setBlocking(True) self.assertSequenceEqual(spy, [[True]]) spy = QSignalSpy(w.invalidatedStateChanged) w.setBlocking(False) self.assertSequenceEqual(spy, [[False]]) def test_ready_state(self): w = MyWidget() spy = QSignalSpy(w.readyStateChanged) w.setReady(False) self.assertSequenceEqual(spy, [[False]]) spy = QSignalSpy(w.readyStateChanged) w.setReady(True) self.assertSequenceEqual(spy, [[True]]) # Test also that setBlocking sets ready state spy = QSignalSpy(w.readyStateChanged) w.setBlocking(True) self.assertSequenceEqual(spy, [[False]]) spy = QSignalSpy(w.readyStateChanged) w.setBlocking(False) self.assertSequenceEqual(spy, [[True]]) class DestroyedSignalSpy(QSignalSpy): """ A signal spy for watching QObject.destroyed signal NOTE: This class specifically does not capture the QObject pointer emitted from the destroyed signal (i.e. it connects to the no arg overload). """ class Mapper(QObject): destroyed_ = pyqtSignal() def __init__(self, obj): # type: (QObject) -> None # Route the signal via a no argument signal to drop the obj pointer. # After the destroyed signal is emitted the pointer is invalid self.__mapper = DestroyedSignalSpy.Mapper() obj.destroyed.connect(self.__mapper.destroyed_) super().__init__(self.__mapper.destroyed_) class WidgetTestInfoSummary(WidgetTest): def test_info_set_warn(self): test = self class TestW(OWBaseWidget): name = "a" def __init__(self): super().__init__() with test.assertWarns(DeprecationWarning): self.info = 4 TestW() def test_io_summaries(self): w = MyWidget() info = w.info # type: StateInfo inmsg: InOutStateWidget = w.findChild(InOutStateWidget, "input-summary") outmsg: InOutStateWidget = w.findChild(InOutStateWidget, "output-summary") self.assertFalse(inmsg.message) self.assertFalse(outmsg.message) w.info.set_input_summary(w.info.NoInput) w.info.set_output_summary(w.info.NoOutput) self.assertTrue(inmsg.message.text) self.assertTrue(outmsg.message.text) info.set_input_summary("Foo") self.assertTrue(inmsg.message) self.assertEqual(inmsg.message.text, "Foo") info.set_input_summary(12_345) info.set_output_summary(1234) self.assertEqual(inmsg.message.text, "12.3k") self.assertEqual(inmsg.message.informativeText, "12345") self.assertEqual(outmsg.message.text, "1234") info.set_input_summary("Foo", "A foo that bars",) info.set_input_summary(None) info.set_output_summary(None) self.assertFalse(inmsg.message.text) self.assertFalse(outmsg.message.text) info.set_output_summary("Foobar", "42") self.assertTrue(outmsg.message) self.assertEqual(outmsg.message.text, "Foobar") with self.assertRaises(TypeError): info.set_input_summary(None, "a") with self.assertRaises(TypeError): info.set_input_summary(info.NoInput, "a") with self.assertRaises(TypeError): info.set_output_summary(None, "a") with self.assertRaises(TypeError): info.set_output_summary(info.NoOutput, "a") info.set_input_summary(1234, "Foo") info.set_output_summary(1234, "Bar") self.assertEqual(inmsg.message.text, "1234") self.assertEqual(inmsg.message.informativeText, "Foo") self.assertEqual(outmsg.message.text, "1234") self.assertEqual(outmsg.message.informativeText, "Bar") def test_info_no_basic_layout(self): with patch.object(MyWidget, "want_basic_layout", False): w = MyWidget() w.info.set_input_summary(w.info.NoInput) inmsg = w.findChild(InOutStateWidget, "input-summary") # type: InOutStateWidget self.assertTrue(inmsg.isVisibleTo(w)) self.assertTrue(inmsg.message) def test_format_number(self): self.assertEqual(StateInfo.format_number(9999), "9999") self.assertEqual(StateInfo.format_number(12_345), "12.3k") self.assertEqual(StateInfo.format_number(12_000), "12k") self.assertEqual(StateInfo.format_number(123_456), "123k") self.assertEqual(StateInfo.format_number(99_999), "100k") self.assertEqual(StateInfo.format_number(1_234_567), "1.23M") self.assertEqual(StateInfo.format_number(999_999), "1M") self.assertEqual(StateInfo.format_number(1_000_000), "1M") def test_overriden_handler(self): class TestWidget(OWBaseWidget, openclass=True): class Inputs(OWBaseWidget.Inputs): inputA = Input("a", SignalTypeA) @Inputs.inputA def handler(self, _): pass class DerivedWidget(TestWidget): name = "tw" @TestWidget.Inputs.inputA def handler(self, obj): super().handler(obj) widget = self.create_widget(DerivedWidget) widget.set_partial_input_summary = MagicMock() self.send_signal(widget.Inputs.inputA, SignalTypeA()) widget.set_partial_input_summary.assert_called_once() @summarize.register(SignalTypeA) def summarize(_: SignalTypeA): return PartialSummary("foo", "bar") class AutoSummarizeTest(WidgetTest): @patch("orangewidget.widget.OWBaseWidget._check_input_handlers") def test_auto_summarize_default(self, _): class TestWidget(OWBaseWidget): name = "tw" class Inputs(OWBaseWidget.Inputs): inputA1 = Input("a1", SignalTypeA) inputA2 = Input("a2", SignalTypeA, auto_summary=True) inputA3 = Input("a3", SignalTypeA, auto_summary=False) class Outputs(OWBaseWidget.Inputs): outputA1 = Output("a", SignalTypeA) outputA2 = Output("b", SignalTypeA, auto_summary=True) outputA3 = Output("c", SignalTypeA, auto_summary=False) self.assertTrue(TestWidget.Inputs.inputA1.auto_summary) self.assertTrue(TestWidget.Inputs.inputA2.auto_summary) self.assertFalse(TestWidget.Inputs.inputA3.auto_summary) self.assertTrue(TestWidget.Outputs.outputA1.auto_summary) self.assertTrue(TestWidget.Outputs.outputA2.auto_summary) self.assertFalse(TestWidget.Outputs.outputA3.auto_summary) @patch("orangewidget.widget.OWBaseWidget._check_input_handlers") def test_warning_no_summarizer(self, _): with self.assertWarns(UserWarning): class TestWidget(OWBaseWidget): name = "tw" class Inputs(OWBaseWidget.Inputs): inputB = Input("b", SignalTypeB) self.assertFalse(TestWidget.Inputs.inputB.auto_summary) with self.assertWarns(UserWarning): class TestWidget(OWBaseWidget): name = "tw" class Outputs(OWBaseWidget.Inputs): outputB = Output("b", SignalTypeB) self.assertFalse(TestWidget.Outputs.outputB.auto_summary) with patch("warnings.warn") as warn: class TestWidget(OWBaseWidget): name = "tw" class Inputs(OWBaseWidget.Inputs): inputB = Input("b", SignalTypeB, auto_summary=True) class Outputs(OWBaseWidget.Inputs): outputB = Output("b", SignalTypeB, auto_summary=False) warn.assert_not_called() self.assertTrue(TestWidget.Inputs.inputB.auto_summary) self.assertFalse(TestWidget.Outputs.outputB.auto_summary) @patch("orangewidget.widget.OWBaseWidget._check_input_handlers") def test_signal_as_qualified_name(self, _): with self.assertWarns(UserWarning): class TestWidget(OWBaseWidget): name = "tw" class Inputs(OWBaseWidget.Inputs): inputA = Input( "a", "orangewidget.tests.test_widget.SignalTypeA") self.assertFalse(TestWidget.Inputs.inputA.auto_summary) with patch("warnings.warn") as warn: class TestWidget(OWBaseWidget): name = "tw" class Inputs(OWBaseWidget.Inputs): inputA = Input( "a", "orangewidget.tests.test_widget.SignalTypeA", auto_summary=False) warn.assert_not_called() self.assertFalse(TestWidget.Inputs.inputA.auto_summary) with patch("warnings.warn") as warn: class TestWidget(OWBaseWidget): name = "tw" class Inputs(OWBaseWidget.Inputs): inputA = Input( "a", "orangewidget.tests.test_widget.SignalTypeA", auto_summary=True) warn.assert_not_called() self.assertTrue(TestWidget.Inputs.inputA.auto_summary) class TestSignals(WidgetTest): @patch("orangewidget.widget.OWBaseWidget._check_input_handlers") @patch("orangewidget.utils.signals.can_summarize") def test_assign_ids(self, *_): class TestWidget(OWBaseWidget): class Inputs(OWBaseWidget.Inputs): inputA = Input("a", int) inputB = Input("b", int, id="c") class Outputs(OWBaseWidget.Outputs): outputA = Output("a", int) outputB = Output("b", int, id="c") self.assertEqual(TestWidget.Inputs.inputA.id, "inputA") self.assertEqual(TestWidget.Inputs.inputB.id, "c") self.assertEqual(TestWidget.Outputs.outputA.id, "outputA") self.assertEqual(TestWidget.Outputs.outputB.id, "c") @patch("orangewidget.widget.OWBaseWidget._check_input_handlers") @patch("orangewidget.utils.signals.can_summarize") def test_prevent_same_name_id(self, *_): with self.assertRaises(RuntimeError): class TestWidget(OWBaseWidget): class Inputs(OWBaseWidget.Inputs): inputA = Input("a", int, id="c") inputB = Input("b", int, id="c") with self.assertRaises(RuntimeError): class TestWidget(OWBaseWidget): class Inputs(OWBaseWidget.Inputs): inputA = Input("a", int) inputB = Input("a", int) with self.assertRaises(RuntimeError): class TestWidget(OWBaseWidget): class Outputs(OWBaseWidget.Outputs): outputA = Output("a", int) outputB = Output("b", int, id="outputA") with self.assertRaises(RuntimeError): class TestWidget(OWBaseWidget): inputs = [("name 1", int, "foo", 0, "x"), Input("name 2", int, id="x")] def foo(self): pass with self.assertWarns(UserWarning): class TestWidget(OWBaseWidget): class Outputs(OWBaseWidget.Outputs): outputA = Output("a", int) outputB = Output("b", int, id="a") class TestWidget(OWBaseWidget): class Inputs(OWBaseWidget.Inputs): inputA = Input("a", int, id="x") class Outputs(OWBaseWidget.Outputs): outputA = Output("a", int, id="x") class TestWidget(OWBaseWidget): inputs = [("name 1", int, "foo"), Input("name 2", int, "foo")] def foo(self): pass class TestWidgetMenu(WidgetTest): def test_menu(self): class Widget(OWBaseWidget): def __init__(self): super().__init__() menubar = self.menuBar() test = menubar.addMenu("Test") test.addAction("Test") w = self.create_widget(Widget) mb = w.menuBar() native = mb.isNativeMenuBar() if native: self.skipTest("Native menu bar in use") sb = w.statusBar() button = sb.findChild(SimpleButton, "status-bar-menu-button") with patch.object(QMenu, "popup") as popup: button.click() QTest.qWait(0) popup.assert_called_once() # close the menu menu = QApplication.activePopupWidget() if menu is not None: menu.close() # Simulate show menu bar on Alt key press QTest.keyPress(w, Qt.Key_Alt, Qt.NoModifier) timer = w._OWBaseWidget__menubar_visible_timer self.assertTrue(timer.isActive()) spy = QSignalSpy(timer.timeout) spy.wait() self.assertTrue(mb.isVisibleTo(w)) QTest.keyRelease(w, Qt.Key_Alt, Qt.NoModifier) self.assertFalse(mb.isVisibleTo(w)) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/tests/utils.py0000644000076500000240000002246214334703654022060 0ustar00primozstaffimport sys import contextlib from AnyQt.QtCore import ( Qt, QObject, QEventLoop, QTimer, QLocale, QPoint, QPointF ) from AnyQt.QtTest import QTest from AnyQt.QtGui import QMouseEvent from AnyQt.QtWidgets import QApplication from orangewidget.utils.combobox import qcombobox_emit_activated class EventSpy(QObject): """ A testing utility class (similar to QSignalSpy) to record events delivered to a QObject instance. Note ---- Only event types can be recorded (as QEvent instances are deleted on delivery). Note ---- Can only be used with a QCoreApplication running. Parameters ---------- object : QObject An object whose events need to be recorded. etype : Union[QEvent.Type, Sequence[QEvent.Type] A event type (or types) that should be recorded """ def __init__(self, object, etype, **kwargs): super().__init__(**kwargs) if not isinstance(object, QObject): raise TypeError self.__object = object try: len(etype) except TypeError: etypes = {etype} else: etypes = set(etype) self.__etypes = etypes self.__record = [] self.__loop = QEventLoop() self.__timer = QTimer(self, singleShot=True) self.__timer.timeout.connect(self.__loop.quit) self.__object.installEventFilter(self) def wait(self, timeout=5000): """ Start an event loop that runs until a spied event or a timeout occurred. Parameters ---------- timeout : int Timeout in milliseconds. Returns ------- res : bool True if the event occurred and False otherwise. Example ------- >>> app = QCoreApplication.instance() or QCoreApplication([]) >>> obj = QObject() >>> spy = EventSpy(obj, QEvent.User) >>> app.postEvent(obj, QEvent(QEvent.User)) >>> spy.wait() True >>> print(spy.events()) [1000] """ count = len(self.__record) self.__timer.stop() self.__timer.setInterval(timeout) self.__timer.start() self.__loop.exec() self.__timer.stop() return len(self.__record) != count def eventFilter(self, reciever, event): if reciever is self.__object and event.type() in self.__etypes: self.__record.append(event.type()) if self.__loop.isRunning(): self.__loop.quit() return super().eventFilter(reciever, event) def events(self): """ Return a list of all (listened to) event types that occurred. Returns ------- events : List[QEvent.Type] """ return list(self.__record) @contextlib.contextmanager def excepthook_catch(raise_on_exit=True): """ Override `sys.excepthook` with a custom handler to record unhandled exceptions. Use this to capture or note exceptions that are raised and unhandled within PyQt slots or virtual function overrides. Note ---- The exceptions are still dispatched to the original `sys.excepthook` Parameters ---------- raise_on_exit : bool If True then the (first) exception that was captured will be reraised on context exit Returns ------- ctx : ContextManager A context manager Example ------- >>> class Obj(QObject): ... signal = pyqtSignal() ... >>> o = Obj() >>> o.signal.connect(lambda : 1/0) >>> with excepthook_catch(raise_on_exit=False) as exc_list: ... o.signal.emit() ... >>> print(exc_list) # doctest: +ELLIPSIS [(, ZeroDivisionError('division by zero',), ... """ excepthook = sys.excepthook seen = [] def excepthook_handle(exctype, value, traceback): seen.append((exctype, value, traceback)) excepthook(exctype, value, traceback) sys.excepthook = excepthook_handle shouldraise = raise_on_exit try: yield seen except BaseException: # propagate/preserve exceptions from within the ctx shouldraise = False raise finally: if sys.excepthook == excepthook_handle: sys.excepthook = excepthook else: raise RuntimeError( "The sys.excepthook that was installed by " "'excepthook_catch' context at enter is not " "the one present at exit.") if shouldraise and seen: raise seen[0][1] class simulate: """ Utility functions for simulating user interactions with Qt widgets. """ @staticmethod def combobox_run_through_all(cbox, delay=-1, callback=None): """ Run through all items in a given combo box, simulating the user focusing the combo box and pressing the Down arrow key activating all the items on the way. Unhandled exceptions from invoked PyQt slots/virtual function overrides are captured and reraised. Parameters ---------- cbox : QComboBox delay : int Run the event loop after the simulated key press (-1, the default, means no delay) callback : callable A callback that will be executed after every item change. Takes no parameters. See Also -------- QTest.keyClick """ assert cbox.focusPolicy() & Qt.TabFocus cbox.setFocus(Qt.TabFocusReason) cbox.setCurrentIndex(-1) for i in range(cbox.count()): with excepthook_catch() as exlist: QTest.keyClick(cbox, Qt.Key_Down, delay=delay) if callback: callback() if exlist: raise exlist[0][1] from exlist[0][1] @staticmethod def combobox_activate_index(cbox, index, delay=-1): """ Activate an item at `index` in a given combo box. The item at index **must** be enabled and selectable. Parameters ---------- cbox : QComboBox index : int delay : int Run the event loop after the signals are emitted for `delay` milliseconds (-1, the default, means no delay). """ assert 0 <= index < cbox.count() model = cbox.model() column = cbox.modelColumn() root = cbox.rootModelIndex() mindex = model.index(index, column, root) assert mindex.flags() & Qt.ItemIsEnabled cbox.setCurrentIndex(index) # QComboBox does not have an interface which would allow selecting # the current item as if a user would. Only setCurrentIndex which # does not emit the activated signals. qcombobox_emit_activated(cbox, index) if delay >= 0: QTest.qWait(delay) @staticmethod def combobox_index_of(cbox, value, role=Qt.DisplayRole): """ Find the index of an **selectable** item in a combo box whose `role` data contains the given `value`. Parameters ---------- cbox : QComboBox value : Any role : Qt.ItemDataRole Returns ------- index : int An index such that `cbox.itemData(index, role) == value` **and** the item is enabled for selection or -1 if such an index could not be found. """ model = cbox.model() column = cbox.modelColumn() root = cbox.rootModelIndex() for i in range(model.rowCount(root)): index = model.index(i, column, root) if index.data(role) == value and \ index.flags() & Qt.ItemIsEnabled: pos = i break else: pos = -1 return pos @staticmethod def combobox_activate_item(cbox, value, role=Qt.DisplayRole, delay=-1): """ Find an **selectable** item in a combo box whose `role` data contains the given value and activate it. Raise an ValueError if the item could not be found. Parameters ---------- cbox : QComboBox value : Any role : Qt.ItemDataRole delay : int Run the event loop after the signals are emitted for `delay` milliseconds (-1, the default, means no delay). """ index = simulate.combobox_index_of(cbox, value, role) if index < 0: raise ValueError("{!r} not in {}".format(value, cbox)) simulate.combobox_activate_index(cbox, index, delay) def override_locale(language): """Execute the wrapped code with a different locale.""" def wrapper(f): def wrap(*args, **kwargs): locale = QLocale() QLocale.setDefault(QLocale(language)) result = f(*args, **kwargs) QLocale.setDefault(locale) return result return wrap return wrapper def mouseMove(widget, pos=QPoint(), delay=-1): # pragma: no-cover # Like QTest.mouseMove, but functional without QCursor.setPos if pos.isNull(): pos = widget.rect().center() me = QMouseEvent(QMouseEvent.MouseMove, QPointF(pos), QPointF(widget.mapToGlobal(pos)), Qt.NoButton, Qt.MouseButtons(0), Qt.NoModifier) if delay > 0: QTest.qWait(delay) QApplication.sendEvent(widget, me) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8577783 orange-widget-base-4.22.0/orangewidget/utils/0000755000076500000240000000000014501051361020321 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/PDFExporter.py0000644000076500000240000000500514334703654023052 0ustar00primozstafffrom pyqtgraph.exporters.Exporter import Exporter from AnyQt import QtCore from AnyQt.QtWidgets import QGraphicsItem, QApplication from AnyQt.QtGui import QPainter, QPdfWriter, QPageSize from AnyQt.QtCore import QMarginsF, Qt, QSizeF, QRectF class PDFExporter(Exporter): """A pdf exporter for pyqtgraph graphs. Based on pyqtgraph's ImageExporter. There is a bug in Qt<5.12 that makes Qt wrongly use a cosmetic pen (QTBUG-68537). Workaround: do not use completely opaque colors. There is also a bug in Qt<5.12 with bold fonts that then remain bold. To see it, save the OWNomogram output.""" def __init__(self, item): Exporter.__init__(self, item) if isinstance(item, QGraphicsItem): scene = item.scene() else: scene = item bgbrush = scene.views()[0].backgroundBrush() bg = bgbrush.color() if bgbrush.style() == Qt.NoBrush: bg.setAlpha(0) self.background = bg # The following code is a workaround for a bug in pyqtgraph 1.1. The suggested # fix upstream was pyqtgraph/pyqtgraph#1458 try: from pyqtgraph.graphicsItems.ViewBox.ViewBox import ChildGroup for item in self.getPaintItems(): if isinstance(item, ChildGroup): if item.flags() & QGraphicsItem.ItemClipsChildrenToShape: item.setFlag(QGraphicsItem.ItemClipsChildrenToShape, False) except: # pylint: disable=bare-except pass def export(self, filename=None): pw = QPdfWriter(filename) dpi = int(QApplication.primaryScreen().logicalDotsPerInch()) pw.setResolution(dpi) pw.setPageMargins(QMarginsF(0, 0, 0, 0)) pw.setPageSize( QPageSize(QSizeF(self.getTargetRect().size()) / dpi * 25.4, QPageSize.Millimeter)) painter = QPainter(pw) try: self.setExportMode(True, {'antialias': True, 'background': self.background, 'painter': painter}) painter.setRenderHint(QPainter.Antialiasing, True) if QtCore.QT_VERSION >= 0x050D00: painter.setRenderHint(QPainter.LosslessImageRendering, True) self.getScene().render(painter, QRectF(self.getTargetRect()), QRectF(self.getSourceRect())) finally: self.setExportMode(False) painter.end() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/__init__.py0000644000076500000240000001361414440334174022447 0ustar00primozstaffimport enum import inspect from typing import Union, Iterator, Optional import sys import warnings from operator import attrgetter from AnyQt.QtCore import QObject, QRect, QSize, QPoint, QTextBoundaryFinder def progress_bar_milestones(count, iterations=100): return set([int(i*count/float(iterations)) for i in range(iterations)]) _NOTSET = object() def deepgetattr(obj, attr, default=_NOTSET): """Works exactly like getattr(), except that attr can be a nested attribute (e.g. "attr1.attr2.attr3"). """ try: return attrgetter(attr)(obj) except AttributeError: if default is _NOTSET: raise return default def getdeepattr(obj, attr, *arg, **kwarg): if isinstance(obj, dict): return obj.get(attr) return deepgetattr(obj, attr, *arg, **kwarg) def to_html(str): return str.replace("<=", "≤").replace(">=", "≥").\ replace("<", "<").replace(">", ">").replace("=\\=", "≠") getHtmlCompatibleString = to_html def dumpObjectTree(obj, _indent=0): """ Dumps Qt QObject tree. Aids in debugging internals. See also: QObject.dumpObjectTree() """ assert isinstance(obj, QObject) print('{indent}{type} "{name}"'.format(indent=' ' * (_indent * 4), type=type(obj).__name__, name=obj.objectName()), file=sys.stderr) for child in obj.children(): dumpObjectTree(child, _indent + 1) def getmembers(obj, predicate=None): """Return all the members of an object in a list of (name, value) pairs sorted by name. Behaves like inspect.getmembers. If a type object is passed as a predicate, only members of that type are returned. """ if isinstance(predicate, type): def mypredicate(x): return isinstance(x, predicate) else: mypredicate = predicate return inspect.getmembers(obj, mypredicate) class DeprecatedSignal: def __init__(self, actual_signal, *args, warning_text='Deprecated', emit_callback=None, **kwargs): self.signal = actual_signal self.warning_text = warning_text self.emit_callback = emit_callback def emit(self, *args, **kwargs): warnings.warn( self.warning_text, DeprecationWarning, stacklevel=2 ) if self.emit_callback: self.emit_callback(*args, **kwargs) return self.signal.emit(*args, **kwargs) def __getattr__(self, item): return self.__signal.item def enum_as_int(value: Union[int, enum.Enum]) -> int: """ Return a `enum.Enum` value as an `int. This is function intended for extracting underlying Qt5/6 enum values specifically with PyQt6 where most Qt enums are represented with `enum.Enum` and lose their numerical value. >>> from PyQt6.QtCore import Qt >>> enum_as_int(Qt.Alignment.AlignLeft) 1 """ if isinstance(value, enum.Enum): return int(value.value) else: return int(value) def dropdown_popup_geometry( size: QSize, origin: QRect, screen: QRect, preferred_direction="down" ) -> QRect: """ Move/constrain the geometry for a drop down popup. Parameters ---------- size : QSize The base popup size if not constrained. origin : QRect The origin rect from which the popup extends (in screen coords.). screen : QRect The available screen geometry into which the popup must fit. preferred_direction : str 'up' or 'down' Returns ------- geometry: QRect Constrained drop down list geometry to fit into screen """ if preferred_direction == "down": # if the popup geometry extends bellow the screen and there is more # room above the popup origin ... geometry = QRect(origin.bottomLeft() + QPoint(0, 1), size) if geometry.bottom() > screen.bottom() \ and origin.center().y() > screen.center().y(): # ...flip the rect about the origin so it extends upwards geometry.moveBottom(origin.top() - 1) elif preferred_direction == "up": geometry = QRect(origin.topLeft() - QPoint(0, 1 + size.height()), size) if geometry.top() < screen.top() \ and origin.center().y() < screen.center().y(): # ... flip, extend down geometry.moveTop(origin.bottom() - 1) else: raise ValueError(f"Invalid 'preferred_direction' ({preferred_direction})") # fixup horizontal position if it extends outside the screen if geometry.left() < screen.left(): geometry.moveLeft(screen.left()) if geometry.right() > screen.right(): geometry.moveRight(screen.right()) # bounded by screen geometry return geometry.intersected(screen) def graphemes(text: str) -> Iterator[str]: """ Return an iterator over grapheme clusters of text """ # match internal QString encoding text_encoded = text.encode("utf-16-le") finder = QTextBoundaryFinder(QTextBoundaryFinder.Grapheme, text) start = 0 while True: pos = finder.toNextBoundary() if pos == -1: return yield text_encoded[start*2: pos*2].decode("utf-16-le") start = pos def grapheme_slice(text: str, start: int = 0, end: int = None) -> str: """ Return a substring of text counting grapheme clusters not codepoints. """ if start < 0 or (end is not None and end < 0): raise ValueError("negative start or end") s = 0 slice_start: Optional[int] = None slice_end: Optional[int] = None for i, g in enumerate(graphemes(text)): if i == start: slice_start = s if i + 1 == end: slice_end = s + len(g) break s += len(g) if slice_start is None: return "" if slice_end is None: slice_end = len(text) return text[slice_start: slice_end] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8581052 orange-widget-base-4.22.0/orangewidget/utils/_webview/0000755000076500000240000000000014501051361022130 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/_webview/helpers.js0000644000076500000240000000333614306600442024140 0ustar00primozstaff/** * Our general helpers for Highcharts, JS, QWebView bridge ... */ // Prevent descent into the values of these keys var _PREVENT_KEYS = [ 'data' // This is the numeric payload in Highcharts ]; function fixupPythonObject(obj) { /** * Replace any strings with their eval'd value if they * start with an empty JavaScript block comment, i.e. these * four characters: */ /**/ if (typeof obj === 'undefined' || obj === null) return; var keys = Object.keys(obj); for (var i=0; i QPalette.ColorGroup: if not state & QStyle.State_Enabled: return QPalette.Disabled elif state & QStyle.State_Active: return QPalette.Active else: return QPalette.Inactive ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/cache.py0000644000076500000240000000266114334703654021760 0ustar00primozstafffrom collections import OrderedDict from collections.abc import MutableMapping from typing import NamedTuple class CacheInfo(NamedTuple): misses: int hits: int maxsize: int currsize: int class LRUCache(MutableMapping): __slots__ = ("__dict", "__maxlen", "__miss", "__hit") def __init__(self, maxlen=100): self.__dict = OrderedDict() self.__maxlen = maxlen self.__miss = 0 self.__hit = 0 def __setitem__(self, key, value): dict_ = self.__dict dict_[key] = value dict_.move_to_end(key) if len(dict_) > self.__maxlen: dict_.popitem(last=False) def __getitem__(self, key): dict_ = self.__dict try: r = dict_[key] except KeyError: self.__miss += 1 raise else: self.__hit += 1 dict_.move_to_end(key) return r def __delitem__(self, key): del self.__dict[key] def __contains__(self, key): return key in self.__dict def __delete__(self, key): del self.__dict[key] def __iter__(self): return iter(self.__dict) def __len__(self): return len(self.__dict) def cache_info(self): return CacheInfo(self.__miss, self.__hit, self.__maxlen, len(self.__dict)) def clear(self) -> None: self.__dict.clear() self.__hit = self.__miss = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/combobox.py0000644000076500000240000004176614440334174022531 0ustar00primozstaffimport sys from typing import Optional from AnyQt.QtCore import ( Qt, QEvent, QObject, QAbstractItemModel, QSortFilterProxyModel, QModelIndex, QSize, QRect, QMargins, QElapsedTimer, QTimer, QT_VERSION ) from AnyQt.QtGui import QMouseEvent, QKeyEvent, QPainter, QPalette, QPen from AnyQt.QtWidgets import ( QWidget, QComboBox, QLineEdit, QAbstractItemView, QListView, QStyleOptionComboBox, QStyleOptionViewItem, QStyle, QStylePainter, QStyledItemDelegate, QApplication ) from orangewidget.utils import dropdown_popup_geometry # we want to have combo box maximally 25 characters wide MAXIMUM_CONTENTS_LENGTH = 25 class ComboBox(QComboBox): """ A QComboBox subclass extended to support bounded contents width hint. Prefer to use this class in place of plain QComboBox when the used model will possibly contain many items. """ def __init__(self, parent=None, **kwargs): self.__maximumContentsLength = MAXIMUM_CONTENTS_LENGTH super().__init__(parent, **kwargs) self.__in_mousePressEvent = False # Yet Another Mouse Release Ignore Timer self.__yamrit = QTimer(self, singleShot=True) view = self.view() # optimization for displaying large models if isinstance(view, QListView): view.setUniformItemSizes(True) view.viewport().installEventFilter(self) def setMaximumContentsLength(self, length): # type: (int) -> None """ Set the maximum contents length hint. The hint specifies the upper bound on the `sizeHint` and `minimumSizeHint` width specified in character length. Set to 0 or negative value to disable. Note ---- This property does not affect the widget's `maximumSize`. The widget can still grow depending on its `sizePolicy`. Parameters ---------- length : int Maximum contents length hint. """ if self.__maximumContentsLength != length: self.__maximumContentsLength = length self.updateGeometry() def maximumContentsLength(self): # type: () -> int """ Return the maximum contents length hint. """ return self.__maximumContentsLength def _get_size_hint(self): sh = super().sizeHint() if self.__maximumContentsLength > 0: width = ( self.fontMetrics().horizontalAdvance("X") * self.__maximumContentsLength + self.iconSize().width() + 4 ) sh = sh.boundedTo(QSize(width, sh.height())) return sh def sizeHint(self): # type: () -> QSize # reimplemented return self._get_size_hint() def minimumSizeHint(self): # type: () -> QSize # reimplemented return self._get_size_hint() # workaround for QTBUG-67583 def mousePressEvent(self, event): # type: (QMouseEvent) -> None # reimplemented self.__in_mousePressEvent = True super().mousePressEvent(event) self.__in_mousePressEvent = False def showPopup(self): # type: () -> None # reimplemented super().showPopup() if self.__in_mousePressEvent: self.__yamrit.start(QApplication.doubleClickInterval()) def eventFilter(self, obj, event): # type: (QObject, QEvent) -> bool if event.type() == QEvent.MouseButtonRelease \ and event.button() == Qt.LeftButton \ and obj is self.view().viewport() \ and self.__yamrit.isActive(): return True else: return super().eventFilter(obj, event) class _ComboBoxListDelegate(QStyledItemDelegate): def paint(self, painter, option, index): # type: (QPainter, QStyleOptionViewItem, QModelIndex) -> None super().paint(painter, option, index) if index.data(Qt.AccessibleDescriptionRole) == "separator": palette = option.palette # type: QPalette brush = palette.brush(QPalette.Disabled, QPalette.WindowText) painter.setPen(QPen(brush, 1.0)) rect = option.rect # type: QRect y = rect.center().y() painter.drawLine(rect.left(), y, rect.left() + rect.width(), y) class ComboBoxSearch(QComboBox): """ A drop down list combo box with filter/search. The popup list view is filtered by text entered in the filter field. Note ---- `popup`, `lineEdit` and `completer` from the base QComboBox class are unused. Setting/modifying them will have no effect. """ # NOTE: Setting editable + QComboBox.NoInsert policy + ... did not achieve # the same results. def __init__(self, parent=None, **kwargs): self.__maximumContentsLength = MAXIMUM_CONTENTS_LENGTH self.__searchline = QLineEdit(visible=False, frame=False) self.__searchline.setAttribute(Qt.WA_MacShowFocusRect, False) self.__popup = None # type: Optional[QAbstractItemModel] self.__proxy = None # type: Optional[QSortFilterProxyModel] self.__popupTimer = QElapsedTimer() super().__init__(parent, **kwargs) self.__searchline.setParent(self) self.__searchline.setFocusProxy(self) self.setFocusPolicy(Qt.StrongFocus) def setMaximumContentsLength(self, length): # type: (int) -> None """ Set the maximum contents length hint. The hint specifies the upper bound on the `sizeHint` and `minimumSizeHint` width specified in character length. Set to 0 or negative value to disable. Note ---- This property does not affect the widget's `maximumSize`. The widget can still grow depending on its `sizePolicy`. Parameters ---------- length : int Maximum contents length hint. """ if self.__maximumContentsLength != length: self.__maximumContentsLength = length self.updateGeometry() def _get_size_hint(self): sh = super().sizeHint() if self.__maximumContentsLength > 0: width = ( self.fontMetrics().horizontalAdvance("X") * self.__maximumContentsLength + self.iconSize().width() + 4 ) sh = sh.boundedTo(QSize(width, sh.height())) return sh def sizeHint(self): # type: () -> QSize # reimplemented return self._get_size_hint() def minimumSizeHint(self): # type: () -> QSize # reimplemented return self._get_size_hint() def showPopup(self): # type: () -> None """ Reimplemented from QComboBox.showPopup Popup up a customized view and filter edit line. Note ---- The .popup(), .lineEdit(), .completer() of the base class are not used. """ if self.__popup is not None: # We have user entered state that cannot be disturbed # (entered filter text, scroll offset, ...) return # pragma: no cover if self.count() == 0: return opt = QStyleOptionComboBox() self.initStyleOption(opt) popup = QListView( uniformItemSizes=True, horizontalScrollBarPolicy=Qt.ScrollBarAlwaysOff, verticalScrollBarPolicy=Qt.ScrollBarAsNeeded, iconSize=self.iconSize(), ) popup.setFocusProxy(self.__searchline) popup.setParent(self, Qt.Popup | Qt.FramelessWindowHint) popup.setItemDelegate(_ComboBoxListDelegate(popup)) proxy = QSortFilterProxyModel( popup, filterCaseSensitivity=Qt.CaseInsensitive ) proxy.setFilterKeyColumn(self.modelColumn()) proxy.setSourceModel(self.model()) popup.setModel(proxy) root = proxy.mapFromSource(self.rootModelIndex()) popup.setRootIndex(root) self.__popup = popup self.__proxy = proxy self.__searchline.setText("") self.__searchline.setPlaceholderText("Filter...") self.__searchline.setVisible(True) self.__searchline.textEdited.connect(proxy.setFilterFixedString) style = self.style() # type: QStyle popuprect_origin = style.subControlRect( QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxListBoxPopup, self ) # type: QRect if sys.platform == "darwin": slmargin = self.__searchline.style() \ .pixelMetric(QStyle.PM_FocusFrameVMargin) popuprect_origin.adjust(slmargin // 2, 0, -(slmargin * 3) // 2, slmargin) popuprect_origin = QRect( self.mapToGlobal(popuprect_origin.topLeft()), popuprect_origin.size() ) editrect = style.subControlRect( QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, self ) # type: QRect self.__searchline.setGeometry(editrect) screenrect = self.screen().availableGeometry() # get the height for the view listrect = QRect() for i in range(min(proxy.rowCount(root), self.maxVisibleItems())): index = proxy.index(i, self.modelColumn(), root) if index.isValid(): listrect = listrect.united(popup.visualRect(index)) if listrect.height() >= screenrect.height(): break window = popup.window() # type: QWidget window.ensurePolished() if window.layout() is not None: window.layout().activate() else: QApplication.sendEvent(window, QEvent(QEvent.LayoutRequest)) margins = qwidget_margin_within(popup.viewport(), window) height = (listrect.height() + 2 * popup.spacing() + margins.top() + margins.bottom()) popup_size = (QSize(popuprect_origin.width(), height) .expandedTo(window.minimumSize()) .boundedTo(window.maximumSize()) .boundedTo(screenrect.size())) popuprect = dropdown_popup_geometry( popup_size, popuprect_origin, screenrect) popup.setGeometry(popuprect) current = proxy.mapFromSource( self.model().index(self.currentIndex(), self.modelColumn(), self.rootModelIndex())) popup.setCurrentIndex(current) popup.scrollTo(current, QAbstractItemView.EnsureVisible) popup.show() popup.setFocus(Qt.PopupFocusReason) popup.installEventFilter(self) popup.viewport().installEventFilter(self) popup.viewport().setMouseTracking(True) self.update() self.__popupTimer.restart() def hidePopup(self): """Reimplemented""" if self.__popup is not None: popup = self.__popup self.__popup = self.__proxy = None popup.setFocusProxy(None) popup.hide() popup.deleteLater() popup.removeEventFilter(self) popup.viewport().removeEventFilter(self) # need to call base hidePopup even though the base showPopup was not # called (update internal state wrt. 'pressed' arrow, ...) super().hidePopup() self.__searchline.hide() self.update() def initStyleOption(self, option): # type: (QStyleOptionComboBox) -> None super().initStyleOption(option) option.editable = True def __updateGeometries(self): opt = QStyleOptionComboBox() self.initStyleOption(opt) editarea = self.style().subControlRect( QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, self) self.__searchline.setGeometry(editarea) def resizeEvent(self, event): """Reimplemented.""" super().resizeEvent(event) self.__updateGeometries() def paintEvent(self, event): """Reimplemented.""" opt = QStyleOptionComboBox() self.initStyleOption(opt) painter = QStylePainter(self) painter.drawComplexControl(QStyle.CC_ComboBox, opt) if not self.__searchline.isVisibleTo(self): opt.editable = False painter.drawControl(QStyle.CE_ComboBoxLabel, opt) def eventFilter(self, obj, event): # pylint: disable=too-many-branches # type: (QObject, QEvent) -> bool """Reimplemented.""" etype = event.type() if etype == QEvent.FocusOut and self.__popup is not None: self.hidePopup() return True if etype == QEvent.Hide and self.__popup is not None: self.hidePopup() return False if etype == QEvent.KeyPress or etype == QEvent.KeyRelease or \ etype == QEvent.ShortcutOverride and obj is self.__popup: event = event # type: QKeyEvent key, modifiers = event.key(), event.modifiers() if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Select): current = self.__popup.currentIndex() if current.isValid(): self.__activateProxyIndex(current) elif key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown): return False # elif key in (Qt.Key_Tab, Qt.Key_Backtab): pass elif key == Qt.Key_Escape or \ (key == Qt.Key_F4 and modifiers & Qt.AltModifier): self.__popup.hide() return True else: # pass the input events to the filter edit line (no propagation # up the parent chain). self.__searchline.event(event) if event.isAccepted(): return True if etype == QEvent.MouseButtonRelease and self.__popup is not None \ and obj is self.__popup.viewport() \ and self.__popupTimer.elapsed() >= \ QApplication.doubleClickInterval(): event = event # type: QMouseEvent index = self.__popup.indexAt(event.pos()) if index.isValid(): self.__activateProxyIndex(index) if etype == QEvent.MouseMove and self.__popup is not None \ and obj is self.__popup.viewport(): event = event # type: QMouseEvent opt = QStyleOptionComboBox() self.initStyleOption(opt) style = self.style() # type: QStyle if style.styleHint(QStyle.SH_ComboBox_ListMouseTracking, opt, self): index = self.__popup.indexAt(event.pos()) if index.isValid() and \ index.flags() & (Qt.ItemIsEnabled | Qt.ItemIsSelectable): self.__popup.setCurrentIndex(index) if etype == QEvent.MouseButtonPress and self.__popup is obj: # Popup border or out of window mouse button press/release. # At least on windows this needs to be handled. style = self.style() opt = QStyleOptionComboBox() self.initStyleOption(opt) opt.subControls = QStyle.SC_All opt.activeSubControls = QStyle.SC_ComboBoxArrow pos = self.mapFromGlobal(event.globalPos()) sc = style.hitTestComplexControl(QStyle.CC_ComboBox, opt, pos, self) if sc != QStyle.SC_None: self.__popup.setAttribute(Qt.WA_NoMouseReplay) self.hidePopup() return super().eventFilter(obj, event) def __activateProxyIndex(self, index): # type: (QModelIndex) -> None # Set current and activate the source index corresponding to the proxy # index in the popup's model. if self.__popup is not None and index.isValid(): proxy = self.__popup.model() assert index.model() is proxy index = proxy.mapToSource(index) assert index.model() is self.model() if index.isValid() and \ index.flags() & (Qt.ItemIsEnabled | Qt.ItemIsSelectable): self.hidePopup() self.setCurrentIndex(index.row()) qcombobox_emit_activated(self, index.row()) def qcombobox_emit_activated(cb: QComboBox, index: int): cb.activated[int].emit(index) text = cb.itemText(index) if QT_VERSION >= 0x050f00: # 5.15 cb.textActivated.emit(text) if QT_VERSION < 0x060000: # 6.0 cb.activated[str].emit(text) def qwidget_margin_within(widget, ancestor): # type: (QWidget, QWidget) -> QMargins """ Return the 'margins' of widget within its 'ancestor' Ancestor must be within the widget's parent hierarchy and both widgets must share the same top level window. Parameters ---------- widget : QWidget ancestor : QWidget Returns ------- margins: QMargins """ assert ancestor.isAncestorOf(widget) assert ancestor.window() is widget.window() r1 = widget.geometry() r2 = ancestor.geometry() topleft = r1.topLeft() bottomright = r1.bottomRight() topleft = widget.mapTo(ancestor, topleft) bottomright = widget.mapTo(ancestor, bottomright) return QMargins(topleft.x(), topleft.y(), r2.right() - bottomright.x(), r2.bottom() - bottomright.y()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/concurrent.py0000644000076500000240000004052114440334174023067 0ustar00primozstaff""" General helper functions and classes for PyQt concurrent programming """ # TODO: Rename the module to something that does not conflict with stdlib # concurrent from typing import Callable, Any, List, Optional import threading import logging import warnings import weakref from functools import partial import concurrent.futures from concurrent.futures import Future, TimeoutError from AnyQt.QtCore import ( Qt, QObject, QMetaObject, QThreadPool, QThread, QRunnable, QSemaphore, QCoreApplication, QEvent, Q_ARG, pyqtSignal as Signal, pyqtSlot as Slot ) from AnyQt import sip _log = logging.getLogger(__name__) class PyOwned: """ A mixin for python owned QObject's used as queued cross thread communication channels. When this object is released from a thread that is not self.thread() it is *resurrected* and scheduled for deferred deletion from its own thread with self.deleteLater() """ # This is a workaround for: # https://www.riverbankcomputing.com/pipermail/pyqt/2020-April/042734.html # Should not be necessary with PyQt5-sip>=12.8 (i.e sip api 12.8) __delete_later_set = set() def __del__(self: QObject): # Note: This is otherwise quite similar to how PyQt5 does this except # for the resurrection (i.e. the wrapper is allowed to be freed, but # C++ part is deleteLater-ed). if sip.ispyowned(self): try: own_thread = self.thread() is QThread.currentThread() except RuntimeError: return if not own_thread: # object resurrection; keep python wrapper alive and schedule # deletion from the object's own thread. PyOwned.__delete_later_set.add(self) ref = weakref.ref(self) # Clear final ref from 'destroyed' signal. As late as possible # in QObject' destruction. def clear(): self = ref() try: PyOwned.__delete_later_set.remove(self) except KeyError: pass self.destroyed.connect(clear, Qt.DirectConnection) self.deleteLater() class FutureRunnable(QRunnable): """ A QRunnable to fulfil a `Future` in a QThreadPool managed thread. Parameters ---------- future : concurrent.futures.Future Future whose contents will be set with the result of executing `func(*args, **kwargs)` after completion func : Callable Function to invoke in a thread args : tuple Positional arguments for `func` kwargs : dict Keyword arguments for `func` Example ------- >>> f = concurrent.futures.Future() >>> task = FutureRunnable(f, int, (42,), {}) >>> QThreadPool.globalInstance().start(task) >>> f.result() 42 """ def __init__(self, future, func, args, kwargs): # type: (Future, Callable, tuple, dict) -> None super().__init__() self.future = future self.task = (func, args, kwargs) def run(self): """ Reimplemented from `QRunnable.run` """ try: if not self.future.set_running_or_notify_cancel(): # future was cancelled return func, args, kwargs = self.task try: result = func(*args, **kwargs) except BaseException as ex: # pylint: disable=broad-except self.future.set_exception(ex) else: self.future.set_result(result) except BaseException: # pylint: disable=broad-except log = logging.getLogger(__name__) log.critical("Exception in worker thread.", exc_info=True) class FutureWatcher(QObject, PyOwned): """ An `QObject` watching the state changes of a `concurrent.futures.Future` Note ---- The state change notification signals (`done`, `finished`, ...) are always emitted when the control flow reaches the event loop (even if the future is already completed when set). Note ---- An event loop must be running, otherwise the notifier signals will not be emitted. Parameters ---------- parent : QObject Parent object. future : Future The future instance to watch. Example ------- >>> app = QCoreApplication.instance() or QCoreApplication([]) >>> f = submit(lambda i, j: i ** j, 10, 3) >>> watcher = FutureWatcher(f) >>> watcher.resultReady.connect(lambda res: print("Result:", res)) >>> watcher.done.connect(app.quit) >>> _ = app.exec() Result: 1000 >>> f.result() 1000 """ #: Signal emitted when the future is done (cancelled or finished) done = Signal(Future) #: Signal emitted when the future is finished (i.e. returned a result #: or raised an exception - but not if cancelled) finished = Signal(Future) #: Signal emitted when the future was cancelled cancelled = Signal(Future) #: Signal emitted with the future's result when successfully finished. resultReady = Signal(object) #: Signal emitted with the future's exception when finished with an #: exception. exceptionReady = Signal(BaseException) # A private event type used to notify the watcher of a Future's completion __FutureDone = QEvent.Type(QEvent.registerEventType()) def __init__(self, future=None, parent=None, **kwargs): super().__init__(parent, **kwargs) self.__future = None if future is not None: self.setFuture(future) def setFuture(self, future): # type: (Future) -> None """ Set the future to watch. Raise a `RuntimeError` if a future is already set. Parameters ---------- future : Future """ if self.__future is not None: raise RuntimeError("Future already set") self.__future = future selfweakref = weakref.ref(self) def on_done(f): assert f is future selfref = selfweakref() if selfref is None: return try: QCoreApplication.postEvent( selfref, QEvent(FutureWatcher.__FutureDone)) except RuntimeError: # Ignore RuntimeErrors (when C++ side of QObject is deleted) # (? Use QObject.destroyed and remove the done callback ?) pass future.add_done_callback(on_done) def future(self): # type: () -> Future """ Return the future instance. """ return self.__future def isCancelled(self): warnings.warn("isCancelled is deprecated", DeprecationWarning, stacklevel=2) return self.__future.cancelled() def isDone(self): warnings.warn("isDone is deprecated", DeprecationWarning, stacklevel=2) return self.__future.done() def result(self): # type: () -> Any """ Return the future's result. Note ---- This method is non-blocking. If the future has not yet completed it will raise an error. """ try: return self.__future.result(timeout=0) except TimeoutError: raise RuntimeError("Future is not yet done") def exception(self): # type: () -> Optional[BaseException] """ Return the future's exception. Note ---- This method is non-blocking. If the future has not yet completed it will raise an error. """ try: return self.__future.exception(timeout=0) except TimeoutError: raise RuntimeError("Future is not yet done") def __emitSignals(self): assert self.__future is not None assert self.__future.done() if self.__future.cancelled(): self.cancelled.emit(self.__future) self.done.emit(self.__future) elif self.__future.done(): self.finished.emit(self.__future) self.done.emit(self.__future) if self.__future.exception(): self.exceptionReady.emit(self.__future.exception()) else: self.resultReady.emit(self.__future.result()) else: assert False def customEvent(self, event): # Reimplemented. if event.type() == FutureWatcher.__FutureDone: self.__emitSignals() super().customEvent(event) class FutureSetWatcher(QObject, PyOwned): """ An `QObject` watching the state changes of a list of `concurrent.futures.Future` instances Note ---- The state change notification signals (`doneAt`, `finishedAt`, ...) are always emitted when the control flow reaches the event loop (even if the future is already completed when set). Note ---- An event loop must be running, otherwise the notifier signals will not be emitted. Parameters ---------- parent : QObject Parent object. futures : List[Future] A list of future instance to watch. Example ------- >>> app = QCoreApplication.instance() or QCoreApplication([]) >>> fs = [submit(lambda i, j: i ** j, 10, 3) for i in range(10)] >>> watcher = FutureSetWatcher(fs) >>> watcher.resultReadyAt.connect( ... lambda i, res: print("Result at {}: {}".format(i, res)) ... ) >>> watcher.doneAll.connect(app.quit) >>> _ = app.exec() Result at 0: 1000 ... """ #: Signal emitted when the future at `index` is done (cancelled or #: finished) doneAt = Signal([int, Future]) #: Signal emitted when the future at index is finished (i.e. returned #: a result) finishedAt = Signal([int, Future]) #: Signal emitted when the future at `index` was cancelled. cancelledAt = Signal([int, Future]) #: Signal emitted with the future's result when successfully #: finished. resultReadyAt = Signal([int, object]) #: Signal emitted with the future's exception when finished with an #: exception. exceptionReadyAt = Signal([int, BaseException]) #: Signal reporting the current completed count progressChanged = Signal([int, int]) #: Signal emitted when all the futures have completed. doneAll = Signal() def __init__(self, futures: Optional[List['Future']] = None, *args, **kwargs): super().__init__(*args, **kwargs) self.__futures = None self.__semaphore = None self.__countdone = 0 if futures is not None: self.setFutures(futures) def setFutures(self, futures): # type: (List[Future]) -> None """ Set the future instances to watch. Raise a `RuntimeError` if futures are already set. Parameters ---------- futures : List[Future] """ if self.__futures is not None: raise RuntimeError("already set") self.__futures = [] selfweakref = weakref.ref(self) schedule_emit = methodinvoke(self, "__emitpending", (int, Future)) # Semaphore counting the number of future that have enqueued # done notifications. Used for the `wait` implementation. self.__semaphore = semaphore = QSemaphore(0) for i, future in enumerate(futures): self.__futures.append(future) def on_done(index, f): try: selfref = selfweakref() # not safe really if selfref is None: # pragma: no cover return try: schedule_emit(index, f) except RuntimeError: # pragma: no cover # Ignore RuntimeErrors (when C++ side of QObject is deleted) # (? Use QObject.destroyed and remove the done callback ?) pass finally: semaphore.release() future.add_done_callback(partial(on_done, i)) if not self.__futures: # `futures` was an empty sequence. methodinvoke(self, "doneAll", ())() @Slot(int, Future) def __emitpending(self, index, future): # type: (int, Future) -> None assert QThread.currentThread() is self.thread() assert self.__futures[index] is future assert future.done() assert self.__countdone < len(self.__futures) self.__futures[index] = None self.__countdone += 1 if future.cancelled(): self.cancelledAt.emit(index, future) self.doneAt.emit(index, future) elif future.done(): self.finishedAt.emit(index, future) self.doneAt.emit(index, future) if future.exception(): self.exceptionReadyAt.emit(index, future.exception()) else: self.resultReadyAt.emit(index, future.result()) else: assert False self.progressChanged.emit(self.__countdone, len(self.__futures)) if self.__countdone == len(self.__futures): self.doneAll.emit() def flush(self): """ Flush all pending signal emits currently enqueued. Must only ever be called from the thread this object lives in (:func:`QObject.thread()`). """ if QThread.currentThread() is not self.thread(): raise RuntimeError("`flush()` called from a wrong thread.") # NOTE: QEvent.MetaCall is the event implementing the # `Qt.QueuedConnection` method invocation. QCoreApplication.sendPostedEvents(self, QEvent.MetaCall) def wait(self): """ Wait for for all the futures to complete and *enqueue* notifications to this object, but do not emit any signals. Use `flush()` to emit all signals after a `wait()` """ if self.__futures is None: raise RuntimeError("Futures were not set.") self.__semaphore.acquire(len(self.__futures)) self.__semaphore.release(len(self.__futures)) class methodinvoke(object): """ A thin wrapper for invoking QObject's method through `QMetaObject.invokeMethod`. This can be used to invoke the method across thread boundaries (or even just for scheduling delayed calls within the same thread). Note ---- An event loop MUST be running in the target QObject's thread. Parameters ---------- obj : QObject A QObject instance. method : str The method name. This method must be registered with the Qt object meta system (e.g. decorated by a Slot decorator). arg_types : tuple A tuple of positional argument types. conntype : Qt.ConnectionType The connection/call type. Qt.QueuedConnection (the default) and Qt.BlockingConnection are the most interesting. See Also -------- QMetaObject.invokeMethod Example ------- >>> app = QCoreApplication.instance() or QCoreApplication([]) >>> quit = methodinvoke(app, "quit", ()) >>> t = threading.Thread(target=quit) >>> t.start() >>> app.exec() 0 """ @staticmethod def from_method(method, arg_types=(), *, conntype=Qt.QueuedConnection): """ Create and return a `methodinvoke` instance from a bound method. Parameters ---------- method : Union[types.MethodType, types.BuiltinMethodType] A bound method of a QObject registered with the Qt meta object system (e.g. decorated by a Slot decorators) arg_types : Tuple[Union[type, str]] A tuple of positional argument types. conntype: Qt.ConnectionType The connection/call type (Qt.QueuedConnection and Qt.BlockingConnection are the most interesting) Returns ------- invoker : methodinvoke """ obj = method.__self__ name = method.__name__ return methodinvoke(obj, name, arg_types, conntype=conntype) def __init__(self, obj, method, arg_types=(), *, conntype=Qt.QueuedConnection): self.obj = obj self.method = method self.arg_types = tuple(arg_types) self.conntype = conntype def __call__(self, *args): args = [Q_ARG(atype, arg) for atype, arg in zip(self.arg_types, args)] return QMetaObject.invokeMethod( self.obj, self.method, self.conntype, *args) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694771101.0 orange-widget-base-4.22.0/orangewidget/utils/filedialogs.py0000644000076500000240000004070514501023635023166 0ustar00primozstafffrom collections import Counter from itertools import count import re import os import sys import typing from typing import Tuple from AnyQt.QtCore import QFileInfo, Qt from AnyQt.QtGui import QBrush from AnyQt.QtWidgets import \ QMessageBox, QFileDialog, QFileIconProvider, QComboBox from orangewidget.io import Compression from orangewidget.settings import Setting if typing.TYPE_CHECKING: from typing_extensions import Protocol else: from abc import ABC as Protocol class Format(Protocol): """ An abstract file format description. Classes belonging to this type must define: PRIORITY: str EXTENSIONS: Tuple[str] # a tuple of filename extensions (including dot) DESCRIPTION: str # A short file type name This class is not intended for subclassing. """ PRIORITY = sys.maxsize # type: int EXTENSIONS = () # type: Tuple[str, ...] DESCRIPTION = "" # type: str def fix_extension(ext, format, suggested_ext, suggested_format): dlg = QMessageBox( QMessageBox.Warning, "Mismatching extension", f"Extension '{ext}' does not match the chosen file format, {format}." "\n\nWould you like to fix this?") role = QMessageBox.AcceptRole change_ext = \ suggested_ext and \ dlg.addButton(f"Change extension to {suggested_ext}", role) change_format =\ suggested_format and \ dlg.addButton("Save as " + suggested_format, role) cancel = dlg.addButton("Back", role) dlg.setEscapeButton(cancel) dlg.exec() if dlg.clickedButton() == cancel: return fix_extension.CANCEL elif dlg.clickedButton() == change_ext: return fix_extension.CHANGE_EXT elif dlg.clickedButton() == change_format: return fix_extension.CHANGE_FORMAT fix_extension.CHANGE_EXT = 0 # type: ignore fix_extension.CHANGE_FORMAT = 1 # type: ignore fix_extension.CANCEL = 2 # type: ignore def unambiguous_paths(fullpaths, minlevel=1): assert minlevel > 0 # split paths using str.split(..., os.path.sep); # os.path.split only splits into head and tail sep = re.escape(os.path.sep) if os.path.altsep is not None: sep = f"{sep}|{re.escape(os.path.altsep)}" splitpaths = [re.split(sep, path) for path in fullpaths] to_check = list(range(len(fullpaths))) paths = [os.path.join(*path[-minlevel:]) for path in splitpaths] level = minlevel while to_check: counts = Counter(paths[i] for i in to_check) to_check = [i for i in to_check if counts[paths[i]] > 1 and len(splitpaths[i]) > level] level += 1 for i in to_check: paths[i] = os.path.join(splitpaths[i][-level], paths[i]) return paths def format_filter(writer): # type: (Format) -> str return '{} (*{})'.format(writer.DESCRIPTION, ' *'.join(writer.EXTENSIONS)) def get_file_name(start_dir, start_filter, file_formats): return open_filename_dialog_save(start_dir, start_filter, sorted(set(file_formats.values()), key=lambda x: x.PRIORITY)) def open_filename_dialog_save(start_dir, start_filter, file_formats): """ The function uses the standard save file dialog with filters from the given file formats. Extension is added automatically, if missing. If the user enters file extension that does not match the file format, (s)he is given a dialog to decide whether to fix the extension or the format. Args: start_dir (str): initial directory, optionally including the filename start_filter (str): initial filter file_formats (a list of FileFormat): file formats Returns: (filename, writer, filter), or `(None, None, None)` on cancel """ while True: dialog = QFileDialog.getSaveFileName filename, format, filter = \ open_filename_dialog(start_dir, start_filter, file_formats, add_all=False, title="Save as...", dialog=dialog) if not filename: return None, None, None base, ext = os.path.splitext(filename) if ext in Compression.all: base, base_ext = os.path.splitext(base) ext = base_ext + ext if not ext: filename += format.EXTENSIONS[0] elif ext not in format.EXTENSIONS: suggested_ext = format.EXTENSIONS[0] suggested_format = False for f in file_formats: # find the first format if ext in f.EXTENSIONS: suggested_format = f break res = fix_extension(ext, format.DESCRIPTION, suggested_ext, suggested_format.DESCRIPTION if suggested_format else False) if res == fix_extension.CANCEL: continue if res == fix_extension.CHANGE_EXT: filename = base + suggested_ext elif res == fix_extension.CHANGE_FORMAT: format = suggested_format filter = format_filter(format) return filename, format, filter def open_filename_dialog(start_dir: str, start_filter: str, file_formats, add_all=True, title="Open...", dialog=None): """ Open file dialog with file formats. Function also returns the format and filter to cover the case where the same extension appears in multiple filters. Args: start_dir (str): initial directory, optionally including the filename start_filter (str): initial filter file_formats (a list of FileFormat): file formats add_all (bool): add a filter for all supported extensions title (str): title of the dialog dialog: a function that creates a QT dialog Returns: (filename, file_format, filter), or `(None, None, None)` on cancel """ file_formats = sorted(set(file_formats), key=lambda w: (w.PRIORITY, w.DESCRIPTION)) filters = [format_filter(f) for f in file_formats] # add all readable files option if add_all: all_extensions = set() for f in file_formats: all_extensions.update(f.EXTENSIONS) file_formats.insert(0, None) filters.insert(0, "All readable files (*{})".format( ' *'.join(sorted(all_extensions)))) if start_filter not in filters: start_filter = filters[0] if dialog is None: dialog = QFileDialog.getOpenFileName filename, filter = dialog( None, title, start_dir, ';;'.join(filters), start_filter) if not filename: return None, None, None if filter in filters: file_format = file_formats[filters.index(filter)] else: file_format = None filter = None return filename, file_format, filter class RecentPath: abspath = '' prefix = None #: Option[str] # BASEDIR | SAMPLE-DATASETS | ... relpath = '' #: Option[str] # path relative to `prefix` title = '' #: Option[str] # title of filename (e.g. from URL) sheet = '' #: Option[str] # sheet file_format = None #: Option[str] # file format as a string def __init__(self, abspath, prefix, relpath, title='', sheet='', file_format=None): if os.name == "nt": # always use a cross-platform pathname component separator abspath = abspath.replace(os.path.sep, "/") if relpath is not None: relpath = relpath.replace(os.path.sep, "/") self.abspath = abspath self.prefix = prefix self.relpath = relpath self.title = title self.sheet = sheet self.file_format = file_format def __eq__(self, other): return (self.abspath == other.abspath or (self.prefix is not None and self.relpath is not None and self.prefix == other.prefix and self.relpath == other.relpath)) @staticmethod def create(path, searchpaths, **kwargs): """ Create a RecentPath item inferring a suitable prefix name and relpath. Parameters ---------- path : str File system path. searchpaths : List[Tuple[str, str]] A sequence of (NAME, prefix) pairs. The sequence is searched for a item such that prefix/relpath == abspath. The NAME is recorded in the `prefix` and relpath in `relpath`. (note: the first matching prefixed path is chosen). """ def isprefixed(prefix, path): """ Is `path` contained within the directory `prefix`. >>> isprefixed("/usr/local/", "/usr/local/shared") True """ normalize = lambda path: os.path.normcase(os.path.normpath(path)) prefix, path = normalize(prefix), normalize(path) if not prefix.endswith(os.path.sep): prefix = prefix + os.path.sep return os.path.commonprefix([prefix, path]) == prefix abspath = os.path.normpath(os.path.abspath(path)) for prefix, base in searchpaths: if isprefixed(base, abspath): relpath = os.path.relpath(abspath, base) return RecentPath(abspath, prefix, relpath, **kwargs) return RecentPath(abspath, None, None, **kwargs) def search(self, searchpaths): """ Return a file system path, substituting the variable paths if required If the self.abspath names an existing path it is returned. Else if the `self.prefix` and `self.relpath` are not `None` then the `searchpaths` sequence is searched for the matching prefix and if found and the {PATH}/self.relpath exists it is returned. If all fails return None. Parameters ---------- searchpaths : List[Tuple[str, str]] A sequence of (NAME, prefixpath) pairs. """ if os.path.exists(self.abspath): return os.path.normpath(self.abspath) for prefix, base in searchpaths: if self.prefix == prefix: path = os.path.join(base, self.relpath) if os.path.exists(path): return os.path.normpath(path) def resolve(self, searchpaths): if self.prefix is None and os.path.exists(self.abspath): return self else: for prefix, base in searchpaths: path = None if self.prefix and self.prefix == prefix: path = os.path.join(base, self.relpath) elif not self.prefix and prefix == "basedir": path = os.path.join(base, self.basename) if path and os.path.exists(path): return RecentPath( os.path.normpath(path), self.prefix, self.relpath, file_format=self.file_format) return None @property def basename(self): return os.path.basename(self.abspath) @property def icon(self): provider = QFileIconProvider() return provider.icon(QFileInfo(self.abspath)) @property def dirname(self): return os.path.dirname(self.abspath) def __repr__(self): return ("{0.__class__.__name__}(abspath={0.abspath!r}, " "prefix={0.prefix!r}, relpath={0.relpath!r}, " "title={0.title!r})").format(self) __str__ = __repr__ class RecentPathsWidgetMixin: """ Provide a setting with recent paths and relocation capabilities The mixin provides methods `add_path` to add paths to the top of the list, and `last_path` to retrieve the most recent path. The widget must also call `select_file(n)` to push the n-th file to the top when the user selects it in the combo. The recommended usage is to connect the combo box signal to `select_file`:: self.file_combo.activated[int].connect(self.select_file) and overload the method `select_file`, for instance like this def select_file(self, n): super().select_file(n) self.open_file() The mixin works by adding a `recent_path` setting storing a list of instances of :obj:`RecentPath` (not pure strings). The widget can also manipulate the settings directly when `add_path` and `last_path` do not suffice. If the widget has a simple combo box with file names, use :obj:`RecentPathsWComboMixin`, which also manages the combo box. Since this is a mixin, make sure to explicitly call its constructor by `RecentPathsWidgetMixin.__init__(self)`. """ #: list with search paths; overload to add, say, documentation datasets dir SEARCH_PATHS = [] #: List[RecentPath] recent_paths = Setting([]) _init_called = False def __init__(self): super().__init__() self._init_called = True self._relocate_recent_files() def _check_init(self): if not self._init_called: raise RuntimeError("RecentPathsWidgetMixin.__init__ was not called") def _search_paths(self): basedir = self.workflowEnv().get("basedir", None) if basedir is None: return self.SEARCH_PATHS return self.SEARCH_PATHS + [("basedir", basedir)] def _relocate_recent_files(self): self._check_init() search_paths = self._search_paths() rec = [] for recent in self.recent_paths: kwargs = dict(title=recent.title, sheet=recent.sheet, file_format=recent.file_format) resolved = recent.resolve(search_paths) if resolved is not None: rec.append( RecentPath.create(resolved.abspath, search_paths, **kwargs)) elif recent.search(search_paths) is not None: rec.append( RecentPath.create(recent.search(search_paths), search_paths, **kwargs) ) else: rec.append(recent) # change the list in-place for the case the widgets wraps this list # in some model (untested!) self.recent_paths[:] = rec def add_path(self, filename): """Add (or move) a file name to the top of recent paths""" self._check_init() recent = RecentPath.create(filename, self._search_paths()) if recent in self.recent_paths: self.recent_paths.remove(recent) self.recent_paths.insert(0, recent) def select_file(self, n): """Move the n-th file to the top of the list""" recent = self.recent_paths[n] del self.recent_paths[n] self.recent_paths.insert(0, recent) def last_path(self): """Return the most recent absolute path or `None` if there is none""" return self.recent_paths[0].abspath if self.recent_paths else None class RecentPathsWComboMixin(RecentPathsWidgetMixin): """ Adds file combo handling to :obj:`RecentPathsWidgetMixin`. The mixin constructs a combo box `self.file_combo` and provides a method `set_file_list` for updating its content. The mixin also overloads the inherited `add_path` and `select_file` to call `set_file_list`. """ def __init__(self): super().__init__() self.file_combo = \ QComboBox(self, sizeAdjustPolicy=QComboBox.AdjustToContents) def add_path(self, filename): """Add (or move) a file name to the top of recent paths""" super().add_path(filename) self.set_file_list() def select_file(self, n): """Move the n-th file to the top of the list""" super().select_file(n) self.set_file_list() def set_file_list(self): """ Sets the items in the file list combo """ self._check_init() self.file_combo.clear() if not self.recent_paths: self.file_combo.addItem("(none)") self.file_combo.model().item(0).setEnabled(False) self.file_combo.setToolTip("") else: self.file_combo.setToolTip(self.recent_paths[0].abspath) paths = unambiguous_paths( [recent.abspath for recent in self.recent_paths], minlevel=2) for i, recent, path in zip(count(), self.recent_paths, paths): self.file_combo.addItem(path) self.file_combo.model().item(i).setToolTip(recent.abspath) if not os.path.exists(recent.abspath): self.file_combo.setItemData(i, QBrush(Qt.red), Qt.ForegroundRole) def update_file_list(self, key, value, oldvalue): if key == "basedir": self._relocate_recent_files() self.set_file_list() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/itemdelegates.py0000644000076500000240000005616314440334174023532 0ustar00primozstaffimport enum from datetime import date, datetime from functools import partial from itertools import filterfalse from types import MappingProxyType as MappingProxy from typing import ( Sequence, Any, Mapping, Dict, TypeVar, Type, Optional, Container, Tuple, ) from typing_extensions import Final import numpy as np from AnyQt.QtCore import ( Qt, QObject, QAbstractItemModel, QModelIndex, QPersistentModelIndex, Slot, QLocale, QRect, QPointF, QSize, QLineF, ) from AnyQt.QtGui import ( QFont, QFontMetrics, QPalette, QColor, QBrush, QIcon, QPixmap, QImage, QPainter, QStaticText, QTransform, QPen ) from AnyQt.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem, \ QApplication, QStyle from orangewidget.utils.cache import LRUCache from orangewidget.utils import enum_as_int, grapheme_slice A = TypeVar("A") def item_data( index: QModelIndex, roles: Sequence[int] ) -> Dict[int, Any]: """Query `index` for all `roles` and return them as a mapping""" model = index.model() datagetter = partial(model.data, index) values = map(datagetter, roles) return dict(zip(roles, values)) class ModelItemCache(QObject): """ An item data cache for accessing QAbstractItemModel.data >>> cache = ModelItemCache() >>> cache.itemData(index, (Qt.DisplayRole, Qt.DecorationRole)) {0: ... """ #: The cache key is a tuple of the persistent index of the *parent*, #: row and column. The parent is used because of performance regression in #: Qt6 ~QPersistentModelIndex destructor when there are many (different) #: persistent indices registered with a model. Using parent, row, column #: coalesces these. #: NOTE: QPersistentModelIndex's hash changes when it is invalidated; #: it must be purged from __cache_data before that (see `__connect_helper`) __KEY = Tuple[QPersistentModelIndex, int, int] __slots__ = ("__model", "__cache_data") def __init__(self, *args, maxsize=100 * 200, **kwargs): super().__init__(*args, **kwargs) self.__model: Optional[QAbstractItemModel] = None self.__cache_data: 'LRUCache[ModelItemCache.__KEY, Any]' = LRUCache(maxsize) def __connect_helper(self, model: QAbstractItemModel) -> None: model.dataChanged.connect(self.invalidate) model.layoutAboutToBeChanged.connect(self.invalidate) model.modelAboutToBeReset.connect(self.invalidate) model.rowsAboutToBeInserted.connect(self.invalidate) model.rowsAboutToBeRemoved.connect(self.invalidate) model.rowsAboutToBeMoved.connect(self.invalidate) model.columnsAboutToBeInserted.connect(self.invalidate) model.columnsAboutToBeRemoved.connect(self.invalidate) model.columnsAboutToBeMoved.connect(self.invalidate) def __disconnect_helper(self, model: QAbstractItemModel) -> None: model.dataChanged.disconnect(self.invalidate) model.layoutAboutToBeChanged.disconnect(self.invalidate) model.modelAboutToBeReset.disconnect(self.invalidate) model.rowsAboutToBeInserted.disconnect(self.invalidate) model.rowsAboutToBeRemoved.disconnect(self.invalidate) model.rowsAboutToBeMoved.disconnect(self.invalidate) model.columnsAboutToBeInserted.disconnect(self.invalidate) model.columnsAboutToBeRemoved.disconnect(self.invalidate) model.columnsAboutToBeMoved.disconnect(self.invalidate) def setModel(self, model: QAbstractItemModel) -> None: if model is self.__model: return if self.__model is not None: self.__disconnect_helper(self.__model) self.__model = None self.__model = model self.__cache_data.clear() if model is not None: self.__connect_helper(model) def model(self) -> Optional[QAbstractItemModel]: return self.__model @Slot() def invalidate(self) -> None: """Invalidate all cached data.""" self.__cache_data.clear() def itemData( self, index: QModelIndex, roles: Sequence[int] ) -> Mapping[int, Any]: """ Return item data from `index` for `roles`. The returned mapping is a read only view of *all* data roles accessed for the index through this caching interface. It will contain at least data for `roles`, but can also contain other ones. """ model = index.model() if model is not self.__model: self.setModel(model) key = QPersistentModelIndex(index.parent()), index.row(), index.column() try: item = self.__cache_data[key] except KeyError: data = item_data(index, roles) view = MappingProxy(data) self.__cache_data[key] = data, view else: data, view = item queryroles = tuple(filterfalse(data.__contains__, roles)) if queryroles: data.update(item_data(index, queryroles)) return view def data(self, index: QModelIndex, role: int) -> Any: """Return item data for `index` and `role`""" model = index.model() if model is not self.__model: self.setModel(model) key = QPersistentModelIndex(index.parent()), index.row(), index.column() try: item = self.__cache_data[key] except KeyError: data = item_data(index, (role,)) view = MappingProxy(data) self.__cache_data[key] = data, view else: data, view = item if role not in data: data[role] = model.data(index, role) return data[role] def cast_(type_: Type[A], value: Any) -> Optional[A]: # similar but not quite the same as qvariant_cast if value is None: return value if type(value) is type_: # pylint: disable=unidiomatic-typecheck return value try: return type_(value) except Exception: # pylint: disable=broad-except # pragma: no cover return None # QStyleOptionViewItem.Feature aliases as python int. Feature.__ior__ # implementation is slower then int.__ior__ _QStyleOptionViewItem_HasDisplay = enum_as_int(QStyleOptionViewItem.HasDisplay) _QStyleOptionViewItem_HasCheckIndicator = enum_as_int(QStyleOptionViewItem.HasCheckIndicator) _QStyleOptionViewItem_HasDecoration = enum_as_int(QStyleOptionViewItem.HasDecoration) class _AlignmentFlagsCache(dict): # A cached int -> Qt.Alignment cache. Used to avoid temporary Qt.Alignment # flags object (de)allocation. def __missing__(self, key: int) -> Qt.AlignmentFlag: a = Qt.AlignmentFlag(key) self.setdefault(key, a) return a _AlignmentCache: Mapping[int, Qt.Alignment] = _AlignmentFlagsCache() _AlignmentMask = int(Qt.AlignHorizontal_Mask | Qt.AlignVertical_Mask) def init_style_option( delegate: QStyledItemDelegate, option: QStyleOptionViewItem, index: QModelIndex, data: Mapping[int, Any], roles: Optional[Container[int]] = None, ) -> None: """ Like `QStyledItemDelegate.initStyleOption` but fill in the fields from `data` mapping. If `roles` is not `None` init the `option` for the specified `roles` only. """ # pylint: disable=too-many-branches option.styleObject = None option.index = index if roles is None: roles = data features = 0 if Qt.DisplayRole in roles: value = data.get(Qt.DisplayRole) if value is not None: option.text = delegate.displayText(value, option.locale) features |= _QStyleOptionViewItem_HasDisplay if Qt.FontRole in roles: value = data.get(Qt.FontRole) font = cast_(QFont, value) if font is not None: font = font.resolve(option.font) option.font = font option.fontMetrics = QFontMetrics(option.font) if Qt.ForegroundRole in roles: value = data.get(Qt.ForegroundRole) foreground = cast_(QBrush, value) if foreground is not None: option.palette.setBrush(QPalette.Text, foreground) if Qt.BackgroundRole in roles: value = data.get(Qt.BackgroundRole) background = cast_(QBrush, value) if background is not None: option.backgroundBrush = background if Qt.TextAlignmentRole in roles: value = data.get(Qt.TextAlignmentRole) alignment = cast_(int, value) if alignment is not None: alignment = alignment & _AlignmentMask option.displayAlignment = _AlignmentCache[alignment] if Qt.CheckStateRole in roles: state = data.get(Qt.CheckStateRole) if state is not None: features |= _QStyleOptionViewItem_HasCheckIndicator state = cast_(int, state) if state is not None: option.checkState = state if Qt.DecorationRole in roles: value = data.get(Qt.DecorationRole) if value is not None: features |= _QStyleOptionViewItem_HasDecoration if isinstance(value, QIcon): option.icon = value elif isinstance(value, QColor): pix = QPixmap(option.decorationSize) pix.fill(value) option.icon = QIcon(pix) elif isinstance(value, QPixmap): option.icon = QIcon(value) option.decorationSize = (value.size() / value.devicePixelRatio()).toSize() elif isinstance(value, QImage): pix = QPixmap.fromImage(value) option.icon = QIcon(value) option.decorationSize = (pix.size() / pix.devicePixelRatio()).toSize() option.features |= QStyleOptionViewItem.ViewItemFeature(features) class CachedDataItemDelegate(QStyledItemDelegate): """ An QStyledItemDelegate with item model data caching. Parameters ---------- roles: Sequence[int] A set of roles to query the model and fill the `QStyleOptionItemView` with. By specifying only a subset of the roles here the delegate can be speed up (e.g. if you know the model does not provide the relevant roles or you just want to ignore some of them). """ __slots__ = ("roles", "__cache",) #: The default roles that are filled in initStyleOption DefaultRoles = ( Qt.DisplayRole, Qt.TextAlignmentRole, Qt.FontRole, Qt.ForegroundRole, Qt.BackgroundRole, Qt.CheckStateRole, Qt.DecorationRole ) def __init__( self, *args, roles: Sequence[int] = None, **kwargs ) -> None: super().__init__(*args, **kwargs) if roles is None: roles = self.DefaultRoles self.roles = tuple(roles) self.__cache = ModelItemCache(self) def cachedItemData( self, index: QModelIndex, roles: Sequence[int] ) -> Mapping[int, Any]: """ Return a mapping of all roles for the index. .. note:: The returned mapping contains at least `roles`, but will also contain all cached roles that were queried previously. """ return self.__cache.itemData(index, roles) def cachedData(self, index: QModelIndex, role: int) -> Any: """Return the data for role from `index`.""" return self.__cache.data(index, role) def initStyleOption( self, option: QStyleOptionViewItem, index: QModelIndex ) -> None: """ Reimplemented. Use caching to query the model data. Also limit the roles queried from the model and filled in `option` to `self.roles`. """ data = self.cachedItemData(index, self.roles) init_style_option(self, option, index, data, self.roles) _Real = (float, np.floating) _Integral = (int, np.integer) _Number = _Integral + _Real _String = (str, np.str_) _DateTime = (date, datetime, np.datetime64) _TypesAlignRight = _Number + _DateTime class StyledItemDelegate(QStyledItemDelegate): """ A `QStyledItemDelegate` subclass supporting a broader range of python and numpy types for display. E.g. supports `np.float*`, `np.(u)int`, `datetime.date`, `datetime.datetime` """ #: Types that are displayed as real (decimal) RealTypes: Final[Tuple[type, ...]] = _Real #: Types that are displayed as integers IntegralTypes: Final[Tuple[type, ...]] = _Integral #: RealTypes and IntegralTypes combined NumberTypes: Final[Tuple[type, ...]] = _Number #: Date time types DateTimeTypes: Final[Tuple[type, ...]] = _DateTime def displayText(self, value: Any, locale: QLocale) -> str: """ Reimplemented. """ # NOTE: Maybe replace the if,elif with a dispatch a table if value is None: return "" elif type(value) is str: # pylint: disable=unidiomatic-typecheck return value # avoid copies elif isinstance(value, _Integral): return super().displayText(int(value), locale) elif isinstance(value, _Real): return super().displayText(float(value), locale) elif isinstance(value, _String): return str(value) elif isinstance(value, datetime): return value.isoformat(sep=" ") elif isinstance(value, date): return value.isoformat() elif isinstance(value, np.datetime64): return self.displayText(value.astype(datetime), locale) return super().displayText(value, locale) _Qt_AlignRight = enum_as_int(Qt.AlignRight) _Qt_AlignLeft = enum_as_int(Qt.AlignLeft) _Qt_AlignHCenter = enum_as_int(Qt.AlignHCenter) _Qt_AlignTop = enum_as_int(Qt.AlignTop) _Qt_AlignBottom = enum_as_int(Qt.AlignBottom) _Qt_AlignVCenter = enum_as_int(Qt.AlignVCenter) _StaticTextKey = Tuple[str, QFont, Qt.TextElideMode, int] _PenKey = Tuple[str, int] _State_Mask = enum_as_int( QStyle.State_Selected | QStyle.State_Enabled | QStyle.State_Active ) class DataDelegate(CachedDataItemDelegate, StyledItemDelegate): """ A QStyledItemDelegate optimized for displaying fixed tabular data. This delegate will automatically display numeric and date/time values aligned to the right. Note ---- Does not support text wrapping """ __slots__ = ( "__static_text_lru_cache", "__pen_lru_cache", "__style" ) #: Types that are right aligned by default (when Qt.TextAlignmentRole #: is not defined by the model or is excluded from self.roles) TypesAlignRight: Final[Tuple[type, ...]] = _TypesAlignRight def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__static_text_lru_cache: LRUCache[_StaticTextKey, QStaticText] self.__static_text_lru_cache = LRUCache(100 * 200) self.__pen_lru_cache: LRUCache[_PenKey, QPen] = LRUCache(100) self.__style = None self.__max_text_length = 500 def initStyleOption( self, option: QStyleOptionViewItem, index: QModelIndex ) -> None: data = self.cachedItemData(index, self.roles) init_style_option(self, option, index, data, self.roles) if data.get(Qt.TextAlignmentRole) is None \ and Qt.TextAlignmentRole in self.roles \ and isinstance(data.get(Qt.DisplayRole), _TypesAlignRight): option.displayAlignment = \ (option.displayAlignment & ~Qt.AlignHorizontal_Mask) | \ Qt.AlignRight def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex ) -> None: opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) widget = option.widget style = QApplication.style() if widget is None else widget.style() # Keep ref to style wrapper. This is ugly, wrong but the wrapping of # C++ QStyle instance takes ~5% unless the wrapper already exists. self.__style = style # Draw empty item cell opt_c = QStyleOptionViewItem(opt) opt_c.text = "" style.drawControl(QStyle.CE_ItemViewItem, opt_c, painter, widget) trect = style.subElementRect(QStyle.SE_ItemViewItemText, opt_c, widget) self.drawViewItemText(style, painter, opt, trect) def drawViewItemText( self, style: QStyle, painter: QPainter, option: QStyleOptionViewItem, rect: QRect ) -> None: """ Draw view item text in `rect` using `style` and `painter`. """ margin = style.pixelMetric( QStyle.PM_FocusFrameHMargin, None, option.widget) + 1 rect = rect.adjusted(margin, 0, -margin, 0) font = option.font text = option.text st = self.__static_text_elided_cache( text, font, option.fontMetrics, option.textElideMode, rect.width() ) tsize = st.size() textalign = enum_as_int(option.displayAlignment) text_pos_x = text_pos_y = 0.0 if textalign & _Qt_AlignLeft: text_pos_x = rect.left() elif textalign & _Qt_AlignRight: text_pos_x = rect.x() + rect.width() - tsize.width() elif textalign & _Qt_AlignHCenter: text_pos_x = rect.x() + rect.width() / 2 - tsize.width() / 2 if textalign & _Qt_AlignVCenter: text_pos_y = rect.y() + rect.height() / 2 - tsize.height() / 2 elif textalign & _Qt_AlignTop: text_pos_y = rect.top() elif textalign & _Qt_AlignBottom: text_pos_y = rect.top() + rect.height() - tsize.height() painter.setPen(self.__pen_cache(option.palette, option.state)) painter.setFont(font) painter.drawStaticText(QPointF(text_pos_x, text_pos_y), st) def __static_text_elided_cache( self, text: str, font: QFont, fontMetrics: QFontMetrics, elideMode: Qt.TextElideMode, width: int ) -> QStaticText: """ Return a `QStaticText` instance for depicting the text with the `font` """ try: return self.__static_text_lru_cache[text, font, elideMode, width] except KeyError: # limit text to some sensible length in case it is a whole epic # tale or similar. elidedText will parse all of it to glyphs which # can be slow. text_limited = self.__cut_text(text) st = QStaticText(fontMetrics.elidedText(text_limited, elideMode, width)) st.prepare(QTransform(), font) # take a copy of the font for cache key key = text, QFont(font), elideMode, width self.__static_text_lru_cache[key] = st return st def __cut_text(self, text): if len(text) > self.__max_text_length: return grapheme_slice(text, end=self.__max_text_length) else: return text def __pen_cache(self, palette: QPalette, state: QStyle.State) -> QPen: """Return a QPen from the `palette` for `state`.""" # NOTE: This method exists mostly to avoid QPen, QColor (de)allocations. key = palette.cacheKey(), enum_as_int(state) & _State_Mask try: return self.__pen_lru_cache[key] except KeyError: pen = QPen(text_color_for_state(palette, state)) self.__pen_lru_cache[key] = pen return pen def text_color_for_state(palette: QPalette, state: QStyle.State) -> QColor: """Return the appropriate `palette` text color for the `state`.""" cgroup = QPalette.Normal if state & QStyle.State_Active else QPalette.Inactive cgroup = cgroup if state & QStyle.State_Enabled else QPalette.Disabled role = QPalette.HighlightedText if state & QStyle.State_Selected else QPalette.Text return palette.color(cgroup, role) class BarItemDataDelegate(DataDelegate): """ An delegate drawing a horizontal bar below its text. Can be used to visualise numerical column distribution. Parameters ---------- parent: Optional[QObject] Parent object color: QColor The default color for the bar. If not set then the palette's foreground role is used. penWidth: int The bar pen width. barFillRatioRole: int The item model role used to query the bar fill ratio (see :method:`barFillRatioData`) barColorRole: int The item model role used to query the bar color. """ __slots__ = ( "color", "penWidth", "barFillRatioRole", "barColorRole", "__line", "__pen" ) def __init__( self, parent: Optional[QObject] = None, color=QColor(), penWidth=5, barFillRatioRole=Qt.UserRole + 1, barColorRole=Qt.UserRole + 2, **kwargs ): super().__init__(parent, **kwargs) self.color = color self.penWidth = penWidth self.barFillRatioRole = barFillRatioRole self.barColorRole = barColorRole # Line and pen instances reused self.__line = QLineF() self.__pen = QPen(color, penWidth, Qt.SolidLine, Qt.RoundCap) def barFillRatioData(self, index: QModelIndex) -> Optional[float]: """ Return a number between 0.0 and 1.0 indicating the bar fill ratio. The default implementation queries the model for `barFillRatioRole` """ return cast_(float, self.cachedData(index, self.barFillRatioRole)) def barColorData(self, index: QModelIndex) -> Optional[QColor]: """ Return the color for the bar. The default implementation queries the model for `barColorRole` """ return cast_(QColor, self.cachedData(index, self.barColorRole)) def sizeHint( self, option: QStyleOptionViewItem, index: QModelIndex ) -> QSize: sh = super().sizeHint(option, index) pw, vmargin = self.penWidth, 1 sh.setHeight(sh.height() + pw + vmargin) return sh def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex ) -> None: opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) widget = option.widget style = QApplication.style() if widget is None else widget.style() self.__style = style text = opt.text opt.text = "" style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget) textrect = style.subElementRect( QStyle.SE_ItemViewItemText, opt, widget) ratio = self.barFillRatioData(index) if ratio is not None and 0. <= ratio <= 1.: color = self.barColorData(index) if color is None: color = self.color if not color.isValid(): color = opt.palette.color(QPalette.WindowText) rect = option.rect pw = self.penWidth hmargin = 3 + pw / 2 # + half pen width for the round line cap vmargin = 1 textoffset = pw + vmargin * 2 baseline = rect.bottom() - textoffset / 2 width = (rect.width() - 2 * hmargin) * ratio painter.save() painter.setRenderHint(QPainter.Antialiasing) pen = self.__pen pen.setColor(color) pen.setWidth(pw) painter.setPen(pen) line = self.__line left = rect.left() + hmargin line.setLine(left, baseline, left + width, baseline) painter.drawLine(line) painter.restore() textrect.adjust(0, 0, 0, -textoffset) opt.text = text self.drawViewItemText(style, painter, opt, textrect) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/itemmodels.py0000644000076500000240000007733414440334174023063 0ustar00primozstaffimport collections from collections import defaultdict from typing import Sequence from math import isnan, isinf from numbers import Number, Integral import operator from contextlib import contextmanager from warnings import warn from AnyQt.QtCore import ( Qt, QObject, QAbstractListModel, QAbstractTableModel, QModelIndex, QItemSelectionModel, QMimeData ) from AnyQt.QtCore import pyqtSignal as Signal from AnyQt.QtWidgets import ( QWidget, QBoxLayout, QToolButton, QAbstractButton, QAction, QStyledItemDelegate ) from AnyQt.QtGui import QPalette, QPen import numpy class _store(dict): pass def _argsort(seq, cmp=None, key=None, reverse=False): indices = range(len(seq)) if key is not None: return sorted(indices, key=lambda i: key(seq[i]), reverse=reverse) elif cmp is not None: from functools import cmp_to_key return sorted(indices, key=cmp_to_key(lambda a, b: cmp(seq[a], seq[b])), reverse=reverse) else: return sorted(indices, key=lambda i: seq[i], reverse=reverse) @contextmanager def signal_blocking(obj): blocked = obj.signalsBlocked() obj.blockSignals(True) try: yield finally: obj.blockSignals(blocked) def _as_contiguous_range(the_slice, length): start, stop, step = the_slice.indices(length) if step == -1: # Equivalent range with positive step start, stop, step = stop + 1, start + 1, 1 elif not (step == 1 or step is None): raise IndexError("Non-contiguous range.") return start, stop, step class AbstractSortTableModel(QAbstractTableModel): """ A sorting proxy table model that sorts its rows in fast numpy, avoiding potentially thousands of calls into ``QSortFilterProxyModel.lessThan()`` or any potentially costly reordering of original data. Override ``sortColumnData()``, adapting it to your underlying model. Make sure to use ``mapToSourceRows()``/``mapFromSourceRows()`` whenever fetching or manipulating table data, such as in ``data()``. When updating the model (inserting, removing rows), the sort order needs to be accounted for (e.g. reset and re-applied). """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__sortInd = None #: Indices sorting the source table self.__sortIndInv = None #: The inverse of __sortInd self.__sortColumn = -1 #: Sort key column, or -1 self.__sortOrder = Qt.AscendingOrder def sortColumnData(self, column): """Return raw, sortable data for column""" raise NotImplementedError def _sortColumnData(self, column): try: # Call the overridden implementation if available data = numpy.asarray(self.sortColumnData(column)) data = data[self.mapToSourceRows(Ellipsis)] except NotImplementedError: # Fallback to slow implementation data = numpy.array([self.index(row, column).data() for row in range(self.rowCount())]) assert data.ndim in (1, 2), 'Data should be 1- or 2-dimensional' return data def sortColumn(self): """The column currently used for sorting (-1 if no sorting is applied)""" return self.__sortColumn def sortOrder(self): """The current sort order""" return self.__sortOrder def mapToSourceRows(self, rows): """Return array of row indices in the source table for given model rows Parameters ---------- rows : int or list of int or numpy.ndarray of dtype=int or Ellipsis View (sorted) rows. Returns ------- numpy.ndarray Source rows matching input rows. If they are the same, simply input `rows` is returned. """ # self.__sortInd[rows] fails if `rows` is an empty list or array if self.__sortInd is not None \ and (isinstance(rows, (Integral, type(Ellipsis))) or len(rows)): new_rows = self.__sortInd[rows] if rows is Ellipsis: new_rows.setflags(write=False) rows = new_rows return rows def mapFromSourceRows(self, rows): """Return array of row indices in the model for given source table rows Parameters ---------- rows : int or list of int or numpy.ndarray of dtype=int or Ellipsis Source model rows. Returns ------- numpy.ndarray ModelIndex (sorted) rows matching input source rows. If they are the same, simply input `rows` is returned. """ # self.__sortInd[rows] fails if `rows` is an empty list or array if self.__sortIndInv is not None \ and (isinstance(rows, (Integral, type(Ellipsis))) or len(rows)): new_rows = self.__sortIndInv[rows] if rows is Ellipsis: new_rows.setflags(write=False) rows = new_rows return rows def resetSorting(self): """Invalidates the current sorting""" return self.sort(-1) def _argsortData(self, data: numpy.ndarray, order): """ Return indices of sorted data. May be reimplemented to handle sorting in a certain way, e.g. to sort NaN values last. """ if order == Qt.DescendingOrder: # to ensure stable descending order, sort reversed data ... data = data[::-1] if data.ndim == 1: indices = numpy.argsort(data, kind="mergesort") else: indices = numpy.lexsort(data.T[::-1]) if order == Qt.DescendingOrder: # ... and reverse (as well as invert) resulting indices indices = len(data) - 1 - indices[::-1] return indices def sort(self, column: int, order: Qt.SortOrder = Qt.AscendingOrder): """ Sort the data by `column` into `order`. To reset the order, pass column=-1. Reimplemented from QAbstractItemModel.sort(). Notes ----- This only affects the model's data presentation. The underlying data table is left unmodified. Use mapToSourceRows()/mapFromSourceRows() when accessing data by row indexes. """ indices = self._sort(column, order) self.__sortColumn = -1 if column < 0 else column self.__sortOrder = order self.setSortIndices(indices) def setSortIndices(self, indices): self.layoutAboutToBeChanged.emit([], QAbstractTableModel.VerticalSortHint) # Store persistent indices as well as their (actual) rows in the # source data table. persistent = self.persistentIndexList() persistent_rows = self.mapToSourceRows([i.row() for i in persistent]) if indices is not None: self.__sortInd = numpy.asarray(indices) self.__sortIndInv = numpy.argsort(indices) else: self.__sortInd = None self.__sortIndInv = None persistent_rows = self.mapFromSourceRows(persistent_rows) self.changePersistentIndexList( persistent, [self.index(row, pind.column()) for row, pind in zip(persistent_rows, persistent)]) self.layoutChanged.emit([], QAbstractTableModel.VerticalSortHint) def _sort(self, column, order): indices = None if column >= 0: # - _sortColumnData returns data in its currently shown order # - _argSortData thus returns an array a, in which a[i] is the row # number (in the current view) to put to line i # - mapToSourceRows maps these indices back to original data. # This contrived procedure (instead of _sortColumnData returning # the original data, saving us from double mapping) ensures stable # sort order on consecutive calls data = numpy.asarray(self._sortColumnData(column)) if data is None: data = numpy.arange(self.rowCount()) elif data.dtype == object: data = data.astype(str) indices = self.mapToSourceRows(self._argsortData(data, order)) return indices class PyTableModel(AbstractSortTableModel): """ A model for displaying python tables (sequences of sequences) in QTableView objects. Parameters ---------- sequence : list The initial list to wrap. parent : QObject Parent QObject. editable: bool or sequence If True, all items are flagged editable. If sequence, the True-ish fields mark their respective columns editable. Notes ----- The model rounds numbers to human readable precision, e.g.: 1.23e-04, 1.234, 1234.5, 12345, 1.234e06. To set additional item roles, use setData(). """ @staticmethod def _RoleData(): return defaultdict(lambda: defaultdict(dict)) def __init__(self, sequence=None, parent=None, editable=False): super().__init__(parent) self._headers = {} self._editable = editable self._table = None self._roleData = None if sequence is None: sequence = [] self.wrap(sequence) def rowCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self) def columnCount(self, parent=QModelIndex()): return 0 if parent.isValid() else max(map(len, self._table), default=0) def flags(self, index): flags = super().flags(index) if not self._editable or not index.isValid(): return flags if isinstance(self._editable, Sequence): return flags | Qt.ItemIsEditable if self._editable[index.column()] else flags return flags | Qt.ItemIsEditable def setData(self, index, value, role=Qt.EditRole): row = self.mapFromSourceRows(index.row()) if role == Qt.EditRole: self[row][index.column()] = value self.dataChanged.emit(index, index) else: self._roleData[row][index.column()][role] = value return True def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return row, column = self.mapToSourceRows(index.row()), index.column() role_value = self._roleData.get(row, {}).get(column, {}).get(role) if role_value is not None: return role_value try: value = self[row][column] except IndexError: return if role == Qt.EditRole: return value # if role == Qt.DecorationRole and isinstance(value, Variable): # return gui.attributeIconDict[value] if role == Qt.DisplayRole: if (isinstance(value, Number) and not (isnan(value) or isinf(value) or isinstance(value, Integral))): absval = abs(value) strlen = len(str(int(absval))) value = '{:.{}{}}'.format(value, 2 if absval < .001 else 3 if strlen < 2 else 1 if strlen < 5 else 0 if strlen < 6 else 3, 'f' if (absval == 0 or absval >= .001 and strlen < 6) else 'e') return str(value) if role == Qt.TextAlignmentRole and isinstance(value, Number): return Qt.AlignRight | Qt.AlignVCenter if role == Qt.ToolTipRole: return str(value) def sortColumnData(self, column): return [row[column] for row in self._table] def setHorizontalHeaderLabels(self, labels): """ Parameters ---------- labels : list of str """ self._headers[Qt.Horizontal] = tuple(labels) def setVerticalHeaderLabels(self, labels): """ Parameters ---------- labels : list of str """ self._headers[Qt.Vertical] = tuple(labels) def headerData(self, section, orientation, role=Qt.DisplayRole): headers = self._headers.get(orientation) if headers and section < len(headers): section = self.mapToSourceRows(section) if orientation == Qt.Vertical else section value = headers[section] if role == Qt.ToolTipRole: role = Qt.DisplayRole if role == Qt.DisplayRole: return value # Use QAbstractItemModel default for non-existent header/sections return super().headerData(section, orientation, role) def removeRows(self, row, count, parent=QModelIndex()): if not parent.isValid(): del self[row:row + count] for rowidx in range(row, row + count): self._roleData.pop(rowidx, None) return True return False def removeColumns(self, column, count, parent=QModelIndex()): self.beginRemoveColumns(parent, column, column + count - 1) for row in self._table: del row[column:column + count] for cols in self._roleData.values(): for col in range(column, column + count): cols.pop(col, None) del self._headers.get(Qt.Horizontal, [])[column:column + count] self.endRemoveColumns() return True def insertRows(self, row, count, parent=QModelIndex()): self.beginInsertRows(parent, row, row + count - 1) self._table[row:row] = [[''] * self.columnCount() for _ in range(count)] self.endInsertRows() return True def insertColumns(self, column, count, parent=QModelIndex()): self.beginInsertColumns(parent, column, column + count - 1) for row in self._table: row[column:column] = [''] * count self.endInsertColumns() return True def __len__(self): return len(self._table) def __bool__(self): return len(self) != 0 def __iter__(self): return iter(self._table) def __getitem__(self, item): return self._table[item] def __delitem__(self, i): if isinstance(i, slice): start, stop, _ = _as_contiguous_range(i, len(self)) stop -= 1 else: start = stop = i = i if i >= 0 else len(self) + i self._check_sort_order() self.beginRemoveRows(QModelIndex(), start, stop) del self._table[i] self.endRemoveRows() def __setitem__(self, i, value): self._check_sort_order() if isinstance(i, slice): start, stop, _ = _as_contiguous_range(i, len(self)) if not isinstance(value, collections.abc.Sized): value = tuple(value) newstop = start + len(value) # Signal changes parent = QModelIndex() if newstop > stop: self.rowsAboutToBeInserted.emit(parent, stop, newstop - 1) elif newstop < stop: self.rowsAboutToBeRemoved.emit(parent, newstop, stop - 1) # Make changes self._table[i] = value # Signal change were made if start != min(stop, newstop): self.dataChanged.emit( self.index(start, 0), self.index(min(stop, newstop) - 1, self.columnCount() - 1)) if newstop > stop: self.rowsInserted.emit(parent, stop, newstop - 1) elif newstop < stop: self.rowsRemoved.emit(parent, newstop, stop - 1) else: self._table[i] = value i %= len(self) self.dataChanged.emit(self.index(i, 0), self.index(i, self.columnCount() - 1)) def _check_sort_order(self): if self.mapToSourceRows(Ellipsis) is not Ellipsis: warn("Can't modify PyTableModel when it's sorted", RuntimeWarning, stacklevel=3) raise RuntimeError("Can't modify PyTableModel when it's sorted") def wrap(self, table): self.beginResetModel() self._table = table self._roleData = self._RoleData() self.resetSorting() self.endResetModel() def tolist(self): return self._table def clear(self): self.beginResetModel() self._table.clear() self.resetSorting() self._roleData.clear() self.endResetModel() def append(self, row): self.extend([row]) def _insertColumns(self, rows): n_max = max(map(len, rows)) if self.columnCount() < n_max: self.insertColumns(self.columnCount(), n_max - self.columnCount()) def extend(self, rows): i, rows = len(self), list(rows) self.insertRows(i, len(rows)) self._insertColumns(rows) self[i:] = rows def insert(self, i, row): self.insertRows(i, 1) self._insertColumns((row,)) self[i] = row def remove(self, val): del self[self._table.index(val)] class SeparatorItem: pass class LabelledSeparator(SeparatorItem): def __init__(self, label=None): self.label = label class SeparatedListDelegate(QStyledItemDelegate): def paint(self, painter, option, index): # type: (QPainter, QStyleOptionViewItem, QModelIndex) -> None super().paint(painter, option, index) data = index.data(Qt.EditRole) if not isinstance(data, LabelledSeparator): return painter.save() palette = option.palette # type: QPalette rect = option.rect # type: QRect if data.label: y = int(rect.bottom() - 0.1 * rect.height()) brush = palette.brush(QPalette.Active, QPalette.WindowText) font = painter.font() font.setPointSizeF(font.pointSizeF() * 0.9) font.setBold(True) painter.setFont(font) painter.setPen(QPen(brush, 1.0)) painter.drawText(rect, Qt.AlignCenter, data.label) else: y = rect.center().y() brush = palette.brush(QPalette.Disabled, QPalette.WindowText) painter.setPen(QPen(brush, 1.0)) painter.drawLine(rect.left(), y, rect.left() + rect.width(), y) painter.restore() class PyListModel(QAbstractListModel): """ A model for displaying python list like objects in Qt item view classes """ MIME_TYPE = "application/x-Orange-PyListModelData" Separator = SeparatorItem() removed = Signal() def __init__(self, iterable=None, parent=None, flags=Qt.ItemIsSelectable | Qt.ItemIsEnabled, list_item_role=Qt.DisplayRole, enable_dnd=False, supportedDropActions=Qt.MoveAction): super().__init__(parent) self._list = [] self._other_data = [] if enable_dnd: flags |= Qt.ItemIsDragEnabled self._flags = flags self.list_item_role = list_item_role self._supportedDropActions = supportedDropActions if iterable is not None: self.extend(iterable) def _is_index_valid(self, index): # This error would happen if one wraps a list into a model and then # modifies a list instead of a model if len(self) != len(self._other_data): raise RuntimeError("Mismatched length of model and its _other_data") if isinstance(index, QModelIndex) and index.isValid(): row, column = index.row(), index.column() return 0 <= row < len(self) and column == 0 elif isinstance(index, int): return -len(self) <= index < len(self) else: return False def wrap(self, lst): """ Wrap the list with this model. All changes to the model are done in place on the passed list """ self.beginResetModel() self._list = lst self._other_data = [_store() for _ in lst] self.endResetModel() def headerData(self, section, orientation, role=Qt.DisplayRole): if role == Qt.DisplayRole: return str(section) def rowCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self._list) def columnCount(self, parent=QModelIndex()): return 0 if parent.isValid() else 1 def data(self, index, role=Qt.DisplayRole): row = index.row() if role in [self.list_item_role, Qt.EditRole] \ and self._is_index_valid(index): return self[row] elif self._is_index_valid(row): if isinstance(self[row], SeparatorItem) \ and role == Qt.AccessibleDescriptionRole: return 'separator' return self._other_data[row].get(role, None) def itemData(self, index): mapping = QAbstractListModel.itemData(self, index) if self._is_index_valid(index): items = list(self._other_data[index.row()].items()) else: items = [] for key, value in items: mapping[key] = value return mapping def parent(self, index=QModelIndex()): return QModelIndex() def setData(self, index, value, role=Qt.EditRole): if role == Qt.EditRole: if self._is_index_valid(index): self._list[index.row()] = value self.dataChanged.emit(index, index) return True elif self._is_index_valid(index): self._other_data[index.row()][role] = value self.dataChanged.emit(index, index) return True return False def setItemData(self, index, data): data = dict(data) if not data: return True # pragma: no cover with signal_blocking(self): for role, value in data.items(): if role == Qt.EditRole and \ self._is_index_valid(index): self._list[index.row()] = value elif self._is_index_valid(index): self._other_data[index.row()][role] = value self.dataChanged.emit(index, index) return True def flags(self, index): if self._is_index_valid(index): row = index.row() default = Qt.NoItemFlags \ if isinstance(self[row], SeparatorItem) else self._flags return self._other_data[row].get("flags", default) else: return self._flags | Qt.ItemIsDropEnabled def insertRows(self, row, count, parent=QModelIndex()): """ Insert ``count`` rows at ``row``, the list fill be filled with ``None`` """ if not parent.isValid(): self[row:row] = [None] * count return True else: return False def removeRows(self, row, count, parent=QModelIndex()): """Remove ``count`` rows starting at ``row`` """ if not parent.isValid(): del self[row:row + count] self.removed.emit() return True else: return False def moveRows(self, sourceParent, sourceRow, count, destinationParent, destinationChild): # type: (QModelIndex, int, int, QModelIndex, int) -> bool """ Move `count` rows starting at `sourceRow` to `destinationChild`. Reimplemented from QAbstractItemModel.moveRows """ if not self.beginMoveRows(sourceParent, sourceRow, sourceRow + count - 1, destinationParent, destinationChild): return False take_slice = slice(sourceRow, sourceRow + count) insert_at = destinationChild if insert_at > sourceRow: insert_at -= count items, other = self._list[take_slice], self._other_data[take_slice] del self._list[take_slice], self._other_data[take_slice] self._list[insert_at:insert_at] = items self._other_data[insert_at: insert_at] = other self.endMoveRows() return True def extend(self, iterable): list_ = list(iterable) count = len(list_) if count == 0: return self.beginInsertRows(QModelIndex(), len(self), len(self) + count - 1) self._list.extend(list_) self._other_data.extend([_store() for _ in list_]) self.endInsertRows() def append(self, item): self.extend([item]) def insert(self, i, val): self.beginInsertRows(QModelIndex(), i, i) self._list.insert(i, val) self._other_data.insert(i, _store()) self.endInsertRows() def remove(self, val): i = self._list.index(val) self.__delitem__(i) def pop(self, i): item = self._list[i] self.__delitem__(i) return item def indexOf(self, value): return self._list.index(value) def clear(self): del self[:] def __len__(self): return len(self._list) def __contains__(self, value): return value in self._list def __iter__(self): return iter(self._list) def __getitem__(self, i): return self._list[i] def __add__(self, iterable): new_list = PyListModel(list(self._list), # method parent is overloaded in Model QObject.parent(self), flags=self._flags, list_item_role=self.list_item_role, supportedDropActions=self.supportedDropActions()) # pylint: disable=protected-access new_list._other_data = list(self._other_data) new_list.extend(iterable) return new_list def __iadd__(self, iterable): self.extend(iterable) return self def __delitem__(self, s): if isinstance(s, slice): start, stop, _ = _as_contiguous_range(s, len(self)) if not len(self) or start == stop: return self.beginRemoveRows(QModelIndex(), start, stop - 1) else: s = operator.index(s) s = len(self) + s if s < 0 else s self.beginRemoveRows(QModelIndex(), s, s) del self._list[s] del self._other_data[s] self.endRemoveRows() def __setitem__(self, s, value): if isinstance(s, slice): start, stop, step = _as_contiguous_range(s, len(self)) self.__delitem__(slice(start, stop, step)) if not isinstance(value, list): value = list(value) if len(value) == 0: return self.beginInsertRows(QModelIndex(), start, start + len(value) - 1) self._list[start:start] = value self._other_data[start:start] = (_store() for _ in value) self.endInsertRows() else: s = operator.index(s) s = len(self) + s if s < 0 else s self._list[s] = value self._other_data[s] = _store() self.dataChanged.emit(self.index(s), self.index(s)) def reverse(self): self._list.reverse() self._other_data.reverse() self.dataChanged.emit(self.index(0), self.index(len(self) - 1)) def sort(self, *args, **kwargs): indices = _argsort(self._list, *args, **kwargs) lst = [self._list[i] for i in indices] other = [self._other_data[i] for i in indices] for i, (new_l, new_o) in enumerate(zip(lst, other)): self._list[i] = new_l self._other_data[i] = new_o self.dataChanged.emit(self.index(0), self.index(len(self) - 1)) def __repr__(self): return "PyListModel(%s)" % repr(self._list) def __bool__(self): return len(self) != 0 def emitDataChanged(self, indexList): if isinstance(indexList, int): indexList = [indexList] #TODO: group indexes into ranges for ind in indexList: self.dataChanged.emit(self.index(ind), self.index(ind)) ########### # Drag/drop ########### def supportedDropActions(self): return self._supportedDropActions def mimeTypes(self): return [self.MIME_TYPE] def mimeData(self, indexlist): if len(indexlist) <= 0: return None def itemData(row): # type: (int) -> Dict[int, Any] if row < len(self._other_data): return {key: val for key, val in self._other_data[row].items() if isinstance(key, int)} else: return {} # pragma: no cover items = [self[i.row()] for i in indexlist] itemdata = [itemData(i.row()) for i in indexlist] mime = QMimeData() mime.setData(self.MIME_TYPE, b'see properties: _items, _itemdata') mime.setProperty('_items', items) mime.setProperty('_itemdata', itemdata) return mime def dropMimeData(self, mime, action, row, column, parent): if action == Qt.IgnoreAction: return True # pragma: no cover if not mime.hasFormat(self.MIME_TYPE): return False # pragma: no cover items = mime.property('_items') itemdata = mime.property('_itemdata') if not items: return False # pragma: no cover if row == -1: row = len(self) # pragma: no cover self[row:row] = items for i, data in enumerate(itemdata): self.setItemData(self.index(row + i), data) return True class ListSingleSelectionModel(QItemSelectionModel): """ Item selection model for list item models with single selection. Defines signal: - selectedIndexChanged(QModelIndex) """ selectedIndexChanged = Signal(QModelIndex) def __init__(self, model, parent=None): super().__init__(model, parent) self.selectionChanged.connect(self.onSelectionChanged) def onSelectionChanged(self, new, _): index = list(new.indexes()) if index: index = index.pop() else: index = QModelIndex() self.selectedIndexChanged.emit(index) def selectedRow(self): """ Return QModelIndex of the selected row or invalid if no selection. """ rows = self.selectedRows() if rows: return rows[0] else: return QModelIndex() def select(self, index, flags=QItemSelectionModel.ClearAndSelect): if isinstance(index, int): index = self.model().index(index) return super().select(self, index, flags) def select_row(view, row): """ Select a `row` in an item view. """ selmodel = view.selectionModel() selmodel.select(view.model().index(row, 0), QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) class ModelActionsWidget(QWidget): def __init__(self, actions=None, parent=None, direction=QBoxLayout.LeftToRight): super().__init__(parent) self.actions = [] self.buttons = [] layout = QBoxLayout(direction) layout.setContentsMargins(0, 0, 0, 0) self.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) if actions is not None: for action in actions: self.addAction(action) self.setLayout(layout) def actionButton(self, action): if isinstance(action, QAction): button = QToolButton(self) button.setDefaultAction(action) return button elif isinstance(action, QAbstractButton): return action def insertAction(self, ind, action, *args): button = self.actionButton(action) self.layout().insertWidget(ind, button, *args) self.buttons.insert(ind, button) self.actions.insert(ind, action) return button def addAction(self, action, *args): return self.insertAction(-1, action, *args) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/listview.py0000644000076500000240000002106014440334174022550 0ustar00primozstafffrom typing import Iterable, Optional import warnings from AnyQt.QtWidgets import QListView, QLineEdit, QStyle from AnyQt.QtGui import QResizeEvent from AnyQt.QtCore import ( Qt, QAbstractItemModel, QModelIndex, QSortFilterProxyModel, QItemSelection, QSize, ) class ListViewFilter(QListView): """ A QListView with implicit and transparent row filtering. """ def __init__( self, *args, model: Optional[QAbstractItemModel] = None, proxy: Optional[QSortFilterProxyModel] = None, preferred_size: Optional[QSize] = None, **kwargs ): super().__init__(*args, **kwargs) self.__search = QLineEdit(self, placeholderText="Filter...") self.__search.textEdited.connect(self.__on_text_edited) self.__preferred_size = preferred_size self.__layout() self.setMinimumHeight(100) if proxy is None: proxy = QSortFilterProxyModel( self, filterCaseSensitivity=Qt.CaseInsensitive ) assert isinstance(proxy, QSortFilterProxyModel) super().setModel(proxy) self.set_source_model(model) def __on_text_edited(self, string: str): self.model().setFilterFixedString(string) def setModel(self, _): raise TypeError("The model cannot be changed. " "Use set_source_model() instead.") def set_source_model(self, model: QAbstractItemModel): self.model().setSourceModel(model) def source_model(self): return self.model().sourceModel() def updateGeometries(self): super().updateGeometries() self.__layout() def __layout(self): margins = self.viewportMargins() sh = self.__search.sizeHint() margins.setTop(sh.height()) vscroll = self.verticalScrollBar() transient = self.style().styleHint(QStyle.SH_ScrollBar_Transient, None, vscroll) w = self.size().width() if vscroll.isVisibleTo(self) and not transient: w = w - vscroll.width() - 1 self.__search.setGeometry(0, 0, w, sh.height()) self.setViewportMargins(margins) def sizeHint(self) -> QSize: size = self.__preferred_size return size if size is not None else super().sizeHint() class ListViewSearch(QListView): """ An QListView with an implicit and transparent row filtering. """ def __init__(self, *a, preferred_size=None, **ak): warnings.warn("ListViewSearch is deprecated and will be removed " "in upcoming releases. Use ListViewFilter instead.", DeprecationWarning) super().__init__(*a, **ak) self.__search = QLineEdit(self, placeholderText="Filter...") self.__search.textEdited.connect(self.__setFilterString) # Use an QSortFilterProxyModel for filtering. Note that this is # never set on the view, only its rows insertes/removed signals are # connected to observe an update row hidden state. self.__pmodel = QSortFilterProxyModel( self, filterCaseSensitivity=Qt.CaseInsensitive ) self.__pmodel.rowsAboutToBeRemoved.connect( self.__filter_rowsAboutToBeRemoved ) self.__pmodel.rowsInserted.connect(self.__filter_rowsInserted) self.__layout() self.preferred_size = preferred_size self.setMinimumHeight(100) def setFilterPlaceholderText(self, text: str): self.__search.setPlaceholderText(text) def filterPlaceholderText(self) -> str: return self.__search.placeholderText() def setFilterProxyModel(self, proxy: QSortFilterProxyModel) -> None: """ Set an instance of QSortFilterProxyModel that will be used for filtering the model. The `proxy` must be a filtering proxy only; it MUST not sort the row of the model. The FilterListView takes ownership of the proxy. """ self.__pmodel.rowsAboutToBeRemoved.disconnect( self.__filter_rowsAboutToBeRemoved ) self.__pmodel.rowsInserted.disconnect(self.__filter_rowsInserted) self.__pmodel = proxy proxy.setParent(self) self.__pmodel.rowsAboutToBeRemoved.connect( self.__filter_rowsAboutToBeRemoved ) self.__pmodel.rowsInserted.connect(self.__filter_rowsInserted) self.__pmodel.setSourceModel(self.model()) self.__filter_reset() def filterProxyModel(self) -> QSortFilterProxyModel: return self.__pmodel def setModel(self, model: QAbstractItemModel) -> None: super().setModel(model) self.__pmodel.setSourceModel(model) self.__filter_reset() self.model().rowsInserted.connect(self.__model_rowInserted) self.model().modelReset.connect(self.__on_modelReset) def __on_modelReset(self): self.__filter_reset() self.__pmodel.setFilterFixedString("") self.__pmodel.setFilterFixedString(self.__search.text()) def setRootIndex(self, index: QModelIndex) -> None: super().setRootIndex(index) self.__filter_reset() def __filter_reset(self): root = self.rootIndex() self.__filter(range(self.__pmodel.rowCount(root))) def __setFilterString(self, string: str): self.__pmodel.setFilterFixedString(string) def setFilterString(self, string: str): """Set the filter string.""" self.__search.setText(string) self.__pmodel.setFilterFixedString(string) def filterString(self): """Return the filter string.""" return self.__search.text() def __filter(self, rows: Iterable[int]) -> None: """Set hidden state for rows based on filter string""" root = self.rootIndex() pm = self.__pmodel for r in rows: self.setRowHidden(r, not pm.filterAcceptsRow(r, root)) def __filter_set(self, rows: Iterable[int], state: bool): for r in rows: self.setRowHidden(r, state) def __filter_rowsAboutToBeRemoved( self, parent: QModelIndex, start: int, end: int ) -> None: fmodel = self.__pmodel mrange = QItemSelection( fmodel.index(start, 0, parent), fmodel.index(end, 0, parent) ) mranges = fmodel.mapSelectionToSource(mrange) for mrange in mranges: self.__filter_set(range(mrange.top(), mrange.bottom() + 1), True) def __filter_rowsInserted( self, parent: QModelIndex, start: int, end: int ) -> None: fmodel = self.__pmodel mrange = QItemSelection( fmodel.index(start, 0, parent), fmodel.index(end, 0, parent) ) mranges = fmodel.mapSelectionToSource(mrange) for mrange in mranges: self.__filter_set(range(mrange.top(), mrange.bottom() + 1), False) def __model_rowInserted(self, _, start: int, end: int) -> None: """ Filter elements when inserted in list - proxy model's rowsAboutToBeRemoved is not called on elements that are hidden when inserting """ self.__filter(range(start, end + 1)) def resizeEvent(self, event: QResizeEvent) -> None: super().resizeEvent(event) def updateGeometries(self) -> None: super().updateGeometries() self.__layout() def __layout(self): margins = self.viewportMargins() search = self.__search sh = search.sizeHint() size = self.size() margins.setTop(sh.height()) vscroll = self.verticalScrollBar() style = self.style() transient = style.styleHint(QStyle.SH_ScrollBar_Transient, None, vscroll) w = size.width() if vscroll.isVisibleTo(self) and not transient: w = w - vscroll.width() - 1 search.setGeometry(0, 0, w, sh.height()) self.setViewportMargins(margins) def sizeHint(self): return ( self.preferred_size if self.preferred_size is not None else super().sizeHint() ) def main(): from itertools import cycle from AnyQt.QtCore import QStringListModel from AnyQt.QtWidgets import QApplication, QWidget, QVBoxLayout app = QApplication([]) w = QWidget() w.setLayout(QVBoxLayout()) lv = ListViewFilter() lv.setUniformItemSizes(True) w.layout().addWidget(lv) c = cycle(list(map(chr, range(ord("A"), ord("Z"))))) s = [f"{next(c)}{next(c)}{next(c)}{next(c)}" for _ in range(50000)] model = QStringListModel(s) lv.set_source_model(model) w.show() app.exec() if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/matplotlib_export.py0000644000076500000240000001536414306600442024457 0ustar00primozstafffrom itertools import chain import numpy as np from matplotlib.colors import to_hex from pyqtgraph.graphicsItems.ScatterPlotItem import ScatterPlotItem from AnyQt.QtCore import Qt from AnyQt.QtGui import QPen, QBrush def numpy_repr(a): """ A numpy repr without summarization """ opts = np.get_printoptions() # avoid numpy repr as it changes between versions # TODO handle numpy repr differences if isinstance(a, np.ndarray): return "array(" + repr(list(a)) + ")" try: np.set_printoptions(threshold=10**10) return repr(a) finally: np.set_printoptions(**opts) def numpy_repr_int(a): # avoid numpy repr as it changes between versions # TODO handle numpy repr differences return "array(" + repr(list(a)) + ", dtype='int')" def compress_if_all_same(l): s = set(l) return s.pop() if len(s) == 1 else l def is_sequence_not_string(a): if isinstance(a, str): return False try: iter(a) return True except TypeError: pass return False def code_with_indices(data, data_name, indices, indices_name): if is_sequence_not_string(data) and indices is not None: return data_name + "[" + indices_name + "]" else: return data_name def index_per_different(l): different = [] different_ind = {} index = [] for e in l: if e not in different_ind: different_ind[e] = len(different) different.append(e) index.append(different_ind[e]) return different, index def scatterplot_code(scatterplot_item): x = scatterplot_item.data['x'] y = scatterplot_item.data['y'] sizes = scatterplot_item.data["size"] code = [] code.append("# data") code.append("x = {}".format(numpy_repr(x))) code.append("y = {}".format(numpy_repr(y))) code.append("# style") sizes = compress_if_all_same(sizes) if sizes == -1: sizes = None code.append("sizes = {}".format(numpy_repr(sizes))) def colortuple(pen): if isinstance(pen, (QPen, QBrush)): color = pen.color() return color.redF(), color.greenF(), color.blueF(), color.alphaF() return pen def width(pen): if isinstance(pen, QPen): return pen.widthF() return pen linewidths = np.array([width(a) for a in scatterplot_item.data["pen"]]) def shown(a): if isinstance(a, (QPen, QBrush)): s = a.style() if s == Qt.NoPen or s == Qt.NoBrush or a.color().alpha() == 0: return False return True shown_edge = [shown(a) for a in scatterplot_item.data["pen"]] shown_brush = [shown(a) for a in scatterplot_item.data["brush"]] # return early if the scatterplot is all transparent if not any(shown_edge) and not any(shown_brush): return "" def do_colors(code, data_column, show, name): colors = [colortuple(a) for a in data_column] if all(a is None for a in colors): colors, index = None, None else: # replace None values with blue colors colors = np.array([((0, 0, 1, 1) if a is None else a) for a in colors]) # set alpha for hidden (Qt.NoPen, Qt.NoBrush) elements to zero colors[:, 3][np.array(show) == 0] = 0 # shorter color names for printout colors = [to_hex(c, keep_alpha=True) for c in colors] colors, index = index_per_different(colors) code.append("{} = {}".format(name, repr(colors))) if index is not None: code.append("{}_index = {}".format(name, numpy_repr_int(index))) decompresssed_code = name if index is not None: decompresssed_code = "array({})[{}_index]".format(name, name) colors = np.array(colors)[index] return colors, decompresssed_code edgecolors, edgecolors_code = do_colors(code, scatterplot_item.data["pen"], shown_edge, "edgecolors") facecolors, facecolors_code = do_colors(code, scatterplot_item.data["brush"], shown_brush, "facecolors") linewidths = compress_if_all_same(linewidths) code.append("linewidths = {}".format(numpy_repr(linewidths))) # possible_markers for scatterplot are in .graph.CurveSymbols def matplotlib_marker(m): if m == "t": return "^" elif m == "t2": return ">" elif m == "t3": return "<" elif m == "star": return "*" elif m == "+": return "P" elif m == "x": return "X" return m # TODO labels are missing # each marker requires one call to matplotlib's scatter! markers = np.array([matplotlib_marker(m) for m in scatterplot_item.data["symbol"]]) for m in set(markers): indices = np.where(markers == m)[0] if np.all(indices == np.arange(x.shape[0])): indices = None if indices is not None: code.append("indices = {}".format(numpy_repr_int(indices))) def indexed(data, data_name, indices=indices): return code_with_indices(data, data_name, indices, "indices") code.append("plt.scatter(x={}, y={}, s={}, marker={},\n" " facecolors={}, edgecolors={},\n" " linewidths={})" .format(indexed(x, "x"), indexed(y, "y"), (indexed(sizes, "sizes") + "**2/4") if sizes is not None else "sizes", repr(m), indexed(facecolors, facecolors_code), indexed(edgecolors, edgecolors_code), indexed(linewidths, "linewidths") )) return "\n".join(code) def scene_code(scene): code = [] code.append("import matplotlib.pyplot as plt") code.append("from numpy import array") code.append("") code.append("plt.clf()") code.append("") for item in scene.items: if isinstance(item, ScatterPlotItem): code.append(scatterplot_code(item)) # TODO currently does not work for graphs without axes and for multiple axes! for position, set_ticks, set_label in [("bottom", "plt.xticks", "plt.xlabel"), ("left", "plt.yticks", "plt.ylabel")]: axis = scene.getAxis(position) code.append("{}({})".format(set_label, repr(str(axis.labelText)))) # textual tick labels if axis._tickLevels is not None: major_minor = list(chain(*axis._tickLevels)) locs = [a[0] for a in major_minor] labels = [a[1] for a in major_minor] code.append("{}({}, {})".format(set_ticks, locs, repr(labels))) return "\n".join(code) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690529732.0 orange-widget-base-4.22.0/orangewidget/utils/messages.py0000644000076500000240000003652014460667704022532 0ustar00primozstaff"""Mixin class for errors, warnings and information A class derived from `OWBaseWidget` can include member classes `Error`, `Warning` and `Information`, derived from the same-named `OWBaseWidget` classes. Each of those contains members that are instances of `UnboundMsg`, which is, for convenience, also exposed as `Orange.widgets.widget.Msg`. These members represent all possible errors, like `Error.no_discrete_vars`, with exception of the deprecated old-style errors. When the widget is instantiated, classes `Error`, `Warning` and `Information` are instantiated and bound to the widget: their attribute `widget` is the link to the widget that instantiated them. Their member messages are replaced with instances of `_BoundMsg`, which are bound to the group through the `group` attribute. A message is shown by calling, e.g. `self.Error.no_discrete_vars()`. The call formats the message and tells the group to activate it:: self.formatted = self.format(*args, **kwargs) self.group.activate_msg(self) The group adds it to the dictionary of active messages (attribute `active`) and emits the signal `messageActivated`. The signal is connected to the widget's method `update_widget_state`, which shows the message in the bar, and `WidgetManager`'s `__on_widget_state_changed`, which manages the icons on the canvas. Clearing messages work analogously. """ import sys import traceback from operator import attrgetter from warnings import warn from inspect import getattr_static from typing import Optional from AnyQt.QtWidgets import QStyle, QSizePolicy from orangewidget import gui from orangewidget.utils.messagewidget import MessagesWidget class UnboundMsg(str): """ The class used for declaring messages in classes derived from MessageGroup. When instantiating the message group, instances of this class are replaced by instances of `_BoundMsg` that are bound to the group. Note: this class is aliased to `Orange.widgets.widget.Msg`. """ def __new__(cls, msg): return str.__new__(cls, msg) def bind(self, group, owner_class=None): return _BoundMsg(self, group, owner_class) # The method is implemented in _BoundMsg # pylint: disable=unused-variable def __call__(self, *args, shown=True, exc_info=None, **kwargs): """ Show the message, or hide it if `show` is set `False` `*args` and `**kwargs` are passed to the `format` method. Args: shown (bool): keyword-only argument that can be set to `False` to hide the message exc_info (Union[BaseException, bool, None]): Optional exception instance whose traceback to store in the message. Can also be a `True` value in which case the exception is retrieved from sys.exc_info() *args: arguments for `format` **kwargs: keyword arguments for `format` """ raise RuntimeError("Message is not bound") # The method is implemented in _BoundMsg def clear(self): """Remove the message.""" raise RuntimeError("Message is not bound") # The method is implemented in _BoundMsg def is_shown(self): """Return True if message is currently displayed.""" raise RuntimeError("Message is not bound") # Ensure that two instance of message are always different # In particular, there may be multiple messages "{}". def __hash__(self): return id(self) def __eq__(self, other): return self is other class _BoundMsg(UnboundMsg): """ A message that is bound to the group. Instances of this class provide the call operator for raising the message, and method `clear` for removing it. When the message is called, the arguments are passed to the message's format` method and the resulting string is stored in the attribute `formatted`. Attributes: group (MessageGroup): the group to which this message belongs owner_class (OWBaseWidget): the class in which the message is defined formatted (str): formatted message """ def __new__(cls, unbound_msg, group, owner_class=None): self = UnboundMsg.__new__(cls, unbound_msg) self.group = group self.owner_class = owner_class self.formatted = "" self.tb = None # type: Optional[str] return self def __call__(self, *args, shown=True, exc_info=None, **kwargs): self.tb = None if not shown: self.clear() else: self.formatted = self.format(*args, **kwargs) if exc_info: if isinstance(exc_info, BaseException): exc_info = (type(exc_info), exc_info, exc_info.__traceback__) elif not isinstance(exc_info, tuple): exc_info = sys.exc_info() if exc_info is not None: self.tb = "".join(traceback.format_exception(*exc_info)) self.group.activate_msg(self) def clear(self): self.group.deactivate_msg(self) def is_shown(self): return self in self.group.active def __str__(self): return self.formatted class _OldStyleMsg(_BoundMsg): """ Class for handling the old-style messages. Instances of this class are instantiated by the old methods `error`, `warning` and `information` and added to the list of active messages, with their old-style id's as keys. Instance of `_OldStyleMsg` are not members of message groups. """ def __new__(cls, text, group): self = _BoundMsg.__new__(cls, text, group) self.formatted = text return self class MessageGroup: """ A groups of messages, e.g. errors, warnings, information messages Widget's `__init__` searches for instances of this class among the widget's class member and instantiates them. Attributes: widget (widget.OWBaseWidget): the widget instance to which the group belongs """ def __init__(self, widget): self.widget = widget # Note: active messages are stored in the dictionary, in which # the key and the corresponding value are one and the same object, # except for old-style classes, for which the key is an (old-style) # id. When we remove support for old-style messages (Orange 4), # this dictionary can be replaced with a set. self._active = {} self._general = UnboundMsg("{}") self._bind_messages() @property def active(self): """ Sequence[_BoundMsg]: Sequence of all currently active messages. """ return self._active.values() def _bind_messages(self): def bind_subgroup(subgroup, widget_class): for name, msg in subgroup.__dict__.items(): if type(msg) is UnboundMsg: msg = msg.bind(self, widget_class) self.__dict__[name] = msg for group in type(self).mro(): for widget_class in type(self.widget).mro(): if group in widget_class.__dict__.values(): break else: # MessageGroups outside widget classes (e.g. mixins) widget_class = None bind_subgroup(group, widget_class) # This binds `_general` -- and any similar cases in which a message is # added to the instance and not as class attribute bind_subgroup(self, None) def add_message(self, name, msg="{}"): """Add and bind message to a group that is already instantiated and bound. If the message with that name already exists, the method does nothing. The method is used by helpers like this (simplified) one:: def check_results(results, msg_group): msg_group.add_message("invalid_results", "Results do not include any data") msg_group.invalid_results.clear() if results.data is None: msg_group.invalid_results() The helper is called from several widgets with `check_results(results, self.Error)` Args: name (str): the name of the member with the message msg (str or UnboundMsg): message text or instance (default `"{}"`) """ if not isinstance(msg, UnboundMsg): msg = UnboundMsg(msg) if name not in self.__dict__: self.__dict__[name] = msg.bind(self) def activate_msg(self, msg, msg_id=None): """Activate a message and emit the signal messageActivated Args: msg (_BoundMsg): the message to activate msg_id (int): id for old-style message (to be removed in the future) """ key = msg if msg_id is None else msg_id if self._active.get(key) == msg: self.widget.messageActivated.emit(msg) return self._active[key] = msg self.widget.messageActivated.emit(msg) def deactivate_msg(self, msg): """Deactivate a message and emit the signal messageDeactivated. Args: msg (_BoundMsg): the message to deactivate """ if msg not in self._active: return inst_msg = self._active.pop(msg) self.widget.messageDeactivated.emit( inst_msg if isinstance(msg, int) else msg) # When when we no longer support old-style messages, replace with: # if msg not in self._active: # return # del self._active[msg] # self.widget.messageDeactivated.emit(msg) # self has default value to avoid PyCharm warnings when calling # self.Error.clear(): PyCharm doesn't know that Error is instantiated def clear(self=None, *, owner=None): """Deactivate all active message from this group.""" for msg in list(self._active): if owner is None or msg.owner_class is owner: self.deactivate_msg(msg) def _add_general(self, id_or_text, text, shown): """Handler for methods `error`, `warning` and `information`; do not call directly. The message is shown as general message. This method also handles deprecated messages with id's.""" if id_or_text is None or id_or_text == "": self._general.clear() elif isinstance(id_or_text, str): self._general(id_or_text, shown=shown) # remaining cases handle deprecated messages with id's elif text: self.activate_msg(_OldStyleMsg(text, self), id_or_text) elif isinstance(id_or_text, list): for msg_id in id_or_text: self.deactivate_msg(msg_id) else: self.deactivate_msg(id_or_text) class MessagesMixin: """ Base class for message mixins. The class provides a constructor for instantiating and binding message groups. Widgets should use `WidgetMessageMixin rather than this class. """ def __init__(self): # type(self).__dict__ wouldn't return inherited messages, hence dir self.message_groups = [] for name in dir(self): group_class = getattr_static(self, name) if isinstance(group_class, type) and \ issubclass(group_class, MessageGroup) and \ group_class is not MessageGroup: bound_group = group_class(self) setattr(self, name, bound_group) self.message_groups.append(bound_group) self.message_groups.sort(key=attrgetter("severity"), reverse=True) class WidgetMessagesMixin(MessagesMixin): """ Provide the necessary methods for handling messages in widgets. The class defines member classes `Error`, `Warning` and `Information` that serve as base classes for these message groups. """ class Error(MessageGroup): """Base class for groups of error messages in widgets""" severity = 3 icon_path = gui.resource_filename("icons/error.png") bar_background = "#ffc6c6" bar_icon = QStyle.SP_MessageBoxCritical class Warning(MessageGroup): """Base class for groups of warning messages in widgets""" severity = 2 icon_path = gui.resource_filename("icons/warning.png") bar_background = "#ffffc9" bar_icon = QStyle.SP_MessageBoxWarning class Information(MessageGroup): """Base class for groups of information messages in widgets""" severity = 1 icon_path = gui.resource_filename("icons/information.png") bar_background = "#ceceff" bar_icon = QStyle.SP_MessageBoxInformation def __init__(self): super().__init__() self.message_bar = None self.messageActivated.connect(self.update_message_state) self.messageDeactivated.connect(self.update_message_state) def clear_messages(self): """Clear all messages""" for group in self.message_groups: group.clear() def update_message_state(self): """Show and update (or hide) the content of the widget's message bar. The method is connected to widget's signals `messageActivated` and `messageDeactivated`. """ if self.message_bar is None: return assert isinstance(self.message_bar, MessagesWidget) def msg(m): # type: (_BoundMsg) -> MessagesWidget.Message text = str(m) extra = "" if "\n" in text: text, extra = text.split("\n", 1) return MessagesWidget.Message( MessagesWidget.Severity(m.group.severity), text=text, informativeText=extra, detailedText=m.tb if m.tb else "" ) messages = [msg for group in self.message_groups for msg in group.active] self.message_bar.clear() if messages: self.message_bar.setMessages((m, msg(m)) for m in messages) self.message_bar.setVisible(bool(messages)) def insert_message_bar(self): """Insert message bar into the widget. This method must be called at the appropriate place in the widget layout setup by any widget that is using this mixin.""" self.message_bar = MessagesWidget(self) self.message_bar.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Maximum) self.layout().addWidget(self.message_bar) self.message_bar.setVisible(False) # pylint doesn't know that Information, Error and Warning are instantiated # and thus the methods are bound # pylint: disable=no-value-for-parameter # This class and classes Information, Error and Warning are friends # pylint: disable=protected-access @staticmethod def _warn_obsolete(text_or_id, what): if not isinstance(text_or_id, str) and text_or_id is not None: warn("'{}' with id is deprecated; use {} class". format(what, what.title()), stacklevel=3) def information(self, text_or_id=None, text=None, shown=True): self._warn_obsolete(text_or_id, "information") self.Information._add_general(text_or_id, text, shown) def warning(self, text_or_id=None, text=None, shown=True): self._warn_obsolete(text_or_id, "warning") self.Warning._add_general(text_or_id, text, shown) def error(self, text_or_id=None, text=None, shown=True): self._warn_obsolete(text_or_id, "error") self.Error._add_general(text_or_id, text, shown) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/messagewidget.py0000644000076500000240000005736614440334174023554 0ustar00primozstaffimport sys import enum import base64 from itertools import chain from xml.sax.saxutils import escape from collections import OrderedDict from typing import ( NamedTuple, Tuple, List, Dict, Iterable, Union, Optional, Hashable ) from AnyQt.QtCore import ( Qt, QSize, QBuffer, QPropertyAnimation, QEasingCurve, Property ) from AnyQt.QtGui import QIcon, QPixmap, QPainter from AnyQt.QtWidgets import ( QWidget, QLabel, QSizePolicy, QStyle, QHBoxLayout, QMenu, QWidgetAction, QStyleOption, QStylePainter, QApplication ) from AnyQt.QtCore import pyqtSignal as Signal from orangecanvas.utils.localization import pl from orangewidget.utils.buttons import flat_button_hover_background __all__ = ["Message", "MessagesWidget"] def image_data(pm): # type: (QPixmap) -> str """ Render the contents of the pixmap as a data URL (RFC-2397) Parameters ---------- pm : QPixmap Returns ------- datauri : str """ pm = QPixmap(pm) device = QBuffer() assert device.open(QBuffer.ReadWrite) pm.save(device, b'png') device.close() data = bytes(device.data()) payload = base64.b64encode(data).decode("ascii") return "data:image/png;base64," + payload class Severity(enum.IntEnum): """ An enum defining a severity level. """ #: General informative message. Information = 1 # == QMessageBox.Information #: A warning message severity. Warning = 2 # == QMessageBox.Warning #: An error message severity. Error = 3 # == QMessageBox.Critical class Message( NamedTuple( "Message", [ ("severity", Severity), ("icon", QIcon), ("text", str), ("informativeText", str), ("detailedText", str), ("textFormat", Qt.TextFormat) ])): """ A stateful message/notification. Parameters ---------- severity : `Severity` Severity level (default: :attr:`Severity.Information`). icon : QIcon Associated icon. If empty the `QStyle.standardIcon` will be used based on severity. text : str Short message text. informativeText : str Extra informative text to append to `text` (space permitting). detailedText : str Extra detailed text (e.g. exception traceback) textFormat : Qt.TextFormat If `Qt.RichText` then the contents of `text`, `informativeText` and `detailedText` will be rendered as html instead of plain text. """ #: Alias for :class:`.Severity` Severity = Severity #: Alias for :attr:`Severity.Information` Information = Severity.Information #: Alias for :attr:`Severity.Warning` Warning = Severity.Warning #: Alias for :attr:`Severity.Error` Error = Severity.Error def __new__(cls, severity=Severity.Information, icon=QIcon(), text="", informativeText="", detailedText="", textFormat=Qt.PlainText): return super().__new__(cls, Severity(severity), QIcon(icon), text, informativeText, detailedText, textFormat) def __bool__(self): return not self.isEmpty() def asHtml(self, includeShortText=True): # type: () -> str """ Render the message as an HTML fragment. """ if self.textFormat == Qt.RichText: render = lambda t: t else: render = lambda t: ('{}' .format(escape(t))) def iconsrc(message, size=12): # type: (Message) -> str """ Return an image src url for message icon. """ icon = message_icon(message) pm = icon.pixmap(size, size) return image_data(pm) imgsize = 12 parts = [ ('
' .format(self.severity.name.lower())) ] if includeShortText: parts += [('
' '' ' {text}' '
' .format(iconurl=iconsrc(self, size=imgsize * 2), imgsize=imgsize, text=render(self.text)))] if self.informativeText: parts += ['
{}
' .format(render(self.informativeText))] if self.detailedText: parts += ['
{}
' .format(render(self.detailedText))] parts += ['
'] return "\n".join(parts) def isEmpty(self): # type: () -> bool """ Is this message instance empty (has no text or icon) """ return (not self.text and self.icon.isNull() and not self.informativeText and not self.detailedText) @property def icon(self): return QIcon(super().icon) def __eq__(self, other): if isinstance(other, Message): return (self.severity == other.severity and self.icon.cacheKey() == other.icon.cacheKey() and self.text == other.text and self.informativeText == other.informativeText and self.detailedText == other.detailedText and self.textFormat == other.textFormat) else: return False def standard_pixmap(severity): # type: (Severity) -> QStyle.StandardPixmap mapping = { Severity.Information: QStyle.SP_MessageBoxInformation, Severity.Warning: QStyle.SP_MessageBoxWarning, Severity.Error: QStyle.SP_MessageBoxCritical, } return mapping[severity] def message_icon(message, style=None): # type: (Message, Optional[QStyle]) -> QIcon """ Return the resolved icon for the message. If `message.icon` is a valid icon then it is used. Otherwise the appropriate style icon is used based on the `message.severity` Parameters ---------- message : Message style : Optional[QStyle] Returns ------- icon : QIcon """ if style is None and QApplication.instance() is not None: style = QApplication.style() if message.icon.isNull(): icon = style.standardIcon(standard_pixmap(message.severity)) else: icon = message.icon return icon def categorize(messages): # type: (List[Message]) -> Tuple[Optional[Message], List[Message], List[Message], List[Message]] """ Categorize the messages by severity picking the message leader if possible. The leader is a message with the highest severity iff it is the only representative of that severity. Parameters ---------- messages : List[Messages] Returns ------- r : Tuple[Optional[Message], List[Message], List[Message], List[Message]] """ errors = [m for m in messages if m.severity == Severity.Error] warnings = [m for m in messages if m.severity == Severity.Warning] info = [m for m in messages if m.severity == Severity.Information] lead = None if len(errors) == 1: lead = errors.pop(-1) elif not errors and len(warnings) == 1: lead = warnings.pop(-1) elif not errors and not warnings and len(info) == 1: lead = info.pop(-1) return lead, errors, warnings, info # pylint: disable=too-many-branches def summarize(messages): # type: (List[Message]) -> Message """ Summarize a list of messages into a single message instance Parameters ---------- messages: List[Message] Returns ------- message: Message """ if not messages: return Message() if len(messages) == 1: return messages[0] lead, errors, warnings, info = categorize(messages) severity = Severity.Information icon = QIcon() leading_text = "" text_parts = [] if lead is not None: severity = lead.severity icon = lead.icon leading_text = lead.text elif errors: severity = Severity.Error elif warnings: severity = Severity.Warning nerrors, nwarnings, ninfo = len(errors), len(warnings), len(info) if errors: text_parts.append(f"{nerrors} {pl(nerrors, 'error')}") if warnings: text_parts.append(f"{nwarnings} {pl(nwarnings, 'warning')}") if info: if not (errors and warnings and lead): text_parts.append(f"{ninfo} {pl(ninfo, 'message')}") else: text_parts.append(f"{ninfo} other {pl(ninfo, 'message')}") if leading_text: text = leading_text if text_parts: text = text + " (" + ", ".join(text_parts) + ")" else: text = ", ".join(text_parts) detailed = "
".join(m.asHtml() for m in chain([lead], errors, warnings, info) if m is not None and not m.isEmpty()) return Message(severity, icon, text, detailedText=detailed, textFormat=Qt.RichText) class ElidingLabel(QLabel): def __init__(self, elide=False, **kwargs): super().__init__(**kwargs) self.__elide = elide self.__originalText = "" def resizeEvent(self, event): if self.__elide: self.__setElidedText(self.__originalText) def __setElidedText(self, text): fm = self.fontMetrics() # Qt sometimes elides even when text width == target width width = self.width() + 1 elided = fm.elidedText(text, Qt.ElideRight, width) super().setText(elided) def setText(self, text): self.__originalText = text if self.__elide: self.__setElidedText(text) else: super().setText(text) def sizeHint(self): fm = self.fontMetrics() w = fm.horizontalAdvance(self.__originalText) h = super().minimumSizeHint().height() return QSize(w, h) def setElide(self, enabled): if self.__elide == enabled: return self.__elide = enabled if enabled: self.__setElidedText(self.__originalText) else: super().setText(self.__originalText) class MessageWidget(QWidget): """ An iconified message display area. `IconifiedMessage` displays a short message along with an icon. """ #: Signal emitted when an embedded html link is clicked #: (if `openExternalLinks` is `False`). linkActivated = Signal(str) #: Signal emitted when an embedded html link is hovered. linkHovered = Signal(str) Message = Message def __init__(self, parent=None, openExternalLinks=False, elideText=False, defaultStyleSheet="", **kwargs): kwargs.setdefault( "sizePolicy", QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) ) super().__init__(parent, **kwargs) self._openExternalLinks = openExternalLinks # type: bool #: The full (joined all messages text - rendered as html), displayed #: in a tooltip. self.message = None #: Leading icon self.__iconwidget = IconWidget( sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) ) #: Inline message text self.__textlabel = ElidingLabel( wordWrap=False, textInteractionFlags=Qt.LinksAccessibleByMouse, openExternalLinks=self._openExternalLinks, sizePolicy=QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum), elide=elideText ) self.__textlabel.linkActivated.connect(self.linkActivated) self.__textlabel.linkHovered.connect(self.linkHovered) self.setLayout(QHBoxLayout()) self.layout().setContentsMargins(2, 1, 2, 1) self.layout().setSpacing(0) self.layout().addWidget(self.__iconwidget, alignment=Qt.AlignLeft) self.layout().addSpacing(4) self.layout().addWidget(self.__textlabel) self.__textlabel.setAttribute(Qt.WA_MacSmallSize) self.__defaultStyleSheet = defaultStyleSheet self.anim = QPropertyAnimation( self.__iconwidget, b"opacity", self.__iconwidget) 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(2) def setMessage(self, message): self.message = message self.ensurePolished() icon = message_icon(message) self.__iconwidget.setIcon(icon) self.__iconwidget.setVisible(not (message.isEmpty() or icon.isNull())) self.__textlabel.setTextFormat(message.textFormat) self.__textlabel.setText(message.text) self.__textlabel.setVisible(bool(message.text)) self.setToolTip(self._styled(message.asHtml())) self.anim.start(QPropertyAnimation.KeepWhenStopped) self.layout().activate() def sizeHint(self): sh = super().sizeHint() h = self.style().pixelMetric(QStyle.PM_SmallIconSize) if not self.message: sh.setWidth(0) return sh.expandedTo(QSize(0, h + 2)) def minimumSizeHint(self): msh = super().minimumSizeHint() h = self.style().pixelMetric(QStyle.PM_SmallIconSize) if not self.message: msh.setWidth(0) else: msh.setWidth(h + 2) return msh.expandedTo(QSize(0, h + 2)) def setOpenExternalLinks(self, state): # type: (bool) -> None """ If `True` then `linkActivated` signal will be emitted when the user clicks on an html link in a message, otherwise links are opened using `QDesktopServices.openUrl` """ # TODO: update popup if open self._openExternalLinks = state self.__textlabel.setOpenExternalLinks(state) def openExternalLinks(self): # type: () -> bool """ """ return self._openExternalLinks def setDefaultStyleSheet(self, css): # type: (str) -> None """ Set a default css to apply to the rendered text. Parameters ---------- css : str A css style sheet as supported by Qt's Rich Text support. Note ---- Not to be confused with `QWidget.styleSheet` See Also -------- `Supported HTML Subset`_ .. _`Supported HTML Subset`: http://doc.qt.io/qt-5/richtext-html-subset.html """ if self.__defaultStyleSheet != css: self.__defaultStyleSheet = css def defaultStyleSheet(self): """ Returns ------- css : str The current style sheet """ return self.__defaultStyleSheet def flashIcon(self): self.anim.start(QPropertyAnimation.KeepWhenStopped) def _styled(self, html): # Prepend css style sheet before a html fragment. if self.__defaultStyleSheet.strip(): return f"\n{html}" else: return html def enterEvent(self, event): super().enterEvent(event) self.update() def leaveEvent(self, event): super().leaveEvent(event) self.update() def changeEvent(self, event): super().changeEvent(event) self.update() def paintEvent(self, event): if not self.message: return opt = QStyleOption() opt.initFrom(self) if opt.state & (QStyle.State_MouseOver | QStyle.State_HasFocus): p = QPainter(self) flat_button_hover_background(p, opt) class MessagesWidget(MessageWidget): """ An iconified multiple message display area. `MessagesWidget` displays a short message along with an icon. If there are multiple messages they are summarized. The user can click on the widget to display the full message text in a popup view. """ Severity = Severity #: General informative message. Information = Severity.Information #: A warning message severity. Warning = Severity.Warning #: An error message severity. Error = Severity.Error def __init__(self, parent=None, openExternalLinks=False, elideText=False, defaultStyleSheet="", **kwargs): super().__init__(parent, openExternalLinks, elideText, defaultStyleSheet, **kwargs) self.__messages = OrderedDict() # type: Dict[Hashable, Message] def setMessage(self, message_id, message): # type: (Hashable, Message) -> None """ Add a `message` for `message_id` to the current display. Note ---- Set an empty `Message` instance to clear the message display but retain the relative ordering in the display should a message for `message_id` reactivate. """ self.__messages[message_id] = message self.__update() def removeMessage(self, message_id): # type: (Hashable) -> None """ Remove message for `message_id` from the display. Note ---- Setting an empty `Message` instance will also clear the display, however the relative ordering of the messages will be retained, should the `message_id` 'reactivate'. """ del self.__messages[message_id] self.__update() def setMessages(self, messages): # type: (Union[Iterable[Tuple[Hashable, Message]], Dict[Hashable, Message]]) -> None """ Set multiple messages in a single call. """ messages = OrderedDict(messages) self.__messages.update(messages) self.__update() def clear(self): # type: () -> None """ Clear all messages. """ self.__messages.clear() self.__update() def messages(self): # type: () -> List[Message] """ Return all set messages. Returns ------- messages: `List[Message]` """ return list(self.__messages.values()) def summarize(self): # type: () -> Message """ Summarize all the messages into a single message. """ messages = [m for m in self.__messages.values() if not m.isEmpty()] if messages: return summarize(messages) else: return Message() def flashIcon(self): for message in self.messages(): if message.severity != Severity.Information: self.anim.start(QPropertyAnimation.KeepWhenStopped) break def __update(self): super().setMessage(self.summarize()) def mousePressEvent(self, event): if event.button() == Qt.LeftButton: message = self.message if message: popup = QMenu(self) label = QLabel( self, textInteractionFlags=Qt.TextBrowserInteraction, openExternalLinks=self._openExternalLinks, ) label.setContentsMargins(4, 4, 4, 4) label.setText(self._styled(message.asHtml())) label.linkActivated.connect(self.linkActivated) label.linkHovered.connect(self.linkHovered) action = QWidgetAction(popup) action.setDefaultWidget(label) popup.addAction(action) popup.popup(event.globalPos(), action) event.accept() return else: super().mousePressEvent(event) class InOutStateWidget(MessageWidget): clicked = Signal() def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.clicked.emit() event.accept() return else: super().mousePressEvent(event) class IconWidget(QWidget): """ A widget displaying an `QIcon` """ def __init__(self, parent=None, icon=QIcon(), iconSize=QSize(), **kwargs): sizePolicy = kwargs.pop("sizePolicy", QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) super().__init__(parent, **kwargs) self._opacity = 1 self.__icon = QIcon(icon) self.__iconSize = QSize(iconSize) self.setSizePolicy(sizePolicy) def setIcon(self, icon): # type: (QIcon) -> None if self.__icon != icon: self.__icon = QIcon(icon) self.updateGeometry() self.update() def getOpacity(self): return self._opacity def setOpacity(self, o): self._opacity = o self.update() opacity = Property(float, fget=getOpacity, fset=setOpacity) def icon(self): # type: () -> QIcon return QIcon(self.__icon) def iconSize(self): # type: () -> QSize if not self.__iconSize.isValid(): size = self.style().pixelMetric(QStyle.PM_ButtonIconSize) return QSize(size, size) else: return QSize(self.__iconSize) def setIconSize(self, iconSize): # type: (QSize) -> None if self.__iconSize != iconSize: self.__iconSize = QSize(iconSize) self.updateGeometry() self.update() def sizeHint(self): sh = self.iconSize() m = self.contentsMargins() return QSize(sh.width() + m.left() + m.right(), sh.height() + m.top() + m.bottom()) def paintEvent(self, event): painter = QStylePainter(self) painter.setOpacity(self._opacity) opt = QStyleOption() opt.initFrom(self) painter.drawPrimitive(QStyle.PE_Widget, opt) if not self.__icon.isNull(): rect = self.contentsRect() if opt.state & QStyle.State_MouseOver: mode = QIcon.Active elif opt.state & QStyle.State_Enabled: mode = QIcon.Normal else: mode = QIcon.Disabled self.__icon.paint(painter, rect, Qt.AlignCenter, mode, QIcon.Off) painter.end() def main(argv=None): # pragma: no cover from AnyQt.QtWidgets import QVBoxLayout, QCheckBox, QStatusBar app = QApplication(list(argv) if argv else []) l1 = QVBoxLayout() l1.setContentsMargins(0, 0, 0, 0) blayout = QVBoxLayout() l1.addLayout(blayout) sb = QStatusBar() w = QWidget() w.setLayout(l1) messages = [ Message(Severity.Error, text="Encountered a HCF", detailedText="AAA! It burns.", textFormat=Qt.RichText), Message(Severity.Warning, text="ACHTUNG!", detailedText=( "
DAS KOMPUTERMASCHINE IST " "NICHT FÜR DER GEFINGERPOKEN
" ), textFormat=Qt.RichText), Message(Severity.Information, text="The rain in spain falls mostly on the plain", informativeText=( "Link" ), textFormat=Qt.RichText), Message(Severity.Error, text="I did not do this!", informativeText="The computer made suggestions...", detailedText="... and the default options was yes."), Message(), ] mw = MessagesWidget(openExternalLinks=True) for i, m in enumerate(messages): cb = QCheckBox(m.text) def toogled(state, i=i, m=m): if state: mw.setMessage(i, m) else: mw.removeMessage(i) cb.toggled[bool].connect(toogled) blayout.addWidget(cb) sb.addWidget(mw) w.layout().addWidget(sb, 0) w.show() return app.exec() if __name__ == "__main__": # pragma: no cover sys.exit(main(sys.argv)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/overlay.py0000644000076500000240000004306514334703654022401 0ustar00primozstaff""" Overlay Message Widget ---------------------- A Widget to display a temporary dismissible message over another widget. """ import sys import enum import functools import operator from collections import namedtuple from AnyQt.QtWidgets import ( QHBoxLayout, QPushButton, QLabel, QSizePolicy, QStyle, QAbstractButton, QWidget, QStyleOption ) from AnyQt.QtGui import QIcon, QPixmap, QPainter from AnyQt.QtCore import Qt, QSize, QRect, QPoint, QEvent from AnyQt.QtCore import pyqtSignal as Signal, pyqtSlot as Slot from .buttons import SimpleButton class OverlayWidget(QWidget): """ A widget positioned on top of another widget. """ def __init__(self, parent=None, alignment=Qt.AlignCenter, **kwargs): super().__init__(parent, **kwargs) self.setContentsMargins(0, 0, 0, 0) self.__alignment = alignment self.__widget = None def setWidget(self, widget): """ Set the widget over which this overlay should be displayed (anchored). :type widget: QWidget """ if self.__widget is not None: self.__widget.removeEventFilter(self) self.__widget.destroyed.disconnect(self.__on_destroyed) self.__widget = widget if self.__widget is not None: self.__widget.installEventFilter(self) self.__widget.destroyed.connect(self.__on_destroyed) if self.__widget is None: self.hide() else: self.__layout() def widget(self): """ Return the overlaid widget. :rtype: QWidget | None """ return self.__widget def setAlignment(self, alignment): """ Set overlay alignment. :type alignment: Qt.Alignment """ if self.__alignment != alignment: self.__alignment = alignment if self.__widget is not None: self.__layout() def alignment(self): """ Return the overlay alignment. :rtype: Qt.Alignment """ return self.__alignment def eventFilter(self, recv, event): # reimplemented if recv is self.__widget: if event.type() == QEvent.Resize or event.type() == QEvent.Move: self.__layout() elif event.type() == QEvent.Show: self.show() elif event.type() == QEvent.Hide: self.hide() return super().eventFilter(recv, event) def event(self, event): # reimplemented if event.type() == QEvent.LayoutRequest: self.__layout() return True else: return super().event(event) def paintEvent(self, event): opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) def showEvent(self, event): super().showEvent(event) # Force immediate re-layout on show self.__layout() def __layout(self): # position itself over `widget` # pylint: disable=too-many-branches widget = self.__widget if widget is None: return alignment = self.__alignment policy = self.sizePolicy() if widget.window() is self.window() and not self.isWindow(): if widget.isWindow(): bounds = widget.rect() else: bounds = QRect(widget.mapTo(widget.window(), QPoint(0, 0)), widget.size()) tl = self.parent().mapFrom(widget.window(), bounds.topLeft()) bounds = QRect(tl, widget.size()) else: if widget.isWindow(): bounds = widget.geometry() else: bounds = QRect(widget.mapToGlobal(QPoint(0, 0)), widget.size()) if self.isWindow(): bounds = bounds else: bounds = QRect(self.parent().mapFromGlobal(bounds.topLeft()), bounds.size()) sh = self.sizeHint() minsh = self.minimumSizeHint() minsize = self.minimumSize() if minsize.isNull(): minsize = minsh maxsize = bounds.size().boundedTo(self.maximumSize()) minsize = minsize.boundedTo(maxsize) effectivesh = sh.expandedTo(minsize).boundedTo(maxsize) hpolicy = policy.horizontalPolicy() vpolicy = policy.verticalPolicy() if not effectivesh.isValid(): effectivesh = QSize(0, 0) vpolicy = hpolicy = QSizePolicy.Ignored def is_expanding(policy: QSizePolicy.Policy): try: return policy & QSizePolicy.ExpandFlag except TypeError: return policy.value & QSizePolicy.ExpandFlag.value def getsize(hint, minimum, maximum, policy): if policy == QSizePolicy.Ignored: return maximum elif is_expanding(policy): return maximum else: return max(hint, minimum) width = getsize(effectivesh.width(), minsize.width(), maxsize.width(), hpolicy) heightforw = self.heightForWidth(width) if heightforw > 0: height = getsize(heightforw, minsize.height(), maxsize.height(), vpolicy) else: height = getsize(effectivesh.height(), minsize.height(), maxsize.height(), vpolicy) size = QSize(width, height) if alignment & Qt.AlignLeft: x = bounds.x() elif alignment & Qt.AlignRight: x = bounds.x() + bounds.width() - size.width() else: x = bounds.x() + max(0, bounds.width() - size.width()) // 2 if alignment & Qt.AlignTop: y = bounds.y() elif alignment & Qt.AlignBottom: y = bounds.y() + bounds.height() - size.height() else: y = bounds.y() + max(0, bounds.height() - size.height()) // 2 geom = QRect(QPoint(x, y), size) self.setGeometry(geom) @Slot() def __on_destroyed(self): self.__widget = None if self.isVisible(): self.hide() class MessageWidget(QWidget): """ A widget displaying a simple message to the user. This is an alternative to a full QMessageBox intended for inline modeless messages. [[icon] {Message text} (Ok) (Cancel)] """ #: Emitted when a button with the AcceptRole is clicked accepted = Signal() #: Emitted when a button with the RejectRole is clicked rejected = Signal() #: Emitted when a button with the HelpRole is clicked helpRequested = Signal() #: Emitted when a button is clicked clicked = Signal(QAbstractButton) class StandardButton(enum.IntEnum): NoButton, Ok, Close, Help = 0x0, 0x1, 0x2, 0x4 NoButton, Ok, Close, Help = list(StandardButton) class ButtonRole(enum.IntEnum): InvalidRole, AcceptRole, RejectRole, HelpRole = 0, 1, 2, 3 InvalidRole, AcceptRole, RejectRole, HelpRole = list(ButtonRole) _Button = namedtuple("_Button", ["button", "role", "stdbutton"]) def __init__(self, parent=None, icon=QIcon(), text="", wordWrap=False, textFormat=Qt.AutoText, standardButtons=NoButton, **kwargs): super().__init__(parent, **kwargs) self.__text = text self.__icon = QIcon() self.__wordWrap = wordWrap self.__standardButtons = MessageWidget.NoButton self.__buttons = [] layout = QHBoxLayout() layout.setContentsMargins(8, 0, 8, 0) self.__iconlabel = QLabel(objectName="icon-label") self.__iconlabel.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) self.__textlabel = QLabel(objectName="text-label", text=text, wordWrap=wordWrap, textFormat=textFormat) if sys.platform == "darwin": self.__textlabel.setAttribute(Qt.WA_MacSmallSize) layout.addWidget(self.__iconlabel) layout.addWidget(self.__textlabel) self.setLayout(layout) self.setIcon(icon) self.setStandardButtons(standardButtons) def setText(self, text): """ Set the current message text. :type message: str """ if self.__text != text: self.__text = text self.__textlabel.setText(text) def text(self): """ Return the current message text. :rtype: str """ return self.__text def setIcon(self, icon): """ Set the message icon. :type icon: QIcon | QPixmap | QString | QStyle.StandardPixmap """ if isinstance(icon, QStyle.StandardPixmap): icon = self.style().standardIcon(icon) else: icon = QIcon(icon) if self.__icon != icon: self.__icon = QIcon(icon) if not self.__icon.isNull(): size = self.style().pixelMetric( QStyle.PM_SmallIconSize, None, self) pm = self.__icon.pixmap(QSize(size, size)) else: pm = QPixmap() self.__iconlabel.setPixmap(pm) self.__iconlabel.setVisible(not pm.isNull()) def icon(self): """ Return the current icon. :rtype: QIcon """ return QIcon(self.__icon) def setWordWrap(self, wordWrap): """ Set the message text wrap property :type wordWrap: bool """ if self.__wordWrap != wordWrap: self.__wordWrap = wordWrap self.__textlabel.setWordWrap(wordWrap) def wordWrap(self): """ Return the message text wrap property. :rtype: bool """ return self.__wordWrap def setTextFormat(self, textFormat): """ Set message text format :type textFormat: Qt.TextFormat """ self.__textlabel.setTextFormat(textFormat) def textFormat(self): """ Return the message text format. :rtype: Qt.TextFormat """ return self.__textlabel.textFormat() def changeEvent(self, event): # reimplemented if event.type() == 177: # QEvent.MacSizeChange: ... super().changeEvent(event) def setStandardButtons(self, buttons): for button in MessageWidget.StandardButton: existing = self.button(button) if button & buttons and existing is None: self.addButton(button) elif existing is not None: self.removeButton(existing) def standardButtons(self): return functools.reduce( operator.ior, (slot.stdbutton for slot in self.__buttons if slot.stdbutton is not None), MessageWidget.NoButton) def addButton(self, button, *rolearg): """ addButton(QAbstractButton, ButtonRole) addButton(str, ButtonRole) addButton(StandardButton) Add and return a button """ stdbutton = None if isinstance(button, QAbstractButton): if len(rolearg) != 1: raise TypeError("Wrong number of arguments for " "addButton(QAbstractButton, role)") role = rolearg[0] elif isinstance(button, MessageWidget.StandardButton): if len(rolearg) != 0: raise TypeError("Wrong number of arguments for " "addButton(StandardButton)") stdbutton = button if button == MessageWidget.Ok: role = MessageWidget.AcceptRole button = QPushButton("Ok", default=False, autoDefault=False) elif button == MessageWidget.Close: role = MessageWidget.RejectRole # button = QPushButton( # default=False, autoDefault=False, flat=True, # icon=QIcon(self.style().standardIcon( # QStyle.SP_TitleBarCloseButton))) button = SimpleButton( icon=QIcon( self.style().standardIcon( QStyle.SP_TitleBarCloseButton))) elif button == MessageWidget.Help: role = MessageWidget.HelpRole button = QPushButton("Help", default=False, autoDefault=False) elif isinstance(button, str): if len(rolearg) != 1: raise TypeError("Wrong number of arguments for " "addButton(str, ButtonRole)") role = rolearg[0] button = QPushButton(button, default=False, autoDefault=False) if sys.platform == "darwin": button.setAttribute(Qt.WA_MacSmallSize) self.__buttons.append(MessageWidget._Button(button, role, stdbutton)) button.clicked.connect(self.__button_clicked) self.__relayout() return button def removeButton(self, button): """ Remove a `button`. :type button: QAbstractButton """ slot = [s for s in self.__buttons if s.button is button] if slot: slot = slot[0] self.__buttons.remove(slot) self.layout().removeWidget(slot.button) slot.button.setParent(None) def buttonRole(self, button): """ Return the ButtonRole for button :type button: QAbstractButton """ for slot in self.__buttons: if slot.button is button: return slot.role else: return MessageWidget.InvalidRole def button(self, standardButton): """ Return the button for the StandardButton. :type standardButton: StandardButton """ for slot in self.__buttons: if slot.stdbutton == standardButton: return slot.button else: return None def __button_clicked(self): button = self.sender() role = self.buttonRole(button) self.clicked.emit(button) if role == MessageWidget.AcceptRole: self.accepted.emit() self.close() elif role == MessageWidget.RejectRole: self.rejected.emit() self.close() elif role == MessageWidget.HelpRole: self.helpRequested.emit() def __relayout(self): for slot in self.__buttons: self.layout().removeWidget(slot.button) order = { MessageOverlayWidget.HelpRole: 0, MessageOverlayWidget.AcceptRole: 2, MessageOverlayWidget.RejectRole: 3, } orderd = sorted(self.__buttons, key=lambda slot: order.get(slot.role, -1)) prev = self.__textlabel for slot in orderd: self.layout().addWidget(slot.button) QWidget.setTabOrder(prev, slot.button) def proxydoc(func): return functools.wraps(func, assigned=["__doc__"], updated=[]) class MessageOverlayWidget(OverlayWidget): #: Emitted when a button with an Accept role is clicked accepted = Signal() #: Emitted when a button with a RejectRole is clicked rejected = Signal() #: Emitted when a button is clicked clicked = Signal(QAbstractButton) #: Emitted when a button with HelpRole is clicked helpRequested = Signal() NoButton, Ok, Close, Help = list(MessageWidget.StandardButton) InvalidRole, AcceptRole, RejectRole, HelpRole = \ list(MessageWidget.ButtonRole) def __init__(self, parent=None, text="", icon=QIcon(), alignment=Qt.AlignTop, wordWrap=False, standardButtons=NoButton, **kwargs): super().__init__(parent, alignment=alignment, **kwargs) layout = QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.__msgwidget = MessageWidget( parent=self, text=text, icon=icon, wordWrap=wordWrap, standardButtons=standardButtons ) self.__msgwidget.accepted.connect(self.accepted) self.__msgwidget.rejected.connect(self.rejected) self.__msgwidget.clicked.connect(self.clicked) self.__msgwidget.helpRequested.connect(self.helpRequested) self.__msgwidget.accepted.connect(self.hide) self.__msgwidget.rejected.connect(self.hide) layout.addWidget(self.__msgwidget) self.setLayout(layout) @proxydoc(MessageWidget.setText) def setText(self, text): self.__msgwidget.setText(text) @proxydoc(MessageWidget.text) def text(self): return self.__msgwidget.text() @proxydoc(MessageWidget.setIcon) def setIcon(self, icon): self.__msgwidget.setIcon(icon) @proxydoc(MessageWidget.icon) def icon(self): return self.__msgwidget.icon() @proxydoc(MessageWidget.textFormat) def textFromat(self): return self.__msgwidget.textFormat() @proxydoc(MessageWidget.setTextFormat) def setTextFormat(self, textFormat): self.__msgwidget.setTextFormat(textFormat) @proxydoc(MessageWidget.setStandardButtons) def setStandardButtons(self, buttons): self.__msgwidget.setStandardButtons(buttons) @proxydoc(MessageWidget.addButton) def addButton(self, *args): return self.__msgwidget.addButton(*args) @proxydoc(MessageWidget.removeButton) def removeButton(self, button): self.__msgwidget.removeButton(button) @proxydoc(MessageWidget.buttonRole) def buttonRole(self, button): return self.__msgwidget.buttonRole(button) @proxydoc(MessageWidget.button) def button(self, standardButton): return self.__msgwidget.button(standardButton) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/progressbar.py0000644000076500000240000000644214306600442023235 0ustar00primozstaffimport time import warnings from AnyQt.QtCore import pyqtProperty, pyqtSignal, pyqtSlot class ProgressBarMixin: __progressBarValue = 0 __progressState = 0 startTime = -1 # used in progressbar captionTitle = "" def setCaption(self, caption): self.captionTitle = caption self.setWindowTitle(caption) @pyqtSlot() def progressBarInit(self): """ Initialize the widget's progress (i.e show and set progress to 0%). """ self.startTime = time.time() self.setWindowTitle(self.captionTitle + " (0% complete)") if self.__progressState != 1: self.__progressState = 1 self.processingStateChanged.emit(1) self.progressBarSet(0) @pyqtSlot(float) def progressBarSet(self, value): """ Set the current progress bar to `value`. Parameters ---------- value : float Progress value. """ old = self.__progressBarValue self.__progressBarValue = value if value > 0: if self.__progressState != 1: warnings.warn("progressBarSet() called without a " "preceding progressBarInit()", stacklevel=2) self.__progressState = 1 self.processingStateChanged.emit(1) usedTime = max(1., time.time() - self.startTime) totalTime = 100.0 * usedTime / value remainingTime = max(0, int(totalTime - usedTime)) hrs = remainingTime // 3600 mins = (remainingTime % 3600) // 60 secs = remainingTime % 60 if hrs > 0: text = "{}:{:02}:{:02}".format(hrs, mins, secs) else: text = "{}:{}:{:02}".format(hrs, mins, secs) self.setWindowTitle("{} ({:d}%, ETA: {})" .format(self.captionTitle, int(value), text)) else: self.setWindowTitle(self.captionTitle + " (0% complete)") if old != value: self.progressBarValueChanged.emit(value) def progressBarValue(self): """ Return the state (value) of the progress bar """ return self.__progressBarValue progressBarValueChanged = pyqtSignal(float) progressBarValue = pyqtProperty( float, fset=progressBarSet, fget=progressBarValue, notify=progressBarValueChanged ) processingStateChanged = pyqtSignal(int) processingState = pyqtProperty( int, fget=lambda self: self.__progressState, notify=processingStateChanged ) @pyqtSlot(float) def progressBarAdvance(self, value): """ Advance the progress bar by `value`. Parameters ---------- value : float Progress value increment. """ self.progressBarSet(self.__progressBarValue + value) @pyqtSlot() def progressBarFinished(self): """ Stop the widget's progress (i.e hide the progress bar). Parameters ---------- value : float Progress value increment. """ self.setWindowTitle(self.captionTitle) if self.__progressState != 0: self.__progressState = 0 self.processingStateChanged.emit(0) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/saveplot.py0000644000076500000240000000313714334703654022551 0ustar00primozstaffimport os.path import traceback from AnyQt.QtWidgets import QMessageBox from AnyQt.QtCore import QSettings from orangewidget.utils import filedialogs # noinspection PyBroadException def save_plot(data, file_formats, start_dir="", filename=""): _LAST_DIR_KEY = "directories/last_graph_directory" _LAST_FILTER_KEY = "directories/last_graph_filter" settings = QSettings() start_dir = settings.value(_LAST_DIR_KEY, start_dir) if not start_dir or \ (not os.path.exists(start_dir) and not os.path.exists(os.path.split(start_dir)[0])): start_dir = os.path.expanduser("~") last_filter = settings.value(_LAST_FILTER_KEY, "") if filename: start_dir = os.path.join(start_dir, filename) filename, writer, filter = \ filedialogs.open_filename_dialog_save(start_dir, last_filter, file_formats) if not filename: return try: writer.write(filename, data) except OSError as e: mb = QMessageBox( None, windowTitle="Error", text='Error occurred while saving file "{}": {}'.format(filename, e), detailedText=traceback.format_exc(), icon=QMessageBox.Critical) mb.exec() else: settings.setValue(_LAST_DIR_KEY, os.path.split(filename)[0]) settings.setValue(_LAST_FILTER_KEY, filter) def main(): # pragma: no cover from AnyQt.QtWidgets import QApplication from orangewidget.widget import OWBaseWidget app = QApplication([]) save_plot(None, OWBaseWidget.graph_writers) if __name__ == "__main__": # pragma: no cover main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/signals.py0000755000076500000240000007363514440334174022364 0ustar00primozstaffimport copy import itertools import warnings from collections import defaultdict from functools import singledispatch import inspect from typing import ( NamedTuple, Union, Optional, Iterable, Dict, Tuple, Any, Sequence, Callable ) from AnyQt.QtCore import Qt, pyqtSignal, QPoint from AnyQt.QtWidgets import QWidgetAction, QMenu, QWidget, QLabel, QVBoxLayout from orangecanvas.registry.description import ( InputSignal, OutputSignal, Single, Multiple, Default, NonDefault, Explicit, Dynamic ) # imported here for easier use by widgets, pylint: disable=unused-import from orangecanvas.scheme.signalmanager import LazyValue from orangewidget.utils.messagewidget import MessagesWidget from orangewidget.workflow.utils import WeakKeyDefaultDict # increasing counter for ensuring the order of Input/Output definitions # is preserved when going through the unordered class namespace of # WidgetSignalsMixin.Inputs/Outputs. _counter = itertools.count() class PartialSummary(NamedTuple): summary: Union[None, str, int] = None details: Optional[str] = None preview_func: Optional[Callable[[], QWidget]] = None def base_summarize(_) -> PartialSummary: return PartialSummary() summarize = singledispatch(base_summarize) summarize.__doc__ = """ Function for summarizing the input or output data. The function must be decorated with `@summarize.register`. It accepts an argument of arbitrary type and returns a `PartialSummary`, which is a tuple consisting of two strings: a short summary (usually a number) and details. """ SUMMARY_STYLE = """ """ def can_summarize(type_, name, explicit): if explicit is not None: return explicit if not isinstance(type_, tuple): type_ = (type_, ) instr = f"To silence this warning, set auto_summary of '{name}' to False." for a_type in type_: if isinstance(a_type, str): warnings.warn( f"Output is specified with qualified name ({a_type}). " "To enable auto summary, set auto_summary to True. " + instr, UserWarning) return False if summarize.dispatch(a_type) is base_summarize: warnings.warn( f"register 'summarize' function for type {a_type.__name__}. " + instr, UserWarning, stacklevel=4) return False return True Closed = type( "Closed", (object,), { "__doc__": "Explicit connection closing sentinel.", "__repr__": lambda self: "Closed", "__str__": lambda self: "Closed", } )() class _Signal: @staticmethod def get_flags(multiple, default, explicit, dynamic): """Compute flags from arguments""" return (Multiple if multiple else Single) | \ (Default if default else NonDefault) | \ (explicit and Explicit) | \ (dynamic and Dynamic) def bound_signal(self, widget): """ Return a copy of the signal bound to a widget. Called from `WidgetSignalsMixin.__init__` """ new_signal = copy.copy(self) new_signal.widget = widget return new_signal def getsignals(signals_cls): # This function is preferred over getmembers because it returns the signals # in order of appearance return [(k, v) for cls in reversed(inspect.getmro(signals_cls)) for k, v in cls.__dict__.items() if isinstance(v, _Signal)] class Input(InputSignal, _Signal): """ Description of an input signal. The class is used to declare input signals for a widget as follows (the example is taken from the widget Test & Score):: class Inputs: train_data = Input("Data", Table, default=True) test_data = Input("Test Data", Table) learner = Input("Learner", Learner, multiple=True) preprocessor = Input("Preprocessor", Preprocess) Every input signal must be used to decorate exactly one method that serves as the input handler, for instance:: @Inputs.train_data def set_train_data(self, data): ... Parameters ---------- name (str): signal name type (type): signal type id (str): a unique id of the signal doc (str, optional): signal documentation replaces (list of str): a list with names of signals replaced by this signal multiple (bool, optional): if set, multiple signals can be connected to this output (default: `False`) default (bool, optional): when the widget accepts multiple signals of the same type, one of them can set this flag to act as the default (default: `False`) explicit (bool, optional): if set, this signal is only used when it is the only option or when explicitly connected in the dialog (default: `False`) auto_summary (bool, optional): by default, the input is reflected in widget's summary for all types with registered `summarize` function. This can be overridden by explicitly setting `auto_summary` to `False` or `True`. Explicitly setting this argument will also silence warnings for types without the summary function and for types defined with a fully qualified string instead of an actual type object. """ Closed = Closed def __init__(self, name, type, id=None, doc=None, replaces=None, *, multiple=False, default=False, explicit=False, auto_summary=None, closing_sentinel=None): flags = self.get_flags(multiple, default, explicit, False) super().__init__(name, type, "", flags, id, doc, replaces or []) self.auto_summary = can_summarize(type, name, auto_summary) self._seq_id = next(_counter) self.closing_sentinel = closing_sentinel def __call__(self, method): """ Decorator that stores decorated method's name in the signal's `handler` attribute. The method is returned unchanged. """ if self.flags & Multiple: def summarize_wrapper(widget, value, id=None): # If this method is overridden, don't summarize if summarize_wrapper is getattr(type(widget), method.__name__): widget.set_partial_input_summary( self.name, summarize(value), id=id) method(widget, value, id) else: def summarize_wrapper(widget, value): if summarize_wrapper is getattr(type(widget), method.__name__): widget.set_partial_input_summary( self.name, summarize(value)) method(widget, value) # Re-binding with the same name can happen in derived classes # We do not allow re-binding to a different name; for the same class # it wouldn't work, in derived class it could mislead into thinking # that the signal is passed to two different methods if self.handler and self.handler != method.__name__: raise ValueError("Input {} is already bound to method {}". format(self.name, self.handler)) self.handler = method.__name__ return summarize_wrapper if self.auto_summary else method class MultiInput(Input): """ A special multiple input descriptor. This type of input has explicit set/insert/remove interface to maintain fully ordered sequence input. This should be preferred to the plain `Input(..., multiple=True)` descriptor. This input type must register three methods in the widget implementation class corresponding to the insert, set/update and remove input commands:: class Inputs: values = MultiInput("Values", object) ... @Inputs.values def set_value(self, index: int, value: object): "Set/update the value at index" ... @Inputs.values.insert def insert_value(self, index: int, value: object): "Insert value at specified index" ... @Inputs.values.remove def remove_value(self, index: int): "Remove value at index" ... Parameters ---------- filter_none: bool If `True` any `None` values sent by workflow execution are implicitly converted to 'remove' notifications. When the value again changes to non-None the input is re-inserted into its proper position. .. versionadded:: 4.13.0 """ insert_handler: str = None remove_handler: str = None def __init__(self, *args, filter_none=False, **kwargs): multiple = kwargs.pop("multiple", True) if not multiple: raise ValueError("multiple cannot be set to False") super().__init__(*args, multiple=True, **kwargs) self.filter_none = filter_none self.closing_sentinel = Closed __summary_ids_mapping = WeakKeyDefaultDict(dict) __id_gen = itertools.count() def __get_summary_ids(self, widget: 'WidgetSignalsMixin'): ids = self.__summary_ids_mapping[widget] return ids.setdefault(self.name, []) def __call__(self, method): def summarize_wrapper(widget, index, value): # If this method is overridden, don't summarize if summarize_wrapper is getattr(type(widget), method.__name__): ids = self.__get_summary_ids(widget) widget.set_partial_input_summary( self.name, summarize(value), id=ids[index], index=index) method(widget, index, value) _ = super().__call__(method) return summarize_wrapper if self.auto_summary else method def insert(self, method): """Register the method as the insert handler""" def summarize_wrapper(widget, index, value): if summarize_wrapper is getattr(type(widget), method.__name__): ids = self.__get_summary_ids(widget) ids.insert(index, next(self.__id_gen)) widget.set_partial_input_summary( self.name, summarize(value), id=ids[index], index=index) method(widget, index, value) self.insert_handler = method.__name__ return summarize_wrapper if self.auto_summary else method def remove(self, method): """"Register the method as the remove handler""" def summarize_wrapper(widget, index): if summarize_wrapper is getattr(type(widget), method.__name__): ids = self.__get_summary_ids(widget) id_ = ids.pop(index) widget.set_partial_input_summary( self.name, summarize(None), id=id_) method(widget, index) self.remove_handler = method.__name__ return summarize_wrapper if self.auto_summary else method def bound_signal(self, widget): if self.insert_handler is None: raise RuntimeError('insert_handler is not set') if self.remove_handler is None: raise RuntimeError('remove_handler is not set') return super().bound_signal(widget) _not_set = object() def _parse_call_id_arg(id=_not_set): if id is _not_set: return None else: warnings.warn( "`id` parameter is deprecated and will be removed in the " "future", FutureWarning, stacklevel=3, ) return id class Output(OutputSignal, _Signal): """ Description of an output signal. The class is used to declare output signals for a widget as follows (the example is taken from the widget Test & Score):: class Outputs: predictions = Output("Predictions", Table) evaluations_results = Output("Evaluation Results", Results) The signal is then transmitted by, for instance:: self.Outputs.predictions.send(predictions) Parameters ---------- name (str): signal name type (type): signal type id (str): a unique id of the signal doc (str, optional): signal documentation replaces (list of str): a list with names of signals replaced by this signal default (bool, optional): when the widget accepts multiple signals of the same type, one of them can set this flag to act as the default (default: `False`) explicit (bool, optional): if set, this signal is only used when it is the only option or when explicitly connected in the dialog (default: `False`) dynamic (bool, optional): Specifies that the instances on the output will in general be subtypes of the declared type and that the output can be connected to any input signal which can accept a subtype of the declared output type (default: `True`) auto_summary (bool, optional): by default, the output is reflected in widget's summary for all types with registered `summarize` function. This can be overridden by explicitly setting `auto_summary` to `False` or `True`. Explicitly setting this argument will also silence warnings for types without the summary function and for types defined with a fully qualified string instead of an actual type object. """ def __init__(self, name, type, id=None, doc=None, replaces=None, *, default=False, explicit=False, dynamic=True, auto_summary=None): flags = self.get_flags(False, default, explicit, dynamic) super().__init__(name, type, flags, id, doc, replaces or []) self.auto_summary = can_summarize(type, name, auto_summary) self.widget = None self._seq_id = next(_counter) def send(self, value, *args, **kwargs): """Emit the signal through signal manager.""" assert self.widget is not None id = _parse_call_id_arg(*args, **kwargs) signal_manager = self.widget.signalManager if signal_manager is not None: if id is not None: extra_args = (id,) else: extra_args = () signal_manager.send(self.widget, self.name, value, *extra_args) if self.auto_summary: self.widget.set_partial_output_summary( self.name, summarize(value), id=id) def invalidate(self): """Invalidate the current output value on the signal""" assert self.widget is not None signal_manager = self.widget.signalManager if signal_manager is not None: signal_manager.invalidate(self.widget, self.name) class WidgetSignalsMixin: """Mixin for managing widget's input and output signals""" class Inputs: pass class Outputs: pass def __init__(self): self.input_summaries = {} self.output_summaries: Dict[str, PartialSummary] = {} self._bind_signals() def _bind_signals(self): for direction, summaries in (("Inputs", self.input_summaries), ("Outputs", self.output_summaries)): bound_cls = getattr(self, direction) bound_signals = bound_cls() for name, signal in getsignals(bound_cls): setattr(bound_signals, name, signal.bound_signal(self)) if signal.auto_summary: summaries[signal.name] = {} setattr(self, direction, bound_signals) def send(self, signalName, value, *args, **kwargs): """ Send a `value` on the `signalName` widget output. An output with `signalName` must be defined in the class ``outputs`` list. """ id = _parse_call_id_arg(*args, **kwargs) if not any(s.name == signalName for s in self.outputs): raise ValueError('{} is not a valid output signal for widget {}'.format( signalName, self.name)) if self.signalManager is not None: if id is not None: extra_args = (id,) else: extra_args = () self.signalManager.send(self, signalName, value, *extra_args) def handleNewSignals(self): """ Invoked by the workflow signal propagation manager after all signals handlers have been called. Reimplement this method in order to coalesce updates from multiple updated inputs. """ pass # Methods used by the meta class @classmethod def convert_signals(cls): """ Maintenance and sanity checks for signals. - Convert tuple descriptions into old-style signals for backwards compatibility - For new-style in classes, copy attribute name to id, if id is not set explicitly - Check that all input signals have handlers - Check that the same name and/or does not refer to different signals. This method is called from the meta-class. """ def signal_from_args(args, signal_type): if isinstance(args, tuple): return signal_type(*args) elif isinstance(args, signal_type): return copy.copy(args) if hasattr(cls, "inputs") and cls.inputs: cls.inputs = [signal_from_args(input_, InputSignal) for input_ in cls.inputs] if hasattr(cls, "outputs") and cls.outputs: cls.outputs = [signal_from_args(output, OutputSignal) for output in cls.outputs] for direction in ("Inputs", "Outputs"): klass = getattr(cls, direction, None) if klass is None: continue for name, signal in klass.__dict__.items(): if isinstance(signal, (_Signal)) and signal.id is None: signal.id = name cls._check_input_handlers() cls._check_ids_unique() @classmethod def _check_input_handlers(cls): unbound = [signal.name for _, signal in getsignals(cls.Inputs) if not signal.handler] if unbound: raise ValueError("unbound signal(s) in {}: {}". format(cls.__name__, ", ".join(unbound))) missing_handlers = [signal.handler for signal in cls.inputs if not hasattr(cls, signal.handler)] if missing_handlers: raise ValueError("missing handlers in {}: {}". format(cls.__name__, ", ".join(missing_handlers))) @classmethod def _check_ids_unique(cls): for direction in ("input", "output"): # Collect signals by name and by id, check for duplicates by_name = {} by_id = {} for signal in cls.get_signals(direction + "s"): if signal.name in by_name: raise RuntimeError( f"Name {signal.name} refers to different {direction} " f"signals of {cls.__name__}" ) by_name[signal.name] = signal if signal.id is not None: if signal.id in by_id: raise RuntimeError( f"Id {signal.id} refers to different {direction} " f"signals of {cls.__name__}" ) by_id[signal.id] = signal # Warn the same name and id refer to different signal for name in set(by_name) & set(by_id): if by_name[name] is not by_id[name]: warnings.warn( f"{name} appears as a name and an id of two different " f"{direction} signals in {cls.__name__}") @classmethod def get_signals(cls, direction, ignore_old_style=False): """ Return a list of `InputSignal` or `OutputSignal` needed for the widget description. For old-style signals, the method returns the original list. New-style signals are collected into a list. Parameters ---------- direction (str): `"inputs"` or `"outputs"` Returns ------- list of `InputSignal` or `OutputSignal` """ old_style = cls.__dict__.get(direction, None) if old_style and not ignore_old_style: return old_style signal_class = getattr(cls, direction.title()) signals = [signal for _, signal in getsignals(signal_class)] return list(sorted(signals, key=lambda s: s._seq_id)) def update_summaries(self): self._update_summary(self.input_summaries) self._update_summary(self.output_summaries) def set_partial_input_summary(self, name, partial_summary, *, id=None, index=None): self.__set_part_summary(self.input_summaries[name], id, partial_summary, index=index) self._update_summary(self.input_summaries) def set_partial_output_summary(self, name, partial_summary, *, id=None): self.__set_part_summary(self.output_summaries[name], id, partial_summary) self._update_summary(self.output_summaries) @staticmethod def __set_part_summary(summary, id, partial_summary, index=None): if partial_summary.summary is None: if id in summary: del summary[id] else: if index is None or id in summary: summary[id] = partial_summary else: # Insert inplace at specified index items = list(summary.items()) items.insert(index, (id, partial_summary)) summary.clear() summary.update(items) def _update_summary(self, summaries): from orangewidget.widget import StateInfo def format_short(partial): summary = partial.summary if summary is None: return "-" if isinstance(summary, int): return StateInfo.format_number(summary) if isinstance(summary, str): return summary raise ValueError("summary must be None, string or int; " f"got {type(summary).__name__}") def format_detail(partial): if partial.summary is None: return "-" return str(partial.details or partial.summary) def join_multiples(partials): if not partials: return "-", "-" shorts = " ".join(map(format_short, partials.values())) details = "
".join(format_detail(partial) for partial in partials.values()) return shorts, details info = self.info is_input = summaries is self.input_summaries assert is_input or summaries is self.output_summaries if not summaries: return if not any(summaries.values()): summary = info.NoInput if is_input else info.NoOutput detail = "" else: summary, details = zip(*map(join_multiples, summaries.values())) summary = " | ".join(summary) detail = "
" \ + "".join(f"" for name, detail in zip(summaries, details)) \ + "
{name}: " f"{detail}
" setter = info.set_input_summary if is_input else info.set_output_summary if detail: setter(summary, SUMMARY_STYLE + detail, format=Qt.RichText) else: setter(summary) def show_preview(self, summaries): view = QWidget(self) view.setLayout(QVBoxLayout()) for name, summary in summaries.items(): if not summary: view.layout().addWidget(QLabel("
" f"" "
{name}: " f"-
")) for i, part in enumerate(summary.values(), start=1): part_no = f" ({i})" if len(summary) > 1 else "" detail = str(part.details or part.summary) or "-" view.layout().addWidget( QLabel("
" f"" "
{name}{part_no}: " f"{detail}
") ) if part.preview_func: preview = part.preview_func() view.layout().addWidget(preview, 1) if view.layout().isEmpty(): return view.layout().addStretch() screen = self.windowHandle().screen() geometry = screen.availableGeometry() preview = QMenu(self) wid, hei = geometry.width(), geometry.height() preview.setFixedSize(wid // 2, hei // 2) view.setFixedSize(wid // 2 - 4, hei // 2 - 4) action = QWidgetAction(preview) action.setDefaultWidget(view) preview.addAction(action) preview.popup(QPoint(wid // 4, hei // 4), action) def get_input_meta(widget: WidgetSignalsMixin, name: str) -> Optional[Input]: """ Return the named input meta description from widget (if it exists). """ def as_input(obj): if isinstance(obj, Input): return obj elif isinstance(obj, InputSignal): rval = Input(obj.name, obj.type, obj.id, obj.doc, obj.replaces, multiple=not obj.single, default=obj.default, explicit=obj.explicit) rval.handler = obj.handler return rval elif isinstance(obj, tuple): return as_input(InputSignal(*obj)) else: raise TypeError inputs: Iterable[Input] = map(as_input, widget.get_signals("inputs")) for input_ in inputs: if input_.name == name: return input_ return None def get_widget_inputs( widget: WidgetSignalsMixin ) -> Dict[str, Sequence[Tuple[Any, Any]]]: state: Dict[str, Sequence[Tuple[Any, Any]]] state = widget.__dict__.setdefault( "_WidgetSignalsMixin__input_state", {} ) return state @singledispatch def notify_input_helper( input: Input, widget: WidgetSignalsMixin, obj, key=None, index=-1 ) -> None: """ Set the input to the `widget` in a way appropriate for the `input` type. """ raise NotImplementedError @notify_input_helper.register(Input) def set_input_helper( input: Input, widget: WidgetSignalsMixin, obj, key=None, index=-1 ): handler = getattr(widget, input.handler) if input.single: args = (obj,) else: args = (obj, key) handler(*args) @notify_input_helper.register(MultiInput) def set_multi_input_helper( input: MultiInput, widget: WidgetSignalsMixin, obj, key=None, index=-1, ): """ Set/update widget's input for a `MultiInput` input to obj. `key` must be a unique for an input slot to update. `index` defines the position where a new input (key that did not previously exist) is inserted. The default -1 indicates that the new input should be appended to the end. An input is removed by using inout.closing_sentinel as the obj. """ inputs_ = get_widget_inputs(widget) inputs = inputs_.setdefault(input.name, ()) filter_none = input.filter_none signal_old = None key_to_pos = {key: i for i, (key, _) in enumerate(inputs)} update = key in key_to_pos new = key not in key_to_pos remove = obj is input.closing_sentinel if new: if not 0 <= index < len(inputs): index = len(inputs) else: index = key_to_pos.get(key) assert index is not None inputs_updated = list(inputs) if new: inputs_updated.insert(index, (key, obj)) elif remove: signal_old = inputs_updated.pop(index) else: signal_old = inputs_updated[index] inputs_updated[index] = (key, obj) inputs_[input.name] = tuple(inputs_updated) if filter_none: def filter_f(obj): return obj is None else: filter_f = None def local_index( key: Any, inputs: Sequence[Tuple[Any, Any]], filter: Optional[Callable[[Any], bool]] = None, ) -> Optional[int]: i = 0 for k, obj in inputs: if key == k: return i elif filter is not None: i += int(not filter(obj)) else: i += 1 return None if filter_none: # normalize signal.value is None to Close signal. filtered = filter_f(obj) if new and filtered: # insert in inputs only (done above) return elif new: # Some inputs before this might be filtered invalidating the # effective index. Find appropriate index for insertion index = len([obj for _, obj in inputs[:index] if not filter_f(obj)]) elif remove: if filter_f(signal_old[1]): # was already notified as removed, only remove from inputs (done above) return else: index = local_index(key, inputs, filter_f) elif update and filtered: if filter_f(signal_old[1]): # did not change; remains filtered return else: # remove it remove = True new = False index = local_index(key, inputs, filter_f) assert index is not None elif update: index = local_index(key, inputs, filter_f) if signal_old is not None and filter_f(signal_old[1]) and not filtered: # update with non-none value, substitute as new signal new = True remove = False index = local_index(key, inputs, filter_f) if new: handler = input.insert_handler args = (index, obj) elif remove: handler = input.remove_handler args = (index, ) else: handler = input.handler args = (index, obj) assert index is not None handler = getattr(widget, handler) handler(*args) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1694782192.861376 orange-widget-base-4.22.0/orangewidget/utils/tests/0000755000076500000240000000000014501051361021463 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/tests/__init__.py0000644000076500000240000000000014306600442023565 0ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_buttons.py0000644000076500000240000000206714306600442024602 0ustar00primozstafffrom AnyQt.QtGui import QFocusEvent from AnyQt.QtWidgets import QStyle, QApplication from orangewidget.tests.base import GuiTest from orangewidget.utils import buttons class SimpleButtonTest(GuiTest): def test_button(self): # Run through various state change and drawing code for coverage b = buttons.SimpleButton() b.setIcon(b.style().standardIcon(QStyle.SP_ComputerIcon)) QApplication.sendEvent(b, QFocusEvent(QFocusEvent.FocusIn)) QApplication.sendEvent(b, QFocusEvent(QFocusEvent.FocusOut)) b.grab() b.setDown(True) b.grab() b.setCheckable(True) b.setChecked(True) b.grab() class TestVariableTextPushButton(GuiTest): def test_button(self): b = buttons.VariableTextPushButton( textChoiceList=["", "A", "MMMMMMM"] ) b.setText("") sh = b.sizeHint() b.setText("A") self.assertEqual(b.sizeHint(), sh) b.setText("MMMMMMM") self.assertEqual(b.sizeHint(), sh) b.setTextChoiceList(["A", "B", "C"]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_combobox.py0000644000076500000240000001173114440334174024717 0ustar00primozstaff# pylint: disable=all from AnyQt.QtCore import Qt, QRect, QSize from AnyQt.QtWidgets import QListView, QApplication, QProxyStyle, QStyle, QStyleFactory from AnyQt.QtTest import QTest, QSignalSpy from orangewidget.tests.base import GuiTest from orangewidget.tests.utils import mouseMove, excepthook_catch from orangewidget.utils import combobox class StyleHintStyle(QProxyStyle): def styleHint(self, hint, option, widget=None, returnData=None) -> int: if hint == QStyle.SH_ComboBox_ListMouseTracking: return 1 return super().styleHint(hint, option, widget, returnData) @staticmethod def create(): return StyleHintStyle(QStyleFactory.create("fusion")) class TestComboBoxSearch(GuiTest): def setUp(self): super().setUp() cb = combobox.ComboBoxSearch() cb.addItem("One") cb.addItem("Two") cb.addItem("Three") cb.insertSeparator(cb.count()) cb.addItem("Four") self.cb = cb def tearDown(self): super().tearDown() self.cb.deleteLater() self.cb = None def test_combobox(self): cb = self.cb cb.grab() cb.showPopup() popup = cb.findChild(QListView) # type: QListView # run through paint code for coverage popup.grab() cb.grab() model = popup.model() self.assertEqual(model.rowCount(), cb.count()) QTest.keyClick(popup, Qt.Key_E) self.assertEqual(model.rowCount(), 2) QTest.keyClick(popup, Qt.Key_Backspace) self.assertEqual(model.rowCount(), cb.count()) QTest.keyClick(popup, Qt.Key_F) self.assertEqual(model.rowCount(), 1) popup.setCurrentIndex(model.index(0, 0)) spy = QSignalSpy(cb.activated[int]) QTest.keyClick(popup, Qt.Key_Enter) self.assertEqual(spy[0], [4]) self.assertEqual(cb.currentIndex(), 4) self.assertEqual(cb.currentText(), "Four") self.assertFalse(popup.isVisible()) def test_combobox_navigation(self): cb = self.cb cb.setCurrentIndex(4) self.assertTrue(cb.currentText(), "Four") cb.showPopup() popup = cb.findChild(QListView) # type: QListView self.assertEqual(popup.currentIndex().row(), 4) QTest.keyClick(popup, Qt.Key_Up) self.assertEqual(popup.currentIndex().row(), 2) QTest.keyClick(popup, Qt.Key_Escape) self.assertFalse(popup.isVisible()) self.assertEqual(cb.currentIndex(), 4) cb.hidePopup() def test_click(self): interval = QApplication.doubleClickInterval() QApplication.setDoubleClickInterval(0) cb = self.cb spy = QSignalSpy(cb.activated[int]) cb.showPopup() popup = cb.findChild(QListView) # type: QListView model = popup.model() rect = popup.visualRect(model.index(2, 0)) QTest.mouseRelease( popup.viewport(), Qt.LeftButton, Qt.NoModifier, rect.center() ) QApplication.setDoubleClickInterval(interval) self.assertEqual(len(spy), 1) self.assertEqual(spy[0], [2]) self.assertEqual(cb.currentIndex(), 2) def test_focus_out(self): cb = self.cb cb.showPopup() popup = cb.findChild(QListView) # Activate some other window to simulate focus out w = QListView() w.show() w.activateWindow() w.hide() self.assertFalse(popup.isVisible()) def test_track(self): cb = self.cb cb.setStyle(StyleHintStyle.create()) cb.showPopup() popup = cb.findChild(QListView) # type: QListView model = popup.model() rect = popup.visualRect(model.index(2, 0)) mouseMove(popup.viewport(), rect.center()) self.assertEqual(popup.currentIndex().row(), 2) cb.hidePopup() def test_empty(self): cb = self.cb cb.clear() cb.showPopup() popup = cb.findChild(QListView) # type: QListView self.assertIsNone(popup) def test_kwargs_enabled_focus_out(self): # PyQt5's property via kwargs can invoke virtual overrides while still # not fully constructed with excepthook_catch(raise_on_exit=True): combobox.ComboBoxSearch(enabled=False) def test_popup_util(self): size = QSize(100, 400) screen = QRect(0, 0, 600, 600) g1 = combobox.dropdown_popup_geometry( size, QRect(200, 100, 100, 20), screen ) self.assertEqual(g1, QRect(200, 120, 100, 400)) g2 = combobox.dropdown_popup_geometry( size, QRect(-10, 0, 100, 20), screen ) self.assertEqual(g2, QRect(0, 20, 100, 400)) g3 = combobox.dropdown_popup_geometry( size, QRect(590, 0, 100, 20), screen ) self.assertEqual(g3, QRect(600 - 100, 20, 100, 400)) g4 = combobox.dropdown_popup_geometry( size, QRect(0, 500, 100, 20), screen ) self.assertEqual(g4, QRect(0, 500 - 400, 100, 400)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_concurrent.py0000644000076500000240000002371214334703654025300 0ustar00primozstaffimport unittest import unittest.mock import threading import random import weakref from concurrent.futures import Future, ThreadPoolExecutor from types import SimpleNamespace from typing import Iterable, Set from AnyQt.QtCore import ( Qt, QObject, QCoreApplication, QThread, QEventLoop, QTimer, pyqtSlot, pyqtSignal ) from AnyQt.QtTest import QSignalSpy from orangewidget.utils.concurrent import ( FutureWatcher, FutureSetWatcher, methodinvoke, PyOwned ) class CoreAppTestCase(unittest.TestCase): def setUp(self): self.app = QCoreApplication.instance() if self.app is None: self.app = QCoreApplication([]) def tearDown(self): self.app.processEvents() del self.app class TestMethodinvoke(CoreAppTestCase): def test_methodinvoke(self): executor = ThreadPoolExecutor() state = [None, None] class StateSetter(QObject): @pyqtSlot(object) def set_state(self, value): state[0] = value state[1] = QThread.currentThread() def func(callback): callback(QThread.currentThread()) obj = StateSetter() f1 = executor.submit(func, methodinvoke(obj, "set_state", (object,))) f1.result() # So invoked method can be called from the event loop self.app.processEvents() self.assertIs(state[1], QThread.currentThread(), "set_state was called from the wrong thread") self.assertIsNot(state[0], QThread.currentThread(), "set_state was invoked in the main thread") executor.shutdown(wait=True) class TestFutureWatcher(CoreAppTestCase): def test_watcher(self): executor = ThreadPoolExecutor(max_workers=1) f = executor.submit(lambda: 42) w = FutureWatcher(f) def spies(w): return SimpleNamespace( done=QSignalSpy(w.done), finished=QSignalSpy(w.finished), result=QSignalSpy(w.resultReady), error=QSignalSpy(w.exceptionReady), cancelled=QSignalSpy(w.cancelled) ) spy = spies(w) self.assertTrue(spy.done.wait()) self.assertEqual(list(spy.done), [[f]]) self.assertEqual(list(spy.finished), [[f]]) self.assertEqual(list(spy.result), [[42]]) self.assertEqual(list(spy.error), []) self.assertEqual(list(spy.cancelled), []) f = executor.submit(lambda: 1/0) w = FutureWatcher(f) spy = spies(w) self.assertTrue(spy.done.wait()) self.assertEqual(list(spy.done), [[f]]) self.assertEqual(list(spy.finished), [[f]]) self.assertEqual(len(spy.error), 1) self.assertIsInstance(spy.error[0][0], ZeroDivisionError) self.assertEqual(list(spy.result), []) self.assertEqual(list(spy.cancelled), []) ev = threading.Event() # block the executor to test cancellation executor.submit(lambda: ev.wait()) f = executor.submit(lambda: 0) w = FutureWatcher(f) self.assertTrue(f.cancel()) ev.set() spy = spies(w) self.assertTrue(spy.done.wait()) self.assertEqual(list(spy.done), [[f]]) self.assertEqual(list(spy.finished), []) self.assertEqual(list(spy.error), []) self.assertEqual(list(spy.result), []) self.assertEqual(list(spy.cancelled), [[f]]) class TestFutureSetWatcher(CoreAppTestCase): def test_watcher(self): def spies(w): # type: (FutureSetWatcher) -> SimpleNamespace return SimpleNamespace( doneAt=QSignalSpy(w.doneAt), finishedAt=QSignalSpy(w.finishedAt), cancelledAt=QSignalSpy(w.cancelledAt), resultAt=QSignalSpy(w.resultReadyAt), exceptionAt=QSignalSpy(w.exceptionReadyAt), doneAll=QSignalSpy(w.doneAll), ) executor = ThreadPoolExecutor(max_workers=5) fs = [executor.submit(lambda i: "Hello {}".format(i), i) for i in range(10)] w = FutureSetWatcher(fs) spy = spies(w) def as_set(seq): # type: (Iterable[list]) -> Set[tuple] seq = list(map(tuple, seq)) set_ = set(seq) assert len(set_) == len(seq) return set_ self.assertTrue(spy.doneAll.wait()) expected = {(i, "Hello {}".format(i)) for i in range(10)} self.assertSetEqual(as_set(spy.doneAt), set(enumerate(fs))) self.assertSetEqual(as_set(spy.finishedAt), set(enumerate(fs))) self.assertSetEqual(as_set(spy.cancelledAt), set()) self.assertSetEqual(as_set(spy.resultAt), expected) self.assertSetEqual(as_set(spy.exceptionAt), set()) rseq = [random.randrange(0, 10) for _ in range(10)] fs = [executor.submit(lambda i: 1 / (i % 3), i) for i in rseq] w = FutureSetWatcher(fs) spy = spies(w) self.assertTrue(spy.doneAll.wait()) self.assertSetEqual(as_set(spy.doneAt), set(enumerate(fs))) self.assertSetEqual(as_set(spy.finishedAt), set(enumerate(fs))) self.assertSetEqual(as_set(spy.cancelledAt), set()) results = {(i, f.result()) for i, f in enumerate(fs) if not f.exception()} exceptions = {(i, f.exception()) for i, f in enumerate(fs) if f.exception()} assert len(results | exceptions) == len(fs) self.assertSetEqual(as_set(spy.resultAt), results) self.assertSetEqual(as_set(spy.exceptionAt), exceptions) executor = ThreadPoolExecutor(max_workers=1) ev = threading.Event() # Block the single worker thread to ensure successful cancel for f2 f1 = executor.submit(lambda: ev.wait()) f2 = executor.submit(lambda: 42) w = FutureSetWatcher([f1, f2]) self.assertTrue(f2.cancel()) # Unblock the worker ev.set() spy = spies(w) self.assertTrue(spy.doneAll.wait()) self.assertSetEqual(as_set(spy.doneAt), {(0, f1), (1, f2)}) self.assertSetEqual(as_set(spy.finishedAt), {(0, f1)}) self.assertSetEqual(as_set(spy.cancelledAt), {(1, f2)}) self.assertSetEqual(as_set(spy.resultAt), {(0, True)}) self.assertSetEqual(as_set(spy.exceptionAt), set()) # doneAll must always be emitted after the doneAt signals. executor = ThreadPoolExecutor(max_workers=2) futures = [executor.submit(pow, 1000, 1000) for _ in range(100)] watcher = FutureSetWatcher(futures) emithistory = [] watcher.doneAt.connect(lambda i, f: emithistory.append(("doneAt", i, f))) watcher.doneAll.connect(lambda: emithistory.append(("doneAll", ))) spy = spies(watcher) watcher.wait() self.assertEqual(len(spy.doneAll), 0) self.assertEqual(len(spy.doneAt), 0) watcher.flush() self.assertEqual(len(spy.doneAt), 100) self.assertEqual(list(spy.doneAll), [[]]) self.assertSetEqual(set(emithistory[:-1]), {("doneAt", i, f) for i, f in enumerate(futures)}) self.assertEqual(emithistory[-1], ("doneAll",)) # doneAll must be emitted even when on an empty futures list watcher = FutureSetWatcher() watcher.setFutures([]) spy = spies(watcher) self.assertTrue(spy.doneAll.wait()) watcher = FutureSetWatcher() watcher.setFutures([]) watcher.wait() watcher = FutureSetWatcher() with self.assertRaises(RuntimeError): watcher.wait() with unittest.mock.patch.object(watcher, "thread", lambda: 42), \ self.assertRaises(RuntimeError): watcher.flush() class TestPyOwned(CoreAppTestCase): def test_py_owned(self): class Obj(QObject, PyOwned): pass executor = ThreadPoolExecutor() ref = SimpleNamespace(obj=Obj()) wref = weakref.ref(ref.obj) event = threading.Event() event.clear() def clear_ref(): del ref.obj event.set() executor.submit(clear_ref) event.wait() self.assertIsNotNone(wref()) self.assertIn(wref(), PyOwned._PyOwned__delete_later_set) loop = QEventLoop() QTimer.singleShot(0, loop.quit) loop.exec() self.assertIsNone(wref()) def test_py_owned_enqueued(self): # https://www.riverbankcomputing.com/pipermail/pyqt/2020-April/042734.html class Emitter(QObject, PyOwned): signal = pyqtSignal() _p_signal = pyqtSignal() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # queued signal -> signal connection self._p_signal.connect(self.signal, Qt.QueuedConnection) def schedule_emit(self): """Schedule `signal` emit""" self._p_signal.emit() executor = ThreadPoolExecutor(max_workers=4) def test_one(): ref = SimpleNamespace() # hold single reference to Emitter obj ref.obj = Emitter() # enqueue 200 meta call events to the obj for i in range(200): ref.obj.schedule_emit() # event signaling the event loop is about to be entered event = threading.Event() def clear_obj(ref=ref): # wait for main thread to signal it is about to enter the event loop event.wait() del ref.obj # clear the last/single ref to obj executor.submit(clear_obj) loop = QEventLoop() QTimer.singleShot(0, loop.quit) # bytecode optimizations, reduce the time between event.set and # exec to minimum set = event.set exec = loop.exec set() # signal/unblock the worker; exec() # enter event loop to process the queued meta calls for i in range(10): test_one() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694771101.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_filedialogs.py0000644000076500000240000001077214501023635025370 0ustar00primozstaffimport sys import os import unittest from unittest.mock import Mock from tempfile import NamedTemporaryFile from orangewidget.utils.filedialogs import RecentPath, open_filename_dialog, \ unambiguous_paths class TestUtils(unittest.TestCase): def test_unambiguous_paths(self): paths = [ "asd.txt", "abc/def/ghi.txt", "abc/def/jkl.txt", "abc/xyz/jkl.txt", "abc/xyz/rty/qwe.txt", "abd/xyz/rty/qwe.txt", "abe/xyz/rty/qwe.txt", ] paths = [t.replace("/", os.path.sep, 1) for t in paths] def test(exp, **kwargs): self.assertEqual(unambiguous_paths(paths, **kwargs), [t.replace("/", os.path.sep) for t in exp]) test(["asd.txt", "ghi.txt", "def/jkl.txt", "xyz/jkl.txt", "abc/xyz/rty/qwe.txt", "abd/xyz/rty/qwe.txt", "abe/xyz/rty/qwe.txt"]) test(["asd.txt", "def/ghi.txt", "def/jkl.txt", "xyz/jkl.txt", "abc/xyz/rty/qwe.txt", "abd/xyz/rty/qwe.txt", "abe/xyz/rty/qwe.txt"], minlevel=2) test(["asd.txt", "abc/def/ghi.txt", "abc/def/jkl.txt", "abc/xyz/jkl.txt", "abc/xyz/rty/qwe.txt", "abd/xyz/rty/qwe.txt", "abe/xyz/rty/qwe.txt"], minlevel=3) test(["asd.txt", "abc/def/ghi.txt", "abc/def/jkl.txt", "abc/xyz/jkl.txt", "abc/xyz/rty/qwe.txt", "abd/xyz/rty/qwe.txt", "abe/xyz/rty/qwe.txt"], minlevel=4) # For simplicity, omit this test on Windows; if it works on Posix paths, # it works on Windows, too. if os.path.sep == "/": t = ["abc/def/ghi.txt", "abc/def/ghi.txt"] self.assertEqual(unambiguous_paths(t), t) if sys.platform == "win32": ptch1 = ptch2 = ptch3 = lambda x: x else: # This test is intended for Windows, but for easier testing of a test # on non-Windows machine, we patch it to make it work on others ptch1 = unittest.mock.patch("os.path.sep", "/") ptch2 = unittest.mock.patch("os.path.altsep", "\\") ptch3 = unittest.mock.patch( "os.path.join", lambda *args, oj=os.path.join: oj(*args).replace("/", "\\")) @ptch1 @ptch2 @ptch3 def test_unambiguous_paths_windows(self): paths = ["C:\\Documents/Newsletters\\Summer2018.pdf", "D:\\Documents/Newsletters\\Summer2018.pdf"] self.assertEqual(unambiguous_paths(paths), ["C:\\Documents\\Newsletters\\Summer2018.pdf", "D:\\Documents\\Newsletters\\Summer2018.pdf"] ) paths = ["C:\\abc\\def\\Summer2018.pdf", "C:\\abc\\deg\\Summer2018.pdf"] self.assertEqual(unambiguous_paths(paths), ["def\\Summer2018.pdf", "deg\\Summer2018.pdf", ] ) paths = ["C:\\deg\\Summer2018.pdf", "D:\\deg\\Summer2018.pdf"] self.assertEqual(unambiguous_paths(paths), ["C:\\deg\\Summer2018.pdf", "D:\\deg\\Summer2018.pdf", ] ) class TestRecentPath(unittest.TestCase): def test_resolve(self): temp_file = NamedTemporaryFile(dir=os.getcwd(), delete=False) file_name = temp_file.name temp_file.close() base_name = os.path.basename(file_name) try: recent_path = RecentPath( os.path.join("temp/datasets", base_name), "", os.path.join("datasets", base_name) ) search_paths = [("basedir", os.getcwd())] self.assertIsNotNone(recent_path.resolve(search_paths)) finally: os.remove(file_name) class TestOpenFilenameDialog(unittest.TestCase): def test_empty_filter(self): class ABCFormat: EXTENSIONS = ('.abc', '.jkl') DESCRIPTION = 'abc file' PRIORITY = 30 name, file_format, file_filter = open_filename_dialog( ".", "", [ABCFormat], dialog=Mock(return_value=("foo.xyz", ""))) self.assertEqual(name, "foo.xyz") self.assertEqual(file_format, None) self.assertEqual(file_filter, None) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_itemdelegates.py0000644000076500000240000002602214440334174025722 0ustar00primozstaffimport unittest from datetime import date, datetime from typing import Any, Dict import numpy as np from AnyQt.QtCore import Qt, QModelIndex, QLocale, QRect, QPoint, QSize from AnyQt.QtGui import QStandardItemModel, QFont, QColor, QIcon, QImage, \ QPainter from AnyQt.QtWidgets import ( QStyleOptionViewItem, QTableView, QAbstractItemDelegate ) from orangecanvas.gui.svgiconengine import SvgIconEngine from orangewidget.tests.base import GuiTest from orangewidget.utils import graphemes, grapheme_slice from orangewidget.utils.itemdelegates import ModelItemCache, \ CachedDataItemDelegate, StyledItemDelegate, DataDelegate, \ BarItemDataDelegate def create_model(rows, columns): model = QStandardItemModel() model.setRowCount(rows) model.setColumnCount(columns) for i in range(rows): for j in range(columns): model.setItemData( model.index(i, j), { Qt.DisplayRole: f"{i}x{j}", Qt.UserRole: i * j, } ) return model class TestModelItemCache(unittest.TestCase): def setUp(self) -> None: super().setUp() self.model = create_model(10, 2) self.cache = ModelItemCache() def tearDown(self) -> None: del self.model del self.cache super().tearDown() def test_cache(self): model = self.model index = model.index(0, 0) res = self.cache.itemData(index, (Qt.DisplayRole, Qt.UserRole)) self.assertEqual(res, {Qt.DisplayRole: "0x0", Qt.UserRole: 0}) res = self.cache.itemData(index, (Qt.DisplayRole, Qt.UserRole, Qt.UserRole + 1)) self.assertEqual(res, {Qt.DisplayRole: "0x0", Qt.UserRole: 0, Qt.UserRole + 1: None}) model.setData(index, "2", Qt.DisplayRole) res = self.cache.data(index, Qt.DisplayRole) self.assertEqual(res, "2") res = self.cache.data(index, Qt.UserRole + 2) self.assertIsNone(res) m1 = create_model(1, 1) res = self.cache.data(m1.index(0, 0), Qt.DisplayRole) self.assertEqual(res, "0x0") class TestCachedDataItemDelegate(unittest.TestCase): def setUp(self) -> None: super().setUp() self.model = create_model(5, 2) self.delegate = CachedDataItemDelegate() def test_delegate(self): opt = QStyleOptionViewItem() index = self.model.index(0, 0) self.delegate.initStyleOption(opt, index) self.assertEqual(opt.text, "0x0") icon = QIcon(SvgIconEngine(b'')) yellow = QColor(Qt.yellow) magenta = QColor(Qt.magenta) data = { Qt.DisplayRole: "AA", Qt.FontRole: QFont("Times New Roman"), Qt.TextAlignmentRole: Qt.AlignRight, Qt.CheckStateRole: Qt.Checked, Qt.DecorationRole: icon, Qt.ForegroundRole: yellow, Qt.BackgroundRole: magenta, } self.model.setItemData(index, data) self.delegate.initStyleOption(opt, index) self.assertEqual(opt.font.family(), QFont("Times New Roman").family()) self.assertEqual(opt.displayAlignment, Qt.AlignRight) self.assertEqual(opt.backgroundBrush.color(), magenta) self.assertEqual(opt.palette.text().color(), yellow) self.assertFalse(opt.icon.isNull()) self.assertEqual(opt.icon.cacheKey(), icon.cacheKey()) res = self.delegate.cachedData(index, Qt.DisplayRole) self.assertEqual(res, "AA") res = self.delegate.cachedItemData( index, (Qt.DisplayRole, Qt.TextAlignmentRole) ) self.assertIn(Qt.DisplayRole, res) self.assertIn(Qt.TextAlignmentRole, res) self.assertEqual(res[Qt.TextAlignmentRole], Qt.AlignRight) self.assertEqual(res[Qt.DisplayRole], "AA") class TestStyledItemDelegate(unittest.TestCase): def test_display_text(self): delegate = StyledItemDelegate() locale = QLocale.c() displayText = lambda value: delegate.displayText(value, locale) self.assertEqual(displayText(None), "") self.assertEqual(displayText(1), "1") self.assertEqual(displayText(np.int64(1)), "1") self.assertEqual(displayText(np.int64(1)), "1") self.assertEqual(displayText(1.5), "1.5") self.assertEqual(displayText(np.float16(1.5)), "1.5") self.assertEqual(displayText("A"), "A") self.assertEqual(displayText(np.str_("A")), "A") self.assertEqual(displayText(date(1999, 12, 31)), "1999-12-31") self.assertEqual(displayText(datetime(1999, 12, 31, 23, 59, 59)), "1999-12-31 23:59:59") self.assertEqual(displayText(np.datetime64(0, "s")), "1970-01-01 00:00:00") class TestDataDelegate(GuiTest): def setUp(self) -> None: super().setUp() self.view = QTableView() self.model = create_model(5, 2) self.delegate = DataDelegate(self.view) self.view.setItemDelegate(self.delegate) def tearDown(self) -> None: self.view.deleteLater() self.view = None self.model = None super().tearDown() def test_init_style_options(self): delegate = self.delegate model = self.model index = model.index(0, 0) model.setData(index, 1, Qt.DisplayRole) opt = QStyleOptionViewItem() delegate.initStyleOption(opt, index) self.assertEqual(opt.displayAlignment, Qt.AlignRight) model.setData(index, "A", Qt.DisplayRole) opt = QStyleOptionViewItem() delegate.initStyleOption(opt, index) self.assertEqual(opt.displayAlignment, Qt.AlignLeft) def test_paint(self): delegate = self.delegate model = self.model index = model.index(0, 0) model.setData(index, 1, Qt.DisplayRole) def paint_with_data(data): model.setItemData(index, data) opt = self.view.viewOptions() opt.rect = QRect(QPoint(0, 0), delegate.sizeHint(opt, index)) delegate.initStyleOption(opt, index) img = QImage(opt.rect.size(), QImage.Format_ARGB32_Premultiplied) p = QPainter(img) try: delegate.paint(p, opt, index) finally: p.end() paint_with_data({Qt.DisplayRole: 1.0}) paint_with_data({Qt.DisplayRole: "AA"}) paint_with_data({Qt.DisplayRole: "AA", Qt.TextAlignmentRole: Qt.AlignLeft | Qt.AlignTop}) paint_with_data({Qt.DisplayRole: "AA", Qt.TextAlignmentRole: Qt.AlignHCenter | Qt.AlignVCenter}) paint_with_data({Qt.DisplayRole: "AA", Qt.TextAlignmentRole: Qt.AlignRight | Qt.AlignBottom}) def test_paint_long_combining(self): text = "ABC" * 1000 opt = self.view.viewOptions() paint_with_data(self.delegate, {Qt.DisplayRole: text}, opt) text = "\N{TAMIL LETTER NA}\N{TAMIL VOWEL SIGN I}" * 10000 paint_with_data(self.delegate, {Qt.DisplayRole: text}, opt) class TestBarItemDataDelegate(GuiTest): def setUp(self) -> None: super().setUp() self.view = QTableView() self.model = create_model(5, 2) self.delegate = BarItemDataDelegate(self.view) self.view.setItemDelegate(self.delegate) def tearDown(self) -> None: self.view.deleteLater() self.view = None self.model = None super().tearDown() def test_size_hint(self): model = self.model index = model.index(0, 0) delegate = self.delegate model.setData(index, 0.5, delegate.barFillRatioRole) sh1 = delegate.sizeHint(self.view.viewOptions(), index) delegate.penWidth += 2 sh2 = delegate.sizeHint(self.view.viewOptions(), index) self.assertGreater(sh2.height(), sh1.height()) def test_paint(self): model = self.model index = model.index(0, 0) delegate = self.delegate model.setData(index, 0.5, delegate.barFillRatioRole) def paint_with_data_(data): paint_with_data(delegate, data, self.view.viewOptions()) model.setItemData(index, data) opt = self.view.viewOptions() size = delegate.sizeHint(opt, index).expandedTo(QSize(10, 10)) opt.rect = QRect(QPoint(0, 0), size) delegate.initStyleOption(opt, index) img = QImage(opt.rect.size(), QImage.Format_ARGB32_Premultiplied) p = QPainter(img) try: delegate.paint(p, opt, index) finally: p.end() paint_with_data_({delegate.barFillRatioRole: 0.2, delegate.barColorRole: QColor(Qt.magenta)}) paint_with_data_({delegate.barFillRatioRole: None, delegate.barColorRole: None}) def paint_with_data( delegate: QAbstractItemDelegate, data: Dict[int, Any], options: QStyleOptionViewItem = None ) -> None: model = create_model(1, 1) index = model.index(0, 0) model.setItemData(index, data) opt = QStyleOptionViewItem(options) if options is not None else QStyleOptionViewItem() size = delegate.sizeHint(opt, index).expandedTo(QSize(10, 10)) opt.rect = QRect(QPoint(0, 0), size) delegate.initStyleOption(opt, index) img = QImage(opt.rect.size(), QImage.Format_ARGB32_Premultiplied) p = QPainter(img) try: delegate.paint(p, opt, index) finally: p.end() class TestGraphemes(unittest.TestCase): def test_grapheme(self): self.assertEqual(list(graphemes("")), []) self.assertEqual(list(graphemes("a")), ["a"]) self.assertEqual(list(graphemes("ab")), ["a", "b"]) text = "\N{TAMIL LETTER NA}\N{TAMIL VOWEL SIGN I}" self.assertEqual(list(graphemes(text)), [text]) self.assertEqual(list(graphemes("a" + text)), ["a", text]) self.assertEqual(list(graphemes(text + "b")), [text, "b"]) self.assertEqual(list(graphemes("a" + text + "b")), ["a", text, "b"]) self.assertEqual(list(graphemes("a" + text + "b" + text)), ["a", text, "b", text]) self.assertEqual(list(graphemes("a" + text + text + "b")), ["a", text, text, "b"]) def test_grapheme_slice(self): self.assertEqual(grapheme_slice(""), "") self.assertEqual(grapheme_slice("", start=1), "") self.assertEqual(grapheme_slice("", end=1), "") self.assertEqual(grapheme_slice("a"), "a") self.assertEqual(grapheme_slice("a", start=1), "") self.assertEqual(grapheme_slice("ab"), "ab") self.assertEqual(grapheme_slice("ab", start=1), "b") self.assertEqual(grapheme_slice("ab", end=1), "a") self.assertEqual(grapheme_slice("ab", start=1, end=1), "") self.assertEqual(grapheme_slice("abc", start=1, end=2), "b") text = "\N{TAMIL LETTER NA}\N{TAMIL VOWEL SIGN I}" self.assertEqual(grapheme_slice(text * 3, end=2), text * 2) self.assertEqual(grapheme_slice("a" + text + "b" + text, end=3), "a" + text + "b") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_itemmodels.py0000644000076500000240000006342014440334174025253 0ustar00primozstaff# Test methods with long descriptive names can omit docstrings # pylint: disable=missing-docstring import unittest from unittest.mock import patch, Mock import numpy as np from AnyQt.QtCore import Qt, QModelIndex, QRect from AnyQt.QtTest import QSignalSpy from AnyQt.QtGui import QPalette, QFont from orangewidget.utils.itemmodels import \ AbstractSortTableModel, PyTableModel, PyListModel, \ _argsort, _as_contiguous_range, SeparatedListDelegate, LabelledSeparator class TestArgsort(unittest.TestCase): def test_argsort(self): self.assertEqual(_argsort("dacb"), [1, 3, 2, 0]) self.assertEqual(_argsort("dacb", reverse=True), [0, 2, 3, 1]) self.assertEqual(_argsort([3, -1, 0, 2], key=abs), [2, 1, 3, 0]) self.assertEqual( _argsort([3, -1, 0, 2], key=abs, reverse=True), [0, 3, 1, 2]) self.assertEqual( _argsort([3, -1, 0, 2], cmp=lambda x, y: (abs(x) > abs(y)) - (abs(x) < abs(y))), [2, 1, 3, 0]) self.assertEqual( _argsort([3, -1, 0, 2], cmp=lambda x, y: (abs(x) > abs(y)) - (abs(x) < abs(y)), reverse=True), [0, 3, 1, 2]) class TestUtils(unittest.TestCase): def test_as_contiguous_range(self): self.assertEqual(_as_contiguous_range(slice(1, 8), 20), (1, 8, 1)) self.assertEqual(_as_contiguous_range(slice(1, 8), 6), (1, 6, 1)) self.assertEqual(_as_contiguous_range(slice(8, 1, -1), 6), (2, 6, 1)) self.assertEqual(_as_contiguous_range(slice(8), 6), (0, 6, 1)) self.assertEqual(_as_contiguous_range(slice(8, None, -1), 6), (0, 6, 1)) self.assertEqual(_as_contiguous_range(slice(7, None, -1), 9), (0, 8, 1)) self.assertEqual(_as_contiguous_range(slice(None, None, -1), 9), (0, 9, 1)) class TestPyTableModel(unittest.TestCase): def setUp(self): self.model = PyTableModel([[1, 4], [2, 3]]) def test_init(self): self.model = PyTableModel() self.assertEqual(self.model.rowCount(), 0) def test_rowCount(self): self.assertEqual(self.model.rowCount(), 2) self.assertEqual(len(self.model), 2) def test_columnCount(self): self.assertEqual(self.model.columnCount(), 2) def test_data(self): mi = self.model.index(0, 0) self.assertEqual(self.model.data(mi), '1') self.assertEqual(self.model.data(mi, Qt.EditRole), 1) def test_editable(self): editable_model = PyTableModel([[0]], editable=True) self.assertFalse(self.model.flags(self.model.index(0, 0)) & Qt.ItemIsEditable) self.assertTrue(editable_model.flags(editable_model.index(0, 0)) & Qt.ItemIsEditable) def test_sort(self): self.model.sort(1) self.assertEqual(self.model.index(0, 0).data(Qt.EditRole), 2) def test_setHeaderLabels(self): self.model.setHorizontalHeaderLabels(['Col 1', 'Col 2']) self.assertEqual(self.model.headerData(1, Qt.Horizontal), 'Col 2') self.assertEqual(self.model.headerData(1, Qt.Vertical), 2) def test_removeRows(self): self.model.removeRows(0, 1) self.assertEqual(len(self.model), 1) self.assertEqual(self.model[0][1], 3) def test_removeColumns(self): self.model.removeColumns(0, 1) self.assertEqual(self.model.columnCount(), 1) self.assertEqual(self.model[1][0], 3) def test_insertRows(self): self.model.insertRows(0, 1) self.assertEqual(self.model[1][0], 1) def test_insertColumns(self): self.model.insertColumns(0, 1) self.assertEqual(self.model[0], ['', 1, 4]) def test_wrap(self): self.model.wrap([[0]]) self.assertEqual(self.model.rowCount(), 1) self.assertEqual(self.model.columnCount(), 1) def test_init_wrap_empty(self): # pylint: disable=protected-access t = [] model = PyTableModel(t) self.assertIs(model._table, t) t.append([1, 2, 3]) self.assertEqual(list(model), [[1, 2, 3]]) def test_clear(self): self.model.clear() self.assertEqual(self.model.rowCount(), 0) def test_append(self): self.model.append([5, 6]) self.assertEqual(self.model[2][1], 6) self.assertEqual(self.model.rowCount(), 3) def test_extend(self): self.model.extend([[5, 6]]) self.assertEqual(self.model[2][1], 6) self.assertEqual(self.model.rowCount(), 3) def test_insert(self): self.model.insert(0, [5, 6]) self.assertEqual(self.model[0][1], 6) self.assertEqual(self.model.rowCount(), 3) def test_remove(self): self.model.remove([2, 3]) self.assertEqual(self.model.rowCount(), 1) def test_other_roles(self): self.model.append([2, 3]) self.model.setData(self.model.index(2, 0), Qt.AlignCenter, Qt.TextAlignmentRole) del self.model[1] self.assertTrue(Qt.AlignCenter & self.model.data(self.model.index(1, 0), Qt.TextAlignmentRole)) def test_set_item_signals(self): def p(*s): return [[x] for x in s] def assert_changed(startrow, stoprow, ncolumns): start, stop = changed[-1][:2] self.assertEqual(start.row(), startrow) self.assertEqual(stop.row(), stoprow) self.assertEqual(start.column(), 0) self.assertEqual(stop.column(), ncolumns) self.model.wrap(p(0, 1, 2, 3, 4, 5)) aboutinserted = QSignalSpy(self.model.rowsAboutToBeInserted) inserted = QSignalSpy(self.model.rowsInserted) aboutremoved = QSignalSpy(self.model.rowsAboutToBeRemoved) removed = QSignalSpy(self.model.rowsRemoved) changed = QSignalSpy(self.model.dataChanged) # Insert rows self.model[2:4] = p(6, 7, 8, 9, 10) + [[11, 2]] self.assertEqual(list(self.model), p(0, 1, 6, 7, 8, 9, 10) + [[11, 2]] + p(4, 5)) self.assertEqual(len(changed), 1) assert_changed(2, 3, 1) self.assertEqual(aboutinserted[-1][1:], [4, 7]) self.assertEqual(inserted[-1][1:], [4, 7]) self.assertEqual(len(aboutremoved), 0) self.assertEqual(len(removed), 0) # Remove rows self.model[2:8] = p(2, 3) self.assertEqual(list(self.model), p(0, 1, 2, 3, 4, 5)) self.assertEqual(len(changed), 2) # one is from before assert_changed(2, 3, 0) self.assertEqual(aboutremoved[-1][1:], [4, 7]) self.assertEqual(removed[-1][1:], [4, 7]) self.assertEqual(len(inserted), 1) # from before self.assertEqual(len(aboutinserted), 1) # from before # Change rows self.model[-5:-3] = p(19, 20) self.assertEqual(list(self.model), p(0, 19, 20, 3, 4, 5)) self.assertEqual(len(changed), 3) # two are from before assert_changed(1, 2, 0) self.assertEqual(len(inserted), 1) # from before self.assertEqual(len(aboutinserted), 1) # from before self.assertEqual(len(removed), 1) # from before self.assertEqual(len(aboutremoved), 1) # from before # Insert without change self.model[3:3] = p(21, 22) self.assertEqual(list(self.model), p(0, 19, 20, 21, 22, 3, 4, 5)) self.assertEqual(len(changed), 3) #from before self.assertEqual(inserted[-1][1:], [3, 4]) self.assertEqual(aboutinserted[-1][1:], [3, 4]) self.assertEqual(len(removed), 1) # from before self.assertEqual(len(aboutremoved), 1) # from before # Remove without change self.model[3:5] = [] self.assertEqual(list(self.model), p(0, 19, 20, 3, 4, 5)) self.assertEqual(len(changed), 3) #from before self.assertEqual(removed[-1][1:], [3, 4]) self.assertEqual(aboutremoved[-1][1:], [3, 4]) self.assertEqual(len(inserted), 2) # from before self.assertEqual(len(aboutinserted), 2) # from before # Remove all self.model[:] = [] self.assertEqual(list(self.model), []) self.assertEqual(len(changed), 3) #from before self.assertEqual(removed[-1][1:], [0, 5]) self.assertEqual(aboutremoved[-1][1:], [0, 5]) self.assertEqual(len(inserted), 2) # from before self.assertEqual(len(aboutinserted), 2) # from before # Add to empty self.model[:] = p(0, 1, 2, 3) self.assertEqual(list(self.model), p(0, 1, 2, 3)) self.assertEqual(len(changed), 3) #from before self.assertEqual(inserted[-1][1:], [0, 3]) self.assertEqual(inserted[-1][1:], [0, 3]) self.assertEqual(len(removed), 3) # from before self.assertEqual(len(aboutremoved), 3) # from before class TestAbstractSortTableModel(unittest.TestCase): def test_sorting(self): assert issubclass(PyTableModel, AbstractSortTableModel) model = PyTableModel([[1, 4], [2, 2], [3, 3]]) model.sort(1, Qt.AscendingOrder) # mapToSourceRows self.assertSequenceEqual(model.mapToSourceRows(...).tolist(), [1, 2, 0]) self.assertEqual(model.mapToSourceRows(1).tolist(), 2) self.assertSequenceEqual(model.mapToSourceRows([1, 2]).tolist(), [2, 0]) self.assertSequenceEqual(model.mapToSourceRows([]), []) self.assertSequenceEqual(model.mapToSourceRows(np.array([], dtype=int)).tolist(), []) self.assertRaises(IndexError, model.mapToSourceRows, np.r_[0.]) # mapFromSourceRows self.assertSequenceEqual(model.mapFromSourceRows(...).tolist(), [2, 0, 1]) self.assertEqual(model.mapFromSourceRows(1).tolist(), 0) self.assertSequenceEqual(model.mapFromSourceRows([1, 2]).tolist(), [0, 1]) self.assertSequenceEqual(model.mapFromSourceRows([]), []) self.assertSequenceEqual(model.mapFromSourceRows(np.array([], dtype=int)).tolist(), []) self.assertRaises(IndexError, model.mapFromSourceRows, np.r_[0.]) model.sort(1, Qt.DescendingOrder) self.assertSequenceEqual(model.mapToSourceRows(...).tolist(), [0, 2, 1]) self.assertSequenceEqual(model.mapFromSourceRows(...).tolist(), [0, 2, 1]) def test_stable_descending_sort(self): def assert_indices_equal(indices): self.assertSequenceEqual(model.mapToSourceRows(...).tolist(), indices) model = PyTableModel([[1, 4], [2, 2], [2, 3], [2, 2], [3, 3]]) model.sort(0, Qt.DescendingOrder) assert_indices_equal([4, 1, 2, 3, 0]) model.sort(1, Qt.DescendingOrder) assert_indices_equal([0, 4, 2, 1, 3]) def test_sorting_fallback(self): class TableModel(PyTableModel): def sortColumnData(self, column): raise NotImplementedError model = TableModel([[1, 4], [2, 2], [3, 3]]) model.sort(1, Qt.DescendingOrder) self.assertSequenceEqual(model.mapToSourceRows(...).tolist(), [0, 2, 1]) model.sort(1, Qt.AscendingOrder) self.assertSequenceEqual(model.mapToSourceRows(...).tolist(), [1, 2, 0]) def test_sorting_2d(self): class Model(AbstractSortTableModel): def rowCount(self): return 3 def sortColumnData(self, _): return np.array([[4, 6, 2], [3, 3, 3], [4, 6, 1]]) model = Model() model.sort(0) self.assertEqual(model.mapToSourceRows(...).tolist(), [1, 2, 0]) def test_setSortIndices(self): model = AbstractSortTableModel() model.rowCount = lambda: 5 spy_about = QSignalSpy(model.layoutAboutToBeChanged) spy_changed = QSignalSpy(model.layoutChanged) model.setSortIndices([4, 0, 1, 3, 2]) self.assertEqual(len(spy_about), 1) self.assertEqual(len(spy_changed), 1) self.assertEqual(model.mapFromSourceRows(...).tolist(), [1, 2, 4, 3, 0]) self.assertEqual(model.mapToSourceRows(...).tolist(), [4, 0, 1, 3, 2]) rows = [0, 1, 2, 3, 4] model.setSortIndices(None) self.assertEqual(len(spy_about), 2) self.assertEqual(len(spy_changed), 2) self.assertEqual(model.mapFromSourceRows(...), ...) self.assertEqual(model.mapToSourceRows(...), ...) self.assertEqual(model.mapFromSourceRows(rows), rows) self.assertEqual(model.mapToSourceRows(rows), rows) # Tests test _is_index_valid and access model._other_data. The latter tests # implementation, but it would be cumbersome and less readable to test function # pylint: disable=protected-access class TestPyListModel(unittest.TestCase): @classmethod def setUpClass(cls): cls.model = PyListModel([1, 2, 3, 4]) def test_wrap(self): model = PyListModel() s = [1, 2] model.wrap(s) self.assertSequenceEqual(model, [1, 2]) model.append(3) self.assertEqual(s, [1, 2, 3]) self.assertEqual(len(model._other_data), 3) s.append(5) self.assertRaises(RuntimeError, model._is_index_valid, 0) def test_is_index_valid(self): self.assertTrue(self.model._is_index_valid(0)) self.assertTrue(self.model._is_index_valid(2)) self.assertTrue(self.model._is_index_valid(-1)) self.assertTrue(self.model._is_index_valid(-4)) self.assertFalse(self.model._is_index_valid(-5)) self.assertFalse(self.model._is_index_valid(5)) def test_index(self): index = self.model.index(2, 0) self.assertTrue(index.isValid()) self.assertEqual(index.row(), 2) self.assertEqual(index.column(), 0) self.assertFalse(self.model.index(5, 0).isValid()) self.assertFalse(self.model.index(-5, 0).isValid()) self.assertFalse(self.model.index(0, 1).isValid()) def test_headerData(self): self.assertEqual(self.model.headerData(3, Qt.Vertical), "3") def test_rowCount(self): self.assertEqual(self.model.rowCount(), len(self.model)) self.assertEqual(self.model.rowCount(self.model.index(2, 0)), 0) def test_columnCount(self): self.assertEqual(self.model.columnCount(), 1) self.assertEqual(self.model.columnCount(self.model.index(2, 0)), 0) def test_indexOf(self): self.assertEqual(self.model.indexOf(3), 2) def test_data(self): mi = self.model.index(2) self.assertEqual(self.model.data(mi), 3) self.assertEqual(self.model.data(mi, Qt.EditRole), 3) self.assertIsNone(self.model.data(self.model.index(5))) def test_itemData(self): model = PyListModel([1, 2, 3, 4]) mi = model.index(2) model.setItemData(mi, {Qt.ToolTipRole: "foo"}) self.assertEqual(model.itemData(mi)[Qt.ToolTipRole], "foo") self.assertEqual(model.itemData(model.index(5)), {}) def test_mimeData(self): model = PyListModel([1, 2]) model._other_data[:] = [{Qt.UserRole: "a"}, {}] mime = model.mimeData([model.index(0), model.index(1)]) self.assertTrue(mime.hasFormat(PyListModel.MIME_TYPE)) def test_dropMimeData(self): model = PyListModel([1, 2]) model.setData(model.index(0), "a", Qt.UserRole) mime = model.mimeData([model.index(0)]) self.assertTrue( model.dropMimeData(mime, Qt.CopyAction, 2, -1, model.index(-1, -1)) ) self.assertEqual(len(model), 3) self.assertEqual( model.itemData(model.index(2)), {Qt.DisplayRole: 1, Qt.EditRole: 1, Qt.UserRole: "a"} ) def test_parent(self): self.assertFalse(self.model.parent(self.model.index(2)).isValid()) def test_set_data(self): model = PyListModel([1, 2, 3, 4]) model.setData(model.index(0), None, Qt.EditRole) self.assertIs(model.data(model.index(0), Qt.EditRole), None) model.setData(model.index(1), "This is two", Qt.ToolTipRole) self.assertEqual(model.data(model.index(1), Qt.ToolTipRole), "This is two",) self.assertFalse(model.setData(model.index(5), "foo")) def test_setitem(self): model = PyListModel([1, 2, 3, 4]) model[1] = 42 self.assertSequenceEqual(model, [1, 42, 3, 4]) model[-1] = 42 self.assertSequenceEqual(model, [1, 42, 3, 42]) with self.assertRaises(IndexError): model[4] # pylint: disable=pointless-statement with self.assertRaises(IndexError): model[-5] # pylint: disable=pointless-statement model = PyListModel([1, 2, 3, 4]) model[0:0] = [-1, 0] self.assertSequenceEqual(model, [-1, 0, 1, 2, 3, 4]) model = PyListModel([1, 2, 3, 4]) model[len(model):len(model)] = [5, 6] self.assertSequenceEqual(model, [1, 2, 3, 4, 5, 6]) model = PyListModel([1, 2, 3, 4]) model[0:2] = (-1, -2) self.assertSequenceEqual(model, [-1, -2, 3, 4]) model = PyListModel([1, 2, 3, 4]) model[-2:] = [-3, -4] self.assertSequenceEqual(model, [1, 2, -3, -4]) model = PyListModel([1, 2, 3, 4]) with self.assertRaises(IndexError): # non unit strides currently not supported model[0:-1:2] = [3, 3] def test_getitem(self): self.assertEqual(self.model[0], 1) self.assertEqual(self.model[2], 3) self.assertEqual(self.model[-1], 4) self.assertEqual(self.model[-4], 1) with self.assertRaises(IndexError): self.model[4] # pylint: disable=pointless-statement with self.assertRaises(IndexError): self.model[-5] # pylint: disable=pointless-statement def test_delitem(self): model = PyListModel([1, 2, 3, 4]) model._other_data = list("abcd") del model[1] self.assertSequenceEqual(model, [1, 3, 4]) self.assertSequenceEqual(model._other_data, "acd") model = PyListModel([1, 2, 3, 4]) model._other_data = list("abcd") del model[1:3] self.assertSequenceEqual(model, [1, 4]) self.assertSequenceEqual(model._other_data, "ad") model = PyListModel([1, 2, 3, 4]) model._other_data = list("abcd") del model[:] self.assertSequenceEqual(model, []) self.assertEqual(len(model._other_data), 0) model = PyListModel([1, 2, 3, 4]) with self.assertRaises(IndexError): # non unit strides currently not supported del model[0:-1:2] self.assertEqual(len(model), len(model._other_data)) def test_add(self): model2 = self.model + [5, 6] self.assertSequenceEqual(model2, [1, 2, 3, 4, 5, 6]) self.assertEqual(len(model2), len(model2._other_data)) def test_iadd(self): model = PyListModel([1, 2, 3, 4]) model += [5, 6] self.assertSequenceEqual(model, [1, 2, 3, 4, 5, 6]) self.assertEqual(len(model), len(model._other_data)) def test_list_specials(self): # Essentially tested in other tests, but let's do it explicitly, too # __len__ self.assertEqual(len(self.model), 4) # __contains__ self.assertTrue(2 in self.model) self.assertFalse(5 in self.model) # __iter__ self.assertSequenceEqual(self.model, [1, 2, 3, 4]) # __bool__ self.assertTrue(bool(self.model)) self.assertFalse(bool(PyListModel())) def test_insert_delete_rows(self): model = PyListModel([1, 2, 3, 4]) success = model.insertRows(0, 3) self.assertIs(success, True) self.assertSequenceEqual(model, [None, None, None, 1, 2, 3, 4]) success = model.removeRows(3, 4) self.assertIs(success, True) self.assertSequenceEqual(model, [None, None, None]) self.assertFalse(model.insertRows(0, 1, model.index(0))) self.assertFalse(model.removeRows(0, 1, model.index(0))) def test_extend(self): model = PyListModel([]) model.extend([1, 2, 3, 4]) self.assertSequenceEqual(model, [1, 2, 3, 4]) model.extend([5, 6]) self.assertSequenceEqual(model, [1, 2, 3, 4, 5, 6]) self.assertEqual(len(model), len(model._other_data)) def test_append(self): model = PyListModel([]) model.append(1) self.assertSequenceEqual(model, [1]) model.append(2) self.assertSequenceEqual(model, [1, 2]) self.assertEqual(len(model), len(model._other_data)) def test_insert(self): model = PyListModel() model.insert(0, 1) self.assertSequenceEqual(model, [1]) self.assertEqual(len(model._other_data), 1) model._other_data = ["a"] model.insert(0, 2) self.assertSequenceEqual(model, [2, 1]) self.assertEqual(model._other_data[1], "a") self.assertNotEqual(model._other_data[0], "a") model._other_data[0] = "b" model.insert(1, 3) self.assertSequenceEqual(model, [2, 3, 1]) self.assertEqual(model._other_data[0], "b") self.assertEqual(model._other_data[2], "a") self.assertNotEqual(model._other_data[1], "b") self.assertNotEqual(model._other_data[1], "a") model._other_data[1] = "c" model.insert(3, 4) self.assertSequenceEqual(model, [2, 3, 1, 4]) self.assertSequenceEqual(model._other_data[:3], ["b", "c", "a"]) model._other_data[3] = "d" model.insert(-1, 5) self.assertSequenceEqual(model, [2, 3, 1, 5, 4]) self.assertSequenceEqual(model._other_data[:3], ["b", "c", "a"]) self.assertEqual(model._other_data[4], "d") self.assertEqual(len(model), len(model._other_data)) def test_remove(self): model = PyListModel([1, 2, 3, 2, 4]) model._other_data = list("abcde") model.remove(2) self.assertSequenceEqual(model, [1, 3, 2, 4]) self.assertSequenceEqual(model._other_data, "acde") def test_pop(self): model = PyListModel([1, 2, 3, 2, 4]) model._other_data = list("abcde") model.pop(1) self.assertSequenceEqual(model, [1, 3, 2, 4]) self.assertSequenceEqual(model._other_data, "acde") def test_clear(self): model = PyListModel([1, 2, 3, 2, 4]) model.clear() self.assertSequenceEqual(model, []) self.assertEqual(len(model), len(model._other_data)) model.clear() self.assertSequenceEqual(model, []) self.assertEqual(len(model), len(model._other_data)) def test_reverse(self): model = PyListModel([1, 2, 3, 4]) model._other_data = list("abcd") model.reverse() self.assertSequenceEqual(model, [4, 3, 2, 1]) self.assertSequenceEqual(model._other_data, "dcba") def test_sort(self): model = PyListModel([3, 1, 4, 2]) model._other_data = list("abcd") model.sort() self.assertSequenceEqual(model, [1, 2, 3, 4]) self.assertSequenceEqual(model._other_data, "bdac") def test_moveRows(self): model = PyListModel([1, 2, 3, 4]) for i in range(model.rowCount()): model.setData(model.index(i), str(i + 1), Qt.UserRole) def modeldata(role): return [model.index(i).data(role) for i in range(model.rowCount())] def userdata(): return modeldata(Qt.UserRole) def editdata(): return modeldata(Qt.EditRole) r = model.moveRows(QModelIndex(), 1, 1, QModelIndex(), 0) self.assertIs(r, True) self.assertSequenceEqual(editdata(), [2, 1, 3, 4]) self.assertSequenceEqual(userdata(), ["2", "1", "3", "4"]) r = model.moveRows(QModelIndex(), 1, 2, QModelIndex(), 4) self.assertIs(r, True) self.assertSequenceEqual(editdata(), [2, 4, 1, 3]) self.assertSequenceEqual(userdata(), ["2", "4", "1", "3"]) r = model.moveRows(QModelIndex(), 3, 1, QModelIndex(), 0) self.assertIs(r, True) self.assertSequenceEqual(editdata(), [3, 2, 4, 1]) self.assertSequenceEqual(userdata(), ["3", "2", "4", "1"]) r = model.moveRows(QModelIndex(), 2, 1, QModelIndex(), 2) self.assertIs(r, False) model = PyListModel([]) r = model.moveRows(QModelIndex(), 0, 0, QModelIndex(), 0) self.assertIs(r, False) def test_separator(self): model = PyListModel([1, PyListModel.Separator, 2]) model.append(model.Separator) model += [1, model.Separator] model.extend([1, model.Separator]) for i in range(len(model)): self.assertIs(model.flags(model.index(i)) == Qt.NoItemFlags, i % 2 != 0, f"in row {i}") class TestSeparatedListDelegate(unittest.TestCase): @patch("AnyQt.QtWidgets.QStyledItemDelegate.paint") def test_paint(self, _): delegate = SeparatedListDelegate() painter = Mock() font = QFont() font.setPointSizeF(10) painter.font = lambda: font option = Mock() option.palette = QPalette() option.rect = QRect(10, 20, 50, 5) index = Mock() index.data = Mock(return_value="foo") delegate.paint(painter, option, index) painter.drawText.assert_not_called() painter.drawLine.assert_not_called() index.data = Mock(return_value=LabelledSeparator()) delegate.paint(painter, option, index) painter.drawLine.assert_called_with(10, 22, 60, 22) painter.drawLine.reset_mock() painter.drawText.assert_not_called() index.data = Mock(return_value=LabelledSeparator("bar")) delegate.paint(painter, option, index) painter.drawLine.assert_called() painter.drawText.assert_called_with(option.rect, Qt.AlignCenter, "bar") if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_listview.py0000644000076500000240000001070214440334174024752 0ustar00primozstaffimport unittest from unittest.mock import Mock from AnyQt.QtCore import QStringListModel, Qt, QSortFilterProxyModel from AnyQt.QtTest import QTest from AnyQt.QtWidgets import QLineEdit from orangewidget.tests.base import GuiTest from orangewidget.utils.itemmodels import PyListModel from orangewidget.utils.listview import ListViewSearch, ListViewFilter class TestListViewSearch(GuiTest): def setUp(self) -> None: super().setUp() self.lv = ListViewSearch() s = ["one", "two", "three", "four"] model = QStringListModel(s) self.lv.setModel(model) def tearDown(self) -> None: super().tearDown() self.lv.deleteLater() self.lv = None def test_list_view(self): num_items = 4 self.assertEqual(num_items, self.lv.model().rowCount()) filter_row = self.lv.findChild(QLineEdit) filter_row.grab() self.lv.grab() QTest.keyClick(filter_row, Qt.Key_E, delay=-1) self.assertListEqual( [False, True, False, True], [self.lv.isRowHidden(i) for i in range(num_items)], ) QTest.keyClick(filter_row, Qt.Key_Backspace) self.assertListEqual( [False] * 4, [self.lv.isRowHidden(i) for i in range(num_items)] ) QTest.keyClick(filter_row, Qt.Key_F) self.assertListEqual( [True, True, True, False], [self.lv.isRowHidden(i) for i in range(num_items)], ) QTest.keyClick(filter_row, Qt.Key_Backspace) QTest.keyClick(filter_row, Qt.Key_T) self.assertListEqual( [True, False, False, True], [self.lv.isRowHidden(i) for i in range(num_items)], ) QTest.keyClick(filter_row, Qt.Key_H) self.assertListEqual( [True, True, False, True], [self.lv.isRowHidden(i) for i in range(num_items)], ) def test_insert_new_value(self): num_items = 4 filter_row = self.lv.findChild(QLineEdit) filter_row.grab() self.lv.grab() QTest.keyClick(filter_row, Qt.Key_E, delay=-1) self.assertListEqual( [False, True, False, True], [self.lv.isRowHidden(i) for i in range(num_items)], ) model = self.lv.model() if model.insertRow(model.rowCount()): index = model.index(model.rowCount() - 1, 0) model.setData(index, "six") self.assertListEqual( [False, True, False, True, True], [self.lv.isRowHidden(i) for i in range(num_items + 1)], ) def test_empty(self): self.lv.setModel(QStringListModel([])) self.assertEqual(0, self.lv.model().rowCount()) filter_row = self.lv.findChild(QLineEdit) filter_row.grab() self.lv.grab() QTest.keyClick(filter_row, Qt.Key_T) QTest.keyClick(filter_row, Qt.Key_Backspace) def test_PyListModel(self): model = PyListModel() view = ListViewSearch() view.setFilterString("two") view.setRowHidden = Mock(side_effect=view.setRowHidden) view.setModel(model) view.setRowHidden.assert_not_called() model.wrap(["one", "two", "three", "four"]) view.setRowHidden.assert_called() self.assertTrue(view.isRowHidden(0)) self.assertFalse(view.isRowHidden(1)) self.assertTrue(view.isRowHidden(2)) self.assertTrue(view.isRowHidden(3)) class TestListViewFilter(GuiTest): def test_filter(self): model = PyListModel() view = ListViewFilter() view._ListViewFilter__search.textEdited.emit("two") view.model().setSourceModel(model) model.wrap(["one", "two", "three", "four"]) self.assertEqual(view.model().rowCount(), 1) self.assertEqual(model.rowCount(), 4) def test_set_model(self): view = ListViewFilter() self.assertRaises(Exception, view.setModel, PyListModel()) def test_set_source_model(self): model = PyListModel() view = ListViewFilter() view.set_source_model(model) self.assertIs(view.model().sourceModel(), model) self.assertIs(view.source_model(), model) def test_set_proxy(self): proxy = QSortFilterProxyModel() view = ListViewFilter(proxy=proxy) self.assertIs(view.model(), proxy) def test_set_proxy_raises(self): self.assertRaises(Exception, ListViewFilter, proxy=PyListModel()) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690529732.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_messages.py0000644000076500000240000000221714460667704024727 0ustar00primozstaffimport unittest from orangewidget.tests.base import WidgetTest from orangewidget.widget import OWBaseWidget, Msg class TestMessages(WidgetTest): def test_clear_owner(self): class WidgetA(OWBaseWidget, openclass=True): class Error(OWBaseWidget.Error): err_a = Msg("error a") class WidgetB(WidgetA): class Error(WidgetA.Error): err_b = Msg("error b") w = self.create_widget(WidgetB) w.Error.err_a() w.Error.err_b() self.assertTrue(w.Error.err_a.is_shown()) self.assertTrue(w.Error.err_b.is_shown()) w.Error.clear() self.assertFalse(w.Error.err_a.is_shown()) self.assertFalse(w.Error.err_b.is_shown()) w.Error.err_a() w.Error.err_b() w.Error.clear(owner=WidgetB) self.assertTrue(w.Error.err_a.is_shown()) self.assertFalse(w.Error.err_b.is_shown()) w.Error.err_a() w.Error.err_b() w.Error.clear(owner=WidgetA) self.assertFalse(w.Error.err_a.is_shown()) self.assertTrue(w.Error.err_b.is_shown()) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_messagewidget.py0000644000076500000240000000407414306600442025734 0ustar00primozstafffrom AnyQt.QtCore import Qt, QSize from orangewidget.tests.base import GuiTest from orangewidget.utils.messagewidget import ( MessagesWidget, Message, IconWidget ) class TestMessageWidget(GuiTest): def test_widget(self): w = MessagesWidget() w.setMessage(0, Message()) self.assertTrue(w.summarize().isEmpty()) self.assertSequenceEqual(w.messages(), [Message()]) w.setMessage(0, Message(Message.Warning, text="a")) self.assertFalse(w.summarize().isEmpty()) self.assertEqual(w.summarize().severity, Message.Warning) self.assertEqual(w.summarize().text, "a") w.setMessage(1, Message(Message.Error, text="#error#")) self.assertEqual(w.summarize().severity, Message.Error) self.assertTrue(w.summarize().text.startswith("#error#")) self.assertSequenceEqual( w.messages(), [Message(Message.Warning, text="a"), Message(Message.Error, text="#error#")]) w.setMessage(2, Message(Message.Information, text="Hello", textFormat=Qt.RichText)) self.assertSequenceEqual( w.messages(), [Message(Message.Warning, text="a"), Message(Message.Error, text="#error#"), Message(Message.Information, text="Hello", textFormat=Qt.RichText)]) w.grab() w.removeMessage(2) w.clear() w.setOpenExternalLinks(True) assert w.openExternalLinks() self.assertEqual(len(w.messages()), 0) self.assertTrue(w.summarize().isEmpty()) class TestIconWidget(GuiTest): def test_widget(self): w = IconWidget() s = w.style() icon = s.standardIcon(s.SP_BrowserStop) w.setIcon(icon) self.assertEqual(w.icon().cacheKey(), icon.cacheKey()) w.setIconSize(QSize(42, 42)) self.assertEqual(w.iconSize(), QSize(42, 42)) self.assertGreaterEqual(w.sizeHint().width(), 42) self.assertGreaterEqual(w.sizeHint().height(), 42) w.setIconSize(QSize()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_overlay.py0000644000076500000240000000615314306600442024565 0ustar00primozstaff import unittest.mock from AnyQt.QtCore import Qt, QEvent from AnyQt.QtTest import QTest from AnyQt.QtWidgets import QWidget, QHBoxLayout, QStyle, QApplication from orangewidget.tests.base import GuiTest from orangewidget.utils.overlay import ( OverlayWidget, MessageOverlayWidget ) class TestOverlay(GuiTest): def test_overlay_message(self): container = QWidget() overlay = MessageOverlayWidget(parent=container) overlay.setWidget(container) overlay.setIcon(QStyle.SP_MessageBoxInformation) container.show() QTest.qWaitForWindowExposed(container) self.assertTrue(overlay.isVisible()) overlay.setText("Hello world! It's so nice here") QApplication.sendPostedEvents(overlay, QEvent.LayoutRequest) self.assertTrue(overlay.geometry().isValid()) button_ok = overlay.addButton(MessageOverlayWidget.Ok) button_close = overlay.addButton(MessageOverlayWidget.Close) button_help = overlay.addButton(MessageOverlayWidget.Help) self.assertTrue(all([button_ok, button_close, button_help])) self.assertIs(overlay.button(MessageOverlayWidget.Ok), button_ok) self.assertIs(overlay.button(MessageOverlayWidget.Close), button_close) self.assertIs(overlay.button(MessageOverlayWidget.Help), button_help) button = overlay.addButton("Click Me!", MessageOverlayWidget.AcceptRole) self.assertIsNot(button, None) self.assertTrue(overlay.buttonRole(button), MessageOverlayWidget.AcceptRole) mock = unittest.mock.MagicMock() overlay.accepted.connect(mock) QTest.mouseClick(button, Qt.LeftButton) self.assertFalse(overlay.isVisible()) mock.assert_called_once_with() def test_layout(self): container = QWidget() container.setLayout(QHBoxLayout()) container1 = QWidget() container.layout().addWidget(container1) container.show() QTest.qWaitForWindowExposed(container) container.resize(600, 600) overlay = OverlayWidget(parent=container) overlay.setWidget(container) overlay.resize(20, 20) overlay.show() center = overlay.geometry().center() self.assertTrue(290 < center.x() < 310) self.assertTrue(290 < center.y() < 310) overlay.setAlignment(Qt.AlignTop | Qt.AlignHCenter) geom = overlay.geometry() self.assertEqual(geom.top(), 0) self.assertTrue(290 < geom.center().x() < 310) overlay.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) geom = overlay.geometry() self.assertEqual(geom.left(), 0) self.assertTrue(290 < geom.center().y() < 310) overlay.setAlignment(Qt.AlignBottom | Qt.AlignRight) geom = overlay.geometry() self.assertEqual(geom.right(), 600 - 1) self.assertEqual(geom.bottom(), 600 - 1) overlay.setWidget(container1) geom = overlay.geometry() self.assertEqual(geom.right(), container1.geometry().right()) self.assertEqual(geom.bottom(), container1.geometry().bottom()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_save_plot.py0000644000076500000240000000206114306600442025072 0ustar00primozstaffimport os import unittest from unittest.mock import Mock from AnyQt.QtWidgets import QFileDialog from orangewidget.utils.filedialogs import format_filter from orangewidget.utils.saveplot import save_plot from orangewidget.widget import OWBaseWidget class TestSavePlot(unittest.TestCase): def setUp(self): QFileDialog.getSaveFileName = Mock(return_value=[None, None]) self.filters = [format_filter(f) for f in OWBaseWidget.graph_writers] def test_save_plot(self): save_plot(None, OWBaseWidget.graph_writers) QFileDialog.getSaveFileName.assert_called_once_with( None, "Save as...", os.path.expanduser("~"), ";;".join(self.filters), self.filters[0] ) def test_save_plot_default_filename(self): save_plot(None, OWBaseWidget.graph_writers, filename="temp.txt") path = os.path.join(os.path.expanduser("~"), "temp.txt") QFileDialog.getSaveFileName.assert_called_once_with( None, "Save as...", path, ";;".join(self.filters), self.filters[0] ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_signals.py0000644000076500000240000002231314334703654024552 0ustar00primozstaff# Tests construct several MockWidget classes with different signals # pylint: disable=function-redefined # pylint: disable=unused-variable # It is our constitutional right to use foo and bar as fake handler names # pylint: disable=blacklisted-name import unittest from unittest.mock import patch, MagicMock from orangewidget.widget import \ Single, Multiple, Default, NonDefault, Explicit, Dynamic from orangewidget.tests.base import WidgetTest from orangewidget.utils.signals import _Signal, Input, Output, \ WidgetSignalsMixin, InputSignal, OutputSignal, MultiInput, summarize, \ PartialSummary from orangewidget.widget import OWBaseWidget class SignalTest(unittest.TestCase): def test_get_flags(self): self.assertEqual(_Signal.get_flags(False, False, False, False), Single | NonDefault) self.assertEqual(_Signal.get_flags(True, False, False, False), Multiple | NonDefault) self.assertEqual(_Signal.get_flags(False, True, False, False), Single | Default) self.assertEqual(_Signal.get_flags(False, False, True, False), Single | NonDefault | Explicit) self.assertEqual(_Signal.get_flags(False, False, False, True), Single | NonDefault | Dynamic) class InputTest(unittest.TestCase): def test_init(self): with patch("orangewidget.utils.signals._Signal.get_flags", return_value=42) as getflags: signal = Input("a name", int, "an id", "a doc", ["x"]) self.assertEqual(signal.name, "a name") self.assertEqual(signal.type, int) self.assertEqual(signal.id, "an id") self.assertEqual(signal.doc, "a doc") self.assertEqual(signal.replaces, ["x"]) self.assertEqual(signal.flags, 42) getflags.assert_called_with(False, False, False, False) Input("a name", int, "an id", "a doc", ["x"], multiple=True) getflags.assert_called_with(True, False, False, False) Input("a name", int, "an id", "a doc", ["x"], default=True) getflags.assert_called_with(False, True, False, False) Input("a name", int, "an id", "a doc", ["x"], explicit=True) getflags.assert_called_with(False, False, True, False) def test_decorate(self): input = Input("a name", int) self.assertEqual(input.handler, "") @input def foo(): pass self.assertEqual(input.handler, "foo") with self.assertRaises(ValueError): @input def bar(): pass class OutputTest(unittest.TestCase): def test_init(self): with patch("orangewidget.utils.signals._Signal.get_flags", return_value=42) as getflags: signal = Output("a name", int, "an id", "a doc", ["x"]) self.assertEqual(signal.name, "a name") self.assertEqual(signal.type, int) self.assertEqual(signal.id, "an id") self.assertEqual(signal.doc, "a doc") self.assertEqual(signal.replaces, ["x"]) self.assertEqual(signal.flags, 42) getflags.assert_called_with(False, False, False, True) Output("a name", int, "an id", "a doc", ["x"], default=True) getflags.assert_called_with(False, True, False, True) Output("a name", int, "an id", "a doc", ["x"], explicit=True) getflags.assert_called_with(False, False, True, True) Output("a name", int, "an id", "a doc", ["x"], dynamic=False) getflags.assert_called_with(False, False, False, False) def test_bind_and_send(self): widget = MagicMock() output = Output("a name", int, "an id", "a doc", ["x"]) bound = output.bound_signal(widget) value = object() id = 42 bound.send(value, id) widget.signalManager.send.assert_called_with( widget, "a name", value, id) class WidgetSignalsMixinTest(WidgetTest): def test_init_binds_outputs(self): class MockWidget(OWBaseWidget): name = "foo" class Outputs: an_output = Output("a name", int) widget = self.create_widget(MockWidget) self.assertEqual(widget.Outputs.an_output.widget, widget) self.assertIsNone(MockWidget.Outputs.an_output.widget) def test_checking_invalid_inputs(self): with self.assertRaises(ValueError): class MockWidget(OWBaseWidget): name = "foo" class Inputs: an_input = Input("a name", int) with self.assertRaises(ValueError): class MockWidget(OWBaseWidget): name = "foo" inputs = [("a name", int, "no_such_handler")] # Now, don't crash class MockWidget(OWBaseWidget): name = "foo" inputs = [("a name", int, "handler")] def handler(self): pass def test_signal_conversion(self): class MockWidget(OWBaseWidget): name = "foo" inputs = [("name 1", int, "foo"), InputSignal("name 2", int, "foo")] outputs = [("name 3", int), OutputSignal("name 4", int)] def foo(self): pass input1, input2 = MockWidget.inputs self.assertIsInstance(input1, InputSignal) self.assertEqual(input1.name, "name 1") self.assertIsInstance(input2, InputSignal) self.assertEqual(input2.name, "name 2") output1, output2 = MockWidget.outputs self.assertIsInstance(output1, OutputSignal) self.assertEqual(output1.name, "name 3") self.assertIsInstance(output2, OutputSignal) self.assertEqual(output2.name, "name 4") def test_get_signals(self): class MockWidget(OWBaseWidget): name = "foo" inputs = [("a name", int, "foo")] outputs = [("another name", float)] def foo(self): pass self.assertIs(MockWidget.get_signals("inputs"), MockWidget.inputs) self.assertIs(MockWidget.get_signals("outputs"), MockWidget.outputs) class MockWidget(OWBaseWidget): name = "foo" class Inputs: an_input = Input("a name", int) class Outputs: an_output = Output("another name", int) @Inputs.an_input def foo(self): pass input, = MockWidget.get_signals("inputs") self.assertIsInstance(input, InputSignal) self.assertEqual(input.name, "a name") output, = MockWidget.get_signals("outputs") self.assertIsInstance(output, OutputSignal) self.assertEqual(output.name, "another name") def test_get_signals_order(self): class TestWidget(WidgetSignalsMixin): class Inputs: input_1 = Input("1", int) input_2 = Input("2", int) input_3 = Input("3", int) input_a = Input("a", object) class Outputs: output_1 = Output("1", int) output_2 = Output("2", int) output_3 = Output("3", int) output_a = Output("a", object) inputs = TestWidget.get_signals("inputs") self.assertTrue(all(isinstance(s, Input) for s in inputs)) self.assertSequenceEqual([s.name for s in inputs], list("123a")) outputs = TestWidget.get_signals("outputs") self.assertTrue(all(isinstance(s, Output) for s in outputs)) self.assertSequenceEqual([s.name for s in outputs], list("123a")) def test_multi_input_summary(self): class Str(str): pass @summarize.register(Str) def _(s): return PartialSummary(str(s), None) class TestWidget(OWBaseWidget): class Inputs: input_a = MultiInput("A", Str) @Inputs.input_a def set_a(self, index, a): pass @Inputs.input_a.insert def insert_a(self, index, a): pass @Inputs.input_a.remove def remove_a(self, index): pass w = self.create_widget(TestWidget) w.insert_a(0, Str("00")) w.insert_a(1, Str("11")) self.assertSequenceEqual( list(w.input_summaries["A"].values()), [PartialSummary("00", None), PartialSummary("11", None)]) w.set_a(0, None) self.assertSequenceEqual( list(w.input_summaries["A"].values()), [PartialSummary("11", None)]) w.set_a(0, Str("00")) self.assertSequenceEqual( list(w.input_summaries["A"].values()), [PartialSummary("00", None), PartialSummary("11", None)]) w.insert_a(1, Str("05")) self.assertSequenceEqual( list(w.input_summaries["A"].values()), [PartialSummary("00", None), PartialSummary("05", None), PartialSummary("11", None)]) w.set_a(1, None) w.remove_a(1) self.assertSequenceEqual( list(w.input_summaries["A"].values()), [PartialSummary("00", None), PartialSummary("11", None)]) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_visual_settings_dlg.py0000644000076500000240000001100014334703654027152 0ustar00primozstaffimport unittest from unittest.mock import Mock from AnyQt.QtWidgets import QComboBox, QCheckBox, QSpinBox, QLineEdit from orangewidget.tests.base import GuiTest from orangewidget.utils.visual_settings_dlg import SettingsDialog, FontList class TestSettingsDialog(GuiTest): def setUp(self): self.defaults = { "Box": {"Items": { "P1": (["Foo", "Bar", "Baz"], "Bar"), "P2": (range(3, 10, 2), 5), "P3": (None, True), "P4": (None, "Foo Bar"), "P5": (FontList([".Foo", ".Bar"]), ".Foo"), }} } self.dlg = SettingsDialog(None, self.defaults) @property def dialog_controls(self): return self.dlg._SettingsDialog__controls def test_initialize(self): controls = self.dialog_controls self.assertEqual(len(controls), len(self.defaults["Box"]["Items"])) self.assertIsInstance(controls[("Box", "Items", "P1")][0], QComboBox) self.assertIsInstance(controls[("Box", "Items", "P2")][0], QSpinBox) self.assertIsInstance(controls[("Box", "Items", "P3")][0], QCheckBox) self.assertIsInstance(controls[("Box", "Items", "P4")][0], QLineEdit) self.assertIsInstance(controls[("Box", "Items", "P5")][0], QComboBox) def test_changed_settings(self): self.dialog_controls[("Box", "Items", "P1")][0].setCurrentText("Foo") self.dialog_controls[("Box", "Items", "P2")][0].setValue(7) self.dialog_controls[("Box", "Items", "P3")][0].setChecked(False) self.dialog_controls[("Box", "Items", "P4")][0].setText("Foo Baz") self.dialog_controls[("Box", "Items", "P5")][0].setCurrentIndex(1) changed = {("Box", "Items", "P1"): "Foo", ("Box", "Items", "P2"): 7, ("Box", "Items", "P3"): False, ("Box", "Items", "P4"): "Foo Baz", ("Box", "Items", "P5"): ".Bar"} self.assertDictEqual(self.dlg.changed_settings, changed) def test_reset(self): ctrls = self.dialog_controls ctrls[("Box", "Items", "P1")][0].setCurrentText("Foo") ctrls[("Box", "Items", "P2")][0].setValue(7) ctrls[("Box", "Items", "P3")][0].setChecked(False) ctrls[("Box", "Items", "P4")][0].setText("Foo Baz") self.dialog_controls[("Box", "Items", "P5")][0].setCurrentIndex(1) self.dlg._SettingsDialog__reset() self.assertDictEqual(self.dlg.changed_settings, {}) self.assertEqual(ctrls[("Box", "Items", "P1")][0].currentText(), "Bar") self.assertEqual(ctrls[("Box", "Items", "P2")][0].value(), 5) self.assertTrue(ctrls[("Box", "Items", "P3")][0].isChecked()) self.assertEqual(ctrls[("Box", "Items", "P4")][0].text(), "Foo Bar") self.assertEqual(ctrls[("Box", "Items", "P5")][0].currentText(), "Foo") def test_setting_changed(self): handler = Mock() self.dlg.setting_changed.connect(handler) self.dialog_controls[("Box", "Items", "P1")][0].setCurrentText("Foo") handler.assert_called_with(('Box', 'Items', 'P1'), "Foo") self.dialog_controls[("Box", "Items", "P2")][0].setValue(7) handler.assert_called_with(('Box', 'Items', 'P2'), 7) self.dialog_controls[("Box", "Items", "P3")][0].setChecked(False) handler.assert_called_with(('Box', 'Items', 'P3'), False) self.dialog_controls[("Box", "Items", "P4")][0].setText("Foo Baz") handler.assert_called_with(('Box', 'Items', 'P4'), "Foo Baz") self.dialog_controls[("Box", "Items", "P5")][0].setCurrentIndex(1) handler.assert_called_with(('Box', 'Items', 'P5'), ".Bar") def test_apply_settings(self): changed = [(("Box", "Items", "P1"), "Foo"), (("Box", "Items", "P2"), 7), (("Box", "Items", "P3"), False), (("Box", "Items", "P4"), "Foo Baz"), (("Box", "Items", "P5"), ".Bar")] self.dlg.apply_settings(changed) ctrls = self.dialog_controls self.assertEqual(ctrls[("Box", "Items", "P1")][0].currentText(), "Foo") self.assertEqual(ctrls[("Box", "Items", "P2")][0].value(), 7) self.assertFalse(ctrls[("Box", "Items", "P3")][0].isChecked()) self.assertEqual(ctrls[("Box", "Items", "P4")][0].text(), "Foo Baz") self.assertEqual(ctrls[("Box", "Items", "P5")][0].currentText(), "Bar") self.assertDictEqual(self.dlg.changed_settings, {k: v for k, v in changed}) if __name__ == '__main__': unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_webview.py0000644000076500000240000000512014306600442024545 0ustar00primozstafffrom os.path import dirname from unittest import skip from AnyQt.QtCore import Qt, QObject, pyqtSlot from AnyQt.QtWidgets import QDialog from AnyQt.QtTest import QTest from orangewidget.tests.base import WidgetTest try: from orangewidget.utils.webview import WebviewWidget, HAVE_WEBKIT, wait except ImportError: pass else: SOME_URL = WebviewWidget.toFileURL(dirname(__file__)) @skip("Times out on Travis") class WebviewWidgetTest(WidgetTest): def test_base(self): w = WebviewWidget() w.evalJS('document.write("foo");') SVG = 'asd' w.onloadJS('''document.write('{}');'''.format(SVG)) w.setUrl(SOME_URL) svg = self.process_events(lambda: w.svg()) self.assertEqual(svg, SVG) self.process_events(until=lambda: 'foo' in w.html()) html = 'asd' self.assertEqual( w.html(), '{}'.format( # WebKit evaluates first document.write first, whereas # WebEngine evaluates onloadJS first 'foo' + html if HAVE_WEBKIT else html + 'foo' )) def test_exposeObject(self): test = self OBJ = dict(a=[1, 2], b='c') done = False class Bridge(QObject): @pyqtSlot('QVariantMap') def check_object(self, obj): nonlocal test, done, OBJ done = True test.assertEqual(obj, OBJ) w = WebviewWidget(bridge=Bridge()) w.setUrl(SOME_URL) w.exposeObject('obj', OBJ) w.evalJS('''pybridge.check_object(window.obj);''') self.process_events(lambda: done) self.assertRaises(ValueError, w.exposeObject, 'obj', QDialog()) def test_escape_hides(self): # NOTE: This test doesn't work as it is supposed to. window = QDialog() w = WebviewWidget(window) window.show() w.setFocus(Qt.OtherFocusReason) self.assertFalse(window.isHidden()) # This event is sent to the wrong widget. Should be sent to the # inner HTML view as focused, but no amount of clicking/ focusing # helped, neither did invoking JS handler directly. I'll live with it. QTest.keyClick(w, Qt.Key_Escape) self.assertTrue(window.isHidden()) def test_runJavaScript(self): w = WebviewWidget() w.runJavaScript('2;') retvals = [] w.runJavaScript('3;', lambda retval: retvals.append(retval)) wait(until=lambda: retvals) self.assertEqual(retvals[0], 3) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/utils/tests/test_widgetpreview.py0000644000076500000240000001561314440334174025777 0ustar00primozstaffimport sys import unittest from unittest.mock import MagicMock, call from AnyQt.QtWidgets import QApplication from orangewidget.tests.base import WidgetTest from orangewidget.utils.widgetpreview import WidgetPreview from orangewidget.widget import OWBaseWidget, Input from orangewidget.utils import widgetpreview # MagicMocks have no names, so we patch Input.__call__ to use them # as input handlers class MockInput(Input): def __call__(self, method): self.handler = self.name return method class TestWidgetPreviewBase(WidgetTest): @classmethod def setUpClass(cls): super().setUpClass() cls.app = app = QApplication.instance() cls.orig_sys_exit = sys.exit cls.orig_app_exec = app.exec cls.orig_qapplication = widgetpreview.QApplication sys.exit = MagicMock() widgetpreview.QApplication = MagicMock(return_value=app) app.exec = MagicMock() @classmethod def tearDownClass(cls): app = QApplication.instance() sys.exit = cls.orig_sys_exit app.exec = cls.orig_app_exec widgetpreview.QApplication = cls.orig_qapplication del cls.app super().tearDownClass() def setUp(self): # Class is defined within setUp to reset mocks before each test class MockWidget(OWBaseWidget): name = "foo" class Inputs: input_int = MockInput("int1", int) input_float1 = MockInput("float1", float, default=True) input_float2 = MockInput("float2", float) input_str1 = MockInput("str1", str) input_str2 = MockInput("str2", str) int1 = Inputs.input_int(MagicMock()) float1 = Inputs.input_float1(MagicMock()) float2 = Inputs.input_float2(MagicMock()) str1 = Inputs.input_str1(MagicMock()) str2 = Inputs.input_str2(MagicMock()) show = MagicMock() saveSettings = MagicMock() super().setUp() self.widgetClass = MockWidget sys.exit.reset_mock() widgetpreview.QApplication.reset_mock() self.app.exec.reset_mock() def tearDown(self) -> None: super().tearDown() def widget_constructor(self): return self.create_widget(self.widgetClass) class TestWidgetPreview(TestWidgetPreviewBase): def test_widget_is_shown_and_ran(self): w = self.widgetClass self.app.exec.reset_mock() previewer = WidgetPreview(self.widget_constructor) previewer.run() w.show.assert_called() w.show.reset_mock() self.app.exec.assert_called() self.app.exec.reset_mock() w.saveSettings.assert_called() w.saveSettings.reset_mock() sys.exit.assert_called() sys.exit.reset_mock() self.assertIsNone(previewer.widget) previewer.run(no_exit=True) w.show.assert_called() w.show.reset_mock() self.app.exec.assert_called() self.app.exec.reset_mock() w.saveSettings.assert_not_called() sys.exit.assert_not_called() self.assertIsNotNone(previewer.widget) widget = previewer.widget previewer.run(no_exec=True, no_exit=True) w.show.assert_not_called() self.app.exec.assert_not_called() w.saveSettings.assert_not_called() sys.exit.assert_not_called() self.assertIs(widget, previewer.widget) previewer.run(no_exec=True) w.show.assert_not_called() self.app.exec.assert_not_called() w.saveSettings.assert_called() sys.exit.assert_called() self.assertIsNone(previewer.widget) def test_single_signal(self): w = self.widgetClass WidgetPreview(self.widget_constructor).run(42) w.int1.assert_called_with(42) WidgetPreview(self.widget_constructor).run(3.14) w.float1.assert_called_with(3.14) self.assertEqual(w.float2.call_count, 0) with self.assertRaises(ValueError): WidgetPreview(self.widget_constructor).run("foo") with self.assertRaises(ValueError): WidgetPreview(self.widget_constructor).run([]) def test_named_signals(self): w = self.widgetClass WidgetPreview(self.widget_constructor).run(42, float2=2.7, str1="foo") w.int1.assert_called_with(42) self.assertEqual(w.float1.call_count, 0) w.float2.assert_called_with(2.7) w.str1.assert_called_with("foo") self.assertEqual(w.str2.call_count, 0) def test_multiple_runs(self): w = self.widgetClass previewer = WidgetPreview(self.widget_constructor) previewer.run(42, no_exit=True) w.int1(43) previewer.send_signals([(44, 1), (45, 2)]) previewer.run(46, no_exit=True) w.int1.assert_has_calls( [call(42), call(43), call(44, 1), call(45, 2), call(46)]) class TestWidgetPreviewInternal(TestWidgetPreviewBase): def test_find_handler_name(self): previewer = WidgetPreview(self.widget_constructor) previewer.create_widget() find_name = previewer._find_handler_name self.assertEqual(find_name(42), "int1") self.assertEqual(find_name(3.14), "float1") self.assertRaises(ValueError, find_name, "foo") self.assertRaises(ValueError, find_name, []) self.assertRaises(ValueError, find_name, [42]) self.assertEqual(find_name([(42, 1)]), "int1") self.assertEqual(find_name([(2, 1), (3, 2)]), "int1") self.assertEqual(find_name([(42.4, 1)]), "float1") self.assertEqual(find_name([(42.4, 1), (5.1, 1)]), "float1") self.assertRaises(ValueError, find_name, [("foo", 1)]) self.assertRaises(ValueError, find_name, []) def test_data_chunks(self): self.assertEqual( list(WidgetPreview._data_chunks(42)), [(42, )]) self.assertEqual( list(WidgetPreview._data_chunks((42, 1))), [(42, 1)]) self.assertEqual( list(WidgetPreview._data_chunks([(42, 1), (65, 3)])), [(42, 1), (65, 3)]) def test_create_widget(self): previewer = WidgetPreview(self.widget_constructor) self.assertIsNone(previewer.widget) previewer.create_widget() self.assertIsInstance(previewer.widget, self.widgetClass) def test_send_signals(self): previewer = WidgetPreview(self.widget_constructor) previewer.create_widget() widget = previewer.widget previewer.send_signals(42) widget.int1.assert_called_with(42) widget.int1.reset_mock() previewer.send_signals( [(42, 1), (40, 2)], str2="foo", float1=[(3.14, 1), (5.1, 8)]) widget.int1.assert_has_calls([call(42, 1), call(40, 2)]) widget.str2.assert_called_with("foo") widget.float1.assert_has_calls([call(3.14, 1), call(5.1, 8)]) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/visual_settings_dlg.py0000644000076500000240000002357714334703654024777 0ustar00primozstaffimport sys from typing import List, Iterable, Tuple, Callable, Union, Dict from functools import singledispatch from AnyQt.QtCore import Qt, pyqtSignal as Signal, QStringListModel, \ QAbstractItemModel from AnyQt.QtWidgets import QDialog, QVBoxLayout, QComboBox, QCheckBox, \ QDialogButtonBox, QSpinBox, QWidget, QApplication, QFormLayout, QLineEdit from orangewidget import gui from orangewidget.utils.combobox import _ComboBoxListDelegate from orangewidget.widget import OWBaseWidget KeyType = Tuple[str, str, str] ValueType = Union[str, int, bool] ValueRangeType = Union[Iterable, None] SettingsType = Dict[str, Tuple[ValueRangeType, ValueType]] class SettingsDialog(QDialog): """ A dialog for settings manipulation. Attributes ---------- master : Union[QWidget, None] Parent widget. settings : Dict[str, Dict[str, SettingsType]] Collection of box names, label texts, parameter names, initial control values and possible control values. """ setting_changed = Signal(object, object) def __init__(self, master: Union[QWidget, None], settings: Dict[str, Dict[str, SettingsType]]): super().__init__(master, windowTitle="Visual Settings") self.__controls: Dict[KeyType, Tuple[QWidget, ValueType]] = {} self.__changed_settings: Dict[KeyType, ValueType] = {} self.setting_changed.connect(self.__on_setting_changed) layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) self.main_box = gui.vBox(self, box=None) # type: QWidget buttons = QDialogButtonBox( orientation=Qt.Horizontal, standardButtons=QDialogButtonBox.Close | QDialogButtonBox.Reset, ) closeButton = buttons.button(QDialogButtonBox.Close) closeButton.clicked.connect(self.close) resetButton = buttons.button(QDialogButtonBox.Reset) resetButton.clicked.connect(self.__reset) resetButton.setAutoDefault(False) layout.addWidget(buttons) self.__initialize(settings) @property def changed_settings(self) -> Dict[KeyType, ValueType]: """ Keys (box, label, parameter) and values for changed settings. Returns ------- settings : Dict[KeyType, ValueType] """ return self.__changed_settings def __on_setting_changed(self, key: KeyType, value: ValueType): self.__changed_settings[key] = value def __reset(self): for key in self.__changed_settings: _set_control_value(*self.__controls[key]) self.__changed_settings = {} def __initialize(self, settings: Dict[str, Dict[str, SettingsType]]): for box_name in settings: box = gui.vBox(self.main_box, box=box_name) form = QFormLayout() form.setFormAlignment(Qt.AlignLeft | Qt.AlignTop) form.setLabelAlignment(Qt.AlignLeft) box.layout().addLayout(form) for label, values in settings[box_name].items(): self.__add_row(form, box_name, label, values) self.main_box.adjustSize() def __add_row(self, form: QFormLayout, box_name: str, label: str, settings: SettingsType): box = gui.hBox(None, box=None) for parameter, (values, default_value) in settings.items(): key = (box_name, label, parameter) control = _add_control(values or default_value, default_value, key, self.setting_changed) control.setToolTip(parameter) box.layout().addWidget(control) self.__controls[key] = (control, default_value) form.addRow(f"{label}:", box) def apply_settings(self, settings: Iterable[Tuple[KeyType, ValueType]]): """ Assign values to controls. Parameters ---------- settings : Iterable[Tuple[KeyType, ValueType] Collection of box names, label texts, parameter names and control values. """ for key, value in settings: _set_control_value(self.__controls[key][0], value) def show_dlg(self): """ Open the dialog. """ self.show() self.raise_() self.activateWindow() class VisualSettingsDialog(SettingsDialog): """ A dialog for visual settings manipulation, that can be used along OWBaseWidget. The OWBaseWidget should implement set_visual_settings. If the OWBaseWidget has visual_settings property as Setting({}), the saved settings are applied. Attributes ---------- master : OWBaseWidget Parent widget. settings : Dict[str, Dict[str, SettingsType]] Collection of box names, label texts, parameter names, initial control values and possible control values. """ def __init__(self, master: OWBaseWidget, settings: Dict[str, Dict[str, SettingsType]]): super().__init__(master, settings) self.setting_changed.connect(master.set_visual_settings) if hasattr(master, "visual_settings"): self.apply_settings(master.visual_settings.items()) master.openVisualSettingsClicked.connect(self.show_dlg) @singledispatch def _add_control(*_): raise NotImplementedError @_add_control.register(list) def _(values: List[str], value: str, key: KeyType, signal: Callable) \ -> QComboBox: combo = QComboBox() combo.addItems(values) combo.setCurrentText(value) combo.currentTextChanged.connect(lambda text: signal.emit(key, text)) return combo class FontList(list): pass @_add_control.register(FontList) def _(values: FontList, value: str, key: KeyType, signal: Callable) \ -> QComboBox: class FontModel(QStringListModel): def data(self, index, role=Qt.DisplayRole): if role == Qt.AccessibleDescriptionRole \ and super().data(index, Qt.DisplayRole) == "": return "separator" value = super().data(index, role) if role == Qt.DisplayRole and value.startswith("."): value = value[1:] return value def flags(self, index): if index.data(Qt.DisplayRole) == "separator": return Qt.NoItemFlags else: return super().flags(index) combo = QComboBox() model = FontModel(values) combo.setModel(model) combo.setCurrentIndex(values.index(value)) combo.currentIndexChanged.connect(lambda i: signal.emit(key, values[i])) combo.setItemDelegate(_ComboBoxListDelegate()) return combo @_add_control.register(range) def _(values: Iterable[int], value: int, key: KeyType, signal: Callable) \ -> QSpinBox: spin = QSpinBox(minimum=values.start, maximum=values.stop, singleStep=values.step, value=value) spin.valueChanged.connect(lambda val: signal.emit(key, val)) return spin @_add_control.register(bool) def _(_: bool, value: bool, key: KeyType, signal: Callable) -> QCheckBox: check = QCheckBox(text=f"{key[-1]} ", checked=value) check.stateChanged.connect(lambda val: signal.emit(key, bool(val))) return check @_add_control.register(str) def _(_: str, value: str, key: KeyType, signal: Callable) -> QLineEdit: line_edit = QLineEdit(value) line_edit.textChanged.connect(lambda text: signal.emit(key, text)) return line_edit @singledispatch def _set_control_value(*_): raise NotImplementedError @_set_control_value.register(QComboBox) def _(combo: QComboBox, value: str): model: QAbstractItemModel = combo.model() values = [model.data(model.index(i, 0), role=Qt.EditRole) for i in range(model.rowCount())] combo.setCurrentIndex(values.index(value)) @_set_control_value.register(QSpinBox) def _(spin: QSpinBox, value: int): spin.setValue(value) @_set_control_value.register(QCheckBox) def _(check: QCheckBox, value: bool): check.setChecked(value) @_set_control_value.register(QLineEdit) def _(edit: QLineEdit, value: str): edit.setText(value) if __name__ == "__main__": from AnyQt.QtWidgets import QPushButton app = QApplication(sys.argv) w = QDialog() w.setFixedSize(400, 200) _items = ["Foo", "Bar", "Baz", "Foo Bar", "Foo Baz", "Bar Baz"] _settings = { "Box 1": { "Item 1": { "Parameter 1": (_items[:10], _items[0]), "Parameter 2": (_items[:10], _items[0]), "Parameter 3": (range(4, 20), 5) }, "Item 2": { "Parameter 1": (_items[:10], _items[1]), "Parameter 2": (range(4, 20), 6), "Parameter 3": (range(4, 20), 7) }, "Item 3": { "Parameter 1": (_items[:10], _items[1]), "Parameter 2": (range(4, 20), 8) }, }, "Box 2": { "Item 1": { "Parameter 1": (_items[:10], _items[0]), "Parameter 2": (None, True) }, "Item 2": { "Parameter 1": (_items[:10], _items[1]), "Parameter 2": (None, False) }, "Item 3": { "Parameter 1": (None, False), "Parameter 2": (None, True) }, "Item 4": { "Parameter 1": (_items[:10], _items[0]), "Parameter 2": (None, False) }, "Item 5": { "Parameter 1": ("", "Foo"), "Parameter 2": (None, False) }, "Item 6": { "Parameter 1": ("", ""), "Parameter 2": (None, False) }, }, } dlg = SettingsDialog(w, _settings) dlg.setting_changed.connect(lambda *res: print(*res)) dlg.finished.connect(lambda res: print(res, dlg.changed_settings)) btn = QPushButton(w) btn.setText("Open dialog") btn.clicked.connect(dlg.show_dlg) w.show() sys.exit(app.exec()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/utils/webview.py0000644000076500000240000005446314334703654022374 0ustar00primozstaff""" This module holds our customized WebView that integrates HTML, CSS & JS into Qt. WebviewWidget provides a somewhat uniform interface (_WebViewBase) around either WebEngineView (extends QWebEngineView) or WebKitView (extends QWebView), as available. """ import os from os.path import abspath, dirname, join import time import threading import warnings import inspect from collections.abc import Iterable, Mapping, Set, Sequence from itertools import count from numbers import Integral, Real from random import random from urllib.parse import urljoin from urllib.request import pathname2url import numpy as np from AnyQt.QtCore import Qt, QObject, QFile, QTimer, QUrl, QSize, QEventLoop, \ pyqtProperty, pyqtSlot, pyqtSignal from AnyQt.QtGui import QColor from AnyQt.QtWidgets import QSizePolicy, QWidget, QApplication from AnyQt import sip try: from AnyQt.QtWebKitWidgets import QWebView HAVE_WEBKIT = True except ImportError: HAVE_WEBKIT = False try: from AnyQt.QtWebEngineWidgets import QWebEngineView from AnyQt.QtWebEngineCore import QWebEngineScript from AnyQt.QtWebChannel import QWebChannel HAVE_WEBENGINE = True except ImportError: HAVE_WEBENGINE = False _WEBVIEW_HELPERS = join(dirname(__file__), '_webview', 'helpers.js') _WEBENGINE_INIT_WEBCHANNEL = join(dirname(__file__), '_webview', 'init-webengine-webchannel.js') _ORANGE_DEBUG = os.environ.get('ORANGE_DEBUG') def _inherit_docstrings(cls): """Inherit methods' docstrings from first superclass that defines them""" for method in cls.__dict__.values(): if inspect.isfunction(method) and method.__doc__ is None: for parent in cls.__mro__[1:]: __doc__ = getattr(parent, method.__name__, None).__doc__ if __doc__: method.__doc__ = __doc__ break return cls class _QWidgetJavaScriptWrapper(QObject): def __init__(self, parent): super().__init__(parent) self.__parent = parent @pyqtSlot() def load_really_finished(self): self.__parent._load_really_finished() @pyqtSlot() def hideWindow(self): w = self.__parent while isinstance(w, QWidget): if w.windowFlags() & (Qt.Window | Qt.Dialog): return w.hide() w = w.parent() if callable(w.parent) else w.parent if HAVE_WEBENGINE: class WebEngineView(QWebEngineView): """ A QWebEngineView initialized to support communication with JS code. Parameters ---------- parent : QWidget Parent widget object. bridge : QObject The "bridge" object exposed as a global object ``pybridge`` in JavaScript. Any methods desired to be accessible from JS need to be decorated with ``@QtCore.pyqtSlot(<*args>, result=)`` decorator. Note: do not use QWidget instances as a bridge, use a minimal QObject subclass implementing the required interface. """ # Prefix added to objects exposed via WebviewWidget.exposeObject() # This caters to this class' subclass _EXPOSED_OBJ_PREFIX = '__ORANGE_' def __init__(self, parent=None, bridge=None, *, debug=False, **kwargs): debug = debug or _ORANGE_DEBUG if debug: port = os.environ.setdefault('QTWEBENGINE_REMOTE_DEBUGGING', '12088') warnings.warn( 'To debug QWebEngineView, set environment variable ' 'QTWEBENGINE_REMOTE_DEBUGGING={port} and then visit ' 'http://127.0.0.1:{port}/ in a Chromium-based browser. ' 'See https://doc.qt.io/qt-5/qtwebengine-debugging.html ' 'This has also been done for you.'.format(port=port)) super().__init__(parent, sizeHint=QSize(500, 400), sizePolicy=QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding), **kwargs) self.bridge = bridge self.debug = debug with open(_WEBVIEW_HELPERS, encoding="utf-8") as f: self._onloadJS(f.read(), name='webview_helpers', injection_point=QWebEngineScript.DocumentCreation) qtwebchannel_js = QFile("://qtwebchannel/qwebchannel.js") if qtwebchannel_js.open(QFile.ReadOnly): source = bytes(qtwebchannel_js.readAll()).decode("utf-8") with open(_WEBENGINE_INIT_WEBCHANNEL, encoding="utf-8") as f: init_webchannel_src = f.read() self._onloadJS(source + init_webchannel_src % dict(exposeObject_prefix=self._EXPOSED_OBJ_PREFIX), name='webchannel_init', injection_point=QWebEngineScript.DocumentCreation) else: warnings.warn( "://qtwebchannel/qwebchannel.js is not readable.", RuntimeWarning) self._onloadJS(';window.__load_finished = true;', name='load_finished', injection_point=QWebEngineScript.DocumentReady) channel = QWebChannel(self) if bridge is not None: if isinstance(bridge, QWidget): warnings.warn( "Don't expose QWidgets in WebView. Construct minimal " "QObjects instead.", DeprecationWarning, stacklevel=2) channel.registerObject("pybridge", bridge) channel.registerObject('__bridge', _QWidgetJavaScriptWrapper(self)) self.page().setWebChannel(channel) def _onloadJS(self, code, name='', injection_point=QWebEngineScript.DocumentReady): script = QWebEngineScript() script.setName(name or ('script_' + str(random())[2:])) script.setSourceCode(code) script.setInjectionPoint(injection_point) script.setWorldId(script.MainWorld) script.setRunsOnSubFrames(False) self.page().scripts().insert(script) self.loadStarted.connect( lambda: self.page().scripts().insert(script)) def runJavaScript(self, javascript, resultCallback=None): """ Parameters ---------- javascript : str javascript code. resultCallback : Optional[(object) -> None] When the script has been executed the `resultCallback` will be called with the result of the last executed statement. """ if resultCallback is not None: self.page().runJavaScript(javascript, resultCallback) else: self.page().runJavaScript(javascript) if HAVE_WEBKIT: class WebKitView(QWebView): """ Construct a new QWebView widget that has no history and supports loading from local URLs. Parameters ---------- parent: QWidget The parent widget. bridge: QObject The QObject to use as a parent. This object is also exposed as ``window.pybridge`` in JavaScript. html: str The HTML to load the view with. debug: bool Whether to enable inspector tools on right click. **kwargs: Passed to QWebView. """ def __init__(self, parent=None, bridge=None, *, debug=False, **kwargs): super().__init__(parent, sizeHint=QSize(500, 400), sizePolicy=QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding), **kwargs) if isinstance(parent, QWidget) and parent.layout() is not None: parent.layout().addWidget(self) # TODO REMOVE self.bridge = bridge self.frame = None debug = debug or _ORANGE_DEBUG self.debug = debug if isinstance(bridge, QWidget): warnings.warn( "Don't expose QWidgets in WebView. Construct minimal " "QObjects instead.", DeprecationWarning, stacklevel=2) def _onload(_ok): if _ok: self.frame = self.page().mainFrame() self.frame.javaScriptWindowObjectCleared.connect( lambda: self.frame.addToJavaScriptWindowObject('pybridge', bridge)) with open(_WEBVIEW_HELPERS, encoding="utf-8") as f: self.frame.evaluateJavaScript(f.read()) self.loadFinished.connect(_onload) _onload(True) history = self.history() history.setMaximumItemCount(0) settings = self.settings() settings.setMaximumPagesInCache(0) settings.setAttribute(settings.LocalContentCanAccessFileUrls, True) settings.setAttribute(settings.LocalContentCanAccessRemoteUrls, False) if debug: settings.setAttribute(settings.LocalStorageEnabled, True) settings.setAttribute(settings.DeveloperExtrasEnabled, True) settings.setObjectCacheCapacities(int(4e6), int(4e6), int(4e6)) settings.enablePersistentStorage() def runJavaScript(self, javascript, resultCallback=None): result = self.page().mainFrame().evaluateJavaScript(javascript) if resultCallback is not None: # Emulate the QtWebEngine's interface and return the result # in a event queue invoked callback QTimer.singleShot(0, lambda: resultCallback(result)) def _to_primitive_types(d): # pylint: disable=too-many-return-statements if isinstance(d, QWidget): raise ValueError("Don't expose QWidgets in WebView. Construct minimal " "QObjects instead.") if isinstance(d, Integral): return int(d) if isinstance(d, Real): return float(d) if isinstance(d, (bool, np.bool_)): return bool(d) if isinstance(d, (str, QObject)): return d if isinstance(d, np.ndarray): return d.tolist() if isinstance(d, Mapping): return {k: _to_primitive_types(d[k]) for k in d} if isinstance(d, Set): return {k: 1 for k in d} if isinstance(d, (Sequence, Iterable)): return [_to_primitive_types(i) for i in d] if d is None: return None if isinstance(d, QColor): return d.name() raise TypeError( 'object must consist of primitive types ' '(allowed: int, float, str, bool, list, ' 'dict, set, numpy.ndarray, ...). Type is: ' + d.__class__) class _WebViewBase: def _evalJS(self, code): """Evaluate JavaScript code and return the result of the last statement.""" raise NotImplementedError def onloadJS(self, code): """Run JS on document load.""" raise NotImplementedError def html(self): """Return HTML contents of the top frame. Warnings -------- In the case of Qt WebEngine implementation, this function calls: QCoreApplication.processEvents(QEventLoop.ExcludeUserInputEvents) until the page's HTML contents is made available (through IPC). """ raise NotImplementedError def exposeObject(self, name, obj): """Expose the object `obj` as ``window.`` in JavaScript. If the object contains any string values that start and end with literal ``/**/``, those are evaluated as JS expressions the result value replaces the string in the object. The exposure, as defined here, represents a snapshot of object at the time of execution. Any future changes on the original Python object are not visible in its JavaScript counterpart. Parameters ---------- name: str The global name the object is exposed as. obj: object The object to expose. Must contain only primitive types, such as: int, float, str, bool, list, dict, set, numpy.ndarray ... """ raise NotImplementedError def __init__(self): self.__is_init = False self.__js_queue = [] @pyqtSlot() def _load_really_finished(self): """Call this from JS when the document is ready.""" self.__is_init = True def dropEvent(self, event): """Prevent loading of drag-and-drop dropped file""" pass def evalJS(self, code): """ Evaluate JavaScript code synchronously (or sequentially, at least). Parameters ---------- code : str The JavaScript code to evaluate in main page frame. The scope is not assured. Assign properties to window if you want to make them available elsewhere. Warnings -------- In the case of Qt WebEngine implementation, this function calls: QCoreApplication.processEvents(QEventLoop.ExcludeUserInputEvents) until the page is fully loaded, all the objects exposed via exposeObject() method are indeed exposed in JS, and the code `code` has finished evaluating. """ def _later(): if not self.__is_init and self.__js_queue: return QTimer.singleShot(1, _later) if self.__js_queue: # '/n' is required when the last line is a comment code = '\n;'.join(self.__js_queue) self.__js_queue.clear() self._evalJS(code) # WebView returns the result of the last evaluated expression. # This result may be too complex an object to safely receive on this # end, so instead, just make it return 0. code += ';0;' self.__js_queue.append(code) QTimer.singleShot(1, _later) def svg(self): """ Return SVG string of the first SVG element on the page, or raise ValueError if not any. """ html = self.html() return html[html.index('') + 6] def setHtml(self, html, base_url=''): """Set the HTML content of the current webframe to `html` (an UTF-8 string).""" super().setHtml(html, QUrl(base_url)) @staticmethod def toFileURL(local_path): """Return local_path as file:// URL""" return urljoin('file:', pathname2url(abspath(local_path))) def setUrl(self, url): """Point the current frame to URL url.""" super().setUrl(QUrl(url)) def contextMenuEvent(self, event): """ Also disable context menu unless debug.""" if self.debug: super().contextMenuEvent(event) def wait(until: callable, timeout=5000): """Process events until condition is satisfied Parameters ---------- until: callable Returns True when condition is satisfied. timeout: int Milliseconds to wait until TimeoutError is raised. """ started = time.perf_counter() while not until(): QApplication.instance().processEvents(QEventLoop.ExcludeUserInputEvents) if (time.perf_counter() - started) * 1000 > timeout: raise TimeoutError() if HAVE_WEBKIT: class _JSObject(QObject): """ This class hopefully prevent options data from being marshalled into a string-like dumb (JSON) object when passed into JavaScript. Or at least relies on Qt to do it as optimally as it knows to.""" def __init__(self, parent, name, obj): super().__init__(parent) self._obj = dict(obj=obj) @pyqtProperty('QVariantMap') def pop_object(self): return self._obj class WebviewWidget(_WebViewBase, WebKitView): def __init__(self, parent=None, bridge=None, *, debug=False, **kwargs): # WebEngine base WebviewWidget has js_timeout parameter, since # user do not know which one will get and passing js_timeout to # WebKitView causes error we should remove kwargs.pop("js_timeout", None) WebKitView.__init__(self, parent, bridge, debug=debug, **kwargs) _WebViewBase.__init__(self) def load_finished(): if not sip.isdeleted(self): self.frame.addToJavaScriptWindowObject( '__bridge', _QWidgetJavaScriptWrapper(self)) self._evalJS('setTimeout(function(){' '__bridge.load_really_finished(); }, 100);') self.loadFinished.connect(load_finished) @pyqtSlot() def _load_really_finished(self): # _WebViewBase's (super) method not visible in JS for some reason super()._load_really_finished() def _evalJS(self, code): return self.frame.evaluateJavaScript(code) def onloadJS(self, code): self.frame.loadFinished.connect( lambda: WebviewWidget.evalJS(self, code)) def html(self): return self.frame.toHtml() def exposeObject(self, name, obj): obj = _to_primitive_types(obj) jsobj = _JSObject(self, name, obj) self.frame.addToJavaScriptWindowObject('__js_object_' + name, jsobj) WebviewWidget.evalJS(self, ''' window.{0} = window.__js_object_{0}.pop_object.obj; fixupPythonObject({0}); 0; '''.format(name)) elif HAVE_WEBENGINE: class IdStore: """Generates and stores unique ids. Used in WebviewWidget._evalJS below to match scheduled js executions and returned results. WebEngine operations are async, so locking is used to guard against problems that could occur if multiple executions ended at exactly the same time. """ def __init__(self): self.id = 0 self.lock = threading.Lock() self.ids = dict() def create(self): with self.lock: self.id += 1 return self.id def store(self, id, value): with self.lock: self.ids[id] = value def __contains__(self, id): return id in self.ids def pop(self, id): with self.lock: return self.ids.pop(id, None) class _JSObjectChannel(QObject): """ This class hopefully prevent options data from being marshalled into a string-like dumb (JSON) object when passed into JavaScript. Or at least relies on Qt to do it as optimally as it knows to.""" # JS webchannel listens to this signal objectChanged = pyqtSignal('QVariantMap') def __init__(self, parent): super().__init__(parent) self._obj = None self._id_gen = count() self._objects = {} def send_object(self, name, obj): if isinstance(obj, QObject): raise ValueError( "QWebChannel doesn't transmit QObject instances. If you " "need a QObject available in JavaScript, pass it as a " "bridge in WebviewWidget constructor.") id = next(self._id_gen) value = self._objects[id] = dict(id=id, name=name, obj=obj) # Wait till JS is connected to receive objects wait(until=lambda: self.receivers(self.objectChanged), timeout=30000) self.objectChanged.emit(value) @pyqtSlot(int) def mark_exposed(self, id): del self._objects[id] def is_all_exposed(self): return len(self._objects) == 0 _NOTSET = object() class WebviewWidget(_WebViewBase, WebEngineView): _html = _NOTSET def __init__(self, parent=None, bridge=None, *, js_timeout=5000, debug=False, **kwargs): WebEngineView.__init__(self, parent, bridge, debug=debug, **kwargs) _WebViewBase.__init__(self) # Tracks objects exposed in JS via exposeObject(). JS notifies once # the object has indeed been exposed (i.e. the new object is available # is JS) because the whole thing is async. # This is needed to stall any evalJS() calls which may expect # the objects being available (but could otherwise be executed before # the objects are exposed in JS). self._jsobject_channel = jsobj = _JSObjectChannel(self) self.page().webChannel().registerObject( '__js_object_channel', jsobj) self._results = IdStore() self.js_timeout = js_timeout def _evalJS(self, code): wait(until=self._jsobject_channel.is_all_exposed) if sip.isdeleted(self): return None result = self._results.create() self.runJavaScript(code, lambda x: self._results.store(result, x)) wait(until=lambda: result in self._results, timeout=self.js_timeout) return self._results.pop(result) def onloadJS(self, code): self._onloadJS(code, injection_point=QWebEngineScript.Deferred) def html(self): self.page().toHtml(lambda html: setattr(self, '_html', html)) wait(until=lambda: self._html is not _NOTSET or sip.isdeleted(self)) html, self._html = self._html, _NOTSET return html def exposeObject(self, name, obj): obj = _to_primitive_types(obj) self._jsobject_channel.send_object(name, obj) def setHtml(self, html, base_url=''): # TODO: remove once anaconda will provide PyQt without this bug. # # At least on some installations of PyQt 5.6.0 with anaconda # WebViewWidget grabs focus on setHTML which can be quite annoying. # For example, if you have a line edit as filter and show results # in WebWiew, then WebView grabs focus after every typed character. # # http://stackoverflow.com/questions/36609489 # https://bugreports.qt.io/browse/QTBUG-52999 initial_state = self.isEnabled() self.setEnabled(False) super().setHtml(html, base_url) self.setEnabled(initial_state) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1668515756.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/utils/widgetpreview.py���������������������������������������0000644�0000765�0000024�00000011411�14334703654�023573� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������import sys import logging import gc import signal from AnyQt.QtWidgets import QApplication class WidgetPreview: """ A helper class for widget previews. Attributes: widget (OWBaseWidget): an instance of the widget or `None` widget_cls (type): the widget class """ def __init__(self, widget_cls): self.widget_cls = widget_cls self.widget = None logging.basicConfig() # Allow termination with CTRL + C signal.signal(signal.SIGINT, signal.SIG_DFL) def run(self, input_data=None, *, no_exec=False, no_exit=False, **kwargs): """ Run a preview of the widget; It first creates a widget, unless it exists from the previous call. This can only happen if `no_exit` was set to `True`. Next, it passes the data signals to the widget. Data given as positional argument must be of a type for which there exist a single or a default handler. Signals can also be given by keyword arguments, where the name of the argument is the name of the handler method. If the data is a list of tuples, the sequence of tuples is sent to the same handler. Next, the method shows the widget and starts the event loop, unless `no_exec` argument is set to `True`. Finally, unless the argument `no_exit` is set to `True`, the method tears down the widget, deletes the reference to the widget and calls Python's garbage collector, as an effort to catch any crashes due to widget members (typically :obj:`QGraphicsScene` elements) outliving the widget. It then calls :obj:`sys.exit` with the exit code from the application's main loop. If `no_exit` is set to `True`, the `run` keeps the widget alive. In this case, subsequent calls to `run` or other methods (`send_signals`, `exec_widget`) will use the same widget. Args: input_data: data used for the default input signal of matching type no_exec (bool): if set to `True`, the widget is not shown and the event loop is not started no_exit (bool): if set to `True`, the widget is not torn down **kwargs: data for input signals """ if self.widget is None: self.create_widget() self.send_signals(input_data, **kwargs) if not no_exec: exit_code = self.exec_widget() else: exit_code = 0 if not no_exit: self.tear_down() sys.exit(exit_code) def create_widget(self): """ Initialize :obj:`QApplication` and construct the widget. """ global app # pylint: disable=global-variable-undefined app = QApplication(sys.argv) self.widget = self.widget_cls() def send_signals(self, input_data=None, **kwargs): """Send signals to the widget""" def call_handler(handler_name, data): handler = getattr(self.widget, handler_name) for chunk in self._data_chunks(data): handler(*chunk) if input_data is not None: handler_name = self._find_handler_name(input_data) call_handler(handler_name, input_data) for handler_name, data in kwargs.items(): call_handler(handler_name, data) self.widget.handleNewSignals() def _find_handler_name(self, data): chunk = next(self._data_chunks(data))[0] chunk_type = type(chunk).__name__ inputs = [signal for signal in self.widget.get_signals("inputs") if isinstance(chunk, signal.type)] if not inputs: raise ValueError(f"no signal handlers for '{chunk_type}'") if len(inputs) > 1: inputs = [signal for signal in inputs if signal.default] if len(inputs) != 1: raise ValueError( f"multiple signal handlers for '{chunk_type}'") return inputs[0].handler @staticmethod def _data_chunks(data): if isinstance(data, list) \ and data \ and all(isinstance(x, tuple) for x in data): yield from iter(data) elif isinstance(data, tuple): yield data else: yield (data,) def exec_widget(self): """Show the widget and start the :obj:`QApplication`'s main loop.""" self.widget.show() self.widget.raise_() return app.exec() def tear_down(self): """Save settings and delete the widget.""" from AnyQt import sip self.widget.saveSettings() self.widget.onDeleteWidget() sip.delete(self.widget) #: pylint: disable=c-extension-no-member self.widget = None gc.collect() app.processEvents() �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1694782192.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/version.py���������������������������������������������������0000644�0000765�0000024�00000000417�14501051360�021221� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������# THIS FILE IS GENERATED FROM ORANGE-WIDGET-BASE SETUP.PY short_version = '4.22.0' version = '4.22.0' full_version = '4.22.0' git_revision = 'afa06382ea8e6635cad86d3f6ead16bdc8548963' release = True if not release: version = full_version short_version += ".dev" �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1694771092.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/widget.py����������������������������������������������������0000755�0000765�0000024�00000234214�14501023624�021030� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������import pkgutil import sys import os import types import warnings import textwrap from functools import partial from operator import attrgetter from math import log10 from typing import Optional, Union, List, cast from AnyQt.QtWidgets import ( QWidget, QDialog, QVBoxLayout, QSizePolicy, QApplication, QStyle, QSplitter, QSplitterHandle, QPushButton, QStatusBar, QProgressBar, QAction, QFrame, QStyleOption, QHBoxLayout, QMenuBar, QMenu, QWIDGETSIZE_MAX ) from AnyQt.QtCore import ( Qt, QObject, QEvent, QRect, QMargins, QByteArray, QDataStream, QBuffer, QSettings, QUrl, QThread, QTimer, QSize, QPoint, QLine, pyqtSignal as Signal ) from AnyQt.QtGui import ( QIcon, QKeySequence, QDesktopServices, QPainter, QColor, QPen, QKeyEvent, QActionEvent ) from orangecanvas.gui.svgiconengine import StyledSvgIconEngine from orangewidget import settings, gui from orangewidget.report import Report from orangewidget.gui import OWComponent, VerticalScrollArea from orangewidget.io import ClipboardFormat, ImgFormat from orangewidget.settings import SettingsHandler from orangewidget.utils import saveplot, getdeepattr from orangewidget.utils.messagewidget import InOutStateWidget from orangewidget.utils.progressbar import ProgressBarMixin from orangewidget.utils.messages import ( WidgetMessagesMixin, UnboundMsg, MessagesWidget ) from orangewidget.utils.signals import ( WidgetSignalsMixin, Input, Output, MultiInput, InputSignal, OutputSignal, Default, NonDefault, Single, Multiple, Dynamic, Explicit ) # pylint: disable=unused-import from orangewidget.utils.overlay import MessageOverlayWidget, OverlayWidget from orangewidget.utils.buttons import SimpleButton from orangewidget.utils import dropdown_popup_geometry # Msg is imported and renamed, so widgets can import it from this module rather # than the one with the mixin (orangewidget.utils.messages). Msg = UnboundMsg __all__ = [ "OWBaseWidget", "Input", "Output", "MultiInput", "Message", "Msg", "StateInfo", ] def _load_styled_icon(name): return QIcon(StyledSvgIconEngine(pkgutil.get_data(__name__, "icons/" + name))) class Message: """ A user message. :param str text: Message text :param str persistent_id: A persistent message id. :param icon: Message icon :type icon: QIcon or QStyle.StandardPixmap :param str moreurl: An url to open when a user clicks a 'Learn more' button. .. seealso:: :const:`OWBaseWidget.UserAdviceMessages` """ #: QStyle.SP_MessageBox* pixmap enums repeated for easier access Question = QStyle.SP_MessageBoxQuestion Information = QStyle.SP_MessageBoxInformation Warning = QStyle.SP_MessageBoxWarning Critical = QStyle.SP_MessageBoxCritical def __init__(self, text, persistent_id, icon=None, moreurl=None): assert isinstance(text, str) assert isinstance(icon, (type(None), QIcon, QStyle.StandardPixmap)) assert persistent_id is not None self.text = text self.icon = icon self.moreurl = moreurl self.persistent_id = persistent_id def _asmappingproxy(mapping): if isinstance(mapping, types.MappingProxyType): return mapping else: return types.MappingProxyType(mapping) class WidgetMetaClass(type(QDialog)): """Meta class for widgets. If the class definition does not have a specific settings handler, the meta class provides a default one that does not handle contexts. Then it scans for any attributes of class settings.Setting: the setting is stored in the handler and the value of the attribute is replaced with the default.""" #noinspection PyMethodParameters # pylint: disable=bad-classmethod-argument def __new__(mcs, name, bases, namespace, openclass=False, **kwargs): cls = super().__new__(mcs, name, bases, namespace, **kwargs) cls.convert_signals() if isinstance(cls.keywords, str): if "," in cls.keywords: cls.keywords = [kw.strip() for kw in cls.keywords.split(",")] else: cls.keywords = cls.keywords.split() if not cls.name: # not a widget return cls cls.settingsHandler = \ SettingsHandler.create(cls, template=cls.settingsHandler) return cls @classmethod # pylint: disable=bad-classmethod-argument def __prepare__(mcs, name, bases, metaclass=None, openclass=False, **kwargs): namespace = super().__prepare__(mcs, name, bases, metaclass, **kwargs) if not openclass: namespace["_final_class"] = True return namespace # pylint: disable=too-many-instance-attributes class OWBaseWidget(QDialog, OWComponent, Report, ProgressBarMixin, WidgetMessagesMixin, WidgetSignalsMixin, metaclass=WidgetMetaClass, openclass=True): """ Base widget class in an Orange widget workflow. """ #: Widget name, as presented in the Canvas. name: str = None #: Short widget description, displayed in canvas help tooltips. description: str = "" #: Widget icon path, relative to the defining module. icon: str = "icons/Unknown.png" class Inputs: """ Define inputs in this nested class as class variables. (type `orangewidget.widget.Input`) Example:: class Inputs: data = Input("Data", Table) Then, register input handler methods with decorators. Example:: @Inputs.data def set_data(self, data): self.my_data = data """ class Outputs: """ Define outputs in this nested class as class variables. (type `orangewidget.widget.Output`) Example:: class Outputs: data = Output("Data", Table) Then, send results to the output with its `send` method. Example:: def commit(self): self.Outputs.data.send(self.my_data) """ # ------------------------------------------------------------------------- # Widget GUI Layout Settings # ------------------------------------------------------------------------- #: Should the widget have basic layout? #: (if not, the rest of the GUI layout settings are ignored) want_basic_layout = True #: Should the widget construct a `controlArea`? want_control_area = True #: Should the widget construct a `mainArea`? #: (a resizable area to the right of the `controlArea`) want_main_area = True #: Should the widget construct a `message_bar`? #: (if not, make sure you show warnings/errors in some other way) want_message_bar = True #: Should the widget's window be resizeable? #: (if not, the widget will derive a fixed size constraint from its layout) resizing_enabled = True #: Should the widget remember its window position/size? save_position = True #: Orientation of the buttonsArea box; valid only if #: `want_control_area` is `True`. Possible values are Qt.Horizontal, #: Qt.Vertical and None for no buttons area buttons_area_orientation = Qt.Horizontal #: A list of advice messages to display to the user. #: (when a widget is first shown a message from this list is selected #: for display. If a user accepts (clicks 'Ok. Got it') the choice is #: recorded and the message is never shown again (closing the message #: will not mark it as seen). Messages can be displayed again by pressing #: Shift + F1) UserAdviceMessages: List[Message] = [] # ------------------------------------------------------------------------- # Miscellaneous Options # ------------------------------------------------------------------------- #: Version of the settings representation #: (subclasses should increase this number when they make breaking #: changes to settings representation (a settings that used to store #: int now stores string) and handle migrations in migrate and #: migrate_context settings) settings_version: int = 1 #: Signal emitted before settings are packed and saved. #: (gives you a chance to sync state to Setting values) settingsAboutToBePacked = Signal() #: Settings handler, can be overridden for context handling. settingsHandler: SettingsHandler = None #: Widget keywords, used for finding it in the quick menu. keywords: Union[str, List[str]] = [] #: Widget priority, used for sorting within a category. priority: int = sys.maxsize #: Short name for widget, displayed in toolbox. #: (set this if the widget's conventional name is long) short_name: str = None #: A list of widget IDs that this widget replaces in older workflows. replaces: List[str] = None #: Widget painted by `Save graph` button graph_name: str = None graph_writers: List[ImgFormat] = [f for f in ImgFormat.formats if getattr(f, 'write_image', None) and getattr(f, "EXTENSIONS", None)] #: Explicitly set widget category, #: should it not already be part of a package. category: str = None #: Ratio between width and height for mainArea widgets, #: set to None for super().sizeHint() mainArea_width_height_ratio: Optional[float] = 1.1 # ------------------------------------------------------------------------- # Private Interface # ------------------------------------------------------------------------- # Custom widget id, kept for backward compatibility id = None # A list of published input definitions. # (conventionally generated from Inputs nested class) inputs = [] # A list of published output definitions. # (conventionally generated from Outputs nested class) outputs = [] contextAboutToBeOpened = Signal(object) contextOpened = Signal() contextClosed = Signal() openVisualSettingsClicked = Signal() # Signals have to be class attributes and cannot be inherited, # say from a mixin. This has something to do with the way PyQt binds them progressBarValueChanged = Signal(float) messageActivated = Signal(Msg) messageDeactivated = Signal(Msg) savedWidgetGeometry = settings.Setting(None) controlAreaVisible = settings.Setting(True, schema_only=True) __report_action = None # type: Optional[QAction] __save_image_action = None # type: Optional[QAction] __reset_action = None # type: Optional[QAction] __visual_settings_action = None # type: Optional[QAction] __menuBar = None # type: QMenuBar # pylint: disable=protected-access, access-member-before-definition def __new__(cls, *args, captionTitle=None, **kwargs): self = super().__new__(cls, None, cls.get_flags()) QDialog.__init__(self, None, self.get_flags()) OWComponent.__init__(self) WidgetMessagesMixin.__init__(self) WidgetSignalsMixin.__init__(self) # handle deprecated left_side_scrolling if hasattr(self, 'left_side_scrolling'): warnings.warn( "'OWBaseWidget.left_side_scrolling' is deprecated.", DeprecationWarning ) stored_settings = kwargs.get('stored_settings', None) if self.settingsHandler: self.settingsHandler.initialize(self, stored_settings) self.signalManager = kwargs.get('signal_manager', None) self.__env = _asmappingproxy(kwargs.get("env", {})) self.graphButton = None self.report_button = None captionTitle = self.name if captionTitle is None else captionTitle # must be set without invoking setCaption self.captionTitle = captionTitle self.setWindowTitle(captionTitle) self.setFocusPolicy(Qt.StrongFocus) # flag indicating if the widget's position was already restored self.__was_restored = False # flag indicating the widget is still expecting the first show event. self.__was_shown = False self.__statusMessage = "" self.__info_ns = None # type: Optional[StateInfo] self.__msgwidget = None # type: Optional[MessageOverlayWidget] self.__msgchoice = 0 self.__statusbar = None # type: Optional[QStatusBar] self.__statusbar_action = None # type: Optional[QAction] self.__menubar_action = None self.__menubar_visible_timer = None # this action is enabled by the canvas framework self.__help_action = QAction( "Help", self, objectName="action-help", toolTip="Show help", enabled=False, shortcut=QKeySequence(Qt.Key_F1) ) self.__report_action = QAction( "Report", self, objectName="action-report", toolTip="Create and display a report", enabled=False, visible=False, shortcut=QKeySequence("alt+r") ) if hasattr(self, "send_report"): self.__report_action.triggered.connect(self.show_report) self.__report_action.setEnabled(True) self.__report_action.setVisible(True) self.__save_image_action = QAction( "Save Image", self, objectName="action-save-image", toolTip="Save image", shortcut=QKeySequence("alt+s"), ) self.__save_image_action.triggered.connect(self.save_graph) self.__save_image_action.setEnabled(bool(self.graph_name)) self.__save_image_action.setVisible(bool(self.graph_name)) self.__reset_action = QAction( "Reset", self, objectName="action-reset-settings", toolTip="Reset settings to default state", enabled=False, visible=False, ) if hasattr(self, "reset_settings"): self.__reset_action.triggered.connect(self.reset_settings) self.__reset_action.setEnabled(True) self.__reset_action.setVisible(True) self.__visual_settings_action = QAction( "Show View Options", self, objectName="action-visual-settings", toolTip="Show View Options", enabled=False, visible=False, ) self.__visual_settings_action.triggered.connect( self.openVisualSettingsClicked) if hasattr(self, "set_visual_settings"): self.__visual_settings_action.setEnabled(True) self.__visual_settings_action.setVisible(True) self.addAction(self.__help_action) self.__copy_action = QAction( "Copy to Clipboard", self, objectName="action-copy-to-clipboard", shortcut=QKeySequence.Copy, enabled=False, visible=False ) self.__copy_action.triggered.connect(self.copy_to_clipboard) if type(self).copy_to_clipboard != OWBaseWidget.copy_to_clipboard \ or self.graph_name is not None: self.__copy_action.setEnabled(True) self.__copy_action.setVisible(True) self.__copy_action.setText("Copy Image to Clipboard") # macOS Minimize action self.__minimize_action = QAction( "Minimize", self, shortcut=QKeySequence("ctrl+m") ) self.__minimize_action.triggered.connect(self.showMinimized) # macOS Close window action self.__close_action = QAction( "Close", self, objectName="action-close-window", shortcut=QKeySequence("ctrl+w") ) self.__close_action.triggered.connect(self.hide) settings = QSettings() settings.beginGroup(__name__ + "/menubar") self.__menubar = mb = QMenuBar(self) # do we have a native menubar nativemb = mb.isNativeMenuBar() if nativemb: # force non native mb via. settings override nativemb = settings.value( "use-native", defaultValue=nativemb, type=bool ) mb.setNativeMenuBar(nativemb) if not nativemb: # without native menu bar configure visibility mbvisible = settings.value( "visible", defaultValue=False, type=bool ) mb.setVisible(mbvisible) self.__menubar_action = QAction( "Show Menu Bar", self, objectName="action-show-menu-bar", checkable=True, shortcut=QKeySequence("ctrl+shift+m") ) self.__menubar_action.setChecked(mbvisible) self.__menubar_action.triggered[bool].connect( self.__setMenuBarVisible ) self.__menubar_visible_timer = QTimer( self, objectName="menu-bar-visible-timer", singleShot=True, interval=settings.value( "alt-key-timeout", defaultValue=50, type=int, ) ) self.__menubar_visible_timer.timeout.connect( self.__menuBarVisibleTimeout ) self.addAction(self.__menubar_action) fileaction = mb.addMenu(_Menu("&File", mb, objectName="menu-file")) fileaction.setVisible(False) fileaction.menu().addSeparator() fileaction.menu().addAction(self.__report_action) fileaction.menu().addAction(self.__save_image_action) fileaction.menu().addAction(self.__reset_action) editaction = mb.addMenu(_Menu("&Edit", mb, objectName="menu-edit")) editaction.setVisible(False) editaction.menu().addAction(self.__copy_action) if sys.platform == "darwin" and mb.isNativeMenuBar(): # QTBUG-17291 editaction.menu().addAction( QAction( "Cut", self, enabled=False, shortcut=QKeySequence(QKeySequence.Cut), )) editaction.menu().addAction( QAction( "Copy", self, enabled=False, shortcut=QKeySequence(QKeySequence.Copy), )) editaction.menu().addAction( QAction( "Paste", self, enabled=False, shortcut=QKeySequence(QKeySequence.Paste), )) editaction.menu().addAction( QAction( "Select All", self, enabled=False, shortcut=QKeySequence(QKeySequence.SelectAll), )) viewaction = mb.addMenu(_Menu("&View", mb, objectName="menu-view")) viewaction.setVisible(False) viewaction.menu().addAction(self.__visual_settings_action) windowaction = mb.addMenu(_Menu("&Window", mb, objectName="menu-window")) windowaction.setVisible(False) if sys.platform == "darwin": windowaction.menu().addAction(self.__close_action) windowaction.menu().addAction(self.__minimize_action) windowaction.menu().addSeparator() helpaction = mb.addMenu(_Menu("&Help", mb, objectName="help-menu")) helpaction.menu().addAction(self.__help_action) self.left_side = None self.controlArea = self.mainArea = self.buttonsArea = None self.__splitter = None if self.want_basic_layout: self.set_basic_layout() self.update_summaries() self.layout().setMenuBar(mb) self.__quick_help_action = QAction( "Quick Help Tip", self, objectName="action-quick-help-tip", shortcut=QKeySequence("shift+f1") ) self.__quick_help_action.setEnabled(bool(self.UserAdviceMessages)) self.__quick_help_action.setVisible(bool(self.UserAdviceMessages)) self.__quick_help_action.triggered.connect(self.__quicktip) helpaction.menu().addAction(self.__quick_help_action) if self.__splitter is not None and self.__splitter.count() > 1: action = QAction( "Show Control Area", self, objectName="action-show-control-area", shortcut=QKeySequence("Ctrl+Shift+D"), checkable=True, autoRepeat=False, ) action.setChecked(True) action.triggered[bool].connect(self.__setControlAreaVisible) self.__splitter.handleClicked.connect(self.__toggleControlArea) viewaction.menu().addAction(action) if self.__menubar_action is not None: viewaction.menu().addAction(self.__menubar_action) if self.controlArea is not None: # Otherwise, the first control has focus self.controlArea.setFocus(Qt.OtherFocusReason) return self def menuBar(self) -> QMenuBar: return self.__menubar def __menuBarVisibleTimeout(self): mb = self.__menubar if mb is not None and mb.isHidden() \ and QApplication.mouseButtons() == Qt.NoButton: mb.setVisible(True) mb.setProperty("__visible_from_alt_key_press", True) def __setMenuBarVisible(self, visible): mb = self.__menubar if mb is not None: mb.setVisible(visible) mb.setProperty("__visible_from_alt_key_press", False) settings = QSettings() settings.beginGroup(__name__ + "/menubar") settings.setValue("visible", visible) # pylint: disable=super-init-not-called def __init__(self, *args, **kwargs): """__init__s are called in __new__; don't call them from here""" def __init_subclass__(cls, **_): for base in cls.__bases__: if hasattr(base, "_final_class"): warnings.warn( "subclassing of widget classes is deprecated and will be " "disabled in the future.\n" f"Extract code from {base.__name__} or explicitly open it " "by adding `openclass=True` to class definition.", RuntimeWarning, stacklevel=3) # raise TypeError(f"class {base.__name__} cannot be subclassed") @classmethod def get_widget_description(cls): if not cls.name: return None properties = {name: getattr(cls, name) for name in ("name", "icon", "description", "priority", "keywords", "replaces", "short_name", "category")} properties["id"] = cls.id or cls.__module__ properties["inputs"] = cls.get_signals("inputs") properties["outputs"] = cls.get_signals("outputs") properties["qualified_name"] = f"{cls.__module__}.{cls.__name__}" return properties @classmethod def get_flags(cls): return Qt.Window if cls.resizing_enabled else Qt.Dialog class _Splitter(QSplitter): handleClicked = Signal() def __init__(self, *args, **kwargs): super().__init__(*args, *kwargs) self.setHandleWidth(18) def _adjusted_size(self, size_method): parent = self.parentWidget() if isinstance(parent, OWBaseWidget) \ and not parent.controlAreaVisible \ and self.count() > 1: indices = range(1, self.count()) else: indices = range(0, self.count()) shs = [size_method(self.widget(i))() for i in indices] height = max((sh.height() for sh in shs), default=0) width = sum(sh.width() for sh in shs) width += max(0, self.handleWidth() * (self.count() - 1)) return QSize(width, height) def sizeHint(self): return self._adjusted_size(attrgetter("sizeHint")) def minimumSizeHint(self): return self._adjusted_size(attrgetter("minimumSizeHint")) def setSizes(self, sizes): super().setSizes(sizes) if len(sizes) == 2: self.handle(1).setControlAreaShown(bool(sizes[0])) def createHandle(self): """Create splitter handle""" return self._Handle( self.orientation(), self, cursor=Qt.PointingHandCursor) class _Handle(QSplitterHandle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.controlAreaShown = True def setControlAreaShown(self, shown): self.controlAreaShown = shown self.update() def paintEvent(self, event): super(QSplitterHandle, self).paintEvent(event) painter = QPainter(self) pen = QPen() pen.setColor(QColor(160, 160, 160)) pen.setCapStyle(Qt.RoundCap) pen.setWidth(3) painter.setPen(pen) w = self.width() - 6 if self.controlAreaShown: x0, x1 = 6, w else: x0, x1 = w, 6 y = self.height() // 2 h = int((w - 6) / 1.12) painter.setRenderHint(painter.Antialiasing) painter.drawLines( QLine(x0, y - h, x1, y), QLine(x1, y, x0, y + h) ) def mouseReleaseEvent(self, event): """Resize on left button""" if event.button() == Qt.LeftButton: self.splitter().handleClicked.emit() super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): """Prevent moving; just show/hide""" return def _insert_splitter(self): self.__splitter = self._Splitter(Qt.Horizontal, self) self.layout().addWidget(self.__splitter) def _insert_control_area(self): self.left_side = gui.vBox(self.__splitter, spacing=0) if self.want_main_area: self.left_side.setSizePolicy( QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) scroll_area = VerticalScrollArea(self.left_side) scroll_area.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) self.controlArea = gui.vBox(scroll_area, spacing=6, sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred)) scroll_area.setWidget(self.controlArea) self.left_side.layout().addWidget(scroll_area) m = 4, 4, 0, 4 else: self.controlArea = gui.vBox(self.left_side, spacing=6) m = 4, 4, 4, 4 if self.buttons_area_orientation is not None: self._insert_buttons_area() self.buttonsArea.layout().setContentsMargins( m[0] + 8, m[1], m[2] + 8, m[3] ) # margins are nice on macOS with this m = m[0], m[1], m[2], m[3] - 2 self.controlArea.layout().setContentsMargins(*m) def _insert_buttons_area(self): if not self.want_main_area: gui.separator(self.left_side) self.buttonsArea = gui.widgetBox( self.left_side, spacing=6, orientation=self.buttons_area_orientation, sizePolicy=(QSizePolicy.MinimumExpanding, QSizePolicy.Maximum) ) def _insert_main_area(self): self.mainArea = gui.vBox( self.__splitter, spacing=6, sizePolicy=QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) ) self.__splitter.addWidget(self.mainArea) self.__splitter.setCollapsible( self.__splitter.indexOf(self.mainArea), False) if self.want_control_area: self.mainArea.layout().setContentsMargins( 0, 4, 4, 4) self.__splitter.setSizes([1, QWIDGETSIZE_MAX]) else: self.mainArea.layout().setContentsMargins( 4, 4, 4, 4) def _create_default_buttons(self): # These buttons are inserted in buttons_area, if it exists # Otherwise it is up to the widget to add them to some layout if self.graph_name is not None: self.graphButton = QPushButton("&Save Image", autoDefault=False) self.graphButton.clicked.connect(self.save_graph) if hasattr(self, "send_report"): self.report_button = QPushButton("&Report", autoDefault=False) self.report_button.clicked.connect(self.show_report) def set_basic_layout(self): """Provide the basic widget layout Which parts are created is regulated by class attributes `want_main_area`, `want_control_area`, `want_message_bar` and `buttons_area_orientation`, the presence of method `send_report` and attribute `graph_name`. """ self.setLayout(QVBoxLayout()) self.layout().setContentsMargins(2, 2, 2, 2) if not self.resizing_enabled: self.layout().setSizeConstraint(QVBoxLayout.SetFixedSize) self._create_default_buttons() self._insert_splitter() if self.want_control_area: self._insert_control_area() if self.want_main_area: self._insert_main_area() if self.want_message_bar: # statusBar() handles 'want_message_bar', 'send_report' # 'graph_name' ... _ = self.statusBar() __progressBar = None # type: Optional[QProgressBar] __statusbar = None # type: Optional[QStatusBar] __statusbar_action = None # type: Optional[QAction] def statusBar(self): # type: () -> QStatusBar """ Return the widget's status bar. The status bar can be hidden/shown (`self.statusBar().setVisible()`). Note ---- The status bar takes control of the widget's bottom margin (`contentsMargins`) to layout itself in the OWBaseWidget. """ statusbar = self.__statusbar if statusbar is None: # Use a OverlayWidget for status bar positioning. c = OverlayWidget(self, alignment=Qt.AlignBottom) c.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) c.setWidget(self) c.setLayout(QVBoxLayout()) c.layout().setContentsMargins(0, 0, 0, 0) self.__statusbar = statusbar = _StatusBar( c, objectName="owwidget-status-bar" ) statusbar.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Maximum) if self.resizing_enabled: statusbar.setSizeGripEnabled(True) else: statusbar.setSizeGripEnabled(False) statusbar.setContentsMargins(0, 0, 7, 0) statusbar.ensurePolished() c.layout().addWidget(statusbar) # Reserve the bottom margins for the status bar margins = self.contentsMargins() margins.setBottom(statusbar.sizeHint().height()) self.setContentsMargins(margins) statusbar.change.connect(self.__updateStatusBarOnChange) # Toggle status bar visibility. This action is not visible and # enabled by default. Client classes can inspect self.actions # and enable it if necessary. self.__statusbar_action = statusbar_action = QAction( "Show Status Bar", self, objectName="action-show-status-bar", toolTip="Show status bar", checkable=True, enabled=False, visible=False, shortcut=QKeySequence("Shift+Ctrl+\\") ) if self.want_message_bar: self.message_bar = MessagesWidget( defaultStyleSheet=( "div.field-text { white-space: pre; }\n" "div.field-detailed-text {\n" " margin-top: 0.5em; margin-bottom: 0.5em; \n" " margin-left: 1em; margin-right: 1em;\n" "}" ), elideText=True, sizePolicy=QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred), visible=False ) self.__progressBar = pb = QProgressBar( maximumWidth=120, minimum=0, maximum=100 ) pb.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Ignored) pb.setAttribute(Qt.WA_LayoutUsesWidgetRect) pb.setAttribute(Qt.WA_MacMiniSize) pb.hide() self.processingStateChanged.connect(self.__processingStateChanged) self.blockingStateChanged.connect(self.__processingStateChanged) self.progressBarValueChanged.connect(lambda v: pb.setValue(int(v))) statusbar.addPermanentWidget(pb) if self.message_bar is not None: statusbar.addPermanentWidget(self.message_bar) statusbar_action.toggled[bool].connect(statusbar.setVisible) self.addAction(statusbar_action) # reserve buttons and in_out_msg areas def hlayout(spacing, left=0, right=0, ): lay = QHBoxLayout(spacing=spacing) lay.setContentsMargins(left, 0, right, 0) return lay buttons = QWidget(statusbar, objectName="buttons", visible=False) buttons.setLayout(hlayout(5, 7)) buttonsLayout = buttons.layout() simple_button = _StatusBar.simple_button icon = _load_styled_icon if self.__menubar is not None \ and not self.__menubar.isNativeMenuBar(): # damn millennials b = _StatusBarButton( icon=icon("hamburger.svg"), toolTip="Menu", objectName="status-bar-menu-button" ) buttonsLayout.addWidget(b) b.clicked.connect(self.__showStatusBarMenu) if self.__help_action is not None: b = simple_button(buttons, self.__help_action, icon("help.svg")) buttonsLayout.addWidget(b) if self.__save_image_action is not None: b = simple_button(buttons, self.__save_image_action, icon("chart.svg")) buttonsLayout.addWidget(b) if self.__report_action is not None: b = simple_button(buttons, self.__report_action, icon("report.svg")) buttonsLayout.addWidget(b) if self.__reset_action is not None: b = simple_button(buttons, self.__reset_action, icon("reset.svg")) buttonsLayout.addWidget(b) if self.__visual_settings_action is not None: b = simple_button(buttons, self.__visual_settings_action, icon("visual-settings.svg")) buttonsLayout.addWidget(b) if buttonsLayout.count(): buttons.setVisible(True) in_out_msg = QWidget(objectName="in-out-msg", visible=False) in_out_msg.setLayout(hlayout(5, left=5)) statusbar.addWidget(buttons) statusbar.addWidget(in_out_msg) # Ensure the status bar and the message widget are visible on # warning and errors. self.messageActivated.connect(self.__ensureStatusBarVisible) if self.__menubar is not None: viewm = self.findChild(QMenu, "menu-view") if viewm is not None: viewm.addAction(statusbar_action) return statusbar def __ensureStatusBarVisible(self, msg: Msg) -> None: statusbar = self.__statusbar if statusbar is not None and msg.group.severity >= 1: statusbar.setVisible(True) def __updateStatusBarOnChange(self): statusbar = self.__statusbar visible = statusbar.isVisibleTo(self) if visible: height = statusbar.height() else: height = 0 margins = self.contentsMargins() margins.setBottom(height) self.setContentsMargins(margins) self.__statusbar_action.setChecked(visible) def __processingStateChanged(self): # Update the progress bar in the widget's status bar pb = self.__progressBar if pb is None: return pb.setVisible(bool(self.processingState) or self.isBlocking()) if self.isBlocking() and not self.processingState: pb.setRange(0, 0) # indeterminate pb elif self.processingState: pb.setRange(0, 100) # determinate pb __info_ns = None # type: Optional[StateInfo] def __info(self): # Create and return the StateInfo object if self.__info_ns is None: self.__info_ns = info = StateInfo(self) # default css for IO summary. css = textwrap.dedent(""" /* vertical row header cell */ tr > th.field-name { text-align: right; padding-right: 0.2em; font-weight: bold; } dt { font-weight: bold; } """) sb = self.statusBar() if sb is not None: in_out_msg = sb.findChild(QWidget, "in-out-msg") assert in_out_msg is not None in_out_msg.setVisible(True) in_msg = InOutStateWidget( objectName="input-summary", visible=False, defaultStyleSheet=css, sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) ) out_msg = InOutStateWidget( objectName="output-summary", visible=False, defaultStyleSheet=css, sizePolicy=QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) ) in_msg.clicked.connect(partial(self.show_preview, self.input_summaries)) out_msg.clicked.connect(partial(self.show_preview, self.output_summaries)) # Insert a separator if these are not the first elements buttons = sb.findChild(QWidget, "buttons") assert buttons is not None if buttons.layout().count() != 0: sep = QFrame(frameShape=QFrame.VLine) sep.setContentsMargins(0, 0, 2, 0) in_out_msg.layout().addWidget(sep) in_out_msg.layout().addWidget(in_msg) in_out_msg.layout().addWidget(out_msg) in_out_msg.setVisible(True) def set_message(msgwidget, m): # type: (MessagesWidget, StateInfo.Summary) -> None message = MessagesWidget.Message( icon=m.icon, text=m.brief, informativeText=m.details, textFormat=m.format ) msgwidget.setMessage(message) msgwidget.setVisible(not message.isEmpty()) info.input_summary_changed.connect( lambda m: set_message(in_msg, m) ) info.output_summary_changed.connect( lambda m: set_message(out_msg, m) ) else: info = self.__info_ns return info @property def info(self): # type: () -> StateInfo """ A namespace for reporting I/O, state ... related messages. .. versionadded:: 3.19 Returns ------- namespace : StateInfo """ # back-compatibility; subclasses were free to assign self.info = # to any value. Preserve this. try: return self.__dict__["info"] except KeyError: pass return self.__info() @info.setter def info(self, val): warnings.warn( "'OWBaseWidget.info' is a property since 3.19 and will be made read " "only in v4.0.", DeprecationWarning, stacklevel=3 ) self.__dict__["info"] = val def __toggleControlArea(self): if self.__splitter is None or self.__splitter.count() < 2: return self.__setControlAreaVisible(not self.__splitter.sizes()[0]) def __setControlAreaVisible(self, visible): # type: (bool) -> None if self.__splitter is None or self.__splitter.count() < 2: return self.controlAreaVisible = visible action = self.findChild(QAction, "action-show-control-area") if action is not None: action.setChecked(visible) splitter = self.__splitter # type: QSplitter w = splitter.widget(0) # Set minimum width to 1 (overrides minimumSizeHint) when control area # is not visible to allow the main area to shrink further. Reset the # minimum width with a 0 if control area is visible. w.setMinimumWidth(int(not visible)) sizes = splitter.sizes() current_size = sizes[0] if bool(current_size) == visible: return current_width = w.width() geom = self.geometry() frame = self.frameGeometry() framemargins = QMargins( frame.left() - geom.left(), frame.top() - geom.top(), frame.right() - geom.right(), frame.bottom() - geom.bottom() ) splitter.setSizes([int(visible), QWIDGETSIZE_MAX]) if not self.isWindow() or \ self.windowState() not in [Qt.WindowNoState, Qt.WindowActive]: # not a window or not in state where we can move move/resize return # force immediate resize recalculation splitter.refresh() self.layout().invalidate() self.layout().activate() if visible: # move left and expand by the exposing widget's width diffx = -w.width() diffw = w.width() else: # move right and shrink by the collapsing width diffx = current_width diffw = -current_width newgeom = QRect( geom.x() + diffx, geom.y(), geom.width() + diffw, geom.height() ) # bound/move by available geometry bounds = self.screen().availableGeometry() bounds = bounds.adjusted( framemargins.left(), framemargins.top(), -framemargins.right(), -framemargins.bottom() ) newsize = newgeom.size().boundedTo(bounds.size()) newgeom = QRect(newgeom.topLeft(), newsize) newgeom.moveLeft(max(newgeom.left(), bounds.left())) newgeom.moveRight(min(newgeom.right(), bounds.right())) self.setGeometry(newgeom) def save_graph(self): """Save the graph with the name given in class attribute `graph_name`. The method is called by the *Save graph* button, which is created automatically if the `graph_name` is defined. """ graph_obj = getdeepattr(self, self.graph_name, None) if graph_obj is None: return saveplot.save_plot(graph_obj, self.graph_writers) def copy_to_clipboard(self): if self.graph_name: graph_obj = getdeepattr(self, self.graph_name, None) if graph_obj is None: return ClipboardFormat.write_image(None, graph_obj) def __restoreWidgetGeometry(self, geometry): # type: (bytes) -> bool def _fullscreen_to_maximized(geometry): """Don't restore windows into full screen mode because it loses decorations and can't be de-fullscreened at least on some platforms. Use Maximized state insted.""" w = QWidget(visible=False) w.restoreGeometry(QByteArray(geometry)) if w.isFullScreen(): w.setWindowState( w.windowState() & ~Qt.WindowFullScreen | Qt.WindowMaximized) return w.saveGeometry() restored = False if geometry: geometry = _fullscreen_to_maximized(geometry) restored = self.restoreGeometry(geometry) if restored and not self.windowState() & \ (Qt.WindowMaximized | Qt.WindowFullScreen): space = self.screen().availableGeometry() frame, geometry = self.frameGeometry(), self.geometry() # Fix the widget size to fit inside the available space width = space.width() - (frame.width() - geometry.width()) width = min(width, geometry.width()) height = space.height() - (frame.height() - geometry.height()) height = min(height, geometry.height()) self.resize(width, height) # Move the widget to the center of available space if it is # currently outside it if not space.contains(self.frameGeometry()): x = max(0, space.width() // 2 - width // 2) y = max(0, space.height() // 2 - height // 2) self.move(x, y) return restored def __updateSavedGeometry(self): if self.__was_restored and self.isVisible(): # Update the saved geometry only between explicit show/hide # events (i.e. changes initiated by the user not by Qt's default # window management). # Note: This should always be stored as bytes and not QByteArray. self.savedWidgetGeometry = bytes(self.saveGeometry()) def sizeHint(self): if not self.want_basic_layout \ or self.mainArea_width_height_ratio is None: return super().sizeHint() # Super sizeHint with scroll_area isn't calculated right (slightly too small on macOS) # on some platforms. This way, width/height should be optimal for most widgets. sh = QSize() # boxes look nice on macOS with starting width/height 4 width = 4 height = 4 if self.want_message_bar: msh = self.statusBar().sizeHint() height += msh.height() if self.want_control_area: csh = self.controlArea.sizeHint() width += csh.width() height += csh.height() if self.buttons_area_orientation: bsh = self.buttonsArea.sizeHint() height += bsh.height() height = max(height, 500) if self.want_main_area: width += self.__splitter.handleWidth() if self.want_control_area: width += int(height * self.mainArea_width_height_ratio) else: return super().sizeHint() return QSize(width, height) # when widget is resized, save the new width and height def resizeEvent(self, event): """Overloaded to save the geometry (width and height) when the widget is resized. """ QDialog.resizeEvent(self, event) # Don't store geometry if the widget is not visible # (the widget receives a resizeEvent (with the default sizeHint) # before first showEvent and we must not overwrite the the # savedGeometry with it) if self.save_position and self.isVisible(): self.__updateSavedGeometry() def moveEvent(self, event): """Overloaded to save the geometry when the widget is moved """ QDialog.moveEvent(self, event) if self.save_position and self.isVisible(): self.__updateSavedGeometry() def hideEvent(self, event): """Overloaded to save the geometry when the widget is hidden """ if self.save_position: self.__updateSavedGeometry() QDialog.hideEvent(self, event) def closeEvent(self, event): """Overloaded to save the geometry when the widget is closed """ if self.save_position and self.isVisible(): self.__updateSavedGeometry() QDialog.closeEvent(self, event) def mousePressEvent(self, event): """ Flash message bar icon on mouse press """ if self.message_bar is not None: self.message_bar.flashIcon() event.ignore() def setVisible(self, visible): # type: (bool) -> None """Reimplemented from `QDialog.setVisible`.""" if visible: # Force cached size hint invalidation ... The size hints are # sometimes not properly invalidated via the splitter's layout and # nested left_part -> controlArea layouts. This causes bad initial # size when the widget is first shown. if self.controlArea is not None: self.controlArea.updateGeometry() if self.buttonsArea is not None: self.buttonsArea.updateGeometry() if self.mainArea is not None: self.mainArea.updateGeometry() super().setVisible(visible) def showEvent(self, event): """Overloaded to restore the geometry when the widget is shown """ QDialog.showEvent(self, event) if not self.__was_restored: # Restore saved geometry/layout on (first) show if self.__splitter is not None: self.__setControlAreaVisible(self.controlAreaVisible) if self.save_position and self.savedWidgetGeometry is not None: self.__restoreWidgetGeometry(bytes(self.savedWidgetGeometry)) self.__was_restored = True if not self.__was_shown: # Mark as explicitly moved/resized if not already. QDialog would # otherwise adjust position/size on subsequent hide/show # (move/resize events coming from the window manager do not set # these flags). self.setAttribute(Qt.WA_Moved, True) self.setAttribute(Qt.WA_Resized, True) self.__was_shown = True self.__quicktipOnce() def __showStatusBarMenu(self): # type: () -> None sb = self.__statusbar mb = self.__menubar if sb is None or mb is None: return b = sb.findChild(SimpleButton, "status-bar-menu-button") if b is None: return actions = [] for action in mb.actions(): if action.isVisible() and action.isEnabled() and action.menu(): actions.append(action) if not actions: return menu = QMenu(self) menu.setAttribute(Qt.WA_DeleteOnClose) menu.addActions(actions) popup_rect = QRect( b.mapToGlobal(QPoint(0, 0)), b.size() ) menu.ensurePolished() screen_rect = b.screen().availableGeometry() menu_rect = dropdown_popup_geometry( menu.sizeHint(), popup_rect, screen_rect, preferred_direction="up" ) menu.popup(menu_rect.topLeft()) def setCaption(self, caption): # save caption title in case progressbar will change it self.captionTitle = str(caption) self.setWindowTitle(caption) def reshow(self): """Put the widget on top of all windows """ self.show() self.raise_() self.activateWindow() def openContext(self, *a): """Open a new context corresponding to the given data. The settings handler first checks the stored context for a suitable match. If one is found, it becomes the current contexts and the widgets settings are initialized accordingly. If no suitable context exists, a new context is created and data is copied from the widget's settings into the new context. Widgets that have context settings must call this method after reinitializing the user interface (e.g. combo boxes) with the new data. The arguments given to this method are passed to the context handler. Their type depends upon the handler. For instance, `DomainContextHandler` expects `Orange.data.Table` or `Orange.data.Domain`. """ self.contextAboutToBeOpened.emit(a) self.settingsHandler.open_context(self, *a) self.contextOpened.emit() def closeContext(self): """Save the current settings and close the current context. Widgets that have context settings must call this method before reinitializing the user interface (e.g. combo boxes) with the new data. """ self.settingsHandler.close_context(self) self.contextClosed.emit() def retrieveSpecificSettings(self): """ Retrieve data that is not registered as setting. This method is called by `orangewidget.settings.ContextHandler.settings_to_widget`. Widgets may define it to retrieve any data that is not stored in widget attributes. See :obj:`Orange.widgets.data.owcolor.OWColor` for an example. """ def storeSpecificSettings(self): """ Store data that is not registered as setting. This method is called by `orangewidget.settings.ContextHandler.settings_from_widget`. Widgets may define it to store any data that is not stored in widget attributes. See :obj:`Orange.widgets.data.owcolor.OWColor` for an example. """ def saveSettings(self): """ Writes widget instance's settings to class defaults. Usually called when the widget is deleted. """ self.settingsHandler.update_defaults(self) def onDeleteWidget(self): """ Invoked by the canvas to notify the widget it has been deleted from the workflow. If possible, subclasses should gracefully cancel any currently executing tasks. """ def handleNewSignals(self): """ Invoked by the workflow signal propagation manager after all signals handlers have been called. Reimplement this method in order to coalesce updates from multiple updated inputs. """ #: Widget's status message has changed. statusMessageChanged = Signal(str) def setStatusMessage(self, text): """ Set widget's status message. This is a short status string to be displayed inline next to the instantiated widget icon in the canvas. """ assert QThread.currentThread() == self.thread() if self.__statusMessage != text: self.__statusMessage = text self.statusMessageChanged.emit(text) def statusMessage(self): """ Return the widget's status message. """ return self.__statusMessage def keyPressEvent(self, e: QKeyEvent) -> None: mb = self.__menubar if not mb.isNativeMenuBar() \ and e.modifiers() == Qt.AltModifier \ and e.key() in [Qt.Key_Alt, Qt.Key_AltGr] \ and QApplication.mouseButtons() == Qt.NoButton \ and mb.isHidden(): self.__menubar_visible_timer.start() elif self.__menubar_visible_timer is not None: # stop the timer on any other key press self.__menubar_visible_timer.stop() super().keyPressEvent(e) def keyReleaseEvent(self, event: QKeyEvent) -> None: mb = self.__menubar if not mb.isNativeMenuBar() \ and event.key() in [Qt.Key_Alt, Qt.Key_AltGr]: self.__menubar_visible_timer.stop() if mb.property("__visible_from_alt_key_press") is True: mb.setVisible(False) mb.setProperty("__visible_from_alt_key_press", False) super().keyReleaseEvent(event) def setBlocking(self, state=True) -> None: """ Set blocking flag for this widget. While this flag is set this widget and all its descendants will not receive any new signals from the workflow signal manager. .. deprecated:: 4.2.0 Setting/clearing this flag is equivalent to `setInvalidated(True); setReady(False)` and `setInvalidated(False); setReady(True)` respectively. Use :func:`setInvalidated` and :func:`setReady` in new code. .. seealso:: :func:`setInvalidated`, :func:`setReady` """ if state: self.__setState(True, False) else: self.__setState(False, True) def isBlocking(self): """ Is this widget blocking signal processing. """ return self.isInvalidated() and not self.isReady() widgetStateChanged = Signal() blockingStateChanged = Signal(bool) processingStateChanged = Signal(int) invalidatedStateChanged = Signal(bool) readyStateChanged = Signal(bool) __invalidated = False __ready = True def setInvalidated(self, state: bool) -> None: """ Set/clear invalidated flag on this widget. While this flag is set none of its descendants will receive new signals from the workflow execution manager. This is useful for instance if the widget does it's work in a separate thread or schedules processing from the event queue. In this case it can set the invalidated flag when starting a task. After the task has completed the widget can clear the flag and send the updated outputs. .. note:: Failure to clear this flag will block dependent nodes forever. .. seealso:: :func:`~Output.invalidate()` for a more fine grained invalidation. """ self.__setState(state, self.__ready) def isInvalidated(self) -> bool: """ Return the widget's invalidated flag. """ return self.__invalidated def setReady(self, state: bool) -> None: """ Set/clear ready flag on this widget. While a ready flag is unset, the widget will not receive any new input updates from the workflow execution manager. By default this flag is True. """ self.__setState(self.__invalidated, state) def isReady(self) -> bool: """ Return the widget's ready state """ return self.__ready def __setState(self, invalidated: bool, ready: bool) -> None: blocking = self.isBlocking() changed = False if self.__ready != ready: self.__ready = ready changed = True self.readyStateChanged.emit(ready) if self.__invalidated != invalidated: self.__invalidated = invalidated self.invalidatedStateChanged.emit(invalidated) changed = True if changed: self.widgetStateChanged.emit() if blocking != self.isBlocking(): self.blockingStateChanged.emit(self.isBlocking()) def workflowEnv(self): """ Return (a view to) the workflow runtime environment. Returns ------- env : types.MappingProxyType """ return self.__env def workflowEnvChanged(self, key, value, oldvalue): """ A workflow environment variable `key` has changed to value. Called by the canvas framework to notify widget of a change in the workflow runtime environment. The default implementation does nothing. """ def saveGeometryAndLayoutState(self): # type: () -> QByteArray """ Save the current geometry and layout state of this widget and child windows (if applicable). Returns ------- state : QByteArray Saved state. """ version = 0x1 have_spliter = 0 splitter_state = 0 if self.__splitter is not None: have_spliter = 1 splitter_state = 1 if self.controlAreaVisible else 0 data = QByteArray() stream = QDataStream(data, QBuffer.WriteOnly) stream.writeUInt32(version) stream.writeUInt16((have_spliter << 1) | splitter_state) stream <<= self.saveGeometry() return data def restoreGeometryAndLayoutState(self, state): # type: (QByteArray) -> bool """ Restore the geometry and layout of this widget to a state previously saved with :func:`saveGeometryAndLayoutState`. Parameters ---------- state : QByteArray Saved state. Returns ------- success : bool `True` if the state was successfully restored, `False` otherwise. """ version = 0x1 stream = QDataStream(state, QBuffer.ReadOnly) version_ = stream.readUInt32() if stream.status() != QDataStream.Ok or version_ != version: return False splitter_state = stream.readUInt16() has_spliter = splitter_state & 0x2 splitter_state = splitter_state & 0x1 if has_spliter and self.__splitter is not None: self.__setControlAreaVisible(bool(splitter_state)) geometry = QByteArray() stream >>= geometry if stream.status() == QDataStream.Ok: state = self.__restoreWidgetGeometry(bytes(geometry)) self.__was_restored = self.__was_restored or state return state else: return False # pragma: no cover def __showMessage(self, message): if self.__msgwidget is not None: self.__msgwidget.hide() self.__msgwidget.deleteLater() self.__msgwidget = None if message is None: return buttons = MessageOverlayWidget.Ok | MessageOverlayWidget.Close if message.moreurl is not None: buttons |= MessageOverlayWidget.Help if message.icon is not None: icon = message.icon else: icon = Message.Information self.__msgwidget = MessageOverlayWidget( parent=self, text=message.text, icon=icon, wordWrap=True, standardButtons=buttons) btn = self.__msgwidget.button(MessageOverlayWidget.Ok) btn.setText("Ok, got it") self.__msgwidget.setStyleSheet(""" MessageOverlayWidget { background: qlineargradient( x1: 0, y1: 0, x2: 0, y2: 1, stop:0 #666, stop:0.3 #6D6D6D, stop:1 #666) } MessageOverlayWidget QLabel#text-label { color: white; }""") if message.moreurl is not None: helpbutton = self.__msgwidget.button(MessageOverlayWidget.Help) helpbutton.setText("Learn more\N{HORIZONTAL ELLIPSIS}") self.__msgwidget.helpRequested.connect( lambda: QDesktopServices.openUrl(QUrl(message.moreurl))) self.__msgwidget.setWidget(self) self.__msgwidget.show() def __quicktip(self): messages = list(self.UserAdviceMessages) if messages: message = messages[self.__msgchoice % len(messages)] self.__msgchoice += 1 self.__showMessage(message) def __quicktipOnce(self): dirpath = settings.widget_settings_dir(versioned=False) try: os.makedirs(dirpath, exist_ok=True) except OSError: # EPERM, EEXISTS, ... pass filename = os.path.join(settings.widget_settings_dir(versioned=False), "user-session-state.ini") namespace = ("user-message-history/{0.__module__}.{0.__qualname__}" .format(type(self))) session_hist = QSettings(filename, QSettings.IniFormat) session_hist.beginGroup(namespace) messages = self.UserAdviceMessages def _ispending(msg): return not session_hist.value( "{}/confirmed".format(msg.persistent_id), defaultValue=False, type=bool) messages = [msg for msg in messages if _ispending(msg)] if not messages: return message = messages[self.__msgchoice % len(messages)] self.__msgchoice += 1 self.__showMessage(message) def _userconfirmed(): session_hist = QSettings(filename, QSettings.IniFormat) session_hist.beginGroup(namespace) session_hist.setValue( "{}/confirmed".format(message.persistent_id), True) session_hist.sync() self.__msgwidget.accepted.connect(_userconfirmed) @classmethod def migrate_settings(cls, settings, version): """Fix settings to work with the current version of widgets Parameters ---------- settings : dict dict of name - value mappings version : Optional[int] version of the saved settings or None if settings were created before migrations """ @classmethod def migrate_context(cls, context, version): """Fix contexts to work with the current version of widgets Parameters ---------- context : Context Context object version : Optional[int] version of the saved context or None if context was created before migrations """ def actionEvent(self, event: QActionEvent) -> None: if event.type() in (QEvent.ActionAdded, QEvent.ActionRemoved): event = cast(QActionEvent, event) action = event.action() if action.objectName().startswith("action-canvas-"): menu = self.findChild(QMenu, "menu-window") if menu is not None: if event.type() == QEvent.ActionAdded: menu.addAction(action) else: menu.removeAction(action) super().actionEvent(event) class _StatusBar(QStatusBar): #: Emitted on a change of geometry or visibility (explicit hide/show) change = Signal() def event(self, event): # type: (QEvent) ->bool if event.type() in {QEvent.Resize, QEvent.ShowToParent, QEvent.HideToParent}: self.change.emit() return super().event(event) def paintEvent(self, event): style = self.style() opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) # Omit the widget instance from the call (QTBUG-60018) style.drawPrimitive(QStyle.PE_PanelStatusBar, opt, painter, None) # Do not draw any PE_FrameStatusBarItem frames. painter.end() @staticmethod def simple_button( parent: QWidget, action: QAction, icon=QIcon() ) -> SimpleButton: if icon.isNull(): icon = action.icon() button = _StatusBarButton( parent, icon=icon, toolTip=action.toolTip(), whatsThis=action.whatsThis(), visible=action.isVisible(), enabled=action.isEnabled(), ) def update(): button.setVisible(action.isVisible()) button.setEnabled(action.isEnabled()) button.setToolTip(action.toolTip()) button.setWhatsThis(action.whatsThis()) action.changed.connect(update) button.clicked.connect(action.triggered) return button class _StatusBarButton(SimpleButton): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # match top/bottom margins of MessagesWidget self.setContentsMargins(1, 1, 1, 1) self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) def sizeHint(self): # Ensure the button has at least font height dimensions. sh = super().sizeHint() h = self.fontMetrics().lineSpacing() return sh.expandedTo(QSize(h, h)) class _Menu(QMenu): """ A QMenu managing self-visibility in a parent menu or menu bar. The menu is visible if it has at least one visible action. """ def actionEvent(self, event): super().actionEvent(event) ma = self.menuAction() if ma is not None: ma.setVisible( any(ac.isVisible() and not ac.isSeparator() for ac in self.actions()) ) #: Input/Output flags (deprecated). #: -------------------------------- #: #: The input/output is the default for its type. #: When there are multiple IO signals with the same type the #: one with the default flag takes precedence when adding a new #: link in the canvas. Default = Default NonDefault = NonDefault #: Single input signal (default) Single = Single #: Multiple outputs can be linked to this signal. #: Signal handlers with this flag have (object, id: object) -> None signature. Multiple = Multiple #: Applies to user interaction only. #: Only connected if specifically requested (in a dedicated "Links" dialog) #: or it is the only possible connection. Explicit = Explicit #: Dynamic output type. #: Specifies that the instances on the output will in general be #: subtypes of the declared type and that the output can be connected #: to any input signal which can accept a subtype of the declared output #: type. Dynamic = Dynamic metric_suffix = ['', 'k', 'M', 'G', 'T', 'P'] class StateInfo(QObject): """ A namespace for OWBaseWidget's detailed input/output/state summary reporting. See Also -------- OWBaseWidget.info """ class Summary: """ Input/output summary description. This class is used to hold and report detailed I/O summaries. Attributes ---------- brief: str A brief (inline) description. details: str A richer detailed description. icon: QIcon An custom icon. If not set a default set will be used to indicate special states (i.e. empty input ...) format: Qt.TextFormat Qt.PlainText if `brief` and `details` are to be rendered as plain text or Qt.RichText if they are HTML. See also -------- :func:`StateInfo.set_input_summary`, :func:`StateInfo.set_output_summary`, :class:`StateInfo.Empty`, :class:`StateInfo.Partial`, `Supported HTML Subset`_ .. _`Supported HTML Subset`: http://doc.qt.io/qt-5/richtext-html-subset.html """ def __init__(self, brief="", details="", icon=QIcon(), format=Qt.PlainText): # type: (str, str, QIcon, Qt.TextFormat) -> None super().__init__() self.__brief = brief self.__details = details self.__icon = QIcon(icon) self.__format = format @property def brief(self) -> str: return self.__brief @property def details(self) -> str: return self.__details @property def icon(self) -> QIcon: return QIcon(self.__icon) @property def format(self) -> Qt.TextFormat: return self.__format def __eq__(self, other): return (isinstance(other, StateInfo.Summary) and self.brief == other.brief and self.details == other.details and self.icon.cacheKey() == other.icon.cacheKey() and self.format == other.format) def as_dict(self): return dict(brief=self.brief, details=self.details, icon=self.icon, format=self.format) def updated(self, **kwargs): state = self.as_dict() state.update(**kwargs) return type(self)(**state) @classmethod def default_icon(cls, role): # type: (str) -> QIcon """ Return a default icon for input/output role. Parameters ---------- role: str "input" or "output" Returns ------- icon: QIcon """ return _load_styled_icon(f"{role}.svg") class Empty(Summary): """ Input/output summary description indicating empty I/O state. """ @classmethod def default_icon(cls, role): return _load_styled_icon(f"{role}-empty.svg") class Partial(Summary): """ Input summary indicating partial input. This state indicates that some inputs are present but more are needed in order for the widget to proceed. """ @classmethod def default_icon(cls, role): return _load_styled_icon(f"{role}-partial.svg") #: Signal emitted when the input summary changes input_summary_changed = Signal(Summary) #: Signal emitted when the output summary changes output_summary_changed = Signal(Summary) #: A default message displayed to indicate no inputs. NoInput = Empty() #: A default message displayed to indicate no output. NoOutput = Empty() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__input_summary = StateInfo.Summary() # type: StateInfo.Summary self.__output_summary = StateInfo.Summary() # type: StateInfo.Summary def set_input_summary(self, summary, details="", icon=QIcon(), format=Qt.PlainText): # type: (Union[StateInfo.Summary, str, int, None], str, QIcon, Qt.TextFormat) -> None """ Set the input summary description. This method has two overloads .. function:: set_input_summary(summary: Optional[StateInfo.Summary]]) .. function:: set_input_summary(summary:str, detailed:str="", icon:QIcon) Note ---- `set_input_summary(None)` clears/resets the current summary. Use `set_input_summary(StateInfo.NoInput)` to indicate no input state. `set_input_summary(int)` to have automatically formatted summary Parameters ---------- summary : Union[Optional[StateInfo.Message], str, int] A populated `StateInfo.Message` instance or a short text description (should not exceed 16 characters) or an integer. details : str A detailed description (only applicable if summary is a string). icon : QIcon An icon. If not specified a default icon will be used (only applicable if `summary` is a string). format : Qt.TextFormat Specify how the `short` and `details` text should be interpreted. Can be `Qt.PlainText` or `Qt.RichText` (only applicable if `summary` is a string or an integer). """ def assert_single_arg(): if not (details == "" and icon.isNull() and format == Qt.PlainText): raise TypeError("No extra arguments expected when `summary` " "is `None` or `Message`") if summary is None: assert_single_arg() summary = StateInfo.Summary() elif isinstance(summary, StateInfo.Summary): assert_single_arg() if isinstance(summary, StateInfo.Empty): summary = summary.updated(details="No data on input", brief='-') if summary.icon.isNull(): summary = summary.updated(icon=summary.default_icon("input")) elif isinstance(summary, str): summary = StateInfo.Summary(summary, details, icon, format=format) if summary.icon.isNull(): summary = summary.updated(icon=summary.default_icon("input")) elif isinstance(summary, int): summary = StateInfo.Summary(self.format_number(summary), details or str(summary), StateInfo.Summary.default_icon("input"), format=format) else: raise TypeError("'None', 'str' or 'Message' instance expected, " "got '{}'" .format(type(summary).__name__)) if self.__input_summary != summary: self.__input_summary = summary self.input_summary_changed.emit(summary) def set_output_summary(self, summary, details="", icon=QIcon(), format=Qt.PlainText): # type: (Union[StateInfo.Summary, str, int, None], str, QIcon, Qt.TextFormat) -> None """ Set the output summary description. Note ---- `set_output_summary(None)` clears/resets the current summary. Use `set_output_summary(StateInfo.NoOutput)` to indicate no output state. `set_output_summary(int)` to have automatically formatted summary Parameters ---------- summary : Union[StateInfo.Summary, str, int, None] A populated `StateInfo.Summary` instance or a short text description (should not exceed 16 characters) or an integer. details : str A detailed description (only applicable if `summary` is a string). icon : QIcon An icon. If not specified a default icon will be used (only applicable if `summary` is a string) format : Qt.TextFormat Specify how the `summary` and `details` text should be interpreted. Can be `Qt.PlainText` or `Qt.RichText` (only applicable if `summary` is a string or an integer). """ def assert_single_arg(): if not (details == "" and icon.isNull() and format == Qt.PlainText): raise TypeError("No extra arguments expected when `summary` " "is `None` or `Message`") if summary is None: assert_single_arg() summary = StateInfo.Summary() elif isinstance(summary, StateInfo.Summary): assert_single_arg() if isinstance(summary, StateInfo.Empty): summary = summary.updated(details="No data on output", brief='-') if summary.icon.isNull(): summary = summary.updated(icon=summary.default_icon("output")) elif isinstance(summary, str): summary = StateInfo.Summary(summary, details, icon, format=format) if summary.icon.isNull(): summary = summary.updated(icon=summary.default_icon("output")) elif isinstance(summary, int): summary = StateInfo.Summary(self.format_number(summary), details or str(summary), StateInfo.Summary.default_icon("output"), format=format) else: raise TypeError("'None', 'str' or 'Message' instance expected, " "got '{}'" .format(type(summary).__name__)) if self.__output_summary != summary: self.__output_summary = summary self.output_summary_changed.emit(summary) @staticmethod def format_number(n: int) -> str: """ Format integers larger then 9999 with metric suffix and at most 3 digits. Example: >>> StateInfo.format_number(12_345) '12.3k' """ if n < 10_000: return str(n) mag = int(log10(n) // 3) n = n / 10 ** (mag * 3) if n >= 999.5: # rounding to higher order n = 1 mag += 1 return f"{n:.3g}{metric_suffix[mag]}" # pylint: disable=too-many-lines ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000034�00000000000�010212� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������28 mtime=1694782192.8626583 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/workflow/����������������������������������������������������0000755�0000765�0000024�00000000000�14501051361�021033� 5����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1662714146.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/workflow/__init__.py�����������������������������������������0000644�0000765�0000024�00000000000�14306600442�023135� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1668515756.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/workflow/config.py�������������������������������������������0000644�0000765�0000024�00000013154�14334703654�022673� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Base (example) Configuration for an Orange Widget based application. """ import warnings import os import sys import itertools from typing import Dict, Any, Optional, Iterable, List import pkg_resources import requests from AnyQt.QtCore import QStandardPaths, QCoreApplication from orangecanvas import config # generated from biolab/orange3-addons repository OFFICIAL_ADDON_LIST = "https://orange.biolab.si/addons/list" WIDGETS_ENTRY = "orange.widgets" class Config(config.Default): """ Basic configuration for running orangewidget based workflow application. """ OrganizationDomain = "biolab.si" ApplicationName = "Orange Canvas" try: from orangewidget.version import short_version as ApplicationVersion except ImportError: ApplicationVersion = "0.0.0" @staticmethod def widgets_entry_points(): """ Return an `EntryPoint` iterator for all registered 'orange.widgets' entry points. """ return pkg_resources.iter_entry_points(WIDGETS_ENTRY) @staticmethod def addon_entry_points(): return Config.widgets_entry_points() @staticmethod def addon_defaults_list(session=None): # type: (Optional[requests.Session]) -> List[Dict[str, Any]] """ Return a list of available add-ons. """ if session is None: session = requests.Session() return session.get(OFFICIAL_ADDON_LIST).json() @staticmethod def core_packages(): # type: () -> List[str] """ Return a list of 'core packages' These packages constitute required the application framework. They cannot be removes via the 'Add-on/plugins' manager. They however can be updated. The package that defines the application's `main()` entry point must always be in this list. """ return ["orange-widget-base"] @staticmethod def examples_entry_points(): # type: () -> Iterable[pkg_resources.EntryPoint] """ Return an iterator over the entry points yielding 'Example Workflows' """ # `iter_entry_points` yields them in unspecified order, so we insert # our first try: default_ep = (pkg_resources.EntryPoint( "Orange3", "Orange.canvas.workflows", dist=pkg_resources.get_distribution("Orange3")),) except pkg_resources.DistributionNotFound: default_ep = tuple() return itertools.chain( default_ep, pkg_resources.iter_entry_points("orange.widgets.tutorials") ) @staticmethod def widget_discovery(*args, **kwargs): from .discovery import WidgetDiscovery return WidgetDiscovery(*args, **kwargs) @staticmethod def workflow_constructor(*args, **kwargs): from .widgetsscheme import WidgetsScheme return WidgetsScheme(*args, **kwargs) def data_dir_base(): """ Return the platform dependent generic application directory. This is usually - on windows: "%USERPROFILE%\\AppData\\Local\\" - on OSX: "~/Library/Application Support/" - other: "~/.local/share/ """ return QStandardPaths.writableLocation(QStandardPaths.GenericDataLocation) def data_dir(versioned=True): """ Return the platform dependent application data directory. This is ``data_dir_base()``/{NAME}/{VERSION}/ directory if versioned is `True` and ``data_dir_base()``/{NAME}/ otherwise, where NAME is `QCoreApplication.applicationName()` and VERSION is `QCoreApplication.applicationVersion()`. """ base = data_dir_base() assert base name = QCoreApplication.applicationName() version = QCoreApplication.applicationVersion() if not name: name = "Orange" if not version: version = "0.0.0" if versioned: return os.path.join(base, name, version) else: return os.path.join(base, name) def cache_dir(): """Return the application cache directory. If the directory path does not yet exists then create it. """ warnings.warn( f"'{__name__}.cache_dir' is deprecated.", DeprecationWarning, stacklevel=2 ) base = QStandardPaths.writableLocation(QStandardPaths.GenericCacheLocation) name = QCoreApplication.applicationName() version = QCoreApplication.applicationVersion() if not name: name = "Orange" if not version: version = "0.0.0" path = os.path.join(base, name, version) try: os.makedirs(path, exist_ok=True) except OSError: pass return path def log_dir(): """ Return the application log directory. """ warnings.warn( f"'{__name__}.log_dir' is deprecated.", DeprecationWarning, stacklevel=2 ) if sys.platform == "darwin": name = QCoreApplication.applicationName() or "Orange" logdir = os.path.join(os.path.expanduser("~/Library/Logs"), name) else: logdir = data_dir() try: os.makedirs(logdir, exist_ok=True) except OSError: pass return logdir def widget_settings_dir(versioned=True): """ Return the platform dependent directory where widgets save their settings. .. deprecated: 4.0.1 """ warnings.warn( f"'{__name__}.widget_settings_dir' is deprecated.", DeprecationWarning, stacklevel=2 ) from orangewidget.settings import widget_settings_dir return widget_settings_dir(versioned) def widgets_entry_points(): return Config.widgets_entry_points() def splash_screen(): return Config.splash_screen() def application_icon(): return Config.application_icon() ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1668515756.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/workflow/discovery.py����������������������������������������0000644�0000765�0000024�00000003716�14334703654�023440� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������from orangecanvas.registry import WidgetDescription from orangecanvas.registry import discovery def widget_desc_from_module(module): """ Get the widget description from a module. The module is inspected for classes that have a method `get_widget_description`. The function calls this method and expects a dictionary, which is used as keyword arguments for :obj:`WidgetDescription`. This method also converts all signal types into qualified names to prevent import problems when cached descriptions are unpickled (the relevant code using this lists should be able to handle missing types better). Parameters ---------- module (`module` or `str`): a module to inspect Returns ------- An instance of :obj:`WidgetDescription` """ if isinstance(module, str): module = __import__(module, fromlist=[""]) for widget_class in module.__dict__.values(): if not hasattr(widget_class, "get_widget_description"): continue description = widget_class.get_widget_description() if description is None: continue description = WidgetDescription(**description) description.package = module.__package__ description.category = widget_class.category return description raise discovery.WidgetSpecificationError class WidgetDiscovery(discovery.WidgetDiscovery): def widget_description(self, module, widget_name=None, category_name=None, distribution=None): """ Return widget description from a module. """ module = discovery.asmodule(module) desc = widget_desc_from_module(module) if widget_name is not None: desc.name = widget_name if category_name is not None and desc.category is None: desc.category = category_name if distribution is not None: desc.project_name = distribution.project_name return desc ��������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1668515756.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/workflow/drophandler.py��������������������������������������0000644�0000765�0000024�00000016321�14334703654�023727� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������""" Drag/Drop handlers for handling drop events on the canvas. This is used to create a widget node when a file is dragged onto the canvas. To define a handler subclass a :class:`OWNodeFromMimeDataDropHandler` or one of its subclasses (e.g :class:`SingleUrlDropHandler`, :class:`SingleFileDropHandler`, ...) and register it with the target application's entry point (the default is 'orangecanvas.document.interactions.DropHandler') in the project's meta data, e.g.:: entry_points = { ... "orange.canvas.drophandler": [ "The widget = fully.qualified.module:class_name", ... ], ... """ import abc from typing import Type, Dict, Any, Sequence from AnyQt.QtCore import QMimeData, QUrl from orangecanvas.document.interactions import NodeFromMimeDataDropHandler from orangecanvas.document.schemeedit import SchemeEditWidget from orangecanvas.utils import qualified_name from orangewidget.widget import OWBaseWidget __all__ = [ "OWNodeFromMimeDataDropHandler", "SingleUrlDropHandler", "UrlsDropHandler", "SingleFileDropHandler", "FilesDropHandler", ] class OWNodeFromMimeDataDropHandler(NodeFromMimeDataDropHandler, abc.ABC): """ Canvas drop handler creating a OWBaseWidget nodes. This implements a default :meth:`.qualifiedName` that is based on :attr:`.WIDGET` class attribute. """ #: Class attribute declaring which OWBaseWidget (sub)class this drop #: handler creates. Concrete subclasses **must** assign this attribute. WIDGET: Type[OWBaseWidget] = None def qualifiedName(self) -> str: """Reimplemented.""" return qualified_name(self.WIDGET) class SingleUrlDropHandler(OWNodeFromMimeDataDropHandler): """ Canvas drop handler accepting a single url drop. Subclasses must define :meth:`canDropUrl` and :meth:`parametersFromUrl` Note ---- Use :class:`SingleFileDropHandler` if you only care about local filesystem paths. """ def canDropMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> bool: """ Reimplemented. Delegate to `canDropFile` method if the `data` has a single local file system path. """ urls = data.urls() if len(urls) != 1: return False return self.canDropUrl(urls[0]) def parametersFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Dict[str, Any]': """ Reimplemented. Delegate to :meth:`parametersFromUrl` method. """ return self.parametersFromUrl(data.urls()[0]) @abc.abstractmethod def canDropUrl(self, url: QUrl) -> bool: """ Can the handler create a node from the `url`. Subclasses must redefine this method. """ raise NotImplementedError @abc.abstractmethod def parametersFromUrl(self, url: QUrl) -> 'Dict[str, Any]': """ Return the node parameters from `url`. Subclasses must redefine this method. """ raise NotImplementedError class UrlsDropHandler(OWNodeFromMimeDataDropHandler): """ Canvas drop handler accepting url drops. Subclasses must define :meth:`canDropUrls` and :meth:`parametersFromUrls` Note ---- Use :class:`FilesDropHandler` if you only care about local filesystem paths. """ def canDropMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> bool: """ Reimplemented. Delegate to :meth:`canDropUrls` method. """ urls = data.urls() if not bool(urls): return False return self.canDropUrls(urls) def parametersFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Dict[str, Any]': """ Reimplemented. Delegate to :meth:`parametersFromUrls` method. """ return self.parametersFromUrls(data.urls()) @abc.abstractmethod def canDropUrls(self, urls: Sequence[QUrl]) -> bool: """ Can the handler create a node from the `urls` list. Subclasses must redefine this method. """ raise NotImplementedError @abc.abstractmethod def parametersFromUrls(self, urls: Sequence[QUrl]) -> 'Dict[str, Any]': """ Return the node parameters from `urls`. Subclasses must redefine this method. """ raise NotImplementedError class SingleFileDropHandler(OWNodeFromMimeDataDropHandler): """ Canvas drop handler accepting single local file path. Subclasses must define :meth:`canDropFile` and :meth:`parametersFromFile` """ def canDropMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> bool: """ Reimplemented. Delegate to :meth:`canDropFile` method if the `data` has a single local file system path. """ urls = data.urls() if len(urls) != 1 or not urls[0].isLocalFile(): return False path = urls[0].toLocalFile() return self.canDropFile(path) def parametersFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Dict[str, Any]': """ Reimplemented. Delegate to :meth:`parametersFromFile` method. """ path = data.urls()[0].toLocalFile() return self.parametersFromFile(path) @abc.abstractmethod def canDropFile(self, path: str) -> bool: """ Can the handler create a node from the file `path`. Subclasses must redefine this method. """ raise NotImplementedError @abc.abstractmethod def parametersFromFile(self, path: str) -> 'Dict[str, Any]': """ Return the node parameters based on file `path`. Subclasses must redefine this method. """ raise NotImplementedError class FilesDropHandler(OWNodeFromMimeDataDropHandler): """ Canvas drop handler accepting local file paths. Subclasses must define :meth:`canDropFiles` and :meth:`parametersFromFiles` """ def canDropMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> bool: """ Reimplemented. Delegate to :meth:`canDropFiles` method if the `data` has only local filesystem paths. """ urls = data.urls() if not urls or not all(url.isLocalFile() for url in urls): return False paths = [url.toLocalFile() for url in urls] return self.canDropFiles(paths) def parametersFromMimeData(self, document: 'SchemeEditWidget', data: 'QMimeData') -> 'Dict[str, Any]': """ Reimplemented. Delegate to :meth:`parametersFromFile` method. """ urls = data.urls() paths = [url.toLocalFile() for url in urls] return self.parametersFromFiles(paths) @abc.abstractmethod def canDropFiles(self, paths: Sequence[str]) -> bool: """ Can the handler create a node from the `paths`. Subclasses must redefine this method. """ raise NotImplementedError @abc.abstractmethod def parametersFromFiles(self, paths: Sequence[str]) -> 'Dict[str, Any]': """ Return the node parameters based on `paths`. Subclasses must redefine this method. """ raise NotImplementedError ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������././@PaxHeader��������������������������������������������������������������������������������������0000000�0000000�0000000�00000000026�00000000000�010213� x����������������������������������������������������������������������������������������������������ustar�00�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������22 mtime=1668515756.0 ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������orange-widget-base-4.22.0/orangewidget/workflow/errorreporting.py�����������������������������������0000644�0000765�0000024�00000023717�14334703654�024517� 0����������������������������������������������������������������������������������������������������ustar�00primoz��������������������������staff������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������import os import sys import time import logging import platform import traceback import uuid from html import escape from threading import Thread from pprint import pformat from tempfile import mkstemp from collections import OrderedDict from urllib.parse import urljoin, urlencode from urllib.request import pathname2url, urlopen, build_opener from urllib.error import URLError from unittest.mock import patch import pkg_resources from AnyQt.QtCore import pyqtSlot, QSettings, Qt from AnyQt.QtGui import QDesktopServices, QFont from AnyQt.QtWidgets import ( QApplication, QCheckBox, QDialog, QHBoxLayout, QLabel, QMessageBox, QPushButton, QStyle, QTextBrowser, QVBoxLayout, QWidget ) from orangewidget.workflow.widgetsscheme import WidgetsScheme from orangewidget.widget import OWBaseWidget REPORT_POST_URL = 'https://service.biolab.si/error_report/' log = logging.getLogger() def get_installed_distributions(): for dist in pkg_resources.working_set: # type: pkg_resources.Distribution name = dist.project_name try: version = dist.version except ValueError: # PKG-INFO/METADATA is not available or parsable. version = "Unknown" yield "{name}=={version}".format(name=name, version=version) def internet_on(): try: urlopen(REPORT_POST_URL, timeout=1) return True except URLError: return False def try_(func, default=None): try: return func() except Exception: return default class ErrorReporting(QDialog): _cache = set() # For errors already handled during one session class DataField: EXCEPTION = 'Exception' MODULE = 'Module' WIDGET_NAME = 'Widget Name' WIDGET_MODULE = 'Widget Module' VERSION = 'Version' ENVIRONMENT = 'Environment' INSTALLED_PACKAGES = 'Installed Packages' MACHINE_ID = 'Machine ID' WIDGET_SCHEME = 'Widget Scheme' STACK_TRACE = 'Stack Trace' LOCALS = 'Local Variables' def __init__(self, data): icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxWarning) F = self.DataField def _finished(*, key=(data.get(F.MODULE), data.get(F.WIDGET_MODULE)), filename=data.get(F.WIDGET_SCHEME)): self._cache.add(key) try: os.remove(filename) except Exception: pass super().__init__(None, Qt.Window, modal=True, sizeGripEnabled=True, windowIcon=icon, windowTitle='Unexpected Error', finished=_finished) self._data = data layout = QVBoxLayout(self) self.setLayout(layout) labels = QWidget(self) labels_layout = QHBoxLayout(self) labels.setLayout(labels_layout) labels_layout.addWidget(QLabel(pixmap=icon.pixmap(50, 50))) labels_layout.addWidget(QLabel( 'The program encountered an unexpected error. Please
' 'report it anonymously to the developers.

' 'The following data will be reported:')) labels_layout.addStretch(1) layout.addWidget(labels) font = QFont('Monospace', 10) font.setStyleHint(QFont.Monospace) font.setFixedPitch(True) textbrowser = QTextBrowser(self, font=font, openLinks=False, lineWrapMode=QTextBrowser.NoWrap, anchorClicked=QDesktopServices.openUrl) layout.addWidget(textbrowser) def _reload_text(): add_scheme = cb.isChecked() settings.setValue('error-reporting/add-scheme', add_scheme) lines = [''] for k, v in data.items(): if k.startswith('_'): continue _v, v = v, escape(str(v)) if k == F.WIDGET_SCHEME: if not add_scheme: continue v = '{}'.format(urljoin('file:', pathname2url(_v)), v) if k in (F.STACK_TRACE, F.LOCALS): v = v.replace('\n', '
').replace(' ', ' ') lines.append(''.format(k, v)) lines.append('
{}:{}
') textbrowser.setHtml(''.join(lines)) settings = QSettings() cb = QCheckBox( 'Include workflow (data will NOT be transmitted)', self, checked=settings.value('error-reporting/add-scheme', True, type=bool)) cb.stateChanged.connect(_reload_text) _reload_text() layout.addWidget(cb) buttons = QWidget(self) buttons_layout = QHBoxLayout(self) buttons.setLayout(buttons_layout) buttons_layout.addWidget( QPushButton('Send Report (Thanks!)', default=True, clicked=self.accept)) buttons_layout.addWidget(QPushButton("Don't Send", default=False, clicked=self.reject)) layout.addWidget(buttons) def accept(self): super().accept() F = self.DataField data = self._data.copy() if not QSettings().value('error-reporting/add-scheme', type=bool): data.pop(F.WIDGET_SCHEME, None) def _post_report(data): MAX_RETRIES = 2 for _retry in range(MAX_RETRIES): try: opener = build_opener() u = opener.open(REPORT_POST_URL) url = u.geturl() urlopen(url, timeout=10, data=urlencode(data).encode('utf8')) except Exception as e: if _retry == MAX_RETRIES - 1: e.__context__ = None log.exception('Error reporting failed', exc_info=e) time.sleep(10) continue break Thread(target=_post_report, args=(data,)).start() @classmethod @patch('sys.excepthook', sys.__excepthook__) # Prevent recursion @pyqtSlot(object) def handle_exception(cls, exc): etype, evalue, tb = exc exception = traceback.format_exception_only(etype, evalue)[-1].strip() stacktrace = ''.join(traceback.format_exception(etype, evalue, tb)) def _find_last_frame(tb): if not tb: return None while tb.tb_next: tb = tb.tb_next return tb err_locals, err_module, frame = None, None, _find_last_frame(tb) if frame: err_module = '{}:{}'.format( frame.tb_frame.f_globals.get('__name__', frame.tb_frame.f_code.co_filename), frame.tb_lineno) err_locals = OrderedDict(sorted(frame.tb_frame.f_locals.items())) err_locals = try_(lambda: pformat(err_locals), try_(lambda: str(err_locals))) def _find_widget_frame(tb): while tb: if isinstance(tb.tb_frame.f_locals.get('self'), OWBaseWidget): return tb tb = tb.tb_next widget_module = widget_class = widget = workflow = None frame = _find_widget_frame(tb) if frame is not None: widget = frame.tb_frame.f_locals['self'] # type: OWBaseWidget widget_class = widget.__class__ widget_module = '{}:{}'.format(widget_class.__module__, frame.tb_lineno) if widget is not None: try: workflow = widget.signalManager.parent() if not isinstance(workflow, WidgetsScheme): raise TypeError except Exception: workflow = None packages = ', '.join(sorted(get_installed_distributions())) settings = QSettings() if settings.contains('error-reporting/machine-id'): machine_id = settings.value('error-reporting/machine-id') else: machine_id = str(uuid.uuid4()) settings.setValue('error-reporting/machine-id', machine_id) # If this exact error was already reported in this session, # just warn about it # or if computer not connected to the internet if (err_module, widget_module) in cls._cache or not internet_on(): QMessageBox(QMessageBox.Warning, 'Error Encountered', 'Error encountered{}:

{}'.format( (' in widget {}'.format(widget_class.name) if widget_class else ''), stacktrace.replace('\n', '
').replace(' ', ' ')), QMessageBox.Ignore).exec() return F = cls.DataField data = OrderedDict() data[F.EXCEPTION] = exception data[F.MODULE] = err_module if widget_class is not None: data[F.WIDGET_NAME] = widget_class.name data[F.WIDGET_MODULE] = widget_module if workflow is not None: fd, filename = mkstemp(prefix='ows-', suffix='.ows.xml') os.close(fd) try: with open(filename, "wb") as f: workflow.save_to(f, pretty=True, pickle_fallback=True) with open(filename, encoding='utf-8') as f: data[F.WIDGET_SCHEME] = f.read() except Exception: pass data[F.VERSION] = QApplication.applicationVersion() data[F.ENVIRONMENT] = 'Python {} on {} {} {} {}'.format( platform.python_version(), platform.system(), platform.release(), platform.version(), platform.machine()) data[F.INSTALLED_PACKAGES] = packages data[F.MACHINE_ID] = machine_id data[F.STACK_TRACE] = stacktrace if err_locals: data[F.LOCALS] = err_locals cls(data=data).exec() def handle_exception(exc): return ErrorReporting.handle_exception(exc) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/workflow/mainwindow.py0000644000076500000240000001761714440334174023605 0ustar00primozstaffimport os from typing import Optional from AnyQt.QtCore import Qt, QSettings, QTimer from AnyQt.QtWidgets import ( QAction, QFileDialog, QMenu, QMenuBar, QWidget, QMessageBox, QDialog, QApplication) from AnyQt.QtGui import QKeySequence from orangecanvas.application.canvasmain import CanvasMainWindow from orangewidget.report.owreport import HAVE_REPORT, OWReport from orangewidget.workflow.widgetsscheme import WidgetsScheme def _insert_action(mb, menuid, beforeactionid, action): # type: (QMenuBar, str, str, QAction) -> bool """ Insert an action into one of a QMenuBar's menu. Parameters ---------- mb : QMenuBar The menu bar menuid : str The target menu's objectName. The menu must be a child of `mb`. beforeactionid : str The objectName of the action before which the action will be inserted. action : QAction The action to insert Returns ------- success: bool True if the actions was successfully inserted (the menu and before actions were found), False otherwise """ def find_action(widget, name): # type: (QWidget, str) -> Optional[QAction] for a in widget.actions(): if a.objectName() == name: return a return None menu = mb.findChild(QMenu, menuid) if menu is not None: sep = find_action(menu, beforeactionid) if sep: menu.insertAction(sep, action) return True return False class OWCanvasMainWindow(CanvasMainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.show_report_action = QAction( "Show report", self, objectName="action-show-report", toolTip="Show a report window", shortcut=QKeySequence("Shift+R"), enabled=HAVE_REPORT, ) self.show_report_action.triggered.connect(self.show_report_view) self.open_report_action = QAction( "Open Report...", self, objectName="action-open-report", toolTip="Open a saved report", enabled=HAVE_REPORT, ) self.open_report_action.triggered.connect(self.open_report) self.reset_widget_settings_action = QAction( self.tr("Reset Widget Settings..."), self, triggered=self.reset_widget_settings ) menubar = self.menuBar() # Insert the 'Load report' in the File menu ... _insert_action(menubar, "file-menu", "open-actions-separator", self.open_report_action) # ... and 'Show report' in the View menu. _insert_action(menubar, "view-menu", "view-visible-actions-separator", self.show_report_action) _insert_action(menubar, "options-menu", "canvas-addons-action", self.reset_widget_settings_action) def open_report(self): """ Present an 'Open report' dialog to the user, load a '.report' file (as saved by OWReport) and create a new canvas window associated with the OWReport instance. """ settings = QSettings() KEY = "report/file-dialog-dir" start_dir = settings.value(KEY, "", type=str) dlg = QFileDialog( self, windowTitle=self.tr("Open Report"), acceptMode=QFileDialog.AcceptOpen, fileMode=QFileDialog.ExistingFile, ) if os.path.isdir(start_dir): dlg.setDirectory(start_dir) dlg.setWindowModality(Qt.ApplicationModal) dlg.setNameFilters(["Report (*.report)"]) def accepted(): directory = dlg.directory().absolutePath() filename = dlg.selectedFiles()[0] settings.setValue(KEY, directory) self._open_report(filename) dlg.accepted.connect(accepted) dlg.exec() def _open_report(self, filename): """ Open and load a '*.report' from 'filename' """ report = OWReport.load(filename) # Create a new window for the report if self.is_transient(): window = self else: window = self.create_new_window() # toggle the window modified flag (this will clear the 'is_transient' # flag on the new window) window.setWindowModified(True) window.setWindowModified(False) report.setParent(window, Qt.Window) sc = window.current_document().scheme() # type: WidgetsScheme sc.set_report_view(report) window.show() window.raise_() window.show_report_view() report._build_html() report.table.selectRow(0) report.show() report.raise_() def show_report_view(self): """ Show the 'Report' view for the current workflow. """ sc = self.current_document().scheme() # type: WidgetsScheme sc.show_report_view() def reset_widget_settings(self): name = QApplication.applicationName() or 'Orange' mb = QMessageBox( self, windowTitle="Clear settings", text=f"{name} needs to be restarted for the changes to take effect.", icon=QMessageBox.Information, informativeText=f"Press OK to restart {name} now.", standardButtons=QMessageBox.Ok | QMessageBox.Cancel, ) res = mb.exec() if res == QMessageBox.Ok: # Touch a finely crafted file inside the settings directory. # The existence of this file is checked by the canvas main # function and is deleted there. from orangewidget.settings import widget_settings_dir dirname = widget_settings_dir() try: os.makedirs(dirname, exist_ok=True) except (FileExistsError, PermissionError): return with open(os.path.join(dirname, "DELETE_ON_START"), "a"): pass 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=f"Settings will be reset on {name}'s next restart", icon=QMessageBox.Information ).exec() else: QApplication.exit(96) QTimer.singleShot(0, restart) def ask_save_report(self): """ Ask whether to save the report or not. Returns: `QDialog.Rejected` if user cancels, `QDialog.Accepted` otherwise """ workflow = self.current_document().scheme() if not isinstance(workflow, WidgetsScheme) or not workflow.has_report(): return QDialog.Accepted report = workflow.report_view() if not report.is_changed(): return QDialog.Accepted mBox = QMessageBox( self, windowTitle="Report window", icon=QMessageBox.Question, text="The report contains unsaved changes.", informativeText="Would you like to save the report?", standardButtons=QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, ) mBox.setDefaultButton(QMessageBox.Save) answ = mBox.exec() if answ == QMessageBox.Cancel: return QDialog.Rejected if answ == QMessageBox.Save: return report.save_report() return QDialog.Accepted def closeEvent(self, event): if self.ask_save_report() == QDialog.Rejected: event.ignore() return super().closeEvent(event) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8633664 orange-widget-base-4.22.0/orangewidget/workflow/tests/0000755000076500000240000000000014501051361022175 5ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1662714146.0 orange-widget-base-4.22.0/orangewidget/workflow/tests/__init__.py0000644000076500000240000000000014306600442024277 0ustar00primozstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/workflow/tests/test_drophandler.py0000644000076500000240000001161714334703654026133 0ustar00primozstafffrom unittest import mock from typing import Sequence from AnyQt.QtCore import Qt, QUrl, QPoint, QMimeData, QPointF from AnyQt.QtGui import QDropEvent, QDragEnterEvent from AnyQt.QtWidgets import QApplication, QWidget from orangecanvas.registry import WidgetRegistry, WidgetDescription from orangecanvas.document.interactions import PluginDropHandler, EntryPoint from orangecanvas.document.schemeedit import SchemeEditWidget from orangewidget.tests.base import GuiTest from orangewidget.settings import Setting from orangewidget.widget import OWBaseWidget from orangewidget.workflow.widgetsscheme import WidgetsScheme from orangewidget.workflow.drophandler import ( SingleFileDropHandler, SingleUrlDropHandler, UrlsDropHandler, FilesDropHandler ) class Widget(OWBaseWidget): name = "eman" param = Setting("") class WidgetSingleUrlDropHandler(SingleUrlDropHandler): WIDGET = Widget def canDropUrl(self, url: QUrl) -> bool: return True def parametersFromUrl(self, url: QUrl) -> 'Dict[str, Any]': return {"param": url.toString()} class WidgetSingleFileDropHandler(SingleFileDropHandler): WIDGET = Widget def canDropFile(self, path: str) -> bool: return True def parametersFromFile(self, path: str) -> 'Dict[str, Any]': return {"param": path} class WidgetUrlsDropHandler(UrlsDropHandler): WIDGET = Widget def canDropUrls(self, urls: Sequence[QUrl]) -> bool: return True def parametersFromUrls(self, urls: Sequence[QUrl]) -> 'Dict[str, Any]': return {"param": [url.toString() for url in urls]} class WidgetFilesDropHandler(FilesDropHandler): WIDGET = Widget def canDropFiles(self, files: Sequence[str]) -> bool: return True def parametersFromFiles(self, paths: Sequence[str]) -> 'Dict[str, Any]': return {"param": list(paths)} def mock_iter_entry_points(module, attr): return mock.patch.object( PluginDropHandler, "iterEntryPoints", lambda _: [EntryPoint("AA", f"{module}:{attr}", "foo")] ) class TestDropHandlers(GuiTest): def setUp(self): super().setUp() reg = WidgetRegistry() reg.register_widget( WidgetDescription(**Widget.get_widget_description()) ) self.w = SchemeEditWidget() self.w.setRegistry(reg) self.w.resize(300, 300) self.w.setScheme(WidgetsScheme()) self.w.setDropHandlers([PluginDropHandler()]) def tearDown(self): self.w.scheme().clear() del self.w super().tearDown() @mock_iter_entry_points(__name__, WidgetSingleUrlDropHandler.__name__) def test_single_file_drop(self): w = self.w workflow = w.scheme() view = w.view() mime = QMimeData() mime.setUrls([QUrl("file:///foo/bar")]) dragDrop(view.viewport(), mime, QPoint(10, 10)) self.assertEqual(len(workflow.nodes), 1) self.assertEqual(workflow.nodes[0].properties, {"param": "file:///foo/bar"}) @mock_iter_entry_points(__name__, WidgetSingleFileDropHandler.__name__) def test_single_url_drop(self): w = self.w workflow = w.scheme() view = w.view() mime = QMimeData() mime.setUrls([QUrl("file:///foo/bar")]) dragDrop(view.viewport(), mime, QPoint(10, 10)) self.assertEqual(len(workflow.nodes), 1) self.assertEqual(workflow.nodes[0].properties, {"param": "/foo/bar"}) @mock_iter_entry_points(__name__, WidgetUrlsDropHandler.__name__) def test_urls_drop(self): w = self.w workflow = w.scheme() mime = QMimeData() mime.setUrls([QUrl("file:///foo/bar")] * 2) dragDrop(w.view().viewport(), mime, QPoint(10, 10)) self.assertEqual(len(workflow.nodes), 1) self.assertEqual(workflow.nodes[0].properties, {"param": ["file:///foo/bar"] * 2}) @mock_iter_entry_points(__name__, WidgetFilesDropHandler.__name__) def test_files_drop(self): w = self.w workflow = w.scheme() mime = QMimeData() mime.setUrls([QUrl("file:///foo/bar")] * 2) dragDrop(w.view().viewport(), mime, QPoint(10, 10)) self.assertEqual(len(workflow.nodes), 1) self.assertEqual(workflow.nodes[0].properties, {"param": ["/foo/bar"] * 2}) def dragDrop( widget: 'QWidget', mime: QMimeData, pos: QPoint = QPoint(-1, -1), action=Qt.CopyAction, buttons=Qt.LeftButton, modifiers=Qt.NoModifier ) -> bool: if pos == QPoint(-1, -1): pos = widget.rect().center() ev = QDragEnterEvent(pos, action, mime, buttons, modifiers) ev.setAccepted(False) QApplication.sendEvent(widget, ev) if not ev.isAccepted(): return False ev = QDropEvent(QPointF(pos), action, mime, buttons, modifiers) ev.setAccepted(False) QApplication.sendEvent(widget, ev) return ev.isAccepted() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/workflow/tests/test_widgetsscheme.py0000644000076500000240000004204314334703654026461 0ustar00primozstaffimport unittest import unittest.mock import logging from types import SimpleNamespace from typing import Type from AnyQt.QtCore import QTimer from AnyQt.QtWidgets import QAction from AnyQt.QtTest import QSignalSpy from orangecanvas.registry import WidgetDescription from orangecanvas.scheme import SchemeNode from orangewidget.report.owreport import OWReport from orangewidget.settings import Setting from orangewidget.workflow.widgetsscheme import OWWidgetManager, WidgetsScheme from orangewidget import widget from orangewidget.tests.base import GuiTest class Number(widget.OWBaseWidget): name = "W1" value = Setting(0) class Outputs: out = widget.Output("X", int) class Adder(widget.OWBaseWidget, openclass=True): name = "Adder" a = None b = None class Inputs: a = widget.Input("A", int) b = widget.Input("B", int) class Outputs: out = widget.Output("A+B", int) @Inputs.a def seta(self, a): self.a = a @Inputs.b def setb(self, b): self.b = b def handleNewSignals(self): if self.a is not None and self.b is not None: out = self.a + self.b else: out = None self.Outputs.out.send(out) class MakeList(widget.OWBaseWidget, openclass=True): name = "List" seq = () class Inputs: element = widget.MultiInput("Element", object) class Outputs: out = widget.Output("List", list) def __init__(self): super().__init__() self.inputs = [] self.events = [] @Inputs.element def set_element(self, index, el): self.inputs[index] = el self.events.append(("set", index, el)) @Inputs.element.insert def insert_element(self, index, el): self.inputs.insert(index, el) self.events.append(("insert", index, el)) @Inputs.element.remove def remove_element(self, index): self.inputs.pop(index) self.events.append(("remove", index)) def handleNewSignals(self): self.Outputs.out.send(list(self.inputs)) class AdderAsync(Adder): def handleNewSignals(self): self.setBlocking(True) QTimer.singleShot(10, self.do_send) def do_send(self): if self.a is not None and self.b is not None: out = self.a + self.b else: out = None self.setBlocking(False) self.Outputs.out.send(out) class Show(widget.OWBaseWidget): name = "Show" class Inputs: X = widget.Input("X", object) x = None @Inputs.X def set_x(self, x): self.x = x def handleNewSignals(self): print(self.x) class OldStyleShow(widget.OWBaseWidget): name = "Show" inputs = [("X", object, "set_x")] x = None def set_x(self, x): self.x = x def handleNewSignals(self): print(self.x) def widget_description(class_): # type: (Type[widget.OWBaseWidget]) -> WidgetDescription return WidgetDescription(**class_.get_widget_description()) def create_workflow(): model = WidgetsScheme() w1_node = model.new_node(widget_description(Number)) w1 = model.widget_for_node(w1_node) w2_node = model.new_node(widget_description(Number)) w2 = model.widget_for_node(w2_node) add_node = model.new_node(widget_description(Adder)) add = model.widget_for_node(add_node) show_node = model.new_node(widget_description(Show)) show = model.widget_for_node(show_node) model.new_link(w1_node, "X", add_node, "A") model.new_link(w2_node, "X", add_node, "B") model.new_link(add_node, "A+B", show_node, "X") class Items(SimpleNamespace): w1_node: SchemeNode w2_node: SchemeNode add_node: SchemeNode show_node: SchemeNode w1: Number w2: Number add: Adder show: Show return model, Items( w1=w1, w2=w2, add=add, show=show, w1_node=w1_node, w2_node=w2_node, add_node=add_node, show_node=show_node ) def create_workflow_2(): model = WidgetsScheme() w1_node = model.new_node(widget_description(Number)) w1 = model.widget_for_node(w1_node) w2_node = model.new_node(widget_description(Number)) w2 = model.widget_for_node(w2_node) list_node = model.new_node(widget_description(MakeList)) list_ = model.widget_for_node(list_node) show_node = model.new_node(widget_description(Show)) show = model.widget_for_node(show_node) model.new_link(w1_node, "X", list_node, "Element") model.new_link(w2_node, "X", list_node, "Element") model.new_link(list_node, "List", show_node, "X") class Items(SimpleNamespace): w1_node: SchemeNode w2_node: SchemeNode list_node: SchemeNode show_node: SchemeNode w1: Number w2: Number list_: MakeList show: Show return model, Items( w1=w1, w2=w2, w1_node=w1_node, w2_node=w2_node, show=show, show_node=show_node, list_node=list_node, list_=list_ ) class TestWidgetScheme(GuiTest): def test_widgetscheme(self): model, widgets = create_workflow() w1, w2, add = widgets.w1, widgets.w2, widgets.add self.assertIs(model.widget_for_node(widgets.w1_node), w1) self.assertIs(model.node_for_widget(w1), widgets.w1_node) r = OWReport() self.assertFalse(model.has_report()) model.set_report_view(r) self.assertTrue(model.has_report()) self.assertIs(w1._get_designated_report_view(), r) self.assertIs(w2._get_designated_report_view(), r) self.assertIs(add._get_designated_report_view(), r) # 'reset' the report model.set_report_view(None) # must create model.report_view r = w1._get_designated_report_view() self.assertIs(model.report_view(), r) # all widgets in the same workflow must share the same instance. self.assertIs(w2._get_designated_report_view(), r) self.assertIs(add._get_designated_report_view(), r) with unittest.mock.patch.object(r, "setVisible", return_value=None) as s: model.show_report_view() s.assert_called_once_with(True) model.sync_node_properties() model.clear() model.set_report_view(None) class TestWidgetManager(GuiTest): def test_state_tracking(self): model, widgets = create_workflow() wm = model.widget_manager sm = model.signal_manager w1, w1_node = widgets.w1, widgets.w1_node w1.setBlocking(True) self.assertFalse(sm.is_ready(w1_node)) self.assertTrue(sm.is_invalidated(w1_node)) w1.setBlocking(False) self.assertTrue(sm.is_ready(w1_node)) self.assertFalse(sm.is_invalidated(w1_node)) w1.setReady(False) self.assertFalse(sm.is_ready(w1_node)) w1.setReady(True) self.assertTrue(sm.is_ready(w1_node)) w1.setInvalidated(True) self.assertTrue(sm.is_invalidated(w1_node)) w1.setInvalidated(False) self.assertFalse(sm.is_invalidated(w1_node)) w1.Outputs.out.invalidate() self.assertTrue(sm.has_invalidated_inputs(widgets.add_node)) w1.Outputs.out.send(1) self.assertFalse(sm.has_invalidated_inputs(widgets.add_node)) w1.setStatusMessage("$%^#") self.assertEqual(w1_node.status_message(), "$%^#") w1.setStatusMessage("") self.assertEqual(w1_node.status_message(), "") w1.progressBarInit() self.assertEqual(w1_node.processing_state, 1) w1.progressBarSet(42) self.assertEqual(w1_node.progress, 42) w1.progressBarFinished() self.assertEqual(w1_node.processing_state, 0) w1.information("We want information.") self.assertTrue( any(m.contents == "We want information." for m in w1_node.state_messages()) ) def test_state_init(self): def __init__(self, *args, **kwargs): super(Adder, self).__init__(*args, **kwargs) self.setReady(False) self.setInvalidated(True) self.progressBarInit() self.setStatusMessage("Aa") with unittest.mock.patch.object(Adder, "__init__", __init__): model, widgets = create_workflow() sm = model.signal_manager node = widgets.add_node self.assertFalse(sm.is_ready(node)) self.assertTrue(sm.is_invalidated(node)) self.assertTrue(sm.is_active(node)) self.assertEqual(node.status_message(), "Aa") def test_remove_blocking(self): model, widgets = create_workflow() wm = model.widget_manager add = widgets.add add.setBlocking(True) add.progressBarInit() with unittest.mock.patch.object(add, "deleteLater") as delete: model.clear() delete.assert_not_called() add.progressBarFinished() add.setBlocking(False) delete.assert_called_once() def test_env_dispatch(self): model, widgets = create_workflow() with unittest.mock.patch.object(widgets.w1, "workflowEnvChanged") as c: model.set_runtime_env("workdir", "/a/b/c/d") c.assert_called_once_with("workdir", "/a/b/c/d", None) model.set_runtime_env("workdir", "/a/b/c") c.assert_called_with("workdir", "/a/b/c", "/a/b/c/d") def test_extra_actions(self): model, widgets = create_workflow() wm = model.widget_manager # set debug level - implicit 'Show properties' action log = logging.getLogger("orangewidget.workflow.widgetsscheme") level = log.level try: log.setLevel(logging.DEBUG) actions = wm.actions_for_context_menu(widgets.w1_node) finally: log.setLevel(level) self.assertTrue(any(a.objectName() == "show-settings" for a in actions)) a = QAction("A", widgets.w1, objectName="-extra-action") a.setProperty("ext-workflow-node-menu-action", True) widgets.w1.addAction(a) actions = wm.actions_for_context_menu(widgets.w1_node) self.assertIn(a, actions) class TestSignalManager(GuiTest): def test_signalmanager(self): model, widgets = create_workflow() sm = model.signal_manager widgets.w1.Outputs.out.send(42) widgets.w2.Outputs.out.send(-42) self.assertSequenceEqual( sm.node_update_front(), [widgets.add_node] ) sm.process_queued() self.assertEqual(widgets.add.a, 42) self.assertEqual(widgets.add.b, -42) link = model.find_links(widgets.add_node, sink_node=widgets.show_node) link = link[0] contents = sm.link_contents(link) self.assertEqual(next(iter(contents.values())), 0) self.assertSequenceEqual( sm.node_update_front(), [widgets.show_node] ) def test_state_ready(self): model, widgets = create_workflow() sm = model.signal_manager widgets.w1.Outputs.out.send(42) widgets.w2.Outputs.out.send(-42) widgets.add.setReady(False) self.assertFalse(sm.is_ready(widgets.add_node)) spy = QSignalSpy(sm.processingStarted[SchemeNode]) sm.process_next() self.assertEqual(len(spy), 0) # must not have processed the node widgets.add.setReady(True) self.assertTrue(sm.is_ready(widgets.add_node)) assert spy.wait() self.assertSequenceEqual(spy, [[widgets.add_node]]) def test_state_invalidated(self): model, widgets = create_workflow() sm = model.signal_manager widgets.w1.Outputs.out.send(42) widgets.w2.Outputs.out.send(-42) self.assertIn(widgets.add_node, sm.node_update_front()) widgets.w1.setInvalidated(True) self.assertTrue(sm.is_invalidated(widgets.w1_node)) self.assertSequenceEqual(sm.node_update_front(), []) widgets.w1.setInvalidated(False) self.assertFalse(sm.is_invalidated(widgets.w1_node)) self.assertIn(widgets.add_node, sm.node_update_front()) spy = QSignalSpy(sm.processingStarted[SchemeNode]) assert spy.wait() self.assertSequenceEqual(spy, [[widgets.add_node]]) def test_multi_input(self): model, widgets = create_workflow_2() w1, w2 = widgets.w1, widgets.w2 sm = model.signal_manager spy = QSignalSpy(widgets.list_node.state_changed) show_link = model.find_links( widgets.list_node, sink_node=widgets.show_node)[0] def show_link_contents(): return next(iter(sm.link_contents(show_link).values())) def check_inputs(expected: list): if widgets.list_node.state() & SchemeNode.Pending: self.assertTrue(spy.wait()) self.assertEqual(show_link_contents(), expected) w1.Outputs.out.send(42) w2.Outputs.out.send(-42) check_inputs([42, -42]) w1.Outputs.out.send(None) check_inputs([None, -42]) w1.Outputs.out.send(1) check_inputs([1, -42]) link = model.find_links(widgets.w1_node, None, widgets.list_node, None)[0] model.remove_link(link) check_inputs([-42]) model.insert_link(0, link) w1.Outputs.out.send(None) check_inputs([None, -42]) @unittest.mock.patch.object(MakeList.Inputs.element, "filter_none", True) def test_multi_input_filter_none(self): # Test MultiInput.filter_none model, widgets = create_workflow_2() w1, w2, list_ = widgets.w1, widgets.w2, widgets.list_ spy = QSignalSpy(widgets.list_node.state_changed) def check_inputs(expected: list): if widgets.list_node.state() & SchemeNode.Pending: self.assertTrue(spy.wait()) self.assertEqual(list_.inputs, expected) def check_events(expected: list): if widgets.list_node.state() & SchemeNode.Pending: self.assertTrue(spy.wait()) self.assertEqual(expected, list_.events) def reset_events(): list_.events.clear() w1.Outputs.out.send(None) w2.Outputs.out.send(42) check_inputs([42]) check_events([("insert", 0, 42)]) reset_events() w1.Outputs.out.send(None) w2.Outputs.out.send(-42) check_inputs([-42]) check_events([("set", 0, -42)]) reset_events() w1.Outputs.out.send(42) check_inputs([42, -42]) check_events([("insert", 0, 42)]) reset_events() w1.Outputs.out.send(None) check_inputs([-42]) check_events([("remove", 0)]) reset_events() w2.Outputs.out.send(None) check_inputs([]) check_events([("remove", 0)]) reset_events() w2.Outputs.out.send(2) check_inputs([2]) check_events([("insert", 0, 2)]) reset_events() w1.Outputs.out.send(1) check_inputs([1, 2]) check_events([("insert", 0, 1)]), reset_events() w2.Outputs.out.send(None) check_inputs([1]) check_events([("remove", 1)]) reset_events() w2.Outputs.out.send(2) check_inputs([1, 2]) check_events([("insert", 1, 2)]) reset_events() l1 = model.find_links(widgets.w1_node, None, widgets.list_node, None)[0] model.remove_link(l1) check_inputs([2]) check_events([("remove", 0)]) reset_events() model.insert_link(0, l1) check_inputs([1, 2]) check_events([("insert", 0, 1)]) reset_events() l2 = model.find_links(widgets.w2_node, None, widgets.list_node, None)[0] model.remove_link(l2) check_inputs([1]) check_events([("remove", 1)]) model.insert_link(1, l2) check_inputs([1, 2]) model.remove_link(l1) check_inputs([2]) model.insert_link(0, l1) w1.Outputs.out.send(None) check_inputs([2]) w1.Outputs.out.send(None) check_inputs([2]) model.remove_link(l1) model.insert_link(0, l1) check_inputs([2]) w1.Outputs.out.send(1) check_inputs([1, 2]) w1.Outputs.out.send(None) check_inputs([2]) model.remove_link(l1) check_inputs([2]) reset_events() model.remove_link(l2) check_inputs([]) check_events([("remove", 0)]) reset_events() w1.Outputs.out.send(None) w2.Outputs.out.send(1) model.insert_link(0, l1) model.insert_link(1, l2) check_inputs([1]) check_events([("insert", 0, 1)]) reset_events() # ensure proper index on input removal when preceding inputs are # filtered model.remove_link(l2) check_inputs([]) check_events([("remove", 0)]) def test_old_style_input(self): model, widgets = create_workflow() show_node = model.new_node(widget_description(OldStyleShow)) show = model.widget_for_node(show_node) model.new_link(widgets.w1_node, "X", show_node, "X") widgets.w1.Outputs.out.send(1) spy = QSignalSpy(show_node.state_changed) spy.wait() self.assertEqual(show.x, 1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1668515756.0 orange-widget-base-4.22.0/orangewidget/workflow/utils.py0000644000076500000240000000202114334703654022555 0ustar00primozstaffimport operator from weakref import WeakKeyDictionary from typing import TypeVar, Sequence, Optional, Callable, Any T = TypeVar("T") def index_of( seq: Sequence[T], el: T, eq: Callable[[T, T], bool] = operator.eq, ) -> Optional[int]: """ Return index of `el` in `seq` where equality is defined by `eq`. Return `None` if not found. """ for i, e in enumerate(seq): if eq(el, e): return i return None class WeakKeyDefaultDict(WeakKeyDictionary): """ A `WeakKeyDictionary` that also acts like a :class:`collections.defaultdict` """ default_factory: Callable[[], Any] def __init__(self, default_factory: Callable[[], Any], *args, **kwargs): super().__init__(*args, **kwargs) self.default_factory = default_factory def __getitem__(self, key): try: value = super().__getitem__(key) except KeyError: value = self.default_factory() self.__setitem__(key, value) return value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1686222972.0 orange-widget-base-4.22.0/orangewidget/workflow/widgetsscheme.py0000644000076500000240000007610714440334174024263 0ustar00primozstaff""" Orange Widgets Workflow ======================= A workflow model for Orange Widgets (OWBaseWidget). This is a subclass of the :class:`~Scheme`. It is responsible for the construction and management of OWBaseWidget instances corresponding to the scheme nodes, as well as delegating the signal propagation to a companion :class:`WidgetsSignalManager` class. .. autoclass:: WidgetsScheme :bases: .. autoclass:: WidgetsManager :bases: .. autoclass:: WidgetsSignalManager :bases: """ import copy import logging import enum import types import warnings from functools import singledispatch from itertools import count from urllib.parse import urlencode from weakref import finalize from typing import Optional, Dict, Any, List, Mapping, overload from AnyQt.QtWidgets import QWidget, QAction from AnyQt.QtGui import QWhatsThisClickedEvent from AnyQt.QtCore import Qt, QCoreApplication, QEvent, QByteArray from AnyQt.QtCore import pyqtSlot as Slot from orangecanvas.registry import WidgetDescription, OutputSignal from orangecanvas.scheme.signalmanager import ( SignalManager, Signal, compress_signals, LazyValue ) from orangecanvas.scheme import Scheme, SchemeNode from orangecanvas.scheme.node import UserMessage from orangecanvas.scheme.widgetmanager import WidgetManager as _WidgetManager from orangecanvas.utils import name_lookup from orangecanvas.resources import icon_loader from orangewidget.utils.signals import get_input_meta, notify_input_helper from orangewidget.widget import OWBaseWidget, Input from orangewidget.report.owreport import OWReport from orangewidget.settings import SettingsPrinter from orangewidget.workflow.utils import index_of, WeakKeyDefaultDict log = logging.getLogger(__name__) class WidgetsScheme(Scheme): """ A workflow scheme containing Orange Widgets (:class:`OWBaseWidget`). Extends the base `Scheme` class to handle the lifetime (creation/deletion, etc.) of `OWBaseWidget` instances corresponding to the nodes in the scheme. The inter-widget signal propagation is delegated to an instance of `WidgetsSignalManager`. """ def __init__(self, parent=None, title=None, description=None, env={}, **kwargs): super().__init__(parent, title, description, env=env, **kwargs) self.widget_manager = WidgetManager() self.signal_manager = WidgetsSignalManager(self) self.widget_manager.set_scheme(self) self.__report_view = None # type: Optional[OWReport] def widget_for_node(self, node): """ Return the OWBaseWidget instance for a `node`. """ return self.widget_manager.widget_for_node(node) def node_for_widget(self, widget): """ Return the SchemeNode instance for the `widget`. """ return self.widget_manager.node_for_widget(widget) def sync_node_properties(self): """ Sync the widget settings/properties with the SchemeNode.properties. Return True if there were any changes in the properties (i.e. if the new node.properties differ from the old value) and False otherwise. """ changed = False for node in self.nodes: settings = self.widget_manager.widget_settings_for_node(node) if settings != node.properties: node.properties = settings changed = True log.debug("Scheme node properties sync (changed: %s)", changed) return changed def show_report_view(self): inst = self.report_view() inst.show() inst.raise_() def has_report(self): """ Does this workflow have an associated report Returns ------- has_report: bool """ return self.__report_view is not None def report_view(self): """ Return a OWReport instance used by the workflow. Returns ------- report : OWReport """ if self.__report_view is None: parent = self.parent() if isinstance(parent, QWidget): window = parent.window() # type: QWidget else: window = None self.__report_view = OWReport() if window is not None: self.__report_view.setParent(window, Qt.Window) return self.__report_view def set_report_view(self, view): """ Set the designated OWReport view for this workflow. Parameters ---------- view : Optional[OWReport] """ self.__report_view = view def dump_settings(self, node: SchemeNode): widget = self.widget_for_node(node) pp = SettingsPrinter(indent=4) pp.pprint(widget.settingsHandler.pack_data(widget)) def event(self, event): if event.type() == QEvent.Close: if self.__report_view is not None: self.__report_view.close() self.signal_manager.stop() return super().event(event) class ProcessingState(enum.IntEnum): """OWBaseWidget processing state flags""" #: Signal manager is updating/setting the widget's inputs InputUpdate = 1 #: Widget has entered a blocking state (OWBaseWidget.isBlocking() is True) BlockingUpdate = 2 #: Widget has entered processing state (progressBarInit/Finish) ProcessingUpdate = 4 #: Widget is still in the process of initialization Initializing = 8 class Item(types.SimpleNamespace): """ A SchemeNode, OWBaseWidget pair tracked by OWWidgetManager """ def __init__(self, node, widget, state): # type: (SchemeNode, Optional[OWBaseWidget], int) -> None super().__init__() self.node = node self.widget = widget self.state = state class OWWidgetManager(_WidgetManager): """ OWBaseWidget instance manager class. This class handles the lifetime of OWBaseWidget instances in a :class:`WidgetsScheme`. """ InputUpdate, BlockingUpdate, ProcessingUpdate, Initializing = ProcessingState #: State mask for widgets that cannot be deleted immediately #: (see __try_delete) DelayDeleteMask = InputUpdate | BlockingUpdate def __init__(self, parent=None, **kwargs): super().__init__(parent, **kwargs) self.__scheme = None self.__signal_manager = None self.__item_for_node = {} # type: Dict[SchemeNode, Item] # Widgets that were 'removed' from the scheme but were at # the time in an input update loop and could not be deleted # immediately self.__delay_delete = {} # type: Dict[OWBaseWidget, Item] # Tracks the widget in the update loop by the SignalManager self.__updating_widget = None # type: Optional[OWBaseWidget] def set_scheme(self, scheme): """ Set the :class:`WidgetsScheme` instance to manage. """ self.__scheme = scheme self.__signal_manager = scheme.findChild(SignalManager) self.__signal_manager.processingStarted[SchemeNode].connect( self.__on_processing_started ) self.__signal_manager.processingFinished[SchemeNode].connect( self.__on_processing_finished ) scheme.runtime_env_changed.connect(self.__on_env_changed) scheme.installEventFilter(self) super().set_workflow(scheme) def scheme(self): """ Return the scheme instance on which this manager is installed. """ return self.__scheme def signal_manager(self): """ Return the signal manager in use on the :func:`scheme`. """ return self.__signal_manager def node_for_widget(self, widget): # type: (QWidget) -> Optional[SchemeNode] """ Reimplemented. """ node = super().node_for_widget(widget) if node is None: # the node -> widget mapping requested (by this subclass or # WidgetSignalManager) before or after the base class tracks it # (in create_widget_for_node or delete_widget_for_node). for item in self.__item_for_node.values(): if item.widget is widget: return item.node return node def widget_settings_for_node(self, node): # type: (SchemeNode) -> Dict[str, Any] """ Return the properties/settings from the widget for node. Parameters ---------- node : SchemeNode Returns ------- settings : dict """ item = self.__item_for_node.get(node) if item is not None and isinstance(item.widget, OWBaseWidget): return self.widget_settings(item.widget) else: return node.properties def widget_settings(self, widget): # type: (OWBaseWidget) -> Dict[str, Any] """ Return the settings from `OWWidget` instance. Parameters ---------- widget : OWBaseWidget Returns ------- settings : Dict[str, Any] """ return widget.settingsHandler.pack_data(widget) def create_widget_for_node(self, node): # type: (SchemeNode) -> QWidget """ Reimplemented. """ widget = self.create_widget_instance(node) return widget def delete_widget_for_node(self, node, widget): # type: (SchemeNode, QWidget) -> None """ Reimplemented. """ assert node not in self.workflow().nodes item = self.__item_for_node.get(node) if item is not None and isinstance(item.widget, OWBaseWidget): assert item.node is node if item.state & ProcessingState.Initializing: raise RuntimeError( "A widget/node {0} was removed while being initialized. " "This is most likely a result of an explicit " "QApplication.processEvents call from the " "'{1.__module__}.{1.__qualname__}' " "widget's __init__." .format(node.title, type(item.widget)) ) # Update the node's stored settings/properties dict before # removing the widget. # TODO: Update/sync whenever the widget settings change. node.properties = self.widget_settings(widget) node.title_changed.disconnect(widget.setCaption) widget.progressBarValueChanged.disconnect(node.set_progress) widget.close() # Save settings to user global settings. widget.saveSettings() # Notify the widget it will be deleted. widget.onDeleteWidget() # Un befriend the report view del widget._Report__report_view if log.isEnabledFor(logging.DEBUG): finalize( widget, log.debug, "Destroyed namespace for: %s", node.title ) # clear the state ?? (have node.reset()??) node.set_progress(0) node.set_processing_state(0) node.set_status_message("") node.set_state(SchemeNode.NoState) msgs = [copy.copy(m) for m in node.state_messages()] for m in msgs: m.contents = "" node.set_state_message(m) self.__item_for_node.pop(node) self.__delete_item(item) def __delete_item(self, item): item.node = None widget = item.widget if item.state & WidgetManager.DelayDeleteMask: # If the widget is in an update loop and/or blocking we # delay the scheduled deletion until the widget is done. # The `__on_{processing,blocking}_state_changed` must call # __try_delete when the mask is cleared. log.debug("Widget %s removed but still in state :%s. " "Deferring deletion.", widget, item.state) self.__delay_delete[widget] = item else: widget.processingStateChanged.disconnect( self.__on_widget_state_changed) widget.widgetStateChanged.disconnect( self.__on_widget_state_changed) widget.deleteLater() item.widget = None def __try_delete(self, item): if not item.state & WidgetManager.DelayDeleteMask: widget = item.widget log.debug("Delayed delete for widget %s", widget) widget.widgetStateChanged.disconnect( self.__on_widget_state_changed) widget.processingStateChanged.disconnect( self.__on_widget_state_changed) item.widget = None widget.deleteLater() del self.__delay_delete[widget] def create_widget_instance(self, node): # type: (SchemeNode) -> OWBaseWidget """ Create a OWBaseWidget instance for the node. """ desc = node.description # type: WidgetDescription signal_manager = self.signal_manager() # Setup mapping for possible reentry via signal manager in widget's # __init__ item = Item(node, None, ProcessingState.Initializing) self.__item_for_node[node] = item try: # Lookup implementation class klass = name_lookup(desc.qualified_name) log.info("WidgetManager: Creating '%s.%s' instance '%s'.", klass.__module__, klass.__name__, node.title) item.widget = widget = klass.__new__( klass, None, captionTitle=node.title, signal_manager=signal_manager, stored_settings=copy.deepcopy(node.properties), # NOTE: env is a view of the real env and reflects # changes to the environment. env=self.scheme().runtime_env() ) widget.__init__() except BaseException: item.widget = None raise finally: # Clear Initializing flag even in case of error item.state &= ~ProcessingState.Initializing # bind the OWBaseWidget to the node node.title_changed.connect(widget.setCaption) # Widget's info/warning/error messages. self.__initialize_widget_messages(node, widget) widget.messageActivated.connect(self.__on_widget_message_changed) widget.messageDeactivated.connect(self.__on_widget_message_changed) # Widget's statusMessage node.set_status_message(widget.statusMessage()) widget.statusMessageChanged.connect(node.set_status_message) # OWBaseWidget's progress bar state (progressBarInit/Finished,Set) widget.progressBarValueChanged.connect(node.set_progress) widget.processingStateChanged.connect( self.__on_widget_state_changed ) # Advertised state for the workflow execution semantics. widget.widgetStateChanged.connect(self.__on_widget_state_changed) # Install a help shortcut on the widget help_action = widget.findChild(QAction, "action-help") if help_action is not None: help_action.setEnabled(True) help_action.setVisible(True) help_action.triggered.connect(self.__on_help_request) widget.setWindowIcon( icon_loader.from_description(desc).get(desc.icon) ) widget.setCaption(node.title) # befriend class Report widget._Report__report_view = self.scheme().report_view self.__update_item(item) return widget def node_processing_state(self, node): """ Return the processing state flags for the node. Same as `manager.widget_processing_state(manger.widget_for_node(node))` """ if node not in self.__item_for_node: if node in self.__scheme.nodes: return ProcessingState.Initializing else: return 0 return self.__item_for_node[node].state def widget_processing_state(self, widget): # type: (OWBaseWidget) -> int """ Return the processing state flags for the widget. The state is an bitwise of the :class:`ProcessingState` flags. """ node = self.node_for_widget(widget) return self.__item_for_node[node].state def save_widget_geometry(self, node, widget): # type: (SchemeNode, QWidget) -> bytes """ Reimplemented. Save and return the current geometry and state for node. """ if isinstance(widget, OWBaseWidget): return bytes(widget.saveGeometryAndLayoutState()) else: return super().save_widget_geometry(node, widget) def restore_widget_geometry(self, node, widget, state): # type: (SchemeNode, QWidget, bytes) -> bool """ Restore the widget geometry state. Reimplemented. """ if isinstance(widget, OWBaseWidget): return widget.restoreGeometryAndLayoutState(QByteArray(state)) else: return super().restore_widget_geometry(node, widget, state) def eventFilter(self, receiver, event): if event.type() == QEvent.Close and receiver is self.__scheme: # Notify the remaining widget instances (if any). for item in list(self.__item_for_node.values()): widget = item.widget if widget is not None: widget.close() widget.saveSettings() widget.onDeleteWidget() widget.deleteLater() return super().eventFilter(receiver, event) def __on_help_request(self): """ Help shortcut was pressed. We send a `QWhatsThisClickedEvent` to the scheme and hope someone responds to it. """ # Sender is the QShortcut, and parent the OWBaseWidget widget = self.sender().parent() try: node = self.node_for_widget(widget) except KeyError: pass else: qualified_name = node.description.qualified_name help_url = "help://search?" + urlencode({"id": qualified_name}) event = QWhatsThisClickedEvent(help_url) QCoreApplication.sendEvent(self.scheme(), event) def __dump_settings(self): sender = self.sender() assert isinstance(sender, QAction) node = sender.data() scheme = self.scheme() scheme.dump_settings(node) def __initialize_widget_messages(self, node, widget): """ Initialize the tracked info/warning/error message state. """ for message_group in widget.message_groups: message = user_message_from_state(message_group) if message: node.set_state_message(message) def __on_widget_message_changed(self, msg): """ The OWBaseWidget info/warning/error state has changed. """ widget = msg.group.widget assert widget is not None node = self.node_for_widget(widget) if node is not None: self.__initialize_widget_messages(node, widget) def __on_processing_started(self, node): """ Signal manager entered the input update loop for the node. """ assert self.__updating_widget is None, "MUST NOT re-enter" # Force widget creation (if not already done) _ = self.widget_for_node(node) item = self.__item_for_node[node] # Remember the widget instance. The node and the node->widget mapping # can be removed between this and __on_processing_finished. if item.widget is not None: self.__updating_widget = item.widget item.state |= ProcessingState.InputUpdate self.__update_node_processing_state(node) def __on_processing_finished(self, node): """ Signal manager exited the input update loop for the node. """ widget = self.__updating_widget self.__updating_widget = None item = None if widget is not None: item = self.__item_for_widget(widget) if item is None: return item.state &= ~ProcessingState.InputUpdate if item.node is not None: self.__update_node_processing_state(node) if widget in self.__delay_delete: self.__try_delete(item) @Slot() def __on_widget_state_changed(self): """ OWBaseWidget state has changed. """ widget = self.sender() item = None if widget is not None: item = self.__item_for_widget(widget) if item is None: warnings.warn( "State change for a non-tracked widget {}".format(widget), RuntimeWarning, ) return if not isinstance(widget, OWBaseWidget): return self.__update_item(item) if item.widget in self.__delay_delete: self.__try_delete(item) def __item_for_widget(self, widget): # type: (OWBaseWidget) -> Optional[Item] node = self.node_for_widget(widget) if node is not None: return self.__item_for_node[node] else: return self.__delay_delete.get(widget) def __update_item(self, item: Item): if item.widget is None: return node, widget = item.node, item.widget progress = widget.processingState invalidated = widget.isInvalidated() ready = widget.isReady() initializing = item.state & ProcessingState.Initializing def setflag(flags: int, flag: int, on: bool) -> int: return flags | flag if on else flags & ~flag if node is not None: state = node.state() state = setflag(state, SchemeNode.Running, progress) state = setflag(state, SchemeNode.NotReady, not (ready or initializing)) state = setflag(state, SchemeNode.Invalidated, invalidated or initializing) node.set_state(state) if progress: node.set_progress(widget.progressBarValue) item.state = setflag( item.state, ProcessingState.BlockingUpdate, not ready) item.state = setflag( item.state, ProcessingState.ProcessingUpdate, progress) self.signal_manager().post_update_request() def __update_node_processing_state(self, node): """ Update the `node.processing_state` to reflect the widget state. """ state = self.node_processing_state(node) node.set_processing_state(1 if state else 0) def __on_env_changed(self, key, newvalue, oldvalue): # Notify widgets of a runtime environment change for item in self.__item_for_node.values(): if item.widget is not None: item.widget.workflowEnvChanged(key, newvalue, oldvalue) def actions_for_context_menu(self, node): # type: (SchemeNode) -> List[QAction] """ Reimplemented from WidgetManager.actions_for_context_menu. Parameters ---------- node : SchemeNode Returns ------- actions : List[QAction] """ actions = [] widget = self.widget_for_node(node) if widget is not None: actions = [a for a in widget.actions() if a.property("ext-workflow-node-menu-action") is True] if log.isEnabledFor(logging.DEBUG): ac = QAction( self.tr("Show settings"), widget, objectName="show-settings", toolTip=self.tr("Show widget settings"), ) ac.setData(node) ac.triggered.connect(self.__dump_settings) actions.append(ac) return super().actions_for_context_menu(node) + actions WidgetManager = OWWidgetManager def user_message_from_state(message_group): return UserMessage( severity=message_group.severity, message_id="{0.__name__}.{0.__qualname__}".format(type(message_group)), contents="
".join(msg.formatted for msg in message_group.active) or None, data={"content-type": "text/html"}) class WidgetsSignalManager(SignalManager): """ A signal manager for a WidgetsScheme. """ def __init__(self, scheme, **kwargs): super().__init__(scheme, **kwargs) def send(self, widget, channelname, value, *args, **kwargs): # type: (OWBaseWidget, str, Any, Any, Any, Any) -> None """ send method compatible with OWBaseWidget. """ scheme = self.scheme() node = scheme.widget_manager.node_for_widget(widget) if node is None: # The Node/Widget was already removed from the scheme. log.debug("Node for '%s' (%s.%s) is not in the scheme.", widget.captionTitle, type(widget).__module__, type(widget).__name__) return try: channel = node.output_channel(channelname) except ValueError: log.error("%r is not valid signal name for %r", channelname, node.description.name) return # parse deprecated id parameter from *args, **kwargs. _not_set = object() def _parse_call_signal_id(signal_id=_not_set): if signal_id is _not_set: return None else: warnings.warn( "'signal_id' parameter is deprecated", DeprecationWarning, stacklevel=3) return signal_id signal_id = _parse_call_signal_id(*args, **kwargs) if signal_id is not None: super().send(node, channel, value, signal_id) # type: ignore else: super().send(node, channel, value) @overload def invalidate(self, widget: OWBaseWidget, channel: str) -> None: ... @overload def invalidate(self, node: SchemeNode, channel: OutputSignal) -> None: ... def invalidate(self, node, channel): """Reimplemented from `SignalManager`""" if not isinstance(node, SchemeNode): scheme = self.scheme() node = scheme.widget_manager.node_for_widget(node) channel = node.output_channel(channel) super().invalidate(node, channel) def is_invalidated(self, node: SchemeNode) -> bool: """Reimplemented from `SignalManager`""" rval = super().is_invalidated(node) state = self.scheme().widget_manager.node_processing_state(node) return rval or state & ( ProcessingState.BlockingUpdate | ProcessingState.Initializing ) def is_ready(self, node: SchemeNode) -> bool: """Reimplemented from `SignalManager`""" rval = super().is_ready(node) state = self.scheme().widget_manager.node_processing_state(node) return rval and not state & ( ProcessingState.InputUpdate | ProcessingState.Initializing ) def send_to_node(self, node, signals): """ Implementation of `SignalManager.send_to_node`. Deliver input signals to an OWBaseWidget instance. """ scheme = self.scheme() assert scheme is not None widget = scheme.widget_for_node(node) if widget is None: return # `signals` are in the order they were 'enqueued' for delivery. # Reorder them to match the order of links in the model. _order = { l: i for i, l in enumerate(scheme.find_links(sink_node=node)) } def order(signal: Signal) -> int: # if link is not in the workflow we are processing the final # 'reset' (None) delivery for a removed connection. return _order.get(signal.link, -1) signals = sorted(signals, key=order) self.process_signals_for_widget(node, widget, signals) def compress_signals(self, signals): """ Reimplemented from :func:`SignalManager.compress_signals`. """ return compress_signals(signals) def process_signals_for_widget(self, node, widget, signals): # type: (SchemeNode, OWBaseWidget, List[Signal]) -> None """ Process new signals for the OWBaseWidget. """ workflow = self.workflow() process_signals_for_widget(widget, signals, workflow) __NODE_ID: Mapping[SchemeNode, int] = WeakKeyDefaultDict(count().__next__) @singledispatch def process_signal_input( input: Input, widget: OWBaseWidget, signal: Signal, workflow: WidgetsScheme ) -> None: """ Deliver the `signal` from the workflow to the widget. This is a generic handler. The default handles `Input` and `MultiInput` inputs. """ raise NotImplementedError @process_signal_input.register(Input) def process_signal_input_default( input: Input, widget: OWBaseWidget, signal: Signal, workflow: WidgetsScheme ): """ """ inputs = get_widget_input_signals(widget) link = signal.link index = signal.index value = signal.value if LazyValue.is_lazy(value): value = value.get_value() index_existing = index_of(inputs, signal, eq=same_input_slot) if index < 0 and index_existing is not None: index = index_existing elif index_existing is not None: index = index_existing # 'input local' index i.e. which connection to the same (multiple) input. index_local = index_of( (s.link for s in inputs if s.channel.name == input.name), signal.link, ) if isinstance(signal, Signal.New): if not 0 <= index < len(inputs): index = len(inputs) inputs.insert(index, signal) index_local = index_of( (s.link for s in inputs if s.channel.name == input.name), signal.link, ) elif isinstance(signal, Signal.Close): old = inputs.pop(index) assert old.link == signal.link value = input.closing_sentinel else: assert inputs[index].link == signal.link inputs[index] = signal wid = __NODE_ID[link.source_node] # historical key format: widget_id, output name and the id passed to send key = (wid, link.source_channel.name, signal.id) notify_input_helper( input, widget, value, key=key, index=index_local ) def get_widget_input_signals(widget: OWBaseWidget) -> List[Signal]: inputs: List[Signal] inputs = widget.__dict__.setdefault( "_OWBaseWidget__process_signal_input", [] ) return inputs def same_input_slot(s1: Signal, s2: Signal) -> bool: return s1.link == s2.link @singledispatch def handle_new_signals(widget, workflow: WidgetsScheme): """ Invoked by the workflow signal propagation manager after all input signal update handlers have been called. The default implementation for OWBaseWidget calls `OWBaseWidget.handleNewSignals` """ widget.handleNewSignals() @singledispatch def process_signals_for_widget(widget, signals, workflow): # type: (OWBaseWidget, List[Signal], WidgetsScheme) -> None """ Process new signals for the OWBaseWidget. """ for signal in signals: input_meta = get_input_meta(widget, signal.channel.name) process_signal_input(input_meta, widget, signal, workflow) handle_new_signals(widget, workflow) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1694782192.8639922 orange-widget-base-4.22.0/setup.cfg0000644000076500000240000000004614501051361016323 0ustar00primozstaff[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1694782154.0 orange-widget-base-4.22.0/setup.py0000755000076500000240000001126414501051312016217 0ustar00primozstaff#! /usr/bin/env python3 import os import subprocess from setuptools import setup, find_packages, Command NAME = 'orange-widget-base' VERSION = '4.22.0' ISRELEASED = True # full version identifier including a git revision identifier for development # build/releases (this is filled/updated in `write_version_py`) FULLVERSION = VERSION DESCRIPTION = 'Base Widget for Orange Canvas' README_FILE = os.path.join(os.path.dirname(__file__), 'README.md') LONG_DESCRIPTION = """ This project implements the base OWBaseWidget class and utilities for use in Orange Canvas workflows. Provides: * `OWBaseWidget` class * `gui` module for building GUI * `WidgetsScheme` the workflow execution model/bridge * basic configuration for a workflow based application """ AUTHOR = 'Bioinformatics Laboratory, FRI UL' AUTHOR_EMAIL = 'info@biolab.si' URL = 'http://orange.biolab.si/' LICENSE = 'GPLv3+' KEYWORDS = ( 'workflow', 'widget' ) CLASSIFIERS = ( 'Development Status :: 4 - Beta', 'Environment :: X11 Applications :: Qt', 'Environment :: Console', 'Environment :: Plugins', 'Programming Language :: Python', 'License :: OSI Approved :: ' 'GNU General Public License v3 or later (GPLv3+)', 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', 'Topic :: Scientific/Engineering :: Artificial Intelligence', 'Topic :: Scientific/Engineering :: Visualization', 'Topic :: Software Development :: Libraries :: Python Modules', 'Intended Audience :: Education', 'Intended Audience :: Science/Research', 'Intended Audience :: Developers', ) INSTALL_REQUIRES = [ "matplotlib", "pyqtgraph", "AnyQt>=0.1.0", "typing_extensions>=3.7.4.3", "orange-canvas-core>=0.1.30,<0.2a", 'appnope; sys_platform=="darwin"' ] EXTRAS_REQUIRE = { } ENTRY_POINTS = { } DATA_FILES = [] # Return the git revision as a string def git_version(): """Return the git revision as a string. Copied from numpy setup.py """ def _minimal_ext_cmd(cmd): # construct minimal environment env = {} for k in ['SYSTEMROOT', 'PATH']: v = os.environ.get(k) if v is not None: env[k] = v # LANGUAGE is used on win32 env['LANGUAGE'] = 'C' env['LANG'] = 'C' env['LC_ALL'] = 'C' out = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, env=env) return out.stdout try: out = _minimal_ext_cmd(['git', 'rev-parse', 'HEAD']) GIT_REVISION = out.strip().decode('ascii') except OSError: GIT_REVISION = "Unknown" return GIT_REVISION def write_version_py(filename='orangewidget/version.py'): # Copied from numpy setup.py cnt = f"""\ # THIS FILE IS GENERATED FROM {NAME.upper()} SETUP.PY short_version = '%(version)s' version = '%(version)s' full_version = '%(full_version)s' git_revision = '%(git_revision)s' release = %(isrelease)s if not release: version = full_version short_version += ".dev" """ global FULLVERSION FULLVERSION = VERSION if os.path.exists('.git'): GIT_REVISION = git_version() elif os.path.exists(filename): # must be a source distribution, use existing version file import imp version = imp.load_source("orangewidget.version", filename) GIT_REVISION = version.git_revision else: GIT_REVISION = "Unknown" if not ISRELEASED: FULLVERSION += '.dev0+' + GIT_REVISION[:7] a = open(filename, 'w') try: a.write(cnt % {'version': VERSION, 'full_version': FULLVERSION, 'git_revision': GIT_REVISION, 'isrelease': str(ISRELEASED)}) finally: a.close() PACKAGES = find_packages() # Extra non .py, .{so,pyd} files that are installed within the package dir # hierarchy PACKAGE_DATA = { "orangewidget": ["icons/*.png", "icons/*.svg"], "orangewidget.report": ["icons/*.svg", "*.html"], "orangewidget.utils": ["_webview/*.js"], } def setup_package(): write_version_py() setup( name=NAME, version=FULLVERSION, description=DESCRIPTION, long_description=LONG_DESCRIPTION, long_description_content_type="text/x-rst", author=AUTHOR, author_email=AUTHOR_EMAIL, url=URL, license=LICENSE, keywords=KEYWORDS, classifiers=CLASSIFIERS, packages=PACKAGES, package_data=PACKAGE_DATA, data_files=DATA_FILES, install_requires=INSTALL_REQUIRES, extras_require=EXTRAS_REQUIRE, entry_points=ENTRY_POINTS, python_requires=">=3.6", zip_safe=False, ) if __name__ == '__main__': setup_package()